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,183 @@
|
|
|
1
|
+
"""Find the player's instances by name or by path.
|
|
2
|
+
|
|
3
|
+
An instance is a folder under the instances folder, named after the instance,
|
|
4
|
+
or a folder elsewhere that the app was told about.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from modsmith.errors import InstanceError
|
|
12
|
+
from modsmith.instances import Instance, InstanceState
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .errors import AppError
|
|
16
|
+
from .paths import AppPaths
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Entry:
|
|
21
|
+
"""One instance the app knows.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
name: Its folder's name.
|
|
25
|
+
path: Its folder.
|
|
26
|
+
state: What it holds, or None if that could not be read.
|
|
27
|
+
external: Whether it is outside the instances folder.
|
|
28
|
+
problem: Why its state could not be read, or None.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
path: Path
|
|
33
|
+
state: InstanceState | None
|
|
34
|
+
external: bool = False
|
|
35
|
+
problem: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _looks_like_path(text: str) -> bool:
|
|
39
|
+
return any(mark in text for mark in ("/", "\\")) or text.startswith((".", "~"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def locate(text: str, config: Config) -> Path:
|
|
43
|
+
"""Return the folder that a name or a path refers to. It may not exist yet.
|
|
44
|
+
|
|
45
|
+
A name is looked up in the instances folder. Anything with a slash, or
|
|
46
|
+
starting with ``.`` or ``~``, is a path.
|
|
47
|
+
"""
|
|
48
|
+
if _looks_like_path(text) or Path(text).is_absolute():
|
|
49
|
+
return Path(text).expanduser().resolve()
|
|
50
|
+
return config.instances_dir / text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _state(instance: Instance) -> InstanceState | None:
|
|
54
|
+
"""Read an instance's state, reporting corruption as an `AppError`."""
|
|
55
|
+
try:
|
|
56
|
+
return instance.state
|
|
57
|
+
except InstanceError as e:
|
|
58
|
+
raise AppError(str(e)) from e
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def open_instance(
|
|
62
|
+
text: str, config: Config, paths: AppPaths | None = None
|
|
63
|
+
) -> tuple[Instance, InstanceState]:
|
|
64
|
+
"""Open an existing instance by name or path.
|
|
65
|
+
|
|
66
|
+
A name is looked up in the instances folder, then among the instances kept
|
|
67
|
+
elsewhere.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
AppError: There is no instance there, or its state is corrupt.
|
|
71
|
+
"""
|
|
72
|
+
instance = Instance(locate(text, config))
|
|
73
|
+
state = _state(instance)
|
|
74
|
+
if state is None and paths is not None and not _looks_like_path(text):
|
|
75
|
+
for path in external_paths(paths):
|
|
76
|
+
if path.name == text:
|
|
77
|
+
instance = Instance(path)
|
|
78
|
+
state = _state(instance)
|
|
79
|
+
break
|
|
80
|
+
if state is None:
|
|
81
|
+
raise AppError(
|
|
82
|
+
f"There is no instance called {text!r}.",
|
|
83
|
+
hint="Run modsmith list to see your instances.",
|
|
84
|
+
)
|
|
85
|
+
return instance, state
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_instance(
|
|
89
|
+
text: str | None, config: Config, paths: AppPaths
|
|
90
|
+
) -> tuple[Instance, InstanceState]:
|
|
91
|
+
"""Open the instance a command names, or the only one if it names none.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
AppError: There is no such instance, there are none, or there are
|
|
95
|
+
several and none was named.
|
|
96
|
+
"""
|
|
97
|
+
if text is not None:
|
|
98
|
+
return open_instance(text, config, paths)
|
|
99
|
+
usable = [
|
|
100
|
+
entry for entry in list_instances(config, paths) if entry.state is not None
|
|
101
|
+
]
|
|
102
|
+
if len(usable) == 1:
|
|
103
|
+
return open_instance(str(usable[0].path), config, paths)
|
|
104
|
+
if not usable:
|
|
105
|
+
raise AppError(
|
|
106
|
+
"There are no instances yet.",
|
|
107
|
+
hint="Install a pack with modsmith install, or start one with modsmith create.",
|
|
108
|
+
)
|
|
109
|
+
names = ", ".join(entry.name for entry in usable)
|
|
110
|
+
raise AppError("Name the instance to use.", hint=f"Your instances: {names}.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def split_target(
|
|
114
|
+
words: list[str], config: Config, paths: AppPaths
|
|
115
|
+
) -> tuple[Instance, InstanceState, list[str]]:
|
|
116
|
+
"""Separate an instance name from the items a command acts on.
|
|
117
|
+
|
|
118
|
+
The first word names the instance if one has that name. Otherwise, when
|
|
119
|
+
there is only one instance, every word is an item.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
AppError: No instance is named and there is not exactly one.
|
|
123
|
+
"""
|
|
124
|
+
if words:
|
|
125
|
+
try:
|
|
126
|
+
instance, state = open_instance(words[0], config, paths)
|
|
127
|
+
except AppError:
|
|
128
|
+
pass
|
|
129
|
+
else:
|
|
130
|
+
return instance, state, words[1:]
|
|
131
|
+
instance, state = resolve_instance(None, config, paths)
|
|
132
|
+
return instance, state, words
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def external_paths(paths: AppPaths) -> list[Path]:
|
|
136
|
+
"""Return the instances kept outside the instances folder."""
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(paths.external_file.read_text(encoding="utf-8"))
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
return []
|
|
141
|
+
except ValueError as e:
|
|
142
|
+
raise AppError(f"{paths.external_file} is corrupt: {e}") from e
|
|
143
|
+
return [Path(item) for item in data if isinstance(item, str)]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def remember_external(paths: AppPaths, path: Path, keep: bool = True) -> None:
|
|
147
|
+
"""Add an instance outside the instances folder to the list, or remove it."""
|
|
148
|
+
known = [item for item in external_paths(paths) if item != path]
|
|
149
|
+
if keep:
|
|
150
|
+
known.append(path)
|
|
151
|
+
paths.external_file.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
text = json.dumps([str(item) for item in known], indent=2)
|
|
153
|
+
paths.external_file.write_text(text, encoding="utf-8")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _entry(path: Path, external: bool) -> Entry | None:
|
|
157
|
+
"""Describe a folder, or return None if it is not an instance."""
|
|
158
|
+
try:
|
|
159
|
+
state = Instance(path).state
|
|
160
|
+
except InstanceError as e:
|
|
161
|
+
return Entry(path.name, path, None, external, str(e))
|
|
162
|
+
if state is None:
|
|
163
|
+
return None
|
|
164
|
+
return Entry(path.name, path, state, external)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def list_instances(config: Config, paths: AppPaths) -> list[Entry]:
|
|
168
|
+
"""List every instance the app knows, sorted by name.
|
|
169
|
+
|
|
170
|
+
Folders in the instances folder that are not instances are skipped, and so
|
|
171
|
+
are remembered folders that are no longer instances.
|
|
172
|
+
"""
|
|
173
|
+
found: list[Entry] = []
|
|
174
|
+
if config.instances_dir.is_dir():
|
|
175
|
+
for path in sorted(config.instances_dir.iterdir()):
|
|
176
|
+
entry = _entry(path, external=False) if path.is_dir() else None
|
|
177
|
+
if entry is not None:
|
|
178
|
+
found.append(entry)
|
|
179
|
+
for path in external_paths(paths):
|
|
180
|
+
entry = _entry(path, external=True)
|
|
181
|
+
if entry is not None:
|
|
182
|
+
found.append(entry)
|
|
183
|
+
return sorted(found, key=lambda item: item.name.lower())
|
modsmith_cli/errors.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Turn errors into short messages with a hint, instead of tracebacks."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from modsmith.errors import (
|
|
9
|
+
Cancelled,
|
|
10
|
+
DependencyError,
|
|
11
|
+
DownloadError,
|
|
12
|
+
LockedError,
|
|
13
|
+
ModsmithError,
|
|
14
|
+
NetworkError,
|
|
15
|
+
UnresolvedEntriesError,
|
|
16
|
+
)
|
|
17
|
+
from modsmith.platforms import Release
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from .core.errors import AppError
|
|
22
|
+
|
|
23
|
+
BUSY = 429
|
|
24
|
+
# The exit status of a command stopped with Ctrl+C, as shells expect.
|
|
25
|
+
INTERRUPTED = 130
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Report:
|
|
30
|
+
"""What to tell the player about an error.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
message: What went wrong.
|
|
34
|
+
hint: What to do about it, or None.
|
|
35
|
+
details: One line per item that failed, such as each file.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
message: str
|
|
39
|
+
hint: str | None = None
|
|
40
|
+
details: list[str] = field(default_factory=list[str])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _network_hint(error: NetworkError) -> str | None:
|
|
44
|
+
"""Say what to do about a failed request, going by its status."""
|
|
45
|
+
if error.status is None:
|
|
46
|
+
return "Check your internet connection, then try again."
|
|
47
|
+
if error.status == BUSY or error.status >= 500:
|
|
48
|
+
return "The service is busy or having trouble. Try again later."
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _unresolved(error: UnresolvedEntriesError) -> Report:
|
|
53
|
+
details: list[str] = []
|
|
54
|
+
for entry in error.entries:
|
|
55
|
+
name = entry.path or (
|
|
56
|
+
entry.ref and f"{entry.ref.platform} file {entry.ref.file_id}"
|
|
57
|
+
)
|
|
58
|
+
page = f" download it from {entry.manual_url}" if entry.manual_url else ""
|
|
59
|
+
details.append(f"{name}{page}")
|
|
60
|
+
return Report(
|
|
61
|
+
(
|
|
62
|
+
"1 file cannot"
|
|
63
|
+
if len(error.entries) == 1
|
|
64
|
+
else f"{len(error.entries)} files cannot"
|
|
65
|
+
)
|
|
66
|
+
+ " be downloaded automatically.",
|
|
67
|
+
"Download them by hand into one folder, then run the command again "
|
|
68
|
+
"with --from <folder>.",
|
|
69
|
+
details,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _dependencies(error: DependencyError) -> Report:
|
|
74
|
+
plan = error.plan
|
|
75
|
+
resolution = plan.resolution
|
|
76
|
+
names = {item.project_id: item.name for item in plan.additions}
|
|
77
|
+
|
|
78
|
+
def named(release: Release) -> str:
|
|
79
|
+
name = names.get(release.project_id)
|
|
80
|
+
return f"{name} {release.version}" if name and release.version else release.name
|
|
81
|
+
|
|
82
|
+
details = [
|
|
83
|
+
f"{named(item.release)} cannot be used with {named(item.chosen)}"
|
|
84
|
+
for item in resolution.conflicts
|
|
85
|
+
]
|
|
86
|
+
details += [
|
|
87
|
+
f"{named(item.release)} needs "
|
|
88
|
+
+ (
|
|
89
|
+
item.project.name
|
|
90
|
+
if item.project
|
|
91
|
+
else str(item.dependency.project_id or item.dependency.release_id)
|
|
92
|
+
)
|
|
93
|
+
+ ", which has no release for this instance"
|
|
94
|
+
for item in resolution.missing
|
|
95
|
+
if item.dependency.kind == "required"
|
|
96
|
+
]
|
|
97
|
+
return Report("This cannot be installed, because of these problems.", None, details)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def describe(error: AppError | ModsmithError) -> Report:
|
|
101
|
+
"""Describe an app or library error for the player."""
|
|
102
|
+
if isinstance(error, AppError):
|
|
103
|
+
return Report(error.message, error.hint)
|
|
104
|
+
if isinstance(error, Cancelled):
|
|
105
|
+
return Report(
|
|
106
|
+
"Cancelled.",
|
|
107
|
+
"Nothing was changed. Run the command again to carry on where it stopped.",
|
|
108
|
+
)
|
|
109
|
+
if isinstance(error, LockedError):
|
|
110
|
+
return Report(
|
|
111
|
+
str(error),
|
|
112
|
+
"Another Modsmith is changing these files. Try again when it is done.",
|
|
113
|
+
)
|
|
114
|
+
if isinstance(error, DownloadError):
|
|
115
|
+
return Report(
|
|
116
|
+
str(error),
|
|
117
|
+
"Nothing was changed. Run the command again to retry.",
|
|
118
|
+
list(error.failures),
|
|
119
|
+
)
|
|
120
|
+
if isinstance(error, UnresolvedEntriesError):
|
|
121
|
+
return _unresolved(error)
|
|
122
|
+
if isinstance(error, DependencyError):
|
|
123
|
+
return _dependencies(error)
|
|
124
|
+
if isinstance(error, NetworkError):
|
|
125
|
+
return Report(str(error), _network_hint(error))
|
|
126
|
+
return Report(str(error))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def report(
|
|
130
|
+
error: AppError | ModsmithError, console: Console | None = None, prefix: str = ""
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Print an error's message, details and hint to standard error.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
error: The error.
|
|
136
|
+
console: Where to print. Defaults to standard error.
|
|
137
|
+
prefix: Shown instead of ``Error:``, for errors that did not stop the
|
|
138
|
+
command.
|
|
139
|
+
"""
|
|
140
|
+
found = describe(error)
|
|
141
|
+
out = console or Console(stderr=True, highlight=False)
|
|
142
|
+
label = (prefix, "yellow") if prefix else ("Error: ", "bold red")
|
|
143
|
+
out.print(Text.assemble(label, found.message), soft_wrap=True)
|
|
144
|
+
for line in found.details:
|
|
145
|
+
out.print(Text(f" {line}"), soft_wrap=True)
|
|
146
|
+
if found.hint:
|
|
147
|
+
# Hints often hold a command, which must stay on one line to be copied.
|
|
148
|
+
out.print(Text(found.hint, style="dim"), soft_wrap=True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _console(kwargs: dict[str, object]) -> Console | None:
|
|
152
|
+
"""Find the run's error console through the command's context, if it has one."""
|
|
153
|
+
ctx = kwargs.get("ctx")
|
|
154
|
+
if isinstance(ctx, typer.Context):
|
|
155
|
+
root = ctx.find_root().obj
|
|
156
|
+
return getattr(root, "err", None)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def reported[**P, R](command: Callable[P, R]) -> Callable[P, R]:
|
|
161
|
+
"""Wrap a command so known errors are reported and end the command.
|
|
162
|
+
|
|
163
|
+
Cancelled commands and Ctrl+C exit with 130, other errors with 1.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
@functools.wraps(command)
|
|
167
|
+
def run(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
168
|
+
try:
|
|
169
|
+
return command(*args, **kwargs)
|
|
170
|
+
except Cancelled as e:
|
|
171
|
+
report(e, _console(kwargs))
|
|
172
|
+
raise typer.Exit(INTERRUPTED) from e
|
|
173
|
+
except KeyboardInterrupt as e:
|
|
174
|
+
report(Cancelled(), _console(kwargs))
|
|
175
|
+
raise typer.Exit(INTERRUPTED) from e
|
|
176
|
+
except (AppError, ModsmithError) as e:
|
|
177
|
+
report(e, _console(kwargs))
|
|
178
|
+
raise typer.Exit(1) from e
|
|
179
|
+
|
|
180
|
+
return run
|
modsmith_cli/target.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared arguments and help for commands that act on an instance."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .core.config import load_config
|
|
8
|
+
from .core.errors import AppError
|
|
9
|
+
from .core.paths import AppPaths
|
|
10
|
+
from .core.registry import list_instances
|
|
11
|
+
|
|
12
|
+
# Click rewraps help text, except a paragraph whose first line is this mark.
|
|
13
|
+
KEEP_LINES = "\b"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def complete_instances(incomplete: str) -> list[str]:
|
|
17
|
+
"""Offer instance names for shell completion."""
|
|
18
|
+
try:
|
|
19
|
+
paths = AppPaths.from_environment()
|
|
20
|
+
entries = list_instances(load_config(paths), paths)
|
|
21
|
+
except AppError:
|
|
22
|
+
return []
|
|
23
|
+
return [entry.name for entry in entries if entry.name.startswith(incomplete)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def examples(*lines: str) -> str:
|
|
27
|
+
"""Format example commands for the end of a command's help."""
|
|
28
|
+
listed = "\n".join(f" {line}" for line in lines)
|
|
29
|
+
return f"Examples:\n\n{KEEP_LINES}\n{listed}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
InstanceName = Annotated[
|
|
33
|
+
str | None,
|
|
34
|
+
typer.Argument(
|
|
35
|
+
help="The instance. Can be left out when you have only one.",
|
|
36
|
+
autocompletion=complete_instances,
|
|
37
|
+
show_default=False,
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
Yes = Annotated[bool, typer.Option("--yes", "-y", help="Do it without asking.")]
|
|
41
|
+
DryRun = Annotated[
|
|
42
|
+
bool, typer.Option("--dry-run", help="Show what would change, and change nothing.")
|
|
43
|
+
]
|
modsmith_cli/ui.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""How commands talk to the player: plans, confirmation, progress and Ctrl+C."""
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
from collections.abc import Generator, Mapping, Sequence
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import FrameType
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from modsmith.instances import Changes
|
|
11
|
+
from modsmith.progress import CancelToken, Progress, ProgressCallback
|
|
12
|
+
from rich.progress import (
|
|
13
|
+
BarColumn,
|
|
14
|
+
TaskID,
|
|
15
|
+
TaskProgressColumn,
|
|
16
|
+
TextColumn,
|
|
17
|
+
TimeRemainingColumn,
|
|
18
|
+
)
|
|
19
|
+
from rich.progress import Progress as Bars
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
|
|
22
|
+
from .context import AppContext
|
|
23
|
+
from .core.errors import AppError
|
|
24
|
+
|
|
25
|
+
# How many paths of each kind a plan lists before summing up the rest.
|
|
26
|
+
SHOWN_PER_KIND = 12
|
|
27
|
+
TASK_NAMES = {
|
|
28
|
+
"files": "Downloading files",
|
|
29
|
+
"overrides": "Writing pack files",
|
|
30
|
+
"minecraft": "Downloading Minecraft",
|
|
31
|
+
"libraries": "Downloading libraries",
|
|
32
|
+
"java": "Downloading Java",
|
|
33
|
+
"identify": "Reading files",
|
|
34
|
+
"fetch": "Downloading",
|
|
35
|
+
"download": "Downloading",
|
|
36
|
+
"processors": "Running installer steps",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class PlanRow:
|
|
42
|
+
"""One kind of change in a plan, as shown to the player."""
|
|
43
|
+
|
|
44
|
+
symbol: str
|
|
45
|
+
style: str
|
|
46
|
+
label: str
|
|
47
|
+
paths: tuple[str, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _size(total: int) -> str:
|
|
51
|
+
"""Format a byte count, such as 12.3 MB."""
|
|
52
|
+
size = float(total)
|
|
53
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
54
|
+
if size < 1000 or unit == "GB":
|
|
55
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
56
|
+
size /= 1000
|
|
57
|
+
return f"{size:.1f} GB"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def show_plan(
|
|
61
|
+
app: AppContext,
|
|
62
|
+
changes: Changes,
|
|
63
|
+
sizes: Mapping[str, int | None] | None = None,
|
|
64
|
+
*,
|
|
65
|
+
title: str | None = None,
|
|
66
|
+
notes: Sequence[Text] = (),
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Print what a change would do. Returns False if it would change nothing.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
app: The run.
|
|
72
|
+
changes: The changes, from a library diff or plan.
|
|
73
|
+
sizes: The download size of each path, where known.
|
|
74
|
+
title: A heading for the plan.
|
|
75
|
+
notes: Lines to print after the changes and before the summary.
|
|
76
|
+
"""
|
|
77
|
+
rows = [
|
|
78
|
+
PlanRow("+", "green", "add", changes.added),
|
|
79
|
+
PlanRow("~", "cyan", "update", changes.updated),
|
|
80
|
+
PlanRow("-", "red", "remove", changes.removed),
|
|
81
|
+
PlanRow("!", "yellow", "keep your version of", changes.conflicts),
|
|
82
|
+
PlanRow("!", "yellow", "keep, though no longer needed,", changes.orphaned),
|
|
83
|
+
]
|
|
84
|
+
if title:
|
|
85
|
+
app.out.print(Text(title, style="bold"))
|
|
86
|
+
for row in rows:
|
|
87
|
+
listed = row.paths if app.verbose else row.paths[:SHOWN_PER_KIND]
|
|
88
|
+
for path in listed:
|
|
89
|
+
app.out.print(Text.assemble((f" {row.symbol} ", row.style), path))
|
|
90
|
+
hidden = len(row.paths) - len(listed)
|
|
91
|
+
if hidden:
|
|
92
|
+
app.out.print(Text(f" {row.symbol} and {hidden} more", style=row.style))
|
|
93
|
+
for path in changes.superseded:
|
|
94
|
+
app.out.print(Text.assemble((" = ", "dim"), f"{path} is now part of the pack"))
|
|
95
|
+
for note in notes:
|
|
96
|
+
app.out.print(note)
|
|
97
|
+
counts = [f"{len(row.paths)} to {row.label}" for row in rows[:3] if row.paths]
|
|
98
|
+
kept = len(changes.conflicts) + len(changes.orphaned)
|
|
99
|
+
if kept:
|
|
100
|
+
counts.append(f"{kept} of your changes kept")
|
|
101
|
+
if not counts:
|
|
102
|
+
app.out.print("Nothing to change.")
|
|
103
|
+
return False
|
|
104
|
+
pending = [*changes.added, *changes.updated]
|
|
105
|
+
total = sum((sizes or {}).get(path) or 0 for path in pending)
|
|
106
|
+
summary = ", ".join(counts)
|
|
107
|
+
if total:
|
|
108
|
+
summary += f"; {_size(total)} to download"
|
|
109
|
+
app.out.print(Text(summary + ".", style="bold"))
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def confirm(app: AppContext, question: str, *, yes: bool) -> None:
|
|
114
|
+
"""Ask before changing files, unless ``--yes`` was given.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
AppError: There is no terminal to ask in, so ``--yes`` is needed.
|
|
118
|
+
typer.Exit: The player said no.
|
|
119
|
+
"""
|
|
120
|
+
if yes:
|
|
121
|
+
return
|
|
122
|
+
if not app.services.interactive():
|
|
123
|
+
raise AppError(
|
|
124
|
+
"This command changes files and there is no terminal to confirm in.",
|
|
125
|
+
hint="Run it again with --yes.",
|
|
126
|
+
)
|
|
127
|
+
if not typer.confirm(question, default=True):
|
|
128
|
+
app.err.print("Nothing was changed.")
|
|
129
|
+
raise typer.Exit(0)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def confirm_name(app: AppContext, name: str, *, yes: bool, force: bool) -> None:
|
|
133
|
+
"""Ask the player to type a name before something that cannot be undone.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
AppError: ``--yes`` was given without ``--force``, or there is no
|
|
137
|
+
terminal.
|
|
138
|
+
typer.Exit: The player typed something else.
|
|
139
|
+
"""
|
|
140
|
+
if yes:
|
|
141
|
+
if not force:
|
|
142
|
+
raise AppError(
|
|
143
|
+
"This cannot be undone, so --yes alone is not enough.",
|
|
144
|
+
hint="Add --force as well.",
|
|
145
|
+
)
|
|
146
|
+
return
|
|
147
|
+
if not app.services.interactive():
|
|
148
|
+
raise AppError(
|
|
149
|
+
"This command cannot be undone and there is no terminal to confirm in.",
|
|
150
|
+
hint="Run it again with --yes --force.",
|
|
151
|
+
)
|
|
152
|
+
typed = typer.prompt(f"Type {name} to confirm", default="", show_default=False)
|
|
153
|
+
if typed != name:
|
|
154
|
+
app.err.print("Nothing was changed.")
|
|
155
|
+
raise typer.Exit(0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@contextmanager
|
|
159
|
+
def progress(app: AppContext) -> Generator[ProgressCallback]:
|
|
160
|
+
"""Show the library's progress updates as bars, when standard error is a terminal."""
|
|
161
|
+
if not app.err.is_terminal:
|
|
162
|
+
yield lambda update: None
|
|
163
|
+
return
|
|
164
|
+
columns = (
|
|
165
|
+
TextColumn("{task.description}"),
|
|
166
|
+
BarColumn(),
|
|
167
|
+
TaskProgressColumn(),
|
|
168
|
+
TimeRemainingColumn(),
|
|
169
|
+
)
|
|
170
|
+
with Bars(*columns, console=app.err, transient=True) as bars:
|
|
171
|
+
tasks: dict[tuple[str, str], TaskID] = {}
|
|
172
|
+
|
|
173
|
+
def update(item: Progress) -> None:
|
|
174
|
+
key = (item.task, item.unit)
|
|
175
|
+
if key not in tasks:
|
|
176
|
+
label = TASK_NAMES.get(item.task, item.task.capitalize())
|
|
177
|
+
tasks[key] = bars.add_task(label, total=item.total)
|
|
178
|
+
bars.update(tasks[key], completed=item.current, total=item.total)
|
|
179
|
+
|
|
180
|
+
yield update
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@contextmanager
|
|
184
|
+
def cancellable() -> Generator[CancelToken]:
|
|
185
|
+
"""Turn the first Ctrl+C into a clean stop, through a `CancelToken`.
|
|
186
|
+
|
|
187
|
+
The library then stops at a safe point and raises `Cancelled`. A second
|
|
188
|
+
Ctrl+C stops at once.
|
|
189
|
+
"""
|
|
190
|
+
token = CancelToken()
|
|
191
|
+
previous = signal.getsignal(signal.SIGINT)
|
|
192
|
+
|
|
193
|
+
def stop(signum: int, frame: FrameType | None) -> None:
|
|
194
|
+
if token.cancelled:
|
|
195
|
+
raise KeyboardInterrupt
|
|
196
|
+
token.cancel()
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
signal.signal(signal.SIGINT, stop)
|
|
200
|
+
except ValueError:
|
|
201
|
+
# Signal handlers can only be set from the main thread.
|
|
202
|
+
yield token
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
yield token
|
|
206
|
+
finally:
|
|
207
|
+
signal.signal(signal.SIGINT, previous)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def show_changelog(app: AppContext, title: str, text: str, *, lines: int) -> None:
|
|
211
|
+
"""Print a changelog under a heading, cut to its first lines unless verbose."""
|
|
212
|
+
kept = [line.rstrip() for line in text.strip().splitlines()]
|
|
213
|
+
if not kept:
|
|
214
|
+
return
|
|
215
|
+
app.out.print(Text(title, style="bold"))
|
|
216
|
+
shown = kept if app.verbose else kept[:lines]
|
|
217
|
+
app.out.print(Text("\n".join(shown)))
|
|
218
|
+
if len(shown) < len(kept):
|
|
219
|
+
app.out.print(Text("... (-v shows all of it)", style="dim"))
|