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,152 @@
1
+ """Commands that look a project or a loader up, without changing anything."""
2
+
3
+ from itertools import islice
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from modsmith import loaders
8
+ from modsmith.errors import UnsupportedError
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from ..context import get
13
+ from ..core.errors import AppError
14
+ from ..core.platforms import explained
15
+ from ..core.refs import parse_project
16
+ from ..ui import show_changelog
17
+ from .install import LOADER_HINT, LOADER_NAMES
18
+ from .search import KINDS, Kind
19
+
20
+ RELEASES_SHOWN = 5
21
+ LOADERS_SHOWN = 15
22
+ CHANGELOG_LINES = 12
23
+
24
+
25
+ def _count(number: int) -> str:
26
+ return f"{number:,}"
27
+
28
+
29
+ def info_command(
30
+ ctx: typer.Context,
31
+ project: Annotated[
32
+ str,
33
+ typer.Argument(
34
+ help="A name, platform:name, or the project's address.",
35
+ show_default=False,
36
+ ),
37
+ ],
38
+ kind: Annotated[
39
+ Kind, typer.Option("--type", "-t", help="The kind of project.")
40
+ ] = Kind.MOD,
41
+ minecraft: Annotated[
42
+ str | None,
43
+ typer.Option("--minecraft", "-m", help="Only releases for this version."),
44
+ ] = None,
45
+ loader: Annotated[
46
+ str | None,
47
+ typer.Option("--loader", "-l", help="Only releases for this loader."),
48
+ ] = None,
49
+ ) -> None:
50
+ """Show a project's details, its newest releases and what changed last."""
51
+ app = get(ctx)
52
+ ref = parse_project(project, app.config.default_platform)
53
+ with explained(ref.platform, [ref.name]), app.platform(ref.platform) as platform:
54
+ found = platform.get_project(ref.name, project_type=KINDS[kind])
55
+ authors = platform.authors(found)
56
+ releases = list(
57
+ islice(
58
+ platform.iter_releases(found, game_version=minecraft, loader=loader),
59
+ RELEASES_SHOWN,
60
+ )
61
+ )
62
+ try:
63
+ changelog = platform.changelog(releases[0]) if releases else ""
64
+ except UnsupportedError:
65
+ changelog = ""
66
+
67
+ app.out.print(
68
+ Text(found.name, style="bold"), Text(f"({ref.platform}:{found.slug})")
69
+ )
70
+ if found.summary:
71
+ app.out.print(found.summary)
72
+ facts = [
73
+ ("By", ", ".join(authors)),
74
+ ("Downloads", _count(found.downloads)),
75
+ ("Categories", ", ".join(found.categories)),
76
+ ("Minecraft", ", ".join(found.game_versions[:8])),
77
+ ("Updated", found.updated[:10]),
78
+ ("Page", found.url),
79
+ ]
80
+ table = Table.grid(padding=(0, 2))
81
+ for label, value in facts:
82
+ if value:
83
+ table.add_row(Text(label, style="dim"), value)
84
+ app.out.print(table)
85
+
86
+ app.out.print()
87
+ if not releases:
88
+ app.out.print("No releases match.")
89
+ return
90
+ listed = Table(box=None, pad_edge=False, title_justify="left")
91
+ for column in ("Release", "Type", "Minecraft", "Loaders", "Published"):
92
+ listed.add_column(column, no_wrap=column != "Minecraft")
93
+ for release in releases:
94
+ listed.add_row(
95
+ release.version or release.name,
96
+ release.release_type,
97
+ ", ".join(release.game_versions[:4]),
98
+ ", ".join(release.loaders),
99
+ release.published[:10],
100
+ )
101
+ app.out.print(listed)
102
+ if changelog:
103
+ app.out.print()
104
+ show_changelog(
105
+ app,
106
+ f"What changed in {releases[0].name}",
107
+ changelog,
108
+ lines=CHANGELOG_LINES,
109
+ )
110
+
111
+
112
+ def loaders_command(
113
+ ctx: typer.Context,
114
+ kind: Annotated[
115
+ str,
116
+ typer.Argument(help="fabric, quilt, forge or neoforge.", show_default=False),
117
+ ],
118
+ minecraft: Annotated[
119
+ str, typer.Argument(help="The Minecraft version.", show_default=False)
120
+ ],
121
+ everything: Annotated[
122
+ bool, typer.Option("--all", help="List every version, not only the newest.")
123
+ ] = False,
124
+ ) -> None:
125
+ """List a mod loader's versions for a Minecraft version."""
126
+ app = get(ctx)
127
+ try:
128
+ found = loaders.versions(kind.lower(), minecraft)
129
+ except UnsupportedError as e:
130
+ raise AppError(f"Modsmith cannot install {kind!r}.", hint=LOADER_HINT) from e
131
+ name = LOADER_NAMES.get(kind.lower(), kind)
132
+ if not found:
133
+ app.out.print(f"{name} has no versions for Minecraft {minecraft}.")
134
+ return
135
+ shown = found if everything else found[:LOADERS_SHOWN]
136
+ table = Table(box=None, pad_edge=False)
137
+ table.add_column("Version", no_wrap=True)
138
+ table.add_column("")
139
+ for item in shown:
140
+ notes = ["recommended"] if item.recommended else []
141
+ if not item.stable:
142
+ notes.append("beta")
143
+ table.add_row(item.version, ", ".join(notes))
144
+ app.out.print(Text(f"{name} for Minecraft {minecraft}", style="bold"))
145
+ app.out.print(table)
146
+ if len(shown) < len(found):
147
+ app.out.print(
148
+ Text(
149
+ f"{len(found) - len(shown)} older versions; see them with --all.",
150
+ style="dim",
151
+ )
152
+ )
@@ -0,0 +1,351 @@
1
+ """``modsmith install`` and ``modsmith create``: new instances, ready to play."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from modsmith.errors import InstanceError, UnresolvedEntriesError, UnsupportedError
8
+ from modsmith.instances import Instance
9
+ from modsmith.loaders import Loader, create, recommended
10
+ from modsmith.minecraft import GameDir, install_version, list_versions
11
+ from modsmith.packs import Modpack
12
+
13
+ from ..context import AppContext, get
14
+ from ..core.config import Config
15
+ from ..core.errors import AppError
16
+ from ..core.install import (
17
+ check_launcher,
18
+ download_sizes,
19
+ fetch_pack,
20
+ match_optional,
21
+ optional_entries,
22
+ profile_key,
23
+ profile_name,
24
+ project_icon,
25
+ with_optional,
26
+ )
27
+ from ..core.names import check_name, name_from
28
+ from ..core.platforms import explained
29
+ from ..core.records import Record, write_record
30
+ from ..core.refs import ProjectRef, parse_pack
31
+ from ..errors import report as report_error
32
+ from ..target import DryRun, Yes
33
+ from ..ui import cancellable, confirm, progress, show_plan
34
+
35
+ LOADER_NAMES = {
36
+ "forge": "Forge",
37
+ "neoforge": "NeoForge",
38
+ "fabric": "Fabric",
39
+ "quilt": "Quilt",
40
+ }
41
+
42
+
43
+ def loader_label(kind: str | None, version: str | None) -> str:
44
+ """Name a loader for people, such as ``Fabric 0.16.10``."""
45
+ if not kind:
46
+ return "no mod loader"
47
+ name = LOADER_NAMES.get(kind.lower(), kind)
48
+ return f"{name} {version}" if version else name
49
+
50
+
51
+ def free_folder(name: str, config: Config) -> Path:
52
+ """Return the folder for a new instance.
53
+
54
+ Raises:
55
+ AppError: An instance or other files are already there.
56
+ """
57
+ path = config.instances_dir / check_name(name)
58
+ try:
59
+ taken = Instance(path).state is not None
60
+ except InstanceError:
61
+ taken = True
62
+ if taken:
63
+ raise AppError(
64
+ f"There is already an instance called {name!r}.",
65
+ hint=f"Update it with: modsmith update {name}. Or choose another "
66
+ "name with --name.",
67
+ )
68
+ if path.exists() and any(path.iterdir()):
69
+ raise AppError(
70
+ f"{path} already holds other files.",
71
+ hint="Choose another name with --name.",
72
+ )
73
+ return path
74
+
75
+
76
+ def _ask_optional(app: AppContext, pack: Modpack) -> list[str]:
77
+ """Let the player pick optional files by number."""
78
+ options = [entry.path for entry in optional_entries(pack) if entry.path]
79
+ app.out.print("This pack has optional files:")
80
+ for index, path in enumerate(options, 1):
81
+ app.out.print(f" {index}. {path}")
82
+ answer = typer.prompt(
83
+ "Which do you want? Numbers separated by spaces, all, or Enter for none",
84
+ default="",
85
+ show_default=False,
86
+ ).strip()
87
+ if answer.lower() == "all":
88
+ return options
89
+ try:
90
+ picked = [int(word) for word in answer.replace(",", " ").split()]
91
+ except ValueError:
92
+ raise AppError(f"{answer!r} is not a list of numbers.") from None
93
+ if any(number < 1 or number > len(options) for number in picked):
94
+ raise AppError(f"Choose numbers from 1 to {len(options)}.")
95
+ return [options[number - 1] for number in dict.fromkeys(picked)]
96
+
97
+
98
+ def choose_optional(
99
+ app: AppContext, pack: Modpack, patterns: list[str], everything: bool, yes: bool
100
+ ) -> list[str]:
101
+ """Decide which optional files to install, asking if nobody said."""
102
+ options = optional_entries(pack)
103
+ if everything:
104
+ return [entry.path for entry in options if entry.path]
105
+ if patterns:
106
+ return match_optional(pack, patterns)
107
+ if not options:
108
+ return []
109
+ if yes or not app.services.interactive():
110
+ app.err.print(
111
+ f"Left out {len(options)} optional files. "
112
+ "Choose them with --optional or --all-optional.",
113
+ style="dim",
114
+ )
115
+ return []
116
+ return _ask_optional(app, pack)
117
+
118
+
119
+ Optional = Annotated[
120
+ list[str] | None,
121
+ typer.Option(
122
+ "--optional",
123
+ help="Install the optional files whose names contain this. Repeat for more.",
124
+ show_default=False,
125
+ ),
126
+ ]
127
+ AllOptional = Annotated[
128
+ bool, typer.Option("--all-optional", help="Install every optional file.")
129
+ ]
130
+ From = Annotated[
131
+ list[Path] | None,
132
+ typer.Option(
133
+ "--from",
134
+ help="A folder with files you downloaded by hand. Repeat for more.",
135
+ show_default=False,
136
+ ),
137
+ ]
138
+ Overwrite = Annotated[
139
+ bool,
140
+ typer.Option(
141
+ "--overwrite", help="Replace files you changed, instead of keeping them."
142
+ ),
143
+ ]
144
+
145
+
146
+ def install_command(
147
+ ctx: typer.Context,
148
+ source: Annotated[
149
+ str,
150
+ typer.Argument(
151
+ help="A pack file, a project such as fabulously-optimized or "
152
+ "curseforge:<id>, or its web address.",
153
+ show_default=False,
154
+ ),
155
+ ],
156
+ name: Annotated[
157
+ str | None,
158
+ typer.Option("--name", help="The instance's name. Defaults to the pack's."),
159
+ ] = None,
160
+ optional: Optional = None,
161
+ all_optional: AllOptional = False,
162
+ from_folders: From = None,
163
+ yes: Yes = False,
164
+ dry_run: DryRun = False,
165
+ ) -> None:
166
+ """Install a modpack as a new instance in the official launcher."""
167
+ app = get(ctx)
168
+ config = app.config
169
+ ref = parse_pack(source, config.default_platform)
170
+ game = GameDir(config.game_dir)
171
+ launcher = (
172
+ None if dry_run else check_launcher(game, app.services.launcher_running())
173
+ )
174
+ platform, wanted = (
175
+ (ref.platform, [ref.name]) if isinstance(ref, ProjectRef) else ("", [])
176
+ )
177
+ with cancellable() as token, progress(app) as report, explained(platform, wanted):
178
+ fetched = fetch_pack(
179
+ ref,
180
+ app.platform,
181
+ cache_dir=app.paths.cache_dir,
182
+ allow_prereleases=config.allow_prereleases,
183
+ progress=report,
184
+ cancel=token,
185
+ )
186
+ path = free_folder(name or name_from(fetched.pack.name), config)
187
+ chosen = choose_optional(app, fetched.pack, optional or [], all_optional, yes)
188
+ pack = with_optional(fetched.pack, chosen)
189
+ missing = [entry for entry in pack.unresolved if entry.wanted("client")]
190
+ if missing and not from_folders and not dry_run:
191
+ raise UnresolvedEntriesError(missing)
192
+
193
+ instance = Instance(path)
194
+ title = f"Install {pack.name} {pack.version} as {path.name}"
195
+ show_plan(app, instance.diff(pack), download_sizes(pack), title=title)
196
+ app.out.print(
197
+ f"Minecraft {pack.minecraft} with "
198
+ f"{loader_label(pack.loader_kind, pack.loader_version)}, "
199
+ "and an installation in the official launcher."
200
+ )
201
+ if missing and not from_folders:
202
+ report_error(
203
+ UnresolvedEntriesError(missing), app.err, prefix="Before you install: "
204
+ )
205
+ if dry_run or launcher is None:
206
+ return
207
+ confirm(app, "Install it?", yes=yes)
208
+ with cancellable() as token, progress(app) as report:
209
+ version_id = pack.install_game(
210
+ game, cache_dir=app.paths.cache_dir, progress=report, cancel=token
211
+ )
212
+ instance.sync(
213
+ pack, adopt_from=from_folders or (), progress=report, cancel=token
214
+ )
215
+ key = profile_key(path.name)
216
+ shown = profile_name(launcher, pack.name, key, path.name)
217
+ key = launcher.add_profile(
218
+ shown,
219
+ version_id,
220
+ instance.path,
221
+ key=key,
222
+ memory_mb=pack.memory_mb,
223
+ icon=project_icon(fetched.project),
224
+ )
225
+ write_record(instance.path, Record(fetched.source, key, tuple(chosen)))
226
+ app.out.print(
227
+ f"Installed {pack.name} as {path.name}. In the official launcher, "
228
+ f"choose the {shown} installation and play."
229
+ )
230
+
231
+
232
+ LOADER_HINT = "Use fabric, quilt, forge or neoforge."
233
+
234
+
235
+ def _registered(kind: str, minecraft: str) -> bool:
236
+ """Return whether the library knows a loader by this name."""
237
+ try:
238
+ create(kind, "0", minecraft)
239
+ except UnsupportedError:
240
+ return False
241
+ return True
242
+
243
+
244
+ def _pick_loader(
245
+ kind: str | None, version: str | None, minecraft: str
246
+ ) -> Loader | None:
247
+ """Return the loader to install, choosing a version if none was given.
248
+
249
+ Raises:
250
+ AppError: The loader is unknown, or has no version for this Minecraft.
251
+ """
252
+ if version and not kind:
253
+ raise AppError("--loader-version needs --loader too.")
254
+ if not kind:
255
+ return None
256
+ try:
257
+ if version:
258
+ return create(kind, version, minecraft)
259
+ return recommended(kind, minecraft)
260
+ except UnsupportedError as e:
261
+ if not _registered(kind, minecraft):
262
+ raise AppError(
263
+ f"Modsmith cannot install {kind!r}.", hint=LOADER_HINT
264
+ ) from e
265
+ raise AppError(
266
+ f"{loader_label(kind, None)} has no version for Minecraft {minecraft}.",
267
+ hint="Check the Minecraft version, or choose another loader.",
268
+ ) from e
269
+
270
+
271
+ def _check_minecraft(game: GameDir, minecraft: str) -> None:
272
+ """Make sure a Minecraft version exists before planning around it.
273
+
274
+ Raises:
275
+ AppError: Mojang has no such version.
276
+ """
277
+ if game.has_version(minecraft):
278
+ return
279
+ if not any(item.id == minecraft for item in list_versions()):
280
+ raise AppError(
281
+ f"There is no Minecraft {minecraft}.",
282
+ hint="Use a version such as 1.21.1. Snapshots work too.",
283
+ )
284
+
285
+
286
+ def create_command(
287
+ ctx: typer.Context,
288
+ name: Annotated[
289
+ str, typer.Argument(help="The new instance's name.", show_default=False)
290
+ ],
291
+ minecraft: Annotated[
292
+ str,
293
+ typer.Option(
294
+ "--minecraft", "-m", help="The Minecraft version.", show_default=False
295
+ ),
296
+ ],
297
+ loader: Annotated[
298
+ str | None,
299
+ typer.Option("--loader", "-l", help="fabric, quilt, forge or neoforge."),
300
+ ] = None,
301
+ loader_version: Annotated[
302
+ str | None,
303
+ typer.Option("--loader-version", help="Defaults to the recommended version."),
304
+ ] = None,
305
+ yes: Yes = False,
306
+ dry_run: DryRun = False,
307
+ ) -> None:
308
+ """Create an empty instance, with or without a mod loader."""
309
+ app = get(ctx)
310
+ config = app.config
311
+ path = free_folder(name, config)
312
+ game = GameDir(config.game_dir)
313
+ launcher = (
314
+ None if dry_run else check_launcher(game, app.services.launcher_running())
315
+ )
316
+ chosen = _pick_loader(loader, loader_version, minecraft)
317
+ if chosen is None:
318
+ _check_minecraft(game, minecraft)
319
+ label = loader_label(
320
+ chosen.kind if chosen else None, chosen.version if chosen else None
321
+ )
322
+ app.out.print(
323
+ f"Create {name}: Minecraft {minecraft} with {label}, "
324
+ "and an installation in the official launcher."
325
+ )
326
+ if dry_run or launcher is None:
327
+ return
328
+ confirm(app, "Create it?", yes=yes)
329
+ with cancellable() as token, progress(app) as report:
330
+ if chosen is None:
331
+ version_id = install_version(
332
+ game, minecraft, progress=report, cancel=token
333
+ ).id
334
+ else:
335
+ version_id = chosen.install(
336
+ game, cache_dir=app.paths.cache_dir, progress=report, cancel=token
337
+ )
338
+ Instance.create(
339
+ path,
340
+ minecraft=minecraft,
341
+ loader=chosen.kind if chosen else None,
342
+ loader_version=chosen.version if chosen else None,
343
+ )
344
+ key = profile_key(name)
345
+ key = launcher.add_profile(
346
+ profile_name(launcher, name, key, name), version_id, path, key=key
347
+ )
348
+ write_record(path, Record(profile=key))
349
+ app.out.print(
350
+ f"Created {name}. In the official launcher, choose the {name} installation."
351
+ )