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.
- modsmith_cli/__init__.py +8 -0
- modsmith_cli/__main__.py +6 -0
- modsmith_cli/app.py +224 -0
- modsmith_cli/commands/__init__.py +1 -0
- modsmith_cli/commands/config.py +81 -0
- modsmith_cli/commands/contents.py +332 -0
- modsmith_cli/commands/discovery.py +152 -0
- modsmith_cli/commands/install.py +351 -0
- modsmith_cli/commands/instance.py +234 -0
- modsmith_cli/commands/list.py +48 -0
- modsmith_cli/commands/search.py +205 -0
- modsmith_cli/commands/update.py +324 -0
- modsmith_cli/context.py +117 -0
- modsmith_cli/core/__init__.py +5 -0
- modsmith_cli/core/config.py +243 -0
- modsmith_cli/core/contents.py +68 -0
- modsmith_cli/core/errors.py +19 -0
- modsmith_cli/core/install.py +244 -0
- modsmith_cli/core/names.py +30 -0
- modsmith_cli/core/paths.py +63 -0
- modsmith_cli/core/platforms.py +98 -0
- modsmith_cli/core/records.py +73 -0
- modsmith_cli/core/refs.py +96 -0
- modsmith_cli/core/registry.py +183 -0
- modsmith_cli/errors.py +180 -0
- modsmith_cli/target.py +43 -0
- modsmith_cli/ui.py +219 -0
- modsmith_cli-0.1.1.dist-info/METADATA +255 -0
- modsmith_cli-0.1.1.dist-info/RECORD +32 -0
- modsmith_cli-0.1.1.dist-info/WHEEL +4 -0
- modsmith_cli-0.1.1.dist-info/entry_points.txt +2 -0
- modsmith_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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}.")
|
modsmith_cli/context.py
ADDED
|
@@ -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,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)
|