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,234 @@
1
+ """Commands that bring in, move and delete whole instances."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from modsmith import loaders
9
+ from modsmith.errors import UnsupportedError
10
+ from modsmith.instances import Instance
11
+ from modsmith.launcher import Launcher, Profile
12
+ from modsmith.loaders import Loader
13
+ from modsmith.minecraft import GameDir
14
+
15
+ from ..context import AppContext, get
16
+ from ..core.config import Config
17
+ from ..core.errors import AppError
18
+ from ..core.install import check_launcher
19
+ from ..core.names import check_name
20
+ from ..core.records import Record, read_record, write_record
21
+ from ..core.registry import (
22
+ list_instances,
23
+ locate,
24
+ remember_external,
25
+ resolve_instance,
26
+ )
27
+ from ..target import DryRun, InstanceName, Yes
28
+ from ..ui import confirm, confirm_name
29
+ from .contents import adopt_files
30
+ from .install import loader_label
31
+
32
+
33
+ def _profile(launcher: Launcher, text: str) -> Profile:
34
+ """Find a launcher installation by its key or, ignoring case, its name."""
35
+ profiles = launcher.profiles()
36
+ if text in profiles:
37
+ return profiles[text]
38
+ named = [item for item in profiles.values() if item.name.lower() == text.lower()]
39
+ if len(named) == 1:
40
+ return named[0]
41
+ listed = ", ".join(sorted(item.name or item.key for item in profiles.values()))
42
+ if named:
43
+ raise AppError(
44
+ f"Several installations are called {text!r}.",
45
+ hint="Use the key of one instead: " + ", ".join(item.key for item in named),
46
+ )
47
+ raise AppError(
48
+ f"The official launcher has no installation called {text!r}.",
49
+ hint=f"Its installations: {listed}." if listed else None,
50
+ )
51
+
52
+
53
+ def _versions(game: GameDir, version_id: str) -> tuple[str, Loader | None]:
54
+ """Return the Minecraft version and the loader an installed version starts.
55
+
56
+ Raises:
57
+ AppError: The version, or one it inherits from, is not installed.
58
+ """
59
+ try:
60
+ loader = loaders.detect(game, version_id)
61
+ version = game.version(version_id)
62
+ except UnsupportedError as e:
63
+ raise AppError(
64
+ f"{e}.", hint="Start it once in the official launcher, then try again."
65
+ ) from e
66
+ if loader is not None:
67
+ return loader.minecraft, loader
68
+ return version.inherits_from or version.id, None
69
+
70
+
71
+ def _inside(path: Path, folder: Path) -> bool:
72
+ return path.resolve().is_relative_to(folder.resolve())
73
+
74
+
75
+ def _check_free_name(app: AppContext, path: Path, moving: Path | None = None) -> None:
76
+ """Refuse a folder name that another instance already has."""
77
+ others = {path.resolve(), moving.resolve() if moving else path.resolve()}
78
+ taken = [
79
+ entry
80
+ for entry in list_instances(app.config, app.paths)
81
+ if entry.name == path.name and entry.path.resolve() not in others
82
+ ]
83
+ if taken:
84
+ raise AppError(
85
+ f"An instance called {path.name} already exists.",
86
+ hint="Rename one of the two folders first.",
87
+ )
88
+
89
+
90
+ def import_command(
91
+ ctx: typer.Context,
92
+ installation: Annotated[
93
+ str,
94
+ typer.Argument(
95
+ help="The installation's name in the official launcher.",
96
+ show_default=False,
97
+ ),
98
+ ],
99
+ yes: Yes = False,
100
+ dry_run: DryRun = False,
101
+ ) -> None:
102
+ """Manage an installation you made in the official launcher as an instance."""
103
+ app = get(ctx)
104
+ game = GameDir(app.config.game_dir)
105
+ profile = _profile(check_launcher(game, running=False), installation)
106
+ if not profile.game_dir:
107
+ raise AppError(
108
+ f"{profile.name} uses the game directory itself.",
109
+ hint="Give it its own game directory in the official launcher first.",
110
+ )
111
+ if not profile.version_id or profile.version_id.startswith("latest-"):
112
+ raise AppError(
113
+ f"{profile.name} starts the newest Minecraft, not one version.",
114
+ hint="Choose a version for it in the official launcher first.",
115
+ )
116
+ path = Path(profile.game_dir).resolve()
117
+ instance = Instance(path)
118
+ if instance.state is not None:
119
+ raise AppError(f"{path} is already an instance.")
120
+ _check_free_name(app, path)
121
+ minecraft, loader = _versions(game, profile.version_id)
122
+ kind, version = (loader.kind, loader.version) if loader else (None, None)
123
+ app.out.print(
124
+ f"Import {profile.name} as {path.name}: Minecraft {minecraft} "
125
+ f"with {loader_label(kind, version)}, in {path}."
126
+ )
127
+ if dry_run:
128
+ return
129
+ confirm(app, "Import it?", yes=yes)
130
+ path.mkdir(parents=True, exist_ok=True)
131
+ Instance.create(
132
+ path,
133
+ minecraft=minecraft,
134
+ loader=kind,
135
+ loader_version=version,
136
+ )
137
+ write_record(path, Record(profile=profile.key))
138
+ if not _inside(path, app.config.instances_dir):
139
+ remember_external(app.paths, path)
140
+ platforms = ["modrinth"] + (["curseforge"] if app.config.curseforge_api_key else [])
141
+ adopt_files(app, instance, platforms)
142
+ app.out.print(f"{path.name} is now an instance.")
143
+
144
+
145
+ def _destination(text: str, config: Config, current: Path) -> Path:
146
+ """Return where an instance moves to: a new name, or a folder path.
147
+
148
+ A new name keeps the instance in the folder it is in now, so an imported
149
+ instance is renamed in place rather than moved into the instances folder.
150
+ """
151
+ path = locate(text, config)
152
+ if path.parent == config.instances_dir:
153
+ path = current.parent / check_name(path.name)
154
+ if path.exists():
155
+ raise AppError(
156
+ f"{path} already exists.",
157
+ hint="Name a folder that does not exist yet.",
158
+ )
159
+ return path
160
+
161
+
162
+ def move_command(
163
+ ctx: typer.Context,
164
+ name: Annotated[
165
+ str, typer.Argument(help="The instance to move.", show_default=False)
166
+ ],
167
+ destination: Annotated[
168
+ str,
169
+ typer.Argument(help="A new name, or a folder path.", show_default=False),
170
+ ],
171
+ ) -> None:
172
+ """Rename an instance or move its folder, and update its launcher installation."""
173
+ app = get(ctx)
174
+ instance, _ = resolve_instance(name, app.config, app.paths)
175
+ old = instance.path.resolve()
176
+ new = _destination(destination, app.config, old)
177
+ _check_free_name(app, new, moving=old)
178
+ record = read_record(old)
179
+ game = GameDir(app.config.game_dir)
180
+ launcher = (
181
+ check_launcher(game, app.services.launcher_running())
182
+ if record.profile
183
+ else None
184
+ )
185
+ new.parent.mkdir(parents=True, exist_ok=True)
186
+ shutil.move(old, new)
187
+ remember_external(app.paths, old, keep=False)
188
+ if not _inside(new, app.config.instances_dir):
189
+ remember_external(app.paths, new)
190
+ if launcher is not None and record.profile:
191
+ profile = launcher.profiles().get(record.profile)
192
+ if profile and profile.version_id:
193
+ launcher.add_profile(
194
+ profile.name, profile.version_id, new, key=record.profile
195
+ )
196
+ where = new.name if new.parent == old.parent else str(new)
197
+ app.out.print(f"Moved {old.name} to {where}.")
198
+
199
+
200
+ def _worlds(path: Path) -> int:
201
+ saves = path / "saves"
202
+ if not saves.is_dir():
203
+ return 0
204
+ return sum(1 for item in saves.iterdir() if (item / "level.dat").is_file())
205
+
206
+
207
+ def delete_command(
208
+ ctx: typer.Context,
209
+ name: InstanceName = None,
210
+ yes: Yes = False,
211
+ force: Annotated[
212
+ bool, typer.Option("--force", help="Allow --yes for this command.")
213
+ ] = False,
214
+ ) -> None:
215
+ """Delete an instance's folder, worlds included, and its launcher installation."""
216
+ app = get(ctx)
217
+ instance, _ = resolve_instance(name, app.config, app.paths)
218
+ path = instance.path.resolve()
219
+ record = read_record(path)
220
+ game = GameDir(app.config.game_dir)
221
+ launcher = (
222
+ check_launcher(game, app.services.launcher_running())
223
+ if record.profile
224
+ else None
225
+ )
226
+ worlds = _worlds(path)
227
+ held = {0: "", 1: ", with its 1 world"}.get(worlds, f", with its {worlds} worlds")
228
+ app.out.print(f"Delete {path} and everything in it{held}. This cannot be undone.")
229
+ confirm_name(app, path.name, yes=yes, force=force)
230
+ instance.delete()
231
+ if launcher is not None and record.profile:
232
+ launcher.remove_profile(record.profile)
233
+ remember_external(app.paths, path, keep=False)
234
+ app.out.print(f"Deleted {path.name}.")
@@ -0,0 +1,48 @@
1
+ """``modsmith list``: list the player's instances."""
2
+
3
+ import typer
4
+ from modsmith.instances import InstanceState
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+
8
+ from ..context import get
9
+ from ..core.registry import list_instances
10
+ from .install import loader_label
11
+
12
+
13
+ def _loader(state: InstanceState) -> str:
14
+ if not state.loader_kind:
15
+ return "vanilla"
16
+ return loader_label(state.loader_kind, state.loader_version)
17
+
18
+
19
+ def _pack(state: InstanceState) -> str:
20
+ if not state.pack_name:
21
+ return "-"
22
+ return " ".join(part for part in (state.pack_name, state.pack_version) if part)
23
+
24
+
25
+ def list_command(ctx: typer.Context) -> None:
26
+ """List your instances."""
27
+ app = get(ctx)
28
+ entries = list_instances(app.config, app.paths)
29
+ if not entries:
30
+ app.out.print("No instances yet.")
31
+ app.out.print(
32
+ "Install a pack with modsmith install, or start one with modsmith create.",
33
+ style="dim",
34
+ )
35
+ return
36
+ table = Table(box=None, pad_edge=False)
37
+ for column in ("Name", "Minecraft", "Loader", "Pack"):
38
+ table.add_column(column)
39
+ table.add_column("Folder", overflow="fold")
40
+ for entry in entries:
41
+ folder = str(entry.path) if entry.external else ""
42
+ if entry.state is None:
43
+ problem = Text(entry.problem or "", style="red")
44
+ table.add_row(entry.name, "", "", problem, folder)
45
+ continue
46
+ state = entry.state
47
+ table.add_row(entry.name, state.minecraft, _loader(state), _pack(state), folder)
48
+ app.out.print(table)
@@ -0,0 +1,205 @@
1
+ """``modsmith search``: find projects on Modrinth and CurseForge."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from modsmith.errors import ModsmithError
9
+ from modsmith.platforms import ProjectType, SearchResults, SearchSort
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ from ..context import AppContext, get
14
+ from ..core.config import PLATFORMS
15
+ from ..core.errors import AppError
16
+ from ..core.platforms import explained, platform_title
17
+ from ..errors import report
18
+
19
+
20
+ class Kind(StrEnum):
21
+ """The kinds of projects to search for."""
22
+
23
+ MOD = "mod"
24
+ MODPACK = "modpack"
25
+ RESOURCEPACK = "resourcepack"
26
+ SHADER = "shader"
27
+
28
+
29
+ KINDS: dict[Kind, ProjectType] = {
30
+ Kind.MOD: "mod",
31
+ Kind.MODPACK: "modpack",
32
+ Kind.RESOURCEPACK: "resourcepack",
33
+ Kind.SHADER: "shader",
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Query:
39
+ """One search, as the player asked for it."""
40
+
41
+ text: str
42
+ kind: ProjectType
43
+ minecraft: str | None
44
+ loader: str | None
45
+ sort: SearchSort
46
+ limit: int
47
+
48
+
49
+ # Narrower than this, the summary is left out.
50
+ MIN_SUMMARY = 12
51
+
52
+
53
+ class Order(StrEnum):
54
+ """How to order search results."""
55
+
56
+ RELEVANCE = "relevance"
57
+ DOWNLOADS = "downloads"
58
+ POPULARITY = "popularity"
59
+ NEWEST = "newest"
60
+ UPDATED = "updated"
61
+
62
+
63
+ ORDERS: dict[Order, SearchSort] = {
64
+ Order.RELEVANCE: "relevance",
65
+ Order.DOWNLOADS: "downloads",
66
+ Order.POPULARITY: "popularity",
67
+ Order.NEWEST: "newest",
68
+ Order.UPDATED: "updated",
69
+ }
70
+
71
+
72
+ class Where(StrEnum):
73
+ """The platforms to search."""
74
+
75
+ MODRINTH = "modrinth"
76
+ CURSEFORGE = "curseforge"
77
+ ALL = "all"
78
+
79
+
80
+ def _count(value: int) -> str:
81
+ """Shorten a download count, such as 1.2M."""
82
+ for size, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")):
83
+ if value >= size:
84
+ return f"{value / size:.1f}".rstrip("0").rstrip(".") + suffix
85
+ return str(value)
86
+
87
+
88
+ def _table(results: SearchResults, title: str | None, width: int) -> Table | None:
89
+ """Lay out results so the name to copy fits, and the summary takes the rest.
90
+
91
+ Returns None if the terminal is too narrow for the columns that must fit.
92
+ """
93
+ rows = [
94
+ (
95
+ project.name,
96
+ f"{project.platform}:{project.slug}",
97
+ _count(project.downloads),
98
+ _one_line(project.summary),
99
+ )
100
+ for project in results.projects
101
+ ]
102
+ headers = ("Name", "Use as", "Downloads")
103
+ widths = [
104
+ max(len(header), *(len(row[index]) for row in rows))
105
+ for index, header in enumerate(headers)
106
+ ]
107
+ # Rich squeezes every column to fit a long line, so the summary gets a cap.
108
+ room = width - sum(widths) - 2 * len(widths)
109
+ if room < 0:
110
+ return None
111
+ table = Table(title=title, title_justify="left", box=None, pad_edge=False)
112
+ table.add_column("Name", no_wrap=True)
113
+ table.add_column("Use as", no_wrap=True)
114
+ table.add_column("Downloads", justify="right", no_wrap=True)
115
+ if room >= MIN_SUMMARY:
116
+ table.add_column("Summary", no_wrap=True, overflow="ellipsis", max_width=room)
117
+ for row in rows:
118
+ table.add_row(*row[: len(table.columns)])
119
+ return table
120
+
121
+
122
+ def _one_line(text: str) -> str:
123
+ """Join a text's lines, because some summaries hold line breaks."""
124
+ return " ".join(text.split())
125
+
126
+
127
+ def _lines(results: SearchResults, title: str | None) -> Text:
128
+ """List results one per pair of lines, for narrow terminals."""
129
+ text = Text()
130
+ if title:
131
+ text.append(f"{title}\n", style="italic")
132
+ for project in results.projects:
133
+ text.append(project.name, style="bold")
134
+ text.append(f" {_count(project.downloads)} downloads\n", style="dim")
135
+ text.append(f" {project.platform}:{project.slug}\n")
136
+ text.rstrip()
137
+ return text
138
+
139
+
140
+ def _search_one(app: AppContext, name: str, query: Query, title: str | None) -> None:
141
+ with explained(name), app.platform(name) as platform:
142
+ results = platform.search(
143
+ query.text,
144
+ project_type=query.kind,
145
+ game_version=query.minecraft,
146
+ loader=query.loader,
147
+ sort=query.sort,
148
+ limit=query.limit,
149
+ )
150
+ if results.projects:
151
+ table = _table(results, title, app.out.width)
152
+ if table is None:
153
+ app.out.print(_lines(results, title), soft_wrap=True)
154
+ else:
155
+ app.out.print(table)
156
+ shown = len(results.projects)
157
+ if results.total > shown:
158
+ app.err.print(f"Showing {shown} of {results.total}.", style="dim")
159
+ else:
160
+ app.out.print(Text(f"Nothing found on {platform_title(name)}.", style="dim"))
161
+
162
+
163
+ def search_command(
164
+ ctx: typer.Context,
165
+ query: Annotated[str, typer.Argument(help="What to look for.", show_default=False)],
166
+ kind: Annotated[
167
+ Kind, typer.Option("--type", "-t", help="The kind of project.")
168
+ ] = Kind.MOD,
169
+ where: Annotated[
170
+ Where | None,
171
+ typer.Option(
172
+ "--platform", "-p", help="Where to search. Defaults to the setting."
173
+ ),
174
+ ] = None,
175
+ minecraft: Annotated[
176
+ str | None, typer.Option("--minecraft", "-m", help="Only this game version.")
177
+ ] = None,
178
+ loader: Annotated[
179
+ str | None, typer.Option("--loader", "-l", help="Only this mod loader.")
180
+ ] = None,
181
+ sort: Annotated[
182
+ Order, typer.Option("--sort", "-s", help="How to order the results.")
183
+ ] = Order.RELEVANCE,
184
+ limit: Annotated[int, typer.Option("--limit", "-n", min=1, max=100)] = 10,
185
+ ) -> None:
186
+ """Search for mods, modpacks, resource packs or shaders."""
187
+ app = get(ctx)
188
+ search = Query(query, KINDS[kind], minecraft, loader, ORDERS[sort], limit)
189
+ names = (
190
+ list(PLATFORMS)
191
+ if where == Where.ALL
192
+ else [(where or app.config.default_platform)]
193
+ )
194
+ failed = 0
195
+ for name in names:
196
+ heading = platform_title(name) if len(names) > 1 else None
197
+ try:
198
+ _search_one(app, str(name), search, heading)
199
+ except (AppError, ModsmithError) as e:
200
+ if len(names) == 1:
201
+ raise
202
+ failed += 1
203
+ report(e, app.err, prefix=f"Skipped {name}: ")
204
+ if failed == len(names):
205
+ raise typer.Exit(1)