llmt-cli 0.2.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.
llmt/scancmd.py ADDED
@@ -0,0 +1,556 @@
1
+ """``llmt scan`` — "you already have these models; seed them."
2
+
3
+ Flow:
4
+ 1. Resolve scan roots (auto-detected known layouts, or explicit --path).
5
+ 2. Prefilter + hash local candidate files (full-file SHA-256).
6
+ 3. Look them up against the registry (POST /api/artifacts/lookup, <=100/chunk).
7
+ 4. Offer only catalog-matched, seedable matches (the local file's
8
+ full-file SHA-256 equals a SHA-256 the catalog returned over the
9
+ TLS lookup — this is NOT an Ed25519 signed-manifest check).
10
+ 5. On accept: plan the layout (link farm, no copy/move) via the frozen
11
+ plan_layout/apply_plan, then add to the user's client (qBit/Transmission/
12
+ fallback) with the add-paused -> recheck -> resume recipe.
13
+
14
+ No telemetry, no ledger, no account, no heartbeat (DR-27): the only network the
15
+ MVP performs is the anonymous public lookup plus whatever a normal BitTorrent
16
+ client does once the torrent is added.
17
+
18
+ Multi-file containers (M5, size-aware Option C, Pat 2026-07-13): before a
19
+ multi-file torrent is handed to a client, scan reads the torrent's full file
20
+ list from its Ed25519-signed manifest and checks which files are already
21
+ present at the layout. Missing license/README-class siblings (<= 10 MiB
22
+ cumulatively per container) stay wanted and are fetched by the client, with a
23
+ one-line notice naming them and the directory they land in; any larger
24
+ missing file is marked do-not-download (qBt filePrio 0 / Transmission
25
+ files-unwanted) BEFORE the torrent can resume, with a warning that the
26
+ torrent will seed only what is present and will show as incomplete. Model
27
+ weights are never re-downloaded.
28
+
29
+ Trust level (honest scoping): a scan "match" means the local file's
30
+ full-file SHA-256 equals a SHA-256 the catalog returned over the TLS-only,
31
+ unauthenticated lookup. Unlike ``pull``/``verify``/``seed`` (Fix M2), scan
32
+ runs NO Ed25519 signed-manifest verification, so it reports matches as
33
+ "catalog-matched", never "verified". Signed-manifest gating for ``scan``
34
+ is a roadmap item.
35
+
36
+ Exit status (D15, Fix 11): scan processes every accepted container to
37
+ completion ("make -k") -- a handoff that fails or cannot be verified
38
+ (``AddResult.ok`` False, or a ``ClientError`` raised for one container) is
39
+ recorded and does NOT abort the others. Each ``--json`` ``added[]`` record
40
+ carries ``handoff_ok`` (True = confirmed seeding, False = failed or not
41
+ confirmed, null = FallbackClient manual instructions) plus
42
+ ``handoff_message``; a
43
+ top-level ``handoff_summary`` counts each state. If ANY handoff failed the
44
+ whole run exits 14 (the client-error code); an all-success and/or
45
+ fallback-only run exits 0.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import os
51
+ import sys
52
+ from typing import Optional
53
+
54
+ from . import scanner, matcher
55
+ from .api import ApiClient, DEFAULT_API_BASE
56
+ from .clients import ClientError, FileSelection, SeedClient, build_client
57
+ from .errors import LayoutError, ManifestVerificationError
58
+ from .layout import (
59
+ CLIENT_LINK_SUPPORT,
60
+ DEFAULT_BASE_DIR,
61
+ M5_AUTO_FETCH_MAX_BYTES,
62
+ apply_plan,
63
+ classify_missing_files,
64
+ plan_layout,
65
+ safe_join,
66
+ sanitize_relpath,
67
+ )
68
+ from .manifest import fetch_verified_manifest, manifest_files
69
+ from .output import emit_json, human_size, safe_text
70
+
71
+
72
+ # --------------------------------------------------------------------------- #
73
+ # argparse wiring (called from cli.build_parser)
74
+ # --------------------------------------------------------------------------- #
75
+
76
+ def _size_arg(raw: str) -> int:
77
+ s = raw.strip().upper()
78
+ mult = 1
79
+ for suf, m in (("K", 1024), ("M", 1024**2), ("G", 1024**3), ("T", 1024**4)):
80
+ if s.endswith(suf) or s.endswith(suf + "B"):
81
+ mult = m
82
+ s = s.rstrip("B").rstrip(suf)
83
+ break
84
+ try:
85
+ return int(float(s) * mult)
86
+ except ValueError:
87
+ raise ValueError(f"invalid size: {raw!r}")
88
+
89
+
90
+ def add_scan_args(sp) -> None:
91
+ sp.add_argument("--path", action="append", default=[],
92
+ help="Explicit directory to scan (repeatable). If given, "
93
+ "only these paths are scanned (bounds scope). "
94
+ "Otherwise known layouts are auto-detected.")
95
+ sp.add_argument("--min-size", type=_size_arg, default=scanner.DEFAULT_MIN_SIZE,
96
+ help="Prefilter: ignore files smaller than this "
97
+ "(e.g. 100M, 2G). Default 1M.")
98
+ sp.add_argument("--all-files", action="store_true",
99
+ help="Hash every file passing the size filter, not just "
100
+ "known model extensions.")
101
+ sp.add_argument("--max-depth", type=int, default=scanner.DEFAULT_MAX_DEPTH,
102
+ help="Max directory recursion depth (loop/DoS guard).")
103
+ sp.add_argument("--layout-base", default=None,
104
+ help="Root for generated seeding link farms "
105
+ "(default ~/.llmt/seeding-layouts). Must be a path "
106
+ "your torrent client can see: a dockerized/remote "
107
+ "client (seedbox/unRAID) only sees its mounted "
108
+ "volumes, so point this at one of them or the "
109
+ "handoff rechecks empty and fails closed.")
110
+ sp.add_argument("--client", choices=["qbittorrent", "transmission", "none"],
111
+ default=None,
112
+ help="Torrent client to add matches to. Default: auto "
113
+ "(qBit/Transmission if a URL is configured, else "
114
+ "fallback to printing the magnet).")
115
+ sp.add_argument("--qb-url", default=None, help="qBittorrent WebUI base URL "
116
+ "(env LLMT_QB_URL).")
117
+ sp.add_argument("--qb-user", default=None, help="qBittorrent user (env "
118
+ "LLMT_QB_USER).")
119
+ sp.add_argument("--qb-pass", default=None, help="qBittorrent password (env "
120
+ "LLMT_QB_PASS).")
121
+ sp.add_argument("--tr-url", default=None, help="Transmission RPC URL (env "
122
+ "LLMT_TR_URL).")
123
+ sp.add_argument("--tr-user", default=None, help="Transmission user (env "
124
+ "LLMT_TR_USER).")
125
+ sp.add_argument("--tr-pass", default=None, help="Transmission password (env "
126
+ "LLMT_TR_PASS).")
127
+ sp.add_argument("--yes", "-y", action="store_true",
128
+ help="Non-interactive: accept and seed every match.")
129
+ sp.add_argument("--dry-run", action="store_true",
130
+ help="Show matches and the plan; create no links and add "
131
+ "nothing to any client.")
132
+
133
+
134
+ # --------------------------------------------------------------------------- #
135
+ # helpers
136
+ # --------------------------------------------------------------------------- #
137
+
138
+ def _resolve_roots(args) -> list[tuple[str, str]]:
139
+ if args.path:
140
+ return [(os.path.abspath(os.path.expanduser(p)), scanner.LAYOUT_PATH)
141
+ for p in args.path]
142
+ return scanner.default_roots()
143
+
144
+
145
+ def _layout_client_hint(client: SeedClient) -> str:
146
+ return client.name if client.name in CLIENT_LINK_SUPPORT else "generic"
147
+
148
+
149
+ def _client_visible_path(plan) -> str:
150
+ """Per the frozen layout contract, the path the client will present."""
151
+ if plan.mode == "link-farm":
152
+ for act in reversed(plan.actions):
153
+ if act.kind in ("hardlink", "symlink"):
154
+ return act.target
155
+ return plan.source_file
156
+
157
+
158
+ def _fmt_files(pairs) -> str:
159
+ return ", ".join(f"{safe_text(path)} ({human_size(size)})"
160
+ for path, size in pairs)
161
+
162
+
163
+ def _plan_file_selection(api, pairs):
164
+ """M5 (size-aware Option C, Pat 2026-07-13): classify a container's files.
165
+
166
+ ``pairs`` is ``[(match, plan), ...]`` -- ALL accepted matches that belong
167
+ to ONE container (same info hash), sharing one save path. The matcher
168
+ yields one Match per LOCAL FILE, so a container the user holds several
169
+ files of arrives here as several pairs; every one of them counts as
170
+ PRESENT (review fix for 6d548a2: previously each match was classified in
171
+ isolation, so a user-held sibling was falsely marked missing and the same
172
+ info-hash was added once per match).
173
+
174
+ For a multi-file torrent (the sanitised container path has a root
175
+ folder), fetch the torrent's full file list from its Ed25519-SIGNED
176
+ manifest, determine which files are already present at the layout, and
177
+ classify the missing ones via :func:`llmt.layout.classify_missing_files`.
178
+
179
+ Returns ``(selection, missing_report, human_lines)``:
180
+ * ``selection`` -- a :class:`FileSelection` for the client
181
+ adapters, or None when nothing is missing
182
+ (fully-present container / single-file torrent
183
+ -- those flows are UNCHANGED, no manifest
184
+ fetch happens for single-file torrents);
185
+ * ``missing_report`` -- ``{"fetched": [...], "skipped": [...]}`` with
186
+ path + size_bytes for ``--json``;
187
+ * ``human_lines`` -- the notice / warning lines for humans.
188
+
189
+ Fail closed: if the manifest cannot be fetched/verified, lists any path
190
+ twice (after normalization -- ambiguous bookkeeping), or does not list
191
+ every matched file with its local digest and size, the container is
192
+ refused (ManifestVerificationError) rather than handed to a client that
193
+ would download unknown siblings.
194
+ """
195
+ empty = {"fetched": [], "skipped": []}
196
+ m0, plan0 = pairs[0]
197
+ if not plan0.torrent_root:
198
+ # Single-file torrent: no siblings can exist -- unchanged flow.
199
+ return None, empty, []
200
+
201
+ container = m0.container
202
+ doc = fetch_verified_manifest(api, container.info_hash_v1)
203
+ files = manifest_files(doc)
204
+
205
+ def norm(path):
206
+ return "/".join(sanitize_relpath(path))
207
+
208
+ by_path = {}
209
+ for f in files:
210
+ rel = norm(f["path"])
211
+ if rel in by_path:
212
+ raise ManifestVerificationError(
213
+ f"the Ed25519-signed manifest for torrent "
214
+ f"{container.info_hash_v1} lists {rel!r} more than once "
215
+ "(after path normalization) -- ambiguous file list; refusing "
216
+ "to seed this container (nothing was added).")
217
+ by_path[rel] = f
218
+
219
+ matched = set()
220
+ for m, plan in pairs:
221
+ entry = by_path.get(plan.in_torrent_path)
222
+ if (entry is None or entry["sha256"] != m.file.sha256
223
+ or (entry["size"] is not None
224
+ and entry["size"] != m.file.size)):
225
+ raise ManifestVerificationError(
226
+ f"the Ed25519-signed manifest for torrent "
227
+ f"{container.info_hash_v1} does not list the matched file "
228
+ f"{plan.in_torrent_path!r} with the locally-computed digest "
229
+ "and size -- catalog/manifest disagreement; refusing to seed "
230
+ "this container (nothing was added).")
231
+ matched.add(plan.in_torrent_path)
232
+
233
+ missing = []
234
+ for rel, f in by_path.items():
235
+ if rel in matched:
236
+ continue # a user-held, catalog-matched file of this container
237
+ expected = safe_join(plan0.save_path, sanitize_relpath(f["path"]))
238
+ present = (os.path.isfile(expected)
239
+ and (f["size"] is None
240
+ or os.path.getsize(expected) == f["size"]))
241
+ if not present:
242
+ missing.append((rel, f["size"]))
243
+
244
+ if not missing:
245
+ # Fully-present multi-file container: unchanged flow, no warning.
246
+ return None, empty, []
247
+
248
+ fetch, skip = classify_missing_files(missing)
249
+ selection = FileSelection(
250
+ unwanted=tuple(path for path, _ in skip),
251
+ expect_fetch=tuple(path for path, _ in fetch),
252
+ )
253
+ report = {
254
+ "fetched": [{"path": path, "size_bytes": size}
255
+ for path, size in fetch],
256
+ "skipped": [{"path": path, "size_bytes": size}
257
+ for path, size in skip],
258
+ }
259
+ land_dir = os.path.join(plan0.save_path, plan0.torrent_root)
260
+ cap = human_size(M5_AUTO_FETCH_MAX_BYTES)
261
+ lines = []
262
+ if fetch:
263
+ total = human_size(sum(size or 0 for _, size in fetch))
264
+ lines.append(
265
+ f"note: {len(fetch)} small companion file(s) missing locally "
266
+ f"will be fetched by your client into {land_dir}: "
267
+ f"{_fmt_files(fetch)} ({total} total, <={cap} cap) -- model "
268
+ f"weights are never re-downloaded.")
269
+ if skip:
270
+ lines.append(
271
+ f"WARNING: {len(skip)} missing file(s) exceed the {cap} "
272
+ f"auto-fetch cap and will NOT be downloaded: "
273
+ f"{_fmt_files(skip)}. They are set to do-not-download before "
274
+ f"the torrent can resume; it will seed only the files you have "
275
+ f"and will show as incomplete in your client.")
276
+ return selection, report, lines
277
+
278
+
279
+ # --------------------------------------------------------------------------- #
280
+ # command
281
+ # --------------------------------------------------------------------------- #
282
+
283
+ def cmd_scan(args) -> int:
284
+ return run_scan(args)
285
+
286
+
287
+ def run_scan(args, *, client: Optional[SeedClient] = None,
288
+ api_client: Optional[ApiClient] = None) -> int:
289
+ as_json = getattr(args, "json", False)
290
+
291
+ def note(msg: str) -> None:
292
+ if not as_json:
293
+ print(msg, file=sys.stderr)
294
+
295
+ roots = _resolve_roots(args)
296
+ if not roots:
297
+ if as_json:
298
+ emit_json({"scanned": 0, "matches": [], "note": "no roots"})
299
+ else:
300
+ print("No model directories found. Pass --path DIR to scan a "
301
+ "specific location.", file=sys.stderr)
302
+ return 0
303
+
304
+ note("Scanning: " + ", ".join(r for r, _ in roots))
305
+ candidates = list(scanner.iter_candidates(
306
+ roots, min_size=args.min_size, all_files=args.all_files,
307
+ max_depth=args.max_depth,
308
+ ))
309
+ note(f"Found {len(candidates)} candidate file(s) to hash "
310
+ f"({human_size(sum(c.size for c in candidates))}).")
311
+
312
+ labels: dict[str, str] = {}
313
+ for root, layout in roots:
314
+ if layout == scanner.LAYOUT_OLLAMA:
315
+ labels.update(scanner.ollama_labels(root))
316
+
317
+ def progress(i: int, total: int, path: str) -> None:
318
+ note(f" [hash {i}/{total}] {os.path.basename(path)}")
319
+
320
+ hashed = scanner.hash_candidates(candidates, labels=labels, progress=progress)
321
+
322
+ api = api_client or ApiClient(base=args.api_base, key=args.api_key,
323
+ timeout=args.timeout)
324
+ results = matcher.lookup_shas(api, [h.sha256 for h in hashed])
325
+ matches, unmatched, size_bad = matcher.assemble_matches(hashed, results)
326
+
327
+ if as_json:
328
+ emit_json({
329
+ "scanned": len(candidates),
330
+ "hashed": len(hashed),
331
+ "matches": [_match_json(m) for m in matches],
332
+ "unmatched": len(unmatched),
333
+ "size_mismatch": [
334
+ {"path": hf.path, "local_size": hf.size,
335
+ "declared_size": e.size_bytes} for hf, e in size_bad],
336
+ })
337
+ else:
338
+ _print_report(matches, hashed, unmatched, size_bad)
339
+
340
+ if not matches:
341
+ return 0
342
+
343
+ # --- accept / confirm -------------------------------------------------- #
344
+ if args.dry_run or args.yes:
345
+ accepted = matches
346
+ else:
347
+ # Never prompt in --json mode: input()'s prompt would corrupt the JSON
348
+ # stream on stdout. Require explicit --yes/--dry-run instead.
349
+ if as_json or not sys.stdin.isatty():
350
+ note("Refusing to prompt on a non-interactive/JSON invocation; "
351
+ "re-run with --yes to seed or --dry-run to preview.")
352
+ return 0
353
+ try:
354
+ ans = input(f"Seed these {len(matches)} file(s)? [y/N] ").strip().lower()
355
+ except EOFError:
356
+ ans = ""
357
+ if ans not in ("y", "yes"):
358
+ print("Aborted; nothing was added.")
359
+ return 0
360
+ accepted = matches
361
+
362
+ client = client or build_client(args)
363
+ if not as_json:
364
+ print(f"\nUsing client: {client.name}"
365
+ + (" (dry-run)" if args.dry_run else ""))
366
+
367
+ # S3 pre-flight: warn early if the client looks path-blind (dockerized or
368
+ # remote) so a 0% recheck below is not the FIRST hint the user gets.
369
+ # Advisory only -- it never blocks; the per-container recheck stays the
370
+ # authoritative, fail-closed gate. Skipped under --json (its advisory
371
+ # goes to stderr only, so the extra login+preferences round-trip would be
372
+ # discarded).
373
+ if not args.dry_run and not as_json:
374
+ probe_fn = getattr(client, "probe_local_visibility", None)
375
+ if callable(probe_fn):
376
+ probe = probe_fn()
377
+ if probe.supported and probe.visible_locally is False:
378
+ eff_base = os.path.abspath(os.path.expanduser(
379
+ args.layout_base or DEFAULT_BASE_DIR))
380
+ note(
381
+ "WARNING: your torrent client's own default save path "
382
+ f"({probe.client_default_path}) does not exist on the "
383
+ "machine llmt is running on -- llmt and the client look "
384
+ "like they are on different filesystems (a dockerized "
385
+ "seedbox/unRAID or a remote client). llmt's link farm at "
386
+ f"{eff_base} is a path on THIS machine, so the client may "
387
+ "not be able to see it and the seed handoff below would "
388
+ "recheck to 0% and fail closed. If that happens, re-run "
389
+ "with --layout-base pointing at a directory the client "
390
+ "can see (one of its mounted volumes).")
391
+
392
+ # M5 (review fix for 6d548a2): the matcher yields one Match per LOCAL
393
+ # FILE, so a container the user holds several files of appears as several
394
+ # matches. Group by torrent identity and hand each torrent to the client
395
+ # exactly ONCE, with every user-held file counted as present -- adding
396
+ # the same info-hash once per match double-adds (qBittorrent answers
397
+ # HTTP 200 "Fails.", Transmission returns torrent-duplicate) and falsely
398
+ # classifies later-matched siblings as missing.
399
+ groups: "dict[str, list]" = {}
400
+ for m in accepted:
401
+ key = (m.container.info_hash_v1 or m.container.magnet
402
+ or f"anon-{id(m.container)}")
403
+ groups.setdefault(key, []).append(m)
404
+
405
+ add_reports = []
406
+ for group in groups.values():
407
+ pairs = []
408
+ for m in group:
409
+ plan = plan_layout(
410
+ m.file.path, m.container.container_dict(),
411
+ base_dir=args.layout_base, client=_layout_client_hint(client),
412
+ )
413
+ pairs.append((m, plan))
414
+ save_paths = sorted({plan.save_path for _, plan in pairs})
415
+ if len(save_paths) > 1:
416
+ # One torrent cannot be added at two save paths (mixed
417
+ # direct-use/link-farm layouts across this container's matched
418
+ # files). Fail closed with an honest error instead of guessing.
419
+ raise LayoutError(
420
+ f"the {len(pairs)} matched files of "
421
+ f"'{safe_text(pairs[0][0].container.describe())}' resolve to "
422
+ "different save paths (mixed direct-use/link-farm layouts): "
423
+ + ", ".join(save_paths) + " -- one torrent cannot be added "
424
+ "at two save paths, so nothing was added for this container. "
425
+ "Keep those files under a single layout and re-run.")
426
+ m0, plan0 = pairs[0]
427
+ # M5: classify missing siblings BEFORE the client handoff so large
428
+ # missing files are do-not-download before any resume is possible;
429
+ # every matched file of this container counts as present.
430
+ selection, missing_report, m5_lines = _plan_file_selection(api, pairs)
431
+ logs = [apply_plan(plan, dry_run=args.dry_run) for _, plan in pairs]
432
+ # D15 (Fix 11): the handoff is the only per-container failure
433
+ # point. "make -k": a failed/refused handoff (ok=False returned, or
434
+ # ClientError raised) is recorded and MUST NOT abort the remaining
435
+ # containers. handoff_ok is tri-state -- True = confirmed seeding,
436
+ # False = failed/unverified (forces a non-zero exit), None =
437
+ # FallbackClient manual instructions (told the user what to do; not
438
+ # a success, not a failure, no non-zero exit for a fallback alone).
439
+ res = None
440
+ handoff_message = ""
441
+ method = client.name
442
+ try:
443
+ res = client.add(
444
+ magnet=m0.container.magnet,
445
+ info_hash=m0.container.info_hash_v1,
446
+ save_path=plan0.save_path,
447
+ torrent_name=m0.container.torrent_name,
448
+ dry_run=args.dry_run, file_selection=selection,
449
+ )
450
+ except ClientError as exc:
451
+ handoff_ok = False
452
+ handoff_message = str(exc)
453
+ else:
454
+ method = res.method
455
+ if res.method == "fallback-magnet":
456
+ handoff_ok = None
457
+ handoff_message = res.message
458
+ else:
459
+ handoff_ok = bool(res.ok)
460
+ handoff_message = res.message
461
+ add_reports.append({
462
+ "pairs": pairs, "logs": logs, "res": res,
463
+ "missing_report": missing_report, "method": method,
464
+ "handoff_ok": handoff_ok, "handoff_message": handoff_message,
465
+ })
466
+ if not as_json:
467
+ print(f"\n {safe_text(m0.container.describe())}")
468
+ for m, _plan in pairs:
469
+ print(f" file : {m.file.path}")
470
+ print(f" plan : {plan0.mode} -> save_path {plan0.save_path}")
471
+ for line in m5_lines:
472
+ print(f" {line}")
473
+ if res is not None:
474
+ for line in res.steps:
475
+ print(f" - {line}")
476
+ if res.method == "fallback-magnet":
477
+ print(f" magnet: {res.message}")
478
+ if handoff_ok is False:
479
+ print(f" [HANDOFF FAILED] not seeding: {handoff_message}")
480
+ elif handoff_ok is None:
481
+ print(" [MANUAL] added, but you must finish the handoff "
482
+ "shown above; NOT yet seeding")
483
+
484
+ n_ok = sum(1 for r in add_reports if r["handoff_ok"] is True)
485
+ n_manual = sum(1 for r in add_reports if r["handoff_ok"] is None)
486
+ n_failed = sum(1 for r in add_reports if r["handoff_ok"] is False)
487
+
488
+ if as_json:
489
+ emit_json({
490
+ "added": [{
491
+ "sha256": r["pairs"][0][0].file.sha256,
492
+ "path": r["pairs"][0][0].file.path,
493
+ "matched_files": [
494
+ {"sha256": m.file.sha256, "path": m.file.path}
495
+ for m, _plan in r["pairs"]],
496
+ "mode": r["pairs"][0][1].mode,
497
+ "save_path": r["pairs"][0][1].save_path,
498
+ "client": r["method"],
499
+ "steps": r["res"].steps if r["res"] is not None else [],
500
+ "dry_run": (r["res"].dry_run if r["res"] is not None
501
+ else bool(args.dry_run)),
502
+ "missing_files": r["missing_report"],
503
+ "handoff_ok": r["handoff_ok"],
504
+ "handoff_message": r["handoff_message"],
505
+ } for r in add_reports],
506
+ "handoff_summary": {
507
+ "ok": n_ok, "manual": n_manual, "failed": n_failed},
508
+ })
509
+ else:
510
+ parts = [f"{n_ok} confirmed seeding"]
511
+ if n_manual:
512
+ parts.append(
513
+ f"{n_manual} awaiting manual action (not yet seeding)")
514
+ if n_failed:
515
+ parts.append(f"{n_failed} FAILED (not seeding)")
516
+ print("\nHandoff summary: " + ", ".join(parts) + ".")
517
+
518
+ # D15: any failed/unverified handoff makes the whole run exit non-zero
519
+ # (reuse the client-error code 14). Manual (fallback) and all-ok do not.
520
+ return ClientError.exit_code if n_failed else 0
521
+
522
+
523
+ def _match_json(m: matcher.Match) -> dict:
524
+ return {
525
+ "sha256": m.file.sha256,
526
+ "path": m.file.path,
527
+ "size": m.file.size,
528
+ "label": m.file.label,
529
+ "layout": m.file.layout,
530
+ "model": m.container.describe(),
531
+ "torrent_name": m.container.torrent_name,
532
+ "status": m.container.status,
533
+ }
534
+
535
+
536
+ def _print_report(matches, hashed, unmatched, size_bad) -> None:
537
+ print()
538
+ if size_bad:
539
+ print("Refused (size disagreement between disk and registry — not "
540
+ "offered):")
541
+ for hf, entry in size_bad:
542
+ print(f" ! {hf.path} (on disk {human_size(hf.size)}, registry "
543
+ f"declared {human_size(entry.size_bytes)})")
544
+ print()
545
+ if not matches:
546
+ print(f"Scanned {len(hashed)} file(s); no seedable catalog matches "
547
+ f"(no local file's full-file SHA-256 matched the catalog).")
548
+ return
549
+ print(f"You already have {len(matches)} model file(s) whose full-file "
550
+ f"SHA-256 matches the catalog (TLS lookup, not an Ed25519 "
551
+ f"signature). Seed these?")
552
+ for i, m in enumerate(matches, 1):
553
+ lbl = f" ({safe_text(m.file.label)})" if m.file.label else ""
554
+ print(f" {i}. {safe_text(m.container.describe())}{lbl}")
555
+ print(f" {m.file.path} [{human_size(m.file.size)}]")
556
+ print()