beck-bios 0.5.0__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.
- beck/__init__.py +9 -0
- beck/__main__.py +7 -0
- beck/cli.py +617 -0
- beck/config.py +392 -0
- beck/credentials.py +51 -0
- beck/errors.py +51 -0
- beck/executor.py +151 -0
- beck/frontends/__init__.py +25 -0
- beck/frontends/base.py +20 -0
- beck/frontends/recalbox.py +150 -0
- beck/hashing.py +63 -0
- beck/legacy_config.py +103 -0
- beck/matcher.py +85 -0
- beck/models.py +165 -0
- beck/output/__init__.py +113 -0
- beck/output/console.py +464 -0
- beck/output/json.py +338 -0
- beck/py.typed +0 -0
- beck/redact.py +48 -0
- beck/targets.py +70 -0
- beck/transports/__init__.py +2 -0
- beck/transports/base.py +39 -0
- beck/transports/local.py +97 -0
- beck/transports/smb.py +205 -0
- beck_bios-0.5.0.dist-info/METADATA +143 -0
- beck_bios-0.5.0.dist-info/RECORD +29 -0
- beck_bios-0.5.0.dist-info/WHEEL +4 -0
- beck_bios-0.5.0.dist-info/entry_points.txt +3 -0
- beck_bios-0.5.0.dist-info/licenses/LICENSE +674 -0
beck/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
"""BECK: match BIOS files by MD5 and send them to the right folder on a retro-gaming device."""
|
|
3
|
+
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = importlib.metadata.version("beck-bios")
|
|
8
|
+
except importlib.metadata.PackageNotFoundError: # run from a clone by check_bios.py
|
|
9
|
+
__version__ = "0+unknown"
|
beck/__main__.py
ADDED
beck/cli.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
"""The ``beck`` command: connects the parts. Only this module turns errors into exit codes."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, NoReturn
|
|
13
|
+
|
|
14
|
+
from beck import __version__
|
|
15
|
+
from beck.config import (
|
|
16
|
+
DEFAULT_FRONTEND,
|
|
17
|
+
Flags,
|
|
18
|
+
Settings,
|
|
19
|
+
find_config,
|
|
20
|
+
load,
|
|
21
|
+
resolve,
|
|
22
|
+
write_template,
|
|
23
|
+
)
|
|
24
|
+
from beck.credentials import password_for
|
|
25
|
+
from beck.errors import BeckError, ConfigError, ReportNotFoundError, TransportError, UsageError
|
|
26
|
+
from beck.executor import execute
|
|
27
|
+
from beck.frontends import get_adapter
|
|
28
|
+
from beck.frontends.base import FrontendAdapter
|
|
29
|
+
from beck.hashing import scan
|
|
30
|
+
from beck.legacy_config import migration_notice, read_legacy
|
|
31
|
+
from beck.matcher import plan
|
|
32
|
+
from beck.models import ItemError, MissingBios, Plan, RunResult, ScanResult
|
|
33
|
+
from beck.output import LOGGER_NAME, escaped_for_terminal, finish_output
|
|
34
|
+
from beck.output.console import ConsoleRenderer, configure_logging
|
|
35
|
+
from beck.output.json import JsonRenderer, write_rejected_command
|
|
36
|
+
from beck.redact import add_secret, forget_secrets, redact
|
|
37
|
+
from beck.targets import URL_FORM, parse_smb_url
|
|
38
|
+
from beck.transports.base import Transport
|
|
39
|
+
from beck.transports.local import LocalTransport
|
|
40
|
+
from beck.transports.smb import DEFAULT_TIMEOUT, SmbTransport
|
|
41
|
+
|
|
42
|
+
DESCRIPTION = "Match your BIOS files by MD5 and send them to the right folder on your Recalbox."
|
|
43
|
+
|
|
44
|
+
EXAMPLES = """\
|
|
45
|
+
examples:
|
|
46
|
+
beck check copy the matching files to your device
|
|
47
|
+
beck scan list your local files and their MD5s
|
|
48
|
+
beck report list what your device is missing
|
|
49
|
+
beck config init create a settings file
|
|
50
|
+
|
|
51
|
+
See docs/cli.md for every option, the exit codes, and what gets deleted.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
COMMANDS = {
|
|
55
|
+
"check": ("Copy your matching BIOS files to the device.", "example:\n beck check\n"),
|
|
56
|
+
"scan": ("List your local BIOS files with their MD5s.", "example:\n beck scan\n"),
|
|
57
|
+
"report": ("List the BIOS files the device reports as missing.", "example:\n beck report\n"),
|
|
58
|
+
"config": ("Find, create or show BECK's settings.", "example:\n beck config show\n"),
|
|
59
|
+
}
|
|
60
|
+
CONFIG_ACTIONS = {
|
|
61
|
+
"path": "print which settings file BECK reads, and why",
|
|
62
|
+
"init": "create that settings file",
|
|
63
|
+
"show": "print the settings a run would use, and where each comes from",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
FAILED_IF_MISSING_EXIT_CODE = 5
|
|
67
|
+
REPORT_FILE_HELP = "read this local copy of the report, not the one on the target"
|
|
68
|
+
|
|
69
|
+
Renderer = ConsoleRenderer | JsonRenderer
|
|
70
|
+
Entries = tuple[list[MissingBios], list[ItemError]] # what a report asks for, and what it refused
|
|
71
|
+
|
|
72
|
+
_log = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Parser(argparse.ArgumentParser):
|
|
76
|
+
"""With ``--json``, a bad command line must still give a document on stdout."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, *args: Any, json_errors: bool = False, **kwargs: Any) -> None:
|
|
79
|
+
super().__init__(*args, **kwargs)
|
|
80
|
+
self.json_errors = json_errors
|
|
81
|
+
|
|
82
|
+
def error(self, message: str) -> NoReturn:
|
|
83
|
+
if self.json_errors:
|
|
84
|
+
raise UsageError(message)
|
|
85
|
+
super().error(escaped_for_terminal(redact(message))) # it repeats what it was given
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _asks_for_json(argv: list[str]) -> bool:
|
|
89
|
+
"""Whether a command line asks for JSON. Needed before parsing, which can fail.
|
|
90
|
+
|
|
91
|
+
Like argparse, this accepts a shortened option: ``--js``.
|
|
92
|
+
"""
|
|
93
|
+
return any(len(arg) > 2 and "--json".startswith(arg) for arg in argv)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load_settings(args: argparse.Namespace) -> Settings:
|
|
97
|
+
location = find_config(args.config, os.environ)
|
|
98
|
+
_log.debug("Settings file: %s (%s)", location.path, location.source)
|
|
99
|
+
file = load(location.path, may_be_missing=location.source == "default")
|
|
100
|
+
flags = Flags(
|
|
101
|
+
target=args.target, target_dir=args.target_dir, source=args.source, frontend=args.frontend
|
|
102
|
+
)
|
|
103
|
+
return resolve(flags, os.environ, file)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _open_target(settings: Settings, password: Callable[[str, str], str]) -> Transport:
|
|
107
|
+
"""The transport for the chosen target. It connects when it's entered, not here."""
|
|
108
|
+
if settings.target_dir is not None:
|
|
109
|
+
return LocalTransport(settings.target_dir)
|
|
110
|
+
if settings.target_url is None:
|
|
111
|
+
raise ConfigError(
|
|
112
|
+
"BECK has no target. Use --target or --target-dir, set BECK_TARGET or BECK_TARGET_DIR, "
|
|
113
|
+
"or name one in the settings file: to create it, run beck config init."
|
|
114
|
+
)
|
|
115
|
+
target = parse_smb_url(settings.target_url)
|
|
116
|
+
user = target.user or settings.smb_user
|
|
117
|
+
return SmbTransport(
|
|
118
|
+
target.host,
|
|
119
|
+
target.port,
|
|
120
|
+
target.share,
|
|
121
|
+
user,
|
|
122
|
+
password(user, target.host),
|
|
123
|
+
timeout=settings.smb_timeout or DEFAULT_TIMEOUT,
|
|
124
|
+
root=target.root,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _connect_to_target(settings: Settings, renderer: Renderer) -> Transport:
|
|
129
|
+
"""The transport for a run that connects: the only thing that may ask for a password."""
|
|
130
|
+
|
|
131
|
+
def password(user: str, host: str) -> str:
|
|
132
|
+
return password_for(user, host, settings.smb_password, can_ask=renderer.can_ask())
|
|
133
|
+
|
|
134
|
+
return _open_target(settings, password)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _entries_from_file(adapter: FrontendAdapter, report_file: str) -> Entries:
|
|
138
|
+
"""Parse a local copy of the report."""
|
|
139
|
+
try:
|
|
140
|
+
raw = Path(report_file).read_bytes()
|
|
141
|
+
except OSError as e:
|
|
142
|
+
raise ReportNotFoundError(f"Can't read the report {report_file}: {e.strerror}") from e
|
|
143
|
+
return _parsed_report(adapter, raw)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _entries_from_target(adapter: FrontendAdapter, transport: Transport) -> Entries:
|
|
147
|
+
"""Parse the report on the target."""
|
|
148
|
+
if not transport.exists(adapter.report_path):
|
|
149
|
+
raise ReportNotFoundError(
|
|
150
|
+
f"{adapter.report_path} not found on {transport.describe()}. "
|
|
151
|
+
"Generate the report on the device first."
|
|
152
|
+
)
|
|
153
|
+
return _parsed_report(adapter, transport.read_bytes(adapter.report_path))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parsed_report(adapter: FrontendAdapter, raw: bytes) -> Entries:
|
|
157
|
+
text = raw.decode("utf-8", errors="replace")
|
|
158
|
+
entries, rejected = adapter.parse_report(text), adapter.rejected_entries(text)
|
|
159
|
+
_log.debug(
|
|
160
|
+
"Report: %d entries, %d refused, from %d bytes", len(entries), len(rejected), len(raw)
|
|
161
|
+
)
|
|
162
|
+
return entries, rejected
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _scan_with_progress(source: Path, renderer: Renderer) -> ScanResult:
|
|
166
|
+
started = time.monotonic()
|
|
167
|
+
scanned = scan(source, renderer.hashing)
|
|
168
|
+
renderer.hashing_done()
|
|
169
|
+
_log.debug(
|
|
170
|
+
"Scanned %s: %d files, %d can't be read, in %.3f s",
|
|
171
|
+
source,
|
|
172
|
+
len(scanned.files),
|
|
173
|
+
len(scanned.unreadable),
|
|
174
|
+
time.monotonic() - started,
|
|
175
|
+
)
|
|
176
|
+
return scanned
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _log_plan(built: Plan) -> None:
|
|
180
|
+
_log.debug(
|
|
181
|
+
"Plan: %d uploads, %d folders, %d duplicates, %d unmatched, %d already filled, "
|
|
182
|
+
"%d still missing, %d incorrect",
|
|
183
|
+
len(built.uploads),
|
|
184
|
+
len(built.directories),
|
|
185
|
+
len(built.duplicates),
|
|
186
|
+
len(built.unmatched),
|
|
187
|
+
len(built.already_filled),
|
|
188
|
+
len(built.still_missing),
|
|
189
|
+
len(built.incorrect),
|
|
190
|
+
)
|
|
191
|
+
for upload in built.uploads:
|
|
192
|
+
_log.debug("Plan: %s -> %s", upload.source.relative, upload.target)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _log_run(result: RunResult, started: float) -> None:
|
|
196
|
+
_log.debug(
|
|
197
|
+
"Ran in %.3f s: %d uploaded, %d kept, %d deleted, %d errors",
|
|
198
|
+
time.monotonic() - started,
|
|
199
|
+
len(result.completed),
|
|
200
|
+
len(result.kept),
|
|
201
|
+
len(result.deleted),
|
|
202
|
+
len(result.errors),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _source_folder(settings: Settings, base_dir: Path) -> Path:
|
|
207
|
+
"""The folder of BIOS files. One the user named must exist, so a typo can't read as empty."""
|
|
208
|
+
source = settings.source_dir
|
|
209
|
+
if source is None:
|
|
210
|
+
return base_dir / "bios"
|
|
211
|
+
if not source.is_dir():
|
|
212
|
+
raise UsageError(f"The source folder {source} doesn't exist")
|
|
213
|
+
return source
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _confirm_deletion(preview: RunResult, renderer: Renderer) -> None:
|
|
217
|
+
"""Ask before deleting. Without a terminal to ask on, deleting needs --yes."""
|
|
218
|
+
cant_ask = UsageError(
|
|
219
|
+
f"BECK would delete {len(preview.deleted)} local files, and can't ask here. Nothing was "
|
|
220
|
+
"changed. To see them, use --dry-run. To confirm, use --yes."
|
|
221
|
+
)
|
|
222
|
+
if not renderer.can_ask():
|
|
223
|
+
raise cant_ask
|
|
224
|
+
try:
|
|
225
|
+
confirmed = renderer.confirm_deletion(preview)
|
|
226
|
+
except OSError as e: # the question or the answer didn't get through
|
|
227
|
+
raise cant_ask from e
|
|
228
|
+
if not confirmed:
|
|
229
|
+
raise UsageError("Nothing was changed, because you didn't confirm the deletion.")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _failing_if_missing(args: argparse.Namespace, result: RunResult) -> list[MissingBios]:
|
|
233
|
+
"""The entries ``--fail-if-missing`` fails on: required, or the report doesn't say.
|
|
234
|
+
|
|
235
|
+
A required INCORRECT file counts, because the device still can't use it, and a failed item wins,
|
|
236
|
+
because the run didn't do all it could (DEC-59). A file kept on the device isn't missing. In a
|
|
237
|
+
dry run, this is what the same run would leave.
|
|
238
|
+
"""
|
|
239
|
+
if not args.fail_if_missing or result.errors:
|
|
240
|
+
return []
|
|
241
|
+
still_needed = (*result.plan.still_missing, *result.plan.incorrect)
|
|
242
|
+
return [entry for entry in still_needed if entry.required is not False]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _exit_code(result: RunResult, failing: list[MissingBios]) -> int:
|
|
246
|
+
"""A failed item wins, because the run didn't do all it could (DEC-59)."""
|
|
247
|
+
if result.errors:
|
|
248
|
+
return TransportError.exit_code
|
|
249
|
+
return FAILED_IF_MISSING_EXIT_CODE if failing else 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _run_check(
|
|
253
|
+
args: argparse.Namespace, settings: Settings, base_dir: Path, renderer: Renderer
|
|
254
|
+
) -> int:
|
|
255
|
+
source = _source_folder(settings, base_dir)
|
|
256
|
+
adapter = get_adapter(settings.frontend)
|
|
257
|
+
|
|
258
|
+
def ended_early(entries: list[MissingBios], rejected: list[ItemError]) -> int:
|
|
259
|
+
"""Nothing to upload. The plan still says what's missing, incorrect and refused."""
|
|
260
|
+
built = plan(entries, [], rejected=rejected)
|
|
261
|
+
result = RunResult(plan=built, errors=built.rejected, dry_run=args.dry_run)
|
|
262
|
+
failing = _failing_if_missing(args, result)
|
|
263
|
+
renderer.ended_early(result, failing)
|
|
264
|
+
return _exit_code(result, failing)
|
|
265
|
+
|
|
266
|
+
transport = _connect_to_target(settings, renderer)
|
|
267
|
+
renderer.opened(transport.url())
|
|
268
|
+
_log.debug("Target: %s", transport.url())
|
|
269
|
+
with transport:
|
|
270
|
+
if args.report_file is not None:
|
|
271
|
+
entries, rejected = _entries_from_file(adapter, args.report_file)
|
|
272
|
+
else:
|
|
273
|
+
entries, rejected = _entries_from_target(adapter, transport)
|
|
274
|
+
if not entries and not rejected:
|
|
275
|
+
renderer.nothing_missing()
|
|
276
|
+
incorrect = [entry for entry in entries if entry.incorrect]
|
|
277
|
+
if not args.overwrite:
|
|
278
|
+
renderer.incorrect(incorrect)
|
|
279
|
+
to_fill = {e.target for e in entries if args.overwrite or not e.incorrect}
|
|
280
|
+
if not to_fill:
|
|
281
|
+
return ended_early(entries, rejected)
|
|
282
|
+
renderer.missing_count(len(to_fill))
|
|
283
|
+
|
|
284
|
+
scanned = _scan_with_progress(source, renderer)
|
|
285
|
+
renderer.unreadable(scanned.unreadable)
|
|
286
|
+
if not scanned.files:
|
|
287
|
+
if args.overwrite:
|
|
288
|
+
renderer.incorrect(incorrect)
|
|
289
|
+
renderer.no_files(source)
|
|
290
|
+
return ended_early(entries, rejected)
|
|
291
|
+
renderer.files_found(len(scanned.files))
|
|
292
|
+
|
|
293
|
+
built = plan(entries, scanned.files, replace_incorrect=args.overwrite, rejected=rejected)
|
|
294
|
+
_log_plan(built)
|
|
295
|
+
options = {
|
|
296
|
+
"overwrite": args.overwrite,
|
|
297
|
+
"move": args.move,
|
|
298
|
+
"clean_unmatched": args.clean_unmatched,
|
|
299
|
+
"verify": args.verify,
|
|
300
|
+
}
|
|
301
|
+
started = time.monotonic()
|
|
302
|
+
if args.dry_run:
|
|
303
|
+
result = execute(built, transport, dry_run=True, **options)
|
|
304
|
+
else:
|
|
305
|
+
if (args.move or args.clean_unmatched) and not args.yes:
|
|
306
|
+
# The preview only reads, so that a refusal leaves everything as it was.
|
|
307
|
+
preview = execute(built, transport, dry_run=True, **options)
|
|
308
|
+
if preview.deleted:
|
|
309
|
+
_confirm_deletion(preview, renderer)
|
|
310
|
+
result = execute(built, transport, dry_run=False, **options)
|
|
311
|
+
_log_run(result, started)
|
|
312
|
+
|
|
313
|
+
if args.overwrite:
|
|
314
|
+
renderer.incorrect(list(result.plan.incorrect))
|
|
315
|
+
failing = _failing_if_missing(args, result)
|
|
316
|
+
renderer.result(result, failing)
|
|
317
|
+
return _exit_code(result, failing)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _run_report(
|
|
321
|
+
args: argparse.Namespace, settings: Settings, base_dir: Path, renderer: Renderer
|
|
322
|
+
) -> int:
|
|
323
|
+
"""List what the report says. It never writes anything, and a local copy needs no target."""
|
|
324
|
+
adapter = get_adapter(settings.frontend)
|
|
325
|
+
if args.report_file is not None:
|
|
326
|
+
entries, rejected = _entries_from_file(adapter, args.report_file)
|
|
327
|
+
else:
|
|
328
|
+
transport = _connect_to_target(settings, renderer)
|
|
329
|
+
renderer.opened(transport.url())
|
|
330
|
+
_log.debug("Target: %s", transport.url())
|
|
331
|
+
with transport:
|
|
332
|
+
entries, rejected = _entries_from_target(adapter, transport)
|
|
333
|
+
renderer.report(entries, rejected)
|
|
334
|
+
return TransportError.exit_code if rejected else 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _run_scan(settings: Settings, base_dir: Path, renderer: Renderer) -> int:
|
|
338
|
+
"""List the local files. It uses no network and writes nothing."""
|
|
339
|
+
source = _source_folder(settings, base_dir)
|
|
340
|
+
renderer.scan(_scan_with_progress(source, renderer), source)
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _shown_settings(settings: Settings, base_dir: Path) -> dict[str, dict[str, Any]]:
|
|
345
|
+
"""What ``beck config show`` shows: never the password, only whether there is one."""
|
|
346
|
+
has_target = settings.target_url is not None or settings.target_dir is not None
|
|
347
|
+
url_user = parse_smb_url(settings.target_url).user if settings.target_url else ""
|
|
348
|
+
source = base_dir / "bios" if settings.source_dir is None else settings.source_dir
|
|
349
|
+
sources = settings.sources
|
|
350
|
+
return {
|
|
351
|
+
"target": {
|
|
352
|
+
# Only its URL: showing the settings connects to nothing, and asks for nothing.
|
|
353
|
+
"value": _open_target(settings, lambda _user, _host: "").url() if has_target else None,
|
|
354
|
+
"source": sources["target"],
|
|
355
|
+
},
|
|
356
|
+
"source": {"value": str(source), "source": sources["source"]},
|
|
357
|
+
"frontend": {"value": settings.frontend, "source": sources["frontend"]},
|
|
358
|
+
"smb_user": {
|
|
359
|
+
"value": url_user or settings.smb_user,
|
|
360
|
+
"source": sources["target"] if url_user else sources["smb_user"],
|
|
361
|
+
},
|
|
362
|
+
"smb_password": {
|
|
363
|
+
"value": "set" if settings.smb_password else "not set",
|
|
364
|
+
"source": sources["smb_password"],
|
|
365
|
+
},
|
|
366
|
+
"smb_timeout": {
|
|
367
|
+
"value": settings.smb_timeout or DEFAULT_TIMEOUT,
|
|
368
|
+
"source": sources["smb_timeout"],
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _run_config(args: argparse.Namespace, base_dir: Path, renderer: Renderer) -> int:
|
|
374
|
+
"""Find, create or show the settings. Only ``show`` reads the file, so bad ones can be found."""
|
|
375
|
+
location = find_config(args.config, os.environ)
|
|
376
|
+
if args.config_action == "init":
|
|
377
|
+
write_template(location.path, force=args.force)
|
|
378
|
+
renderer.config_created(location, location.path.exists())
|
|
379
|
+
elif args.config_action == "path":
|
|
380
|
+
renderer.config_path(location, location.path.exists())
|
|
381
|
+
else:
|
|
382
|
+
renderer.config_file(location, location.path.exists())
|
|
383
|
+
settings = _load_settings(args)
|
|
384
|
+
renderer.config_settings(
|
|
385
|
+
location, location.path.exists(), _shown_settings(settings, base_dir)
|
|
386
|
+
)
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _notice_old_settings(args: argparse.Namespace, base_dir: Path) -> None:
|
|
391
|
+
"""Help someone who still has the old file, until they have a new one."""
|
|
392
|
+
if args.quiet:
|
|
393
|
+
return
|
|
394
|
+
old = base_dir / "config" / "config.yml"
|
|
395
|
+
legacy = read_legacy(old)
|
|
396
|
+
if legacy is None:
|
|
397
|
+
return
|
|
398
|
+
new = find_config(args.config, os.environ).path
|
|
399
|
+
if not new.exists():
|
|
400
|
+
for line in migration_notice(old, legacy, new):
|
|
401
|
+
logging.getLogger(LOGGER_NAME).warning("%s", line)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _add_global_options(parser: argparse.ArgumentParser, *, on_a_command: bool) -> None:
|
|
405
|
+
"""Options that work before or after the command.
|
|
406
|
+
|
|
407
|
+
On a command, the default must be SUPPRESS. Otherwise, argparse sets it after reading the part
|
|
408
|
+
before the command, so ``beck --no-color check`` would lose the flag.
|
|
409
|
+
"""
|
|
410
|
+
parser.add_argument(
|
|
411
|
+
"-v",
|
|
412
|
+
dest="verbose",
|
|
413
|
+
action="store_true",
|
|
414
|
+
default=argparse.SUPPRESS if on_a_command else False,
|
|
415
|
+
help="add debug logs on stderr: steps, timings and the plan, never credentials",
|
|
416
|
+
)
|
|
417
|
+
parser.add_argument(
|
|
418
|
+
"-q",
|
|
419
|
+
dest="quiet",
|
|
420
|
+
action="store_true",
|
|
421
|
+
default=argparse.SUPPRESS if on_a_command else False,
|
|
422
|
+
help="with check, print only errors, deletions and how the run ended",
|
|
423
|
+
)
|
|
424
|
+
parser.add_argument(
|
|
425
|
+
"--no-color",
|
|
426
|
+
action="store_true",
|
|
427
|
+
default=argparse.SUPPRESS if on_a_command else False,
|
|
428
|
+
help="don't use colors, the same as setting NO_COLOR",
|
|
429
|
+
)
|
|
430
|
+
parser.add_argument(
|
|
431
|
+
"--config",
|
|
432
|
+
metavar="FILE",
|
|
433
|
+
default=argparse.SUPPRESS if on_a_command else None,
|
|
434
|
+
help="read the settings from this file, not the default one (see docs/cli.md)",
|
|
435
|
+
)
|
|
436
|
+
parser.add_argument(
|
|
437
|
+
"--json",
|
|
438
|
+
action="store_true",
|
|
439
|
+
default=argparse.SUPPRESS if on_a_command else False,
|
|
440
|
+
help="print one JSON document on stdout, and never ask anything (see docs/json-output.md)",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _add_target_options(parser: argparse.ArgumentParser) -> Any:
|
|
445
|
+
group = parser.add_mutually_exclusive_group()
|
|
446
|
+
group.add_argument("--target", metavar="URL", help=f"an SMB target: {URL_FORM}")
|
|
447
|
+
group.add_argument(
|
|
448
|
+
"--target-dir",
|
|
449
|
+
metavar="DIR",
|
|
450
|
+
help="a folder: a mounted SD card or USB drive, or the device itself",
|
|
451
|
+
)
|
|
452
|
+
return group
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
|
|
456
|
+
parser = _Parser(
|
|
457
|
+
json_errors=json_errors,
|
|
458
|
+
prog="beck",
|
|
459
|
+
description=DESCRIPTION,
|
|
460
|
+
epilog=EXAMPLES,
|
|
461
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
462
|
+
)
|
|
463
|
+
parser.add_argument("--version", action="version", version=f"beck {__version__}")
|
|
464
|
+
_add_global_options(parser, on_a_command=False)
|
|
465
|
+
commands = parser.add_subparsers(dest="command", title="commands", metavar="<command>")
|
|
466
|
+
for name, (summary, example) in COMMANDS.items():
|
|
467
|
+
command = commands.add_parser(
|
|
468
|
+
name,
|
|
469
|
+
json_errors=json_errors,
|
|
470
|
+
help=summary,
|
|
471
|
+
description=summary,
|
|
472
|
+
epilog=example,
|
|
473
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
474
|
+
)
|
|
475
|
+
_add_global_options(command, on_a_command=True)
|
|
476
|
+
if name in ("check", "scan"):
|
|
477
|
+
command.add_argument(
|
|
478
|
+
"--source", metavar="DIR", help="your folder of BIOS files (default: ./bios)"
|
|
479
|
+
)
|
|
480
|
+
if name == "check":
|
|
481
|
+
group = _add_target_options(command)
|
|
482
|
+
command.add_argument("--report-file", metavar="FILE", help=REPORT_FILE_HELP)
|
|
483
|
+
command.add_argument(
|
|
484
|
+
"--overwrite",
|
|
485
|
+
action="store_true",
|
|
486
|
+
help="replace files already on the device, even ones the report calls INCORRECT",
|
|
487
|
+
)
|
|
488
|
+
command.add_argument(
|
|
489
|
+
"--dry-run",
|
|
490
|
+
action="store_true",
|
|
491
|
+
help="show the plan, including what would be deleted, and change nothing",
|
|
492
|
+
)
|
|
493
|
+
command.add_argument(
|
|
494
|
+
"--move",
|
|
495
|
+
action="store_true",
|
|
496
|
+
help="delete a local file once it's uploaded to all its targets and sizes match",
|
|
497
|
+
)
|
|
498
|
+
command.add_argument(
|
|
499
|
+
"--clean-unmatched",
|
|
500
|
+
action="store_true",
|
|
501
|
+
help="delete local files the current report doesn't ask for (read docs/cli.md)",
|
|
502
|
+
)
|
|
503
|
+
command.add_argument(
|
|
504
|
+
"--yes", action="store_true", help="confirm deletions without asking"
|
|
505
|
+
)
|
|
506
|
+
command.add_argument(
|
|
507
|
+
"--verify",
|
|
508
|
+
action="store_true",
|
|
509
|
+
help="read each upload back and compare its MD5, which is slower",
|
|
510
|
+
)
|
|
511
|
+
command.add_argument(
|
|
512
|
+
"--fail-if-missing",
|
|
513
|
+
action="store_true",
|
|
514
|
+
help="exit with 5 if required files are still missing or incorrect after the run",
|
|
515
|
+
)
|
|
516
|
+
command.add_argument(
|
|
517
|
+
"--frontend",
|
|
518
|
+
metavar="NAME",
|
|
519
|
+
help=f"the kind of device (default: {DEFAULT_FRONTEND})",
|
|
520
|
+
)
|
|
521
|
+
if name == "report":
|
|
522
|
+
group = _add_target_options(command)
|
|
523
|
+
group.add_argument("--report-file", metavar="FILE", help=REPORT_FILE_HELP)
|
|
524
|
+
if name == "config":
|
|
525
|
+
actions = command.add_subparsers(
|
|
526
|
+
dest="config_action", required=True, title="actions", metavar="<action>"
|
|
527
|
+
)
|
|
528
|
+
for action, help_text in CONFIG_ACTIONS.items():
|
|
529
|
+
inner = actions.add_parser(
|
|
530
|
+
action, json_errors=json_errors, help=help_text, description=help_text
|
|
531
|
+
)
|
|
532
|
+
_add_global_options(inner, on_a_command=True)
|
|
533
|
+
if action == "init":
|
|
534
|
+
inner.add_argument(
|
|
535
|
+
"--force", action="store_true", help="replace a file that's already there"
|
|
536
|
+
)
|
|
537
|
+
parser.set_defaults(
|
|
538
|
+
target=None,
|
|
539
|
+
target_dir=None,
|
|
540
|
+
frontend=None,
|
|
541
|
+
source=None,
|
|
542
|
+
report_file=None,
|
|
543
|
+
overwrite=False,
|
|
544
|
+
dry_run=False,
|
|
545
|
+
move=False,
|
|
546
|
+
clean_unmatched=False,
|
|
547
|
+
yes=False,
|
|
548
|
+
fail_if_missing=False,
|
|
549
|
+
verify=False,
|
|
550
|
+
config_action=None,
|
|
551
|
+
force=False,
|
|
552
|
+
)
|
|
553
|
+
return parser
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def main(argv: list[str] | None = None, *, base_dir: Path | None = None) -> int:
|
|
557
|
+
"""Run BECK and return its exit code. Settings and files are found under ``base_dir``.
|
|
558
|
+
|
|
559
|
+
With no ``base_dir``, that's the current directory: an installed tool has no folder of its own.
|
|
560
|
+
Output that can't be written doesn't change the run or its exit code (DEC-62).
|
|
561
|
+
"""
|
|
562
|
+
try:
|
|
563
|
+
return _main(argv, base_dir)
|
|
564
|
+
finally:
|
|
565
|
+
finish_output()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _main(argv: list[str] | None, base_dir: Path | None) -> int:
|
|
569
|
+
forget_secrets()
|
|
570
|
+
add_secret(os.environ.get("BECK_SMB_PASSWORD", ""))
|
|
571
|
+
parser = _build_parser(json_errors=_asks_for_json(sys.argv[1:] if argv is None else argv))
|
|
572
|
+
try:
|
|
573
|
+
args = parser.parse_args(argv)
|
|
574
|
+
if args.verbose and args.quiet: # checked here because they can be on different sides
|
|
575
|
+
parser.error("-v and -q can't be used together")
|
|
576
|
+
except UsageError as error:
|
|
577
|
+
write_rejected_command(str(error))
|
|
578
|
+
return error.exit_code
|
|
579
|
+
logger = configure_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
|
580
|
+
_log.debug("BECK %s, Python %s", __version__, platform.python_version())
|
|
581
|
+
renderer: Renderer
|
|
582
|
+
if args.json:
|
|
583
|
+
# The frontend can come from the settings file, which may be what fails.
|
|
584
|
+
renderer = JsonRenderer(
|
|
585
|
+
args.command or "check",
|
|
586
|
+
frontend=args.frontend or DEFAULT_FRONTEND,
|
|
587
|
+
dry_run=args.dry_run,
|
|
588
|
+
action=args.config_action,
|
|
589
|
+
)
|
|
590
|
+
else:
|
|
591
|
+
environ = {**os.environ, "NO_COLOR": "1"} if args.no_color else None
|
|
592
|
+
# A list is what report and scan are for, so quiet is only for check.
|
|
593
|
+
quiet = args.quiet and args.command in (None, "check")
|
|
594
|
+
renderer = ConsoleRenderer(environ=environ, quiet=quiet, progress=not args.verbose)
|
|
595
|
+
if args.command is None and not args.quiet:
|
|
596
|
+
logger.warning("Hint: use 'beck check'. Running beck with no command is going away.")
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
folder = Path.cwd() if base_dir is None else base_dir
|
|
600
|
+
_notice_old_settings(args, folder)
|
|
601
|
+
if args.command == "config":
|
|
602
|
+
return _run_config(args, folder, renderer)
|
|
603
|
+
settings = _load_settings(args)
|
|
604
|
+
renderer.uses_frontend(settings.frontend)
|
|
605
|
+
if args.command == "scan":
|
|
606
|
+
return _run_scan(settings, folder, renderer)
|
|
607
|
+
if args.command == "report":
|
|
608
|
+
return _run_report(args, settings, folder, renderer)
|
|
609
|
+
return _run_check(args, settings, folder, renderer)
|
|
610
|
+
except BeckError as error:
|
|
611
|
+
renderer.error(error)
|
|
612
|
+
return error.exit_code
|
|
613
|
+
except Exception:
|
|
614
|
+
# A bug in BECK: the traceback goes to stderr, for the bug report.
|
|
615
|
+
logging.getLogger(LOGGER_NAME).exception("Unexpected error")
|
|
616
|
+
renderer.unexpected_error()
|
|
617
|
+
return 1
|