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,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)
|