modsmith-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ """``modsmith update``: bring an instance's pack and additions up to date."""
2
+
3
+ from dataclasses import dataclass, replace
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from modsmith.errors import DependencyError, UnresolvedEntriesError, UnsupportedError
9
+ from modsmith.instances import Changes, Instance, InstanceState, Plan
10
+ from modsmith.minecraft import GameDir
11
+ from modsmith.packs import Modpack
12
+ from rich.text import Text
13
+
14
+ from ..context import AppContext, get
15
+ from ..core.install import (
16
+ FetchedPack,
17
+ check_launcher,
18
+ download_pack,
19
+ download_sizes,
20
+ fetch_pack,
21
+ latest_pack,
22
+ optional_entries,
23
+ with_optional,
24
+ )
25
+ from ..core.platforms import explained
26
+ from ..core.records import Record, read_record, write_record
27
+ from ..core.refs import LocalPack
28
+ from ..core.registry import resolve_instance
29
+ from ..errors import report as report_error
30
+ from ..target import DryRun, InstanceName, Yes
31
+ from ..ui import cancellable, confirm, progress, show_changelog, show_plan
32
+ from .install import (
33
+ AllOptional,
34
+ From,
35
+ Optional,
36
+ Overwrite,
37
+ choose_optional,
38
+ loader_label,
39
+ )
40
+
41
+ CHANGELOG_LINES = 6
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Choices:
46
+ """What the player asked ``update`` to do."""
47
+
48
+ file: Path | None
49
+ optional: list[str]
50
+ all_optional: bool
51
+ prereleases: bool
52
+ yes: bool
53
+
54
+ @property
55
+ def reselect(self) -> bool:
56
+ """Whether the player chose optional files again."""
57
+ return bool(self.optional) or self.all_optional
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class PackUpdate:
62
+ """A newer pack, ready to sync into an instance."""
63
+
64
+ fetched: FetchedPack
65
+ pack: Modpack
66
+ chosen: tuple[str, ...]
67
+ new_game: bool
68
+
69
+
70
+ def _game(pack: Modpack | InstanceState) -> str:
71
+ return f"Minecraft {pack.minecraft} with " + loader_label(
72
+ pack.loader_kind, pack.loader_version
73
+ )
74
+
75
+
76
+ def _newer_pack(
77
+ app: AppContext, record: Record, choices: Choices
78
+ ) -> tuple[FetchedPack | None, str]:
79
+ """Fetch the pack to update to, or explain why there is none."""
80
+ source = record.source
81
+ if choices.file is not None:
82
+ ref = LocalPack(choices.file)
83
+ fetched = fetch_pack(
84
+ ref, app.platform, cache_dir=app.paths.cache_dir, allow_prereleases=False
85
+ )
86
+ return fetched, ""
87
+ if source is None:
88
+ return None, "It has no pack."
89
+ if source.platform is None or source.project_id is None:
90
+ return None, "Its pack came from a file. Update it with --file <newer pack>."
91
+ with (
92
+ explained(source.platform),
93
+ app.platform(source.platform) as platform,
94
+ cancellable() as token,
95
+ progress(app) as report,
96
+ ):
97
+ project, release = latest_pack(
98
+ platform, source.project_id, allow_prereleases=choices.prereleases
99
+ )
100
+ if release.id == source.release_id and not choices.reselect:
101
+ return None, f"{project.name} is up to date."
102
+ fetched = download_pack(
103
+ platform,
104
+ project,
105
+ release,
106
+ app.platform,
107
+ cache_dir=app.paths.cache_dir,
108
+ progress=report,
109
+ cancel=token,
110
+ )
111
+ try:
112
+ changelog = platform.changelog(release)
113
+ except UnsupportedError:
114
+ changelog = ""
115
+ show_changelog(
116
+ app, f"What changed in {release.name}", changelog, lines=CHANGELOG_LINES
117
+ )
118
+ return fetched, ""
119
+
120
+
121
+ def _kept_choice(app: AppContext, pack: Modpack, record: Record) -> tuple[str, ...]:
122
+ """Keep the optional files chosen before, where the new pack still has them."""
123
+ offered = {entry.path for entry in optional_entries(pack)}
124
+ kept = tuple(path for path in record.optional if path in offered)
125
+ gone = len(record.optional) - len(kept)
126
+ if gone:
127
+ app.err.print(
128
+ (
129
+ "1 optional file you chose is"
130
+ if gone == 1
131
+ else f"{gone} optional files you chose are"
132
+ )
133
+ + " not in this release. "
134
+ "Choose again with --optional.",
135
+ style="yellow",
136
+ )
137
+ return kept
138
+
139
+
140
+ def _pack_update(
141
+ app: AppContext, instance: Instance, state: InstanceState, choices: Choices
142
+ ) -> PackUpdate | None:
143
+ """Show what updating the pack would change, if there is a newer one."""
144
+ record = read_record(instance.path)
145
+ fetched, reason = _newer_pack(app, record, choices)
146
+ if fetched is None:
147
+ app.out.print(Text(reason, style="dim"))
148
+ return None
149
+ if choices.reselect:
150
+ chosen = tuple(
151
+ choose_optional(
152
+ app, fetched.pack, choices.optional, choices.all_optional, choices.yes
153
+ )
154
+ )
155
+ else:
156
+ chosen = _kept_choice(app, fetched.pack, record)
157
+ pack = with_optional(fetched.pack, chosen)
158
+ title = f"Update {state.pack_name or 'the pack'} {state.pack_version or ''}"
159
+ title = f"{title.rstrip()} to {pack.name} {pack.version}"
160
+ pending = show_plan(app, instance.diff(pack), download_sizes(pack), title=title)
161
+ new_game = _game(pack) != _game(state)
162
+ if new_game:
163
+ app.out.print(f"It moves from {_game(state)} to {_game(pack)}.")
164
+ if not (pending or new_game):
165
+ return None
166
+ return PackUpdate(fetched, pack, chosen, new_game)
167
+
168
+
169
+ def _addition_updates(
170
+ app: AppContext, instance: Instance, state: InstanceState, prereleases: bool
171
+ ) -> list[Plan]:
172
+ """Plan updates for the projects added on their own, platform by platform."""
173
+ plans: list[Plan] = []
174
+ for name in sorted({item.platform for item in state.additions}):
175
+ with explained(name), app.platform(name) as platform:
176
+ plan = instance.plan_updates(platform, release_only=not prereleases)
177
+ logs: list[tuple[str, str]] = []
178
+ for item in plan.additions:
179
+ try:
180
+ release = platform.get_release(item.release_id)
181
+ logs.append((release.name, platform.changelog(release)))
182
+ except UnsupportedError:
183
+ continue
184
+ if not plan.additions:
185
+ continue
186
+ plans.append(plan)
187
+ for title, text in logs:
188
+ show_changelog(app, f"What changed in {title}", text, lines=CHANGELOG_LINES)
189
+ return plans
190
+
191
+
192
+ def _show_additions(app: AppContext, name: str, plans: list[Plan]) -> bool:
193
+ changes = Changes(
194
+ added=tuple(path for plan in plans for path in plan.changes.added),
195
+ updated=tuple(path for plan in plans for path in plan.changes.updated),
196
+ removed=tuple(path for plan in plans for path in plan.changes.removed),
197
+ conflicts=tuple(path for plan in plans for path in plan.changes.conflicts),
198
+ )
199
+ sizes = {item.path: item.entry.size for plan in plans for item in plan.additions}
200
+ pending = show_plan(app, changes, sizes, title=f"Update what you added to {name}")
201
+ for plan in plans:
202
+ if not plan.ok:
203
+ raise DependencyError(plan)
204
+ return pending
205
+
206
+
207
+ def _apply_pack(
208
+ app: AppContext,
209
+ instance: Instance,
210
+ update: PackUpdate,
211
+ *,
212
+ overwrite: bool,
213
+ from_folders: list[Path],
214
+ ) -> None:
215
+ record = read_record(instance.path)
216
+ game = GameDir(app.config.game_dir)
217
+ launcher = (
218
+ check_launcher(game, app.services.launcher_running())
219
+ if update.new_game and record.profile
220
+ else None
221
+ )
222
+ with cancellable() as token, progress(app) as report:
223
+ version_id = update.pack.install_game(
224
+ game, cache_dir=app.paths.cache_dir, progress=report, cancel=token
225
+ )
226
+ instance.sync(
227
+ update.pack,
228
+ overwrite=overwrite,
229
+ adopt_from=from_folders,
230
+ progress=report,
231
+ cancel=token,
232
+ )
233
+ if launcher is not None and record.profile:
234
+ profile = launcher.profiles().get(record.profile)
235
+ launcher.add_profile(
236
+ profile.name if profile else update.pack.name,
237
+ version_id,
238
+ instance.path,
239
+ key=record.profile,
240
+ )
241
+ write_record(
242
+ instance.path,
243
+ replace(record, source=update.fetched.source, optional=update.chosen),
244
+ )
245
+
246
+
247
+ def update_command(
248
+ ctx: typer.Context,
249
+ name: InstanceName = None,
250
+ pack_only: Annotated[
251
+ bool, typer.Option("--pack-only", help="Update only the pack.")
252
+ ] = False,
253
+ additions_only: Annotated[
254
+ bool,
255
+ typer.Option("--additions-only", help="Update only what you added yourself."),
256
+ ] = False,
257
+ file: Annotated[
258
+ Path | None,
259
+ typer.Option("--file", help="Update the pack from this pack file."),
260
+ ] = None,
261
+ optional: Optional = None,
262
+ all_optional: AllOptional = False,
263
+ pre: Annotated[
264
+ bool, typer.Option("--pre", help="Allow alpha and beta releases.")
265
+ ] = False,
266
+ overwrite: Overwrite = False,
267
+ from_folders: From = None,
268
+ yes: Yes = False,
269
+ dry_run: DryRun = False,
270
+ ) -> None:
271
+ """Update an instance's pack, and the projects you added to it."""
272
+ app = get(ctx)
273
+ instance, state = resolve_instance(name, app.config, app.paths)
274
+ choices = Choices(
275
+ file, optional or [], all_optional, pre or app.config.allow_prereleases, yes
276
+ )
277
+ pack = None if additions_only else _pack_update(app, instance, state, choices)
278
+ missing = (
279
+ [entry for entry in pack.pack.unresolved if entry.wanted("client")]
280
+ if pack is not None and not from_folders
281
+ else []
282
+ )
283
+ if missing and not dry_run:
284
+ raise UnresolvedEntriesError(missing)
285
+ if missing:
286
+ report_error(
287
+ UnresolvedEntriesError(missing), app.err, prefix="Before you update: "
288
+ )
289
+ plans: list[Plan] = []
290
+ if pack is not None and pack.new_game and state.additions:
291
+ app.out.print(
292
+ Text(
293
+ "What you added is updated after the pack, the next time you run "
294
+ "modsmith update.",
295
+ style="dim",
296
+ )
297
+ )
298
+ elif pack is None or not pack.new_game:
299
+ plans = (
300
+ _addition_updates(app, instance, state, choices.prereleases)
301
+ if not pack_only and state.additions
302
+ else []
303
+ )
304
+ pending = _show_additions(app, instance.path.name, plans) if plans else False
305
+ if pack is None and not pending:
306
+ app.out.print(f"Everything in {instance.path.name} is up to date.")
307
+ return
308
+ if dry_run:
309
+ return
310
+ confirm(app, "Update them?", yes=yes)
311
+ if pack is not None:
312
+ _apply_pack(
313
+ app, instance, pack, overwrite=overwrite, from_folders=from_folders or []
314
+ )
315
+ with cancellable() as token, progress(app) as report:
316
+ for plan in plans:
317
+ instance.add(
318
+ plan,
319
+ overwrite=overwrite,
320
+ adopt_from=from_folders or (),
321
+ progress=report,
322
+ cancel=token,
323
+ )
324
+ app.out.print(f"Updated {instance.path.name}.")
@@ -0,0 +1,117 @@
1
+ """What every command needs: settings, folders, platforms and output."""
2
+
3
+ import dataclasses
4
+ import os
5
+ import sys
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from enum import StrEnum
9
+ from functools import cached_property
10
+ from typing import cast
11
+
12
+ import typer
13
+ from modsmith.launcher import is_running
14
+ from modsmith.platforms import Platform
15
+ from rich.console import Console
16
+
17
+ from .core.config import Config, load_config
18
+ from .core.paths import AppPaths
19
+ from .core.platforms import PlatformOpener, open_platform
20
+
21
+
22
+ class ColorMode(StrEnum):
23
+ """When to use colour."""
24
+
25
+ AUTO = "auto"
26
+ ALWAYS = "always"
27
+ NEVER = "never"
28
+
29
+
30
+ def _interactive() -> bool:
31
+ """Return whether a person can answer questions on this terminal."""
32
+ return sys.stdin.isatty() and sys.stdout.isatty()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Services:
37
+ """What the command line gets from the outside world.
38
+
39
+ Tests pass their own as the click context's ``obj``.
40
+
41
+ Attributes:
42
+ open_platform: Opens a platform by name.
43
+ environ: The environment variables, or None for the real ones.
44
+ interactive: Whether a person can answer questions.
45
+ launcher_running: Whether the official launcher is open.
46
+ """
47
+
48
+ open_platform: PlatformOpener = open_platform
49
+ environ: Mapping[str, str] | None = None
50
+ interactive: Callable[[], bool] = _interactive
51
+ launcher_running: Callable[[], bool] = is_running
52
+
53
+
54
+ def _console(color: ColorMode, *, stderr: bool) -> Console:
55
+ """Make a console that follows the colour choice and ``NO_COLOR``."""
56
+ if color == ColorMode.ALWAYS:
57
+ return Console(stderr=stderr, force_terminal=True)
58
+ if color == ColorMode.NEVER:
59
+ return Console(stderr=stderr, no_color=True, color_system=None)
60
+ return Console(stderr=stderr)
61
+
62
+
63
+ @dataclass
64
+ class AppContext:
65
+ """The state of one command-line run.
66
+
67
+ Attributes:
68
+ paths: The app's folders.
69
+ services: The outside world.
70
+ overrides: Settings given as command-line options.
71
+ verbose: How much detail to show, from ``-v``.
72
+ color: When to use colour.
73
+ out: Where results go.
74
+ err: Where errors, warnings and progress go.
75
+ """
76
+
77
+ paths: AppPaths
78
+ services: Services
79
+ overrides: dict[str, object] = field(default_factory=dict[str, object])
80
+ verbose: int = 0
81
+ color: ColorMode = ColorMode.AUTO
82
+ out: Console = field(init=False)
83
+ err: Console = field(init=False)
84
+
85
+ def __post_init__(self) -> None:
86
+ self.out = _console(self.color, stderr=False)
87
+ self.err = _console(self.color, stderr=True)
88
+
89
+ @property
90
+ def environ(self) -> Mapping[str, str]:
91
+ """The environment variables."""
92
+ return (
93
+ self.services.environ if self.services.environ is not None else os.environ
94
+ )
95
+
96
+ @cached_property
97
+ def config(self) -> Config:
98
+ """The settings in effect. Read on first use, so ``config`` can fix bad ones.
99
+
100
+ Raises:
101
+ AppError: A setting is invalid.
102
+ """
103
+ config = load_config(self.paths, self.environ)
104
+ return dataclasses.replace(config, **self.overrides)
105
+
106
+ def platform(self, name: str) -> Platform:
107
+ """Open a platform by name.
108
+
109
+ Raises:
110
+ AppError: The platform needs a key that is not set.
111
+ """
112
+ return self.services.open_platform(name, self.config)
113
+
114
+
115
+ def get(ctx: typer.Context) -> AppContext:
116
+ """Return the run's state from any command's context."""
117
+ return cast(AppContext, ctx.find_root().obj)
@@ -0,0 +1,5 @@
1
+ """The command line's work, kept apart from how it is shown.
2
+
3
+ Nothing here prints or asks questions. Functions take what they need as
4
+ arguments and return results or raise `AppError`, so any front end can use them.
5
+ """
@@ -0,0 +1,243 @@
1
+ """The app's settings: defaults, then ``config.toml``, then environment variables.
2
+
3
+ Command-line options override all three, in the front end.
4
+ """
5
+
6
+ import os
7
+ import tomllib
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass, fields
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import tomli_w
14
+ from modsmith.minecraft import GameDir
15
+
16
+ from .errors import AppError
17
+ from .paths import AppPaths
18
+
19
+ PLATFORMS = ("modrinth", "curseforge")
20
+ TRUE = {"true", "yes", "on", "1"}
21
+ FALSE = {"false", "no", "off", "0"}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Config:
26
+ """The settings in effect.
27
+
28
+ Attributes:
29
+ instances_dir: Where new instances are created.
30
+ game_dir: The official launcher's game directory.
31
+ curseforge_api_key: The key for the CurseForge API, or None.
32
+ default_platform: Where to look when a project names no platform.
33
+ allow_prereleases: Whether alpha and beta releases may be chosen.
34
+ """
35
+
36
+ instances_dir: Path
37
+ game_dir: Path
38
+ curseforge_api_key: str | None = None
39
+ default_platform: str = "modrinth"
40
+ allow_prereleases: bool = False
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Setting:
45
+ """How one setting is described, read and checked.
46
+
47
+ Attributes:
48
+ key: Its name in ``config.toml``.
49
+ description: What it does.
50
+ parse: Turns text into the setting's value, or raises `AppError`.
51
+ variable: The environment variable that overrides it, or None.
52
+ secret: Whether to hide its value when it is shown.
53
+ """
54
+
55
+ key: str
56
+ description: str
57
+ parse: Callable[[str], Any]
58
+ variable: str | None = None
59
+ secret: bool = False
60
+
61
+
62
+ def _path(text: str) -> Path:
63
+ if not text.strip():
64
+ raise AppError("A path cannot be empty.")
65
+ return Path(text).expanduser()
66
+
67
+
68
+ def _boolean(text: str) -> bool:
69
+ value = text.strip().lower()
70
+ if value in TRUE:
71
+ return True
72
+ if value in FALSE:
73
+ return False
74
+ raise AppError(f"{text!r} is not true or false.")
75
+
76
+
77
+ def _platform(text: str) -> str:
78
+ value = text.strip().lower()
79
+ if value not in PLATFORMS:
80
+ raise AppError(
81
+ f"{text!r} is not a platform.", hint="Use modrinth or curseforge."
82
+ )
83
+ return value
84
+
85
+
86
+ def _secret(text: str) -> str:
87
+ if not text.strip():
88
+ raise AppError("The key cannot be empty.")
89
+ return text.strip()
90
+
91
+
92
+ SETTINGS = {
93
+ item.key: item
94
+ for item in [
95
+ Setting(
96
+ "instances_dir",
97
+ "Where new instances are created",
98
+ _path,
99
+ "MODSMITH_INSTANCES_DIR",
100
+ ),
101
+ Setting(
102
+ "game_dir",
103
+ "The official launcher's game directory",
104
+ _path,
105
+ "MODSMITH_GAME_DIR",
106
+ ),
107
+ Setting(
108
+ "curseforge_api_key",
109
+ "Your CurseForge API key",
110
+ _secret,
111
+ "CURSEFORGE_API_KEY",
112
+ secret=True,
113
+ ),
114
+ Setting(
115
+ "default_platform",
116
+ "Where to look when a project names no platform",
117
+ _platform,
118
+ ),
119
+ Setting(
120
+ "allow_prereleases",
121
+ "Whether alpha and beta releases may be chosen",
122
+ _boolean,
123
+ ),
124
+ ]
125
+ }
126
+ assert set(SETTINGS) == {item.name for item in fields(Config)}
127
+
128
+
129
+ def setting(key: str) -> Setting:
130
+ """Return a setting by name.
131
+
132
+ Raises:
133
+ AppError: There is no such setting.
134
+ """
135
+ try:
136
+ return SETTINGS[key]
137
+ except KeyError:
138
+ known = ", ".join(SETTINGS)
139
+ raise AppError(
140
+ f"There is no setting called {key!r}.", hint=f"Settings: {known}."
141
+ ) from None
142
+
143
+
144
+ def read_file(paths: AppPaths) -> dict[str, Any]:
145
+ """Return the settings saved in ``config.toml``, as written.
146
+
147
+ Raises:
148
+ AppError: The file is not valid TOML.
149
+ """
150
+ try:
151
+ with paths.config_file.open("rb") as file:
152
+ return tomllib.load(file)
153
+ except FileNotFoundError:
154
+ return {}
155
+ except tomllib.TOMLDecodeError as e:
156
+ raise AppError(
157
+ f"{paths.config_file} is not valid TOML: {e}",
158
+ hint="Fix or delete the file.",
159
+ ) from e
160
+
161
+
162
+ def load_config(paths: AppPaths, environ: Mapping[str, str] = os.environ) -> Config:
163
+ """Work out the settings in effect.
164
+
165
+ Args:
166
+ paths: The app's folders.
167
+ environ: The environment variables.
168
+
169
+ Returns:
170
+ The settings.
171
+
172
+ Raises:
173
+ AppError: A saved or environment value is invalid.
174
+ """
175
+ values: dict[str, Any] = {
176
+ "instances_dir": paths.default_instances_dir,
177
+ "game_dir": GameDir.default().path,
178
+ }
179
+ for key, raw in read_file(paths).items():
180
+ if key in SETTINGS:
181
+ values[key] = _checked(
182
+ key,
183
+ str(raw).lower() if isinstance(raw, bool) else str(raw),
184
+ f"{paths.config_file}",
185
+ )
186
+ for key, item in SETTINGS.items():
187
+ raw = environ.get(item.variable) if item.variable else None
188
+ if raw:
189
+ values[key] = _checked(key, raw, f"${item.variable}")
190
+ return Config(**values)
191
+
192
+
193
+ def _checked(key: str, raw: str, source: str) -> Any:
194
+ """Parse a value, saying where a bad one came from."""
195
+ try:
196
+ return SETTINGS[key].parse(raw)
197
+ except AppError as e:
198
+ raise AppError(f"{key} in {source}: {e.message}", hint=e.hint) from e
199
+
200
+
201
+ def sources(paths: AppPaths, environ: Mapping[str, str] = os.environ) -> dict[str, str]:
202
+ """Return where each setting's value comes from.
203
+
204
+ Each is ``default``, ``config file`` or ``$VARIABLE``.
205
+ """
206
+ saved = read_file(paths)
207
+ found: dict[str, str] = {}
208
+ for key, item in SETTINGS.items():
209
+ if item.variable and environ.get(item.variable):
210
+ found[key] = f"${item.variable}"
211
+ elif key in saved:
212
+ found[key] = "config file"
213
+ else:
214
+ found[key] = "default"
215
+ return found
216
+
217
+
218
+ def save_setting(paths: AppPaths, key: str, raw: str | None) -> None:
219
+ """Save a setting to ``config.toml``, or remove it when ``raw`` is None.
220
+
221
+ Raises:
222
+ AppError: The setting does not exist or the value is invalid.
223
+ """
224
+ item = setting(key)
225
+ saved = read_file(paths)
226
+ if raw is None:
227
+ saved.pop(key, None)
228
+ else:
229
+ value = item.parse(raw)
230
+ saved[key] = str(value) if isinstance(value, Path) else value
231
+ paths.config_file.parent.mkdir(parents=True, exist_ok=True)
232
+ paths.config_file.write_text(tomli_w.dumps(saved), encoding="utf-8")
233
+
234
+
235
+ def shown(key: str, value: Any) -> str:
236
+ """Format a setting's value for display, hiding secrets."""
237
+ if value is None:
238
+ return "not set"
239
+ if SETTINGS[key].secret:
240
+ return "set (hidden)"
241
+ if isinstance(value, bool):
242
+ return "true" if value else "false"
243
+ return str(value)