bindery-cli 0.47.2__tar.gz → 0.48.1__tar.gz
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.
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/PKG-INFO +1 -1
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/patchnotes.md +66 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/pyproject.toml +1 -1
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/roadmap.md +1 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/cli.py +274 -22
- bindery_cli-0.48.1/tests/test_help.py +208 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/uv.lock +1 -1
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/.github/FUNDING.yml +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/.gitignore +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/AGENTS.md +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/CLAUDE.md +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/LICENSE +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/README.md +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/logo.svg +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/plugin/__init__.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/project.done +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/run_tests.sh +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/spec.md +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/audit.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/epub.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/library.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/transforms.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/validate.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_audit.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_cli.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_comment_hyphens.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_cover_tocdrift.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_epub.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_flags_real_core.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_flags_wiring.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_library.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_matcher_hardening.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_pagination.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_plugin.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_space_renames.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_transforms.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_validate.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_version.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_watermark.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.1}/tests/test_xml_hardening.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.48.1
|
|
4
4
|
Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
|
|
5
5
|
Project-URL: Homepage, https://github.com/VirInvictus/bindery-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/VirInvictus/bindery-cli
|
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
2
|
|
|
3
|
+
## v0.48.1 (2026-10-06)
|
|
4
|
+
|
|
5
|
+
### The 12-agent audit: the exit contracts tell the truth
|
|
6
|
+
|
|
7
|
+
Three waves of four lenses audited this help surface. The flag
|
|
8
|
+
inventory verified perfect (30/30 repair flags, zero misses, zero
|
|
9
|
+
fabrications); the defects were the exit contracts and the safety
|
|
10
|
+
silences:
|
|
11
|
+
|
|
12
|
+
- **repair --help stated an exit contract the verb has never honored**
|
|
13
|
+
(trouble returns 1, not 2; a PARTIAL repair writes its output and
|
|
14
|
+
returns 0). The page and --help-json now tell the code's truth, per
|
|
15
|
+
verb: repair 0/written + 1, audit's deliberate inverse contract
|
|
16
|
+
(0 clean; 1 findings; 2 usage/environment; 3 findings plus a failed
|
|
17
|
+
report write), library and the run slices on the unified 0/2/1.
|
|
18
|
+
calibre_gates names the third metadata.db door (run phase3, guarded
|
|
19
|
+
transitively), and the contract admits the missing-stack exit-2 and
|
|
20
|
+
the exit-3 class.
|
|
21
|
+
- **The library page states the no-backup default**: --apply without
|
|
22
|
+
--backup/--backup-inplace replaces in place and keeps no copy of the
|
|
23
|
+
original. --no-validate says the write still happens (status
|
|
24
|
+
unvalidated). --backup names the outside-the-library refusal.
|
|
25
|
+
--only fatals names its --audit/--sweep precondition.
|
|
26
|
+
- audit and doctor got real descriptions (the eight analyzers, the CWD
|
|
27
|
+
library resolution, the one-id --json rule; doctor's always-exit-0
|
|
28
|
+
contract); the --help-repairs preamble names the five always-on
|
|
29
|
+
fixes, defines the gate metric, enumerates the status vocabulary,
|
|
30
|
+
and states the no-worse classes; phase1's exit list gains
|
|
31
|
+
audit-flagged books; repair's positionals, default output name, and
|
|
32
|
+
never-modifies-the-input are stated; the --json envelopes name all
|
|
33
|
+
keys including books; the root help points at doctor/README/spec.
|
|
34
|
+
- One code fix: the no-backup lossy warning now covers
|
|
35
|
+
--strip-stub-docs (it warned for its three siblings only).
|
|
36
|
+
|
|
37
|
+
15 pins updated/added (639 total).
|
|
38
|
+
|
|
39
|
+
## v0.48.0 (2026-10-06)
|
|
40
|
+
|
|
41
|
+
### The closed-Calibre guard, stated contracts, and --help-json
|
|
42
|
+
|
|
43
|
+
The AI-grokability probe (a fresh agent learning the CLI from help
|
|
44
|
+
alone, the same method CalibreQuarry ran) confirmed the repair-semantics
|
|
45
|
+
help is complete and found the cross-cutting contracts missing. Three
|
|
46
|
+
fixes:
|
|
47
|
+
|
|
48
|
+
- **The closed-Calibre gate is enforced, not just stated.** audit
|
|
49
|
+
--tag and library --apply --install-to-calibre both write
|
|
50
|
+
metadata.db, and nothing stopped that write under a live Calibre
|
|
51
|
+
(WritableCalibreDB waits on the sqlite lock and proceeds; a running
|
|
52
|
+
Calibre holds no persistent exclusive lock). Both doors now share a
|
|
53
|
+
fail-closed pgrep refusal (exit 1: an environment condition, not a
|
|
54
|
+
usage error), and --install-to-calibre's help says so.
|
|
55
|
+
- **The help states the contracts** the probe could not find: exit
|
|
56
|
+
codes on the repair, library, and run-slice pages (0 clean, 2
|
|
57
|
+
trouble, 1 invocation problem); neither mode ever prompts (--apply is
|
|
58
|
+
the only confirmation); an existing output without --force is exit 1;
|
|
59
|
+
NCX-001 defined (toc.ncx dtb:uid != the OPF unique-identifier); --id
|
|
60
|
+
works without --sweep; the repair and library --json envelopes name
|
|
61
|
+
their keys.
|
|
62
|
+
- **--help-json**: the complete surface (subcommands with every flag,
|
|
63
|
+
the run slices, the full repair reference, the exit contract, the
|
|
64
|
+
Calibre gates) as deterministic JSON, generated from the live parser
|
|
65
|
+
and _REPAIR_FLAG_TABLE, never colored.
|
|
66
|
+
|
|
67
|
+
15 new tests (639 total).
|
|
68
|
+
|
|
3
69
|
## v0.47.2 (2026-10-06)
|
|
4
70
|
|
|
5
71
|
### The --help-repairs reference keeps argparse's color
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.48.1"
|
|
8
8
|
description = "Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
classifiers = [
|
|
@@ -41,6 +41,7 @@ The plugin vendors the core and never gates (the epubcheck latency ruling).
|
|
|
41
41
|
| 0.45 | Structured fix records (`fixes`, `ncx_uid_synced`, `watermark_refusals`) in every JSON payload; CalibreQuarry consumes them with the 0.45.0 floor | v0.45.0 |
|
|
42
42
|
| 0.46 | RSC-016 `--fix-comment-double-hyphen` (the last open repair class) and the RepairFlags dataclass | v0.46.0 |
|
|
43
43
|
| 0.47 | The intake-wave batch (five open issues): the stub classifier's body-pool + image-carrier exemption, the NCName-complete `--fix-ids`, and `--fix-svg-dup-ids`, `--fix-cdata-terminator`, `--fix-misnested-inline`, `--fix-stray-close`, `--fix-unterminated-attr` | v0.47.0 |
|
|
44
|
+
| 0.48 | The AI-probe batch: the enforced closed-Calibre gate on both metadata.db doors (`audit --tag`, `library --apply --install-to-calibre`), the stated contracts (exit codes, no-prompt, NCX-001, --id scoping, json envelope keys), and `--help-json` | v0.48.0 |
|
|
44
45
|
|
|
45
46
|
## Open work
|
|
46
47
|
|
|
@@ -11,7 +11,7 @@ Calibre plugin built from this core.
|
|
|
11
11
|
Single source of truth for the version. pyproject.toml must match VERSION below.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "0.
|
|
14
|
+
VERSION = "0.48.1"
|
|
15
15
|
__version__ = VERSION
|
|
16
16
|
|
|
17
17
|
from .epub import RepairReport, repair_epub # noqa: E402
|
|
@@ -395,8 +395,171 @@ def _epubs_for_ids(root: Path, id_csv: str) -> list[Path] | None:
|
|
|
395
395
|
return epubs
|
|
396
396
|
|
|
397
397
|
|
|
398
|
+
class _HelpJsonAction(argparse.Action):
|
|
399
|
+
"""The machine surface: dump the complete CLI surface as JSON and exit.
|
|
400
|
+
Like _HelpRepairsAction, deliberately not a store_true dest, and never
|
|
401
|
+
colored: the output must stay valid JSON under any environment."""
|
|
402
|
+
|
|
403
|
+
def __init__(
|
|
404
|
+
self,
|
|
405
|
+
option_strings,
|
|
406
|
+
dest=argparse.SUPPRESS,
|
|
407
|
+
default=argparse.SUPPRESS,
|
|
408
|
+
help=None,
|
|
409
|
+
):
|
|
410
|
+
super().__init__(
|
|
411
|
+
option_strings=option_strings,
|
|
412
|
+
dest=dest,
|
|
413
|
+
default=default,
|
|
414
|
+
nargs=0,
|
|
415
|
+
help=help,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
419
|
+
payload = {
|
|
420
|
+
"tool": "bindery",
|
|
421
|
+
"version": __version__,
|
|
422
|
+
"exit_contract": {
|
|
423
|
+
"0": "clean (repair: also written, including partial)",
|
|
424
|
+
"1": "library/run slices: trouble found; repair: trouble "
|
|
425
|
+
"(refused/failed/unreadable) or invocation problem; audit: "
|
|
426
|
+
"FINDINGS (the inverse contract, per spec.md); "
|
|
427
|
+
"lock-class refusals (Calibre open at a metadata.db door)",
|
|
428
|
+
"2": "library/run slices: trouble found (rejected/error/"
|
|
429
|
+
"unreadable/partial books; unscoped phase3 sweep); audit: "
|
|
430
|
+
"usage/environment errors; argparse usage errors; missing "
|
|
431
|
+
"vir_tui/cquarry on Python < 3.14",
|
|
432
|
+
"3": "audit: findings PLUS a failed --json/--tag write",
|
|
433
|
+
"130": "interrupted",
|
|
434
|
+
"per_verb": {
|
|
435
|
+
"repair": "0 clean/written (partial writes, exit 0); "
|
|
436
|
+
"1 trouble or invocation problem; never 2",
|
|
437
|
+
"audit": "0 clean; 1 findings; 2 usage/environment; "
|
|
438
|
+
"3 findings + failed report write",
|
|
439
|
+
"library": "0 clean; 2 trouble (rejected/error/"
|
|
440
|
+
"unreadable/partial); 1 invocation problem",
|
|
441
|
+
"run phase1": "0 clean; 2 trouble (audit-flagged, "
|
|
442
|
+
"rejected, partial, error, unreadable); 1 invocation",
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
"calibre_gates": [
|
|
446
|
+
"audit --tag",
|
|
447
|
+
"library --apply --install-to-calibre",
|
|
448
|
+
"run phase3 (transitively: it wraps library --apply "
|
|
449
|
+
"--install-to-calibre)",
|
|
450
|
+
],
|
|
451
|
+
"subcommands": [],
|
|
452
|
+
"repair_reference": [
|
|
453
|
+
{
|
|
454
|
+
"group": title,
|
|
455
|
+
"flags": [
|
|
456
|
+
{"dest": dest, "summary": summary, "reference": long}
|
|
457
|
+
for dest, summary, long in rows
|
|
458
|
+
],
|
|
459
|
+
}
|
|
460
|
+
for title, rows in _REPAIR_FLAG_TABLE.items()
|
|
461
|
+
],
|
|
462
|
+
}
|
|
463
|
+
subparsers = next(
|
|
464
|
+
(a for a in parser._actions if isinstance(a, argparse._SubParsersAction)),
|
|
465
|
+
None,
|
|
466
|
+
)
|
|
467
|
+
sub_helps = (
|
|
468
|
+
{pa.dest: pa.help for pa in subparsers._choices_actions}
|
|
469
|
+
if subparsers
|
|
470
|
+
else {}
|
|
471
|
+
)
|
|
472
|
+
for name, sub in subparsers.choices.items() if subparsers else []:
|
|
473
|
+
|
|
474
|
+
def sub_flags(subparser):
|
|
475
|
+
out = []
|
|
476
|
+
for group in subparser._action_groups:
|
|
477
|
+
for action in group._group_actions:
|
|
478
|
+
if action.dest in ("help",):
|
|
479
|
+
continue
|
|
480
|
+
entry = {
|
|
481
|
+
"flags": list(action.option_strings) or [action.dest],
|
|
482
|
+
"dest": action.dest,
|
|
483
|
+
"help": " ".join((action.help or "").split()),
|
|
484
|
+
}
|
|
485
|
+
if action.metavar:
|
|
486
|
+
met = action.metavar
|
|
487
|
+
entry["metavar"] = (
|
|
488
|
+
" ".join(met) if isinstance(met, tuple) else met
|
|
489
|
+
)
|
|
490
|
+
if action.choices:
|
|
491
|
+
entry["choices"] = [str(c) for c in action.choices]
|
|
492
|
+
out.append(entry)
|
|
493
|
+
return out
|
|
494
|
+
|
|
495
|
+
payload["subcommands"].append(
|
|
496
|
+
{
|
|
497
|
+
"name": name,
|
|
498
|
+
"help": " ".join((sub_helps.get(name) or "").split()),
|
|
499
|
+
"description": " ".join((sub.description or "").split()),
|
|
500
|
+
"flags": sub_flags(sub),
|
|
501
|
+
"flag_groups": [
|
|
502
|
+
{
|
|
503
|
+
"title": group.title,
|
|
504
|
+
"flags": [
|
|
505
|
+
" ".join((a.help or "").split())
|
|
506
|
+
for a in group._group_actions
|
|
507
|
+
if a.dest != "help"
|
|
508
|
+
],
|
|
509
|
+
}
|
|
510
|
+
for group in sub._action_groups
|
|
511
|
+
if group.title
|
|
512
|
+
],
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
# the run slices are one level deeper
|
|
516
|
+
run_choice = subparsers.choices.get("run") if subparsers else None
|
|
517
|
+
if run_choice is not None:
|
|
518
|
+
run_subs = next(
|
|
519
|
+
(
|
|
520
|
+
a
|
|
521
|
+
for a in run_choice._actions
|
|
522
|
+
if isinstance(a, argparse._SubParsersAction)
|
|
523
|
+
),
|
|
524
|
+
None,
|
|
525
|
+
)
|
|
526
|
+
run_entry = next(
|
|
527
|
+
(s for s in payload["subcommands"] if s["name"] == "run"),
|
|
528
|
+
None,
|
|
529
|
+
)
|
|
530
|
+
if (
|
|
531
|
+
run_choice is not None
|
|
532
|
+
and run_subs is not None
|
|
533
|
+
and run_entry is not None
|
|
534
|
+
):
|
|
535
|
+
slice_helps = {pa.dest: pa.help for pa in run_subs._choices_actions}
|
|
536
|
+
run_entry["slices"] = [
|
|
537
|
+
{
|
|
538
|
+
"name": name,
|
|
539
|
+
"help": " ".join((slice_helps.get(name) or "").split()),
|
|
540
|
+
"flags": [
|
|
541
|
+
{
|
|
542
|
+
"flags": list(a.option_strings) or [a.dest],
|
|
543
|
+
"dest": a.dest,
|
|
544
|
+
"help": " ".join((a.help or "").split()),
|
|
545
|
+
}
|
|
546
|
+
for group in sub._action_groups
|
|
547
|
+
for a in group._group_actions
|
|
548
|
+
if a.dest != "help"
|
|
549
|
+
],
|
|
550
|
+
}
|
|
551
|
+
for name, sub in run_subs.choices.items()
|
|
552
|
+
]
|
|
553
|
+
print(json.dumps(payload, indent=1, sort_keys=True))
|
|
554
|
+
raise SystemExit(0)
|
|
555
|
+
|
|
556
|
+
|
|
398
557
|
def run_library(args) -> int:
|
|
399
558
|
root = Path(args.path).expanduser()
|
|
559
|
+
if args.apply and args.install_to_calibre:
|
|
560
|
+
rc = _require_closed_calibre("--apply --install-to-calibre")
|
|
561
|
+
if rc:
|
|
562
|
+
return rc
|
|
400
563
|
# cquarry-backed id resolution for --install-to-calibre: one lazy map
|
|
401
564
|
# build per run, read-only against metadata.db.
|
|
402
565
|
id_resolver = CalibreIdResolver(root)
|
|
@@ -485,7 +648,12 @@ def run_library(args) -> int:
|
|
|
485
648
|
if (
|
|
486
649
|
args.apply
|
|
487
650
|
and not wants_backup
|
|
488
|
-
and (
|
|
651
|
+
and (
|
|
652
|
+
args.strip_pagination
|
|
653
|
+
or args.strip_broken_tags
|
|
654
|
+
or args.strip_watermarks
|
|
655
|
+
or args.strip_stub_docs
|
|
656
|
+
)
|
|
489
657
|
):
|
|
490
658
|
print(
|
|
491
659
|
"WARNING: the --strip-* modes are lossy; strongly consider --backup DIR "
|
|
@@ -1694,12 +1862,21 @@ def _repair_flag_reference(color: bool | None = None) -> str:
|
|
|
1694
1862
|
+ " repair-flag reference (shared by `bindery repair` and "
|
|
1695
1863
|
"`bindery library`)",
|
|
1696
1864
|
"",
|
|
1697
|
-
"The always-on core pass is exactly five well-formedness fixes
|
|
1698
|
-
"
|
|
1699
|
-
"
|
|
1700
|
-
"
|
|
1701
|
-
"
|
|
1702
|
-
"
|
|
1865
|
+
"The always-on core pass is exactly five well-formedness fixes",
|
|
1866
|
+
"(strip prolog junk, drop duplicate xmlns, escape bare ampersands,",
|
|
1867
|
+
"fix named entities, self-close void tags), the NCX pipeline, and",
|
|
1868
|
+
"the mimetype fix. Everything below is opt-in, and every repair is",
|
|
1869
|
+
"epubcheck-gated: applied only when the measured result improved",
|
|
1870
|
+
"(strictly fewer findings than before); the lossy strips, the",
|
|
1871
|
+
"cover wiring repairs, and the space-entry renames are held to the",
|
|
1872
|
+
"weaker no-worse bar (not more findings). Library mode is dry-run",
|
|
1873
|
+
"by default; `--all` enables every flag below at once. Statuses a",
|
|
1874
|
+
"record can carry: accept (gate-applied), partial (written but",
|
|
1875
|
+
"still failing epubcheck), reject (the gate refused), error,",
|
|
1876
|
+
"unreadable, nochange (nothing to do), equal (already as good as",
|
|
1877
|
+
"the result), unvalidated (applied under --no-validate, never",
|
|
1878
|
+
"gate-checked). The README and spec.md carry the full rationale",
|
|
1879
|
+
"for each repair.",
|
|
1703
1880
|
]
|
|
1704
1881
|
for title, rows in _REPAIR_FLAG_TABLE.items():
|
|
1705
1882
|
lines.append("")
|
|
@@ -1720,6 +1897,37 @@ def _repair_flag_reference(color: bool | None = None) -> str:
|
|
|
1720
1897
|
return "\n".join(lines)
|
|
1721
1898
|
|
|
1722
1899
|
|
|
1900
|
+
def _calibre_running() -> bool:
|
|
1901
|
+
"""pgrep -x calibre. Fail-closed: this gates a metadata.db write, and
|
|
1902
|
+
an unrunnable or timed-out pgrep means "can't tell", so the safe
|
|
1903
|
+
answer is running (refusing costs a closed-Calibre re-run; guessing
|
|
1904
|
+
wrong writes underneath a live Calibre)."""
|
|
1905
|
+
try:
|
|
1906
|
+
return (
|
|
1907
|
+
subprocess.run(
|
|
1908
|
+
["pgrep", "-x", "calibre"], capture_output=True, timeout=10
|
|
1909
|
+
).returncode
|
|
1910
|
+
== 0
|
|
1911
|
+
)
|
|
1912
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1913
|
+
return True
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
def _require_closed_calibre(door: str) -> int:
|
|
1917
|
+
"""The lock-class refusal shared by the metadata.db write doors
|
|
1918
|
+
(`audit --tag`, `library --apply --install-to-calibre`). Exit 1, not
|
|
1919
|
+
a usage error: an open Calibre is an environment condition, exactly
|
|
1920
|
+
like the companion tools' convention (usage problems exit 2)."""
|
|
1921
|
+
if _calibre_running():
|
|
1922
|
+
print(
|
|
1923
|
+
f"ERROR: Calibre is running; close it before {door} "
|
|
1924
|
+
"(the write goes through metadata.db).",
|
|
1925
|
+
file=sys.stderr,
|
|
1926
|
+
)
|
|
1927
|
+
return 1
|
|
1928
|
+
return 0
|
|
1929
|
+
|
|
1930
|
+
|
|
1723
1931
|
class _HelpRepairsAction(argparse.Action):
|
|
1724
1932
|
"""The second help level: print the full repair-flag reference and exit.
|
|
1725
1933
|
|
|
@@ -1765,12 +1973,15 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
|
|
|
1765
1973
|
g.add_argument(
|
|
1766
1974
|
"--all",
|
|
1767
1975
|
action="store_true",
|
|
1768
|
-
help="enable every opt-in fix flag (safe, structural, and lossy)"
|
|
1976
|
+
help="enable every opt-in fix flag (safe, structural, and lossy); "
|
|
1977
|
+
"this IS the lossy consent here (run phase1's separate "
|
|
1978
|
+
"--apply-lossy door is the phase1 equivalent)",
|
|
1769
1979
|
)
|
|
1770
1980
|
g.add_argument(
|
|
1771
1981
|
"--no-validate",
|
|
1772
1982
|
action="store_true",
|
|
1773
|
-
help="skip the epubcheck gate"
|
|
1983
|
+
help="skip the epubcheck gate: the repair still writes/appends "
|
|
1984
|
+
"with status unvalidated (no gate check runs)",
|
|
1774
1985
|
)
|
|
1775
1986
|
p.add_argument(
|
|
1776
1987
|
"--help-repairs",
|
|
@@ -1781,6 +1992,10 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
|
|
|
1781
1992
|
|
|
1782
1993
|
|
|
1783
1994
|
def run_audit_cmd(args: argparse.Namespace) -> int:
|
|
1995
|
+
if getattr(args, "tag", None):
|
|
1996
|
+
rc = _require_closed_calibre("audit --tag")
|
|
1997
|
+
if rc:
|
|
1998
|
+
return rc
|
|
1784
1999
|
selected = list(ALL) if args.mode == "all" else [args.mode]
|
|
1785
2000
|
max_doc = args.max_doc_chars
|
|
1786
2001
|
if args.min_chars > args.thin_chars:
|
|
@@ -1853,6 +2068,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1853
2068
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1854
2069
|
)
|
|
1855
2070
|
ap.add_argument("--version", action="version", version=f"bindery {__version__}")
|
|
2071
|
+
ap.add_argument(
|
|
2072
|
+
"--help-json",
|
|
2073
|
+
action=_HelpJsonAction,
|
|
2074
|
+
help="dump the complete CLI surface (subcommands, flags, the "
|
|
2075
|
+
"repair reference, the exit contract) as JSON and exit",
|
|
2076
|
+
)
|
|
1856
2077
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1857
2078
|
|
|
1858
2079
|
r = sub.add_parser(
|
|
@@ -1862,7 +2083,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1862
2083
|
description="Repair one EPUB into a new file with the epubcheck-gated "
|
|
1863
2084
|
"core pass: the five well-formedness fixes, the NCX pipeline, and the "
|
|
1864
2085
|
"mimetype fix run always; every other repair is opt-in (grouped "
|
|
1865
|
-
"below) and applied only when epubcheck confirms the result improved."
|
|
2086
|
+
"below) and applied only when epubcheck confirms the result improved. "
|
|
2087
|
+
"Never prompts. Exit codes: 0 clean or written (a PARTIAL repair "
|
|
2088
|
+
"writes its best output and exits 0; the --json record's status "
|
|
2089
|
+
"says so), 1 trouble (repair refused by the gate, epubcheck "
|
|
2090
|
+
"failure, or unreadable input) or invocation problem (bad args, "
|
|
2091
|
+
"or an existing output without --force). This verb does not use "
|
|
2092
|
+
"the tool-wide trouble=2 contract; library and the run slices do.",
|
|
1866
2093
|
epilog="examples:\n"
|
|
1867
2094
|
" bindery repair book.epub out.epub\n"
|
|
1868
2095
|
" bindery repair book.epub out.epub --all\n"
|
|
@@ -1882,8 +2109,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1882
2109
|
"--json",
|
|
1883
2110
|
dest="json_path",
|
|
1884
2111
|
metavar="FILE",
|
|
1885
|
-
help="write a machine-readable report
|
|
1886
|
-
"
|
|
2112
|
+
help="write a machine-readable report: one record per file with "
|
|
2113
|
+
"mode, path, output, status, before, after, and the per-fix "
|
|
2114
|
+
"summary (the library --json per-book shape)",
|
|
1887
2115
|
)
|
|
1888
2116
|
_add_repair_flags(r)
|
|
1889
2117
|
r.set_defaults(func=run_repair)
|
|
@@ -1892,12 +2120,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1892
2120
|
"doctor",
|
|
1893
2121
|
help="check the environment: Python stack tier, epubcheck, Java, "
|
|
1894
2122
|
"html5lib, and Calibre-library discovery",
|
|
2123
|
+
description="Check the environment and exit 0 ALWAYS: the output "
|
|
2124
|
+
"is the diagnosis, never a failure (run this first). Checks: the "
|
|
2125
|
+
"Python stack tier, epubcheck, Java, html5lib, and Calibre-library "
|
|
2126
|
+
"discovery.",
|
|
1895
2127
|
)
|
|
1896
2128
|
doc.set_defaults(func=run_doctor)
|
|
1897
2129
|
|
|
1898
2130
|
audit = sub.add_parser(
|
|
1899
2131
|
"audit",
|
|
1900
2132
|
help="audit EPUB body text to detect non-schema content flaws (OCR damage, hardcoded page numbers, empty books, non-English text)",
|
|
2133
|
+
description="Read-only content audit (eight analyzers: monolithic "
|
|
2134
|
+
"scans, completeness, cover wiring, toc drift, and the body-text "
|
|
2135
|
+
"battery over OCR damage, hardcoded page numbers, empty/thin "
|
|
2136
|
+
"books, and non-English text). Library mode resolves the Calibre "
|
|
2137
|
+
"library from the CURRENT DIRECTORY's metadata.db, or pass a "
|
|
2138
|
+
"directory, or --id for specific books (--id plus --json takes "
|
|
2139
|
+
"exactly one id). Exit codes are the audit inverse: 0 clean, "
|
|
2140
|
+
"1 FINDINGS (flagged books), 2 usage or environment errors, "
|
|
2141
|
+
"3 findings plus a failed --json/--tag write. Never prompts.",
|
|
1901
2142
|
)
|
|
1902
2143
|
audit.add_argument(
|
|
1903
2144
|
"mode",
|
|
@@ -1949,7 +2190,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1949
2190
|
"--id",
|
|
1950
2191
|
metavar="BOOK_IDS",
|
|
1951
2192
|
default=None,
|
|
1952
|
-
help="audit library book(s) by Calibre id
|
|
2193
|
+
help="audit library book(s) by Calibre id: one id or a comma-separated "
|
|
1953
2194
|
"list (fetched via cquarry's single-entity get_book; cannot be "
|
|
1954
2195
|
"combined with a directory)",
|
|
1955
2196
|
)
|
|
@@ -1970,7 +2211,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1970
2211
|
description="Scan a Calibre library tree for repair candidates and "
|
|
1971
2212
|
"repair them in place. Dry run by default; --apply atomically "
|
|
1972
2213
|
"replaces accepted books. Shares the repair-flag set with `bindery "
|
|
1973
|
-
"repair` (--all enables every opt-in)."
|
|
2214
|
+
"repair` (--all enables every opt-in). Never prompts: --apply is "
|
|
2215
|
+
"the only confirmation. --apply WITHOUT --backup or --backup-inplace "
|
|
2216
|
+
"replaces in place and keeps NO copy of the original. Exit codes: "
|
|
2217
|
+
"0 all clean, 2 any flagged/rejected/error/unreadable/partial book, "
|
|
2218
|
+
"1 invocation problem. --apply --install-to-calibre writes "
|
|
2219
|
+
"metadata.db and demands a closed Calibre (refused, exit 1, while "
|
|
2220
|
+
"it runs).",
|
|
1974
2221
|
epilog="examples:\n"
|
|
1975
2222
|
" bindery library ~/docs/Calibre\\ Library --sweep\n"
|
|
1976
2223
|
" bindery library ~/docs/Calibre\\ Library --id 1234 --apply "
|
|
@@ -1988,7 +2235,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1988
2235
|
"--only",
|
|
1989
2236
|
choices=("fatals", "ncx", "all"),
|
|
1990
2237
|
default="all",
|
|
1991
|
-
help="restrict to books with fatals, NCX-001 mismatch
|
|
2238
|
+
help="restrict to books with fatals, the NCX-001 mismatch "
|
|
2239
|
+
"(toc.ncx dtb:uid != the OPF unique-identifier), or all (default); "
|
|
2240
|
+
"fatals requires --audit CSV or --sweep",
|
|
1992
2241
|
)
|
|
1993
2242
|
rc.add_argument(
|
|
1994
2243
|
"--limit", type=int, help="process at most N candidates (for sampling)"
|
|
@@ -2021,9 +2270,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2021
2270
|
"--id",
|
|
2022
2271
|
default="",
|
|
2023
2272
|
metavar="IDS",
|
|
2024
|
-
help="comma-separated Calibre book ids to scope the
|
|
2025
|
-
"(resolved via cquarry's get_format_path;
|
|
2026
|
-
"--audit)",
|
|
2273
|
+
help="comma-separated Calibre book ids to scope the run to "
|
|
2274
|
+
"(resolved via cquarry's get_format_path; works without --sweep, "
|
|
2275
|
+
"since ids replace the tree walk; mutually exclusive with --audit)",
|
|
2027
2276
|
)
|
|
2028
2277
|
bk = lib.add_argument_group("backups")
|
|
2029
2278
|
bk.add_argument("--backup", help="directory to mirror backups into before --apply")
|
|
@@ -2044,7 +2293,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2044
2293
|
out.add_argument(
|
|
2045
2294
|
"--json",
|
|
2046
2295
|
metavar="FILE",
|
|
2047
|
-
help="write a machine-readable JSON report of the run to FILE",
|
|
2296
|
+
help="write a machine-readable JSON report of the run to FILE. Top-level envelope keys: mode (apply|dry-run), root, only, validate, candidates, summary, books (one record per candidate)",
|
|
2048
2297
|
)
|
|
2049
2298
|
out.add_argument(
|
|
2050
2299
|
"--manual-list",
|
|
@@ -2055,7 +2304,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2055
2304
|
out.add_argument(
|
|
2056
2305
|
"--install-to-calibre",
|
|
2057
2306
|
action="store_true",
|
|
2058
|
-
help="with --apply, re-register the format natively in metadata.db "
|
|
2307
|
+
help="Calibre must be closed for this door (the row write goes through metadata.db; refused with exit 1 while it runs). with --apply, re-register the format natively in metadata.db "
|
|
2059
2308
|
"through cquarry's write module (remove + add in one transaction) "
|
|
2060
2309
|
"instead of a bare filesystem replace",
|
|
2061
2310
|
)
|
|
@@ -2074,7 +2323,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2074
2323
|
"phase1",
|
|
2075
2324
|
help="the EPUB pre-import vetting slice over a directory of loose "
|
|
2076
2325
|
"files: corruption sweep, epubcheck, content battery, monolithic, "
|
|
2077
|
-
"watermark detection, repairability. Read-only without --apply-lossy"
|
|
2326
|
+
"watermark detection, repairability. Read-only without --apply-lossy. "
|
|
2327
|
+
"Exit codes: 0 clean, 2 trouble (reject/partial/error/unreadable "
|
|
2328
|
+
"books; decisions_needed in --json), 1 invocation problem",
|
|
2078
2329
|
)
|
|
2079
2330
|
p1.add_argument("path")
|
|
2080
2331
|
p1.add_argument(
|
|
@@ -2110,7 +2361,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2110
2361
|
help="the post-import scoped repair sweep: library --id <ids> --sweep "
|
|
2111
2362
|
"--only all --apply --all --install-to-calibre with a pre/post "
|
|
2112
2363
|
"summary. Refuses unscoped library-wide sweeps (exit 2); run from "
|
|
2113
|
-
"the library directory"
|
|
2364
|
+
"the library directory. Exit codes follow library's: 0 clean, "
|
|
2365
|
+
"2 trouble, 1 invocation problem",
|
|
2114
2366
|
)
|
|
2115
2367
|
p3.add_argument(
|
|
2116
2368
|
"--ids",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Tests for the help-surface truth set and the machine surface: the
|
|
2
|
+
fail-closed Calibre guard on the metadata.db write doors, the contracts
|
|
3
|
+
the AI-grokability probe found missing from help (exit codes, the gate,
|
|
4
|
+
NCX-001, --id scoping, the json envelopes), and --help-json."""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import contextlib
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import unittest
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
from bindery import __version__, cli
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestCalibreGuard(unittest.TestCase):
|
|
18
|
+
"""The lock-class refusal on audit --tag and library --apply
|
|
19
|
+
--install-to-calibre: fail-closed pgrep, exit 1, before any work."""
|
|
20
|
+
|
|
21
|
+
def test_refuses_when_calibre_runs(self):
|
|
22
|
+
with mock.patch.object(cli, "_calibre_running", return_value=True):
|
|
23
|
+
rc = cli._require_closed_calibre("the door")
|
|
24
|
+
self.assertEqual(rc, 1)
|
|
25
|
+
|
|
26
|
+
def test_passes_when_calibre_closed(self):
|
|
27
|
+
with mock.patch.object(cli, "_calibre_running", return_value=False):
|
|
28
|
+
rc = cli._require_closed_calibre("the door")
|
|
29
|
+
self.assertEqual(rc, 0)
|
|
30
|
+
|
|
31
|
+
def test_pgrep_failure_is_fail_closed(self):
|
|
32
|
+
# an unrunnable pgrep means "can't tell": refuse
|
|
33
|
+
with mock.patch.object(
|
|
34
|
+
cli.subprocess,
|
|
35
|
+
"run",
|
|
36
|
+
side_effect=OSError("no pgrep"),
|
|
37
|
+
):
|
|
38
|
+
self.assertTrue(cli._calibre_running())
|
|
39
|
+
with mock.patch.object(
|
|
40
|
+
cli.subprocess,
|
|
41
|
+
"run",
|
|
42
|
+
side_effect=cli.subprocess.TimeoutExpired(cmd="pgrep", timeout=10),
|
|
43
|
+
):
|
|
44
|
+
self.assertTrue(cli._calibre_running())
|
|
45
|
+
|
|
46
|
+
def test_library_guard_fires_before_anything_else(self):
|
|
47
|
+
# a nonexistent path would fail "not a directory" (exit 1) anyway;
|
|
48
|
+
# the stderr message proves the guard spoke first
|
|
49
|
+
args = SimpleNamespace(
|
|
50
|
+
apply=True,
|
|
51
|
+
install_to_calibre=True,
|
|
52
|
+
path="/nonexistent/library",
|
|
53
|
+
)
|
|
54
|
+
err = io.StringIO()
|
|
55
|
+
with (
|
|
56
|
+
mock.patch.object(cli, "_calibre_running", return_value=True),
|
|
57
|
+
contextlib.redirect_stderr(err),
|
|
58
|
+
):
|
|
59
|
+
rc = cli.run_library(args)
|
|
60
|
+
self.assertEqual(rc, 1)
|
|
61
|
+
self.assertIn("Calibre is running", err.getvalue())
|
|
62
|
+
|
|
63
|
+
def test_library_without_install_door_is_not_gated(self):
|
|
64
|
+
# a plain --apply (in-place) or dry run never consults the guard
|
|
65
|
+
args = SimpleNamespace(
|
|
66
|
+
apply=True,
|
|
67
|
+
install_to_calibre=False,
|
|
68
|
+
path="/nonexistent/library",
|
|
69
|
+
)
|
|
70
|
+
err = io.StringIO()
|
|
71
|
+
with (
|
|
72
|
+
mock.patch.object(cli, "_calibre_running", return_value=True),
|
|
73
|
+
contextlib.redirect_stderr(err),
|
|
74
|
+
):
|
|
75
|
+
rc = cli.run_library(args)
|
|
76
|
+
self.assertEqual(rc, 1)
|
|
77
|
+
self.assertIn("not a directory", err.getvalue())
|
|
78
|
+
|
|
79
|
+
def test_audit_tag_guard_fires_first(self):
|
|
80
|
+
args = SimpleNamespace(tag="Flagged")
|
|
81
|
+
err = io.StringIO()
|
|
82
|
+
with (
|
|
83
|
+
mock.patch.object(cli, "_calibre_running", return_value=True),
|
|
84
|
+
contextlib.redirect_stderr(err),
|
|
85
|
+
):
|
|
86
|
+
rc = cli.run_audit_cmd(args)
|
|
87
|
+
self.assertEqual(rc, 1)
|
|
88
|
+
self.assertIn("audit --tag", err.getvalue())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestHelpTruth(unittest.TestCase):
|
|
92
|
+
"""The contracts the probe found missing, now stated where users and
|
|
93
|
+
agents read them."""
|
|
94
|
+
|
|
95
|
+
def _parser(self):
|
|
96
|
+
return cli.build_parser()
|
|
97
|
+
|
|
98
|
+
def _sub(self, parser, name):
|
|
99
|
+
subparsers = next(
|
|
100
|
+
a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
|
|
101
|
+
)
|
|
102
|
+
return subparsers.choices[name]
|
|
103
|
+
|
|
104
|
+
def test_repair_page_states_the_contract(self):
|
|
105
|
+
text = self._sub(self._parser(), "repair").description
|
|
106
|
+
self.assertIn("Never prompts", text)
|
|
107
|
+
self.assertIn("PARTIAL repair writes its best output and exits 0", text)
|
|
108
|
+
self.assertIn("does not use", text)
|
|
109
|
+
self.assertIn("without --force", text)
|
|
110
|
+
|
|
111
|
+
def test_library_page_states_gate_and_codes(self):
|
|
112
|
+
text = self._sub(self._parser(), "library").description
|
|
113
|
+
self.assertIn("Never prompts", text)
|
|
114
|
+
self.assertIn("closed Calibre", text)
|
|
115
|
+
self.assertIn(
|
|
116
|
+
"0 all clean, 2 any flagged/rejected/error/unreadable/partial", text
|
|
117
|
+
)
|
|
118
|
+
self.assertIn("keeps NO copy of the original", text)
|
|
119
|
+
|
|
120
|
+
def test_install_to_calibre_names_the_gate(self):
|
|
121
|
+
parser = self._parser()
|
|
122
|
+
lib = self._sub(parser, "library")
|
|
123
|
+
helps = " ".join(
|
|
124
|
+
a.help or "" for g in lib._action_groups for a in g._group_actions
|
|
125
|
+
)
|
|
126
|
+
self.assertIn("Calibre must be closed", helps)
|
|
127
|
+
|
|
128
|
+
def test_ncx001_is_defined(self):
|
|
129
|
+
parser = self._parser()
|
|
130
|
+
lib = self._sub(parser, "library")
|
|
131
|
+
helps = " ".join(
|
|
132
|
+
a.help or "" for g in lib._action_groups for a in g._group_actions
|
|
133
|
+
)
|
|
134
|
+
self.assertIn("dtb:uid", helps)
|
|
135
|
+
|
|
136
|
+
def test_library_id_works_without_sweep(self):
|
|
137
|
+
parser = self._parser()
|
|
138
|
+
lib = self._sub(parser, "library")
|
|
139
|
+
helps = " ".join(
|
|
140
|
+
a.help or "" for g in lib._action_groups for a in g._group_actions
|
|
141
|
+
)
|
|
142
|
+
self.assertIn("works without --sweep", helps)
|
|
143
|
+
|
|
144
|
+
def test_run_pages_carry_exit_codes(self):
|
|
145
|
+
run = self._sub(self._parser(), "run")
|
|
146
|
+
run_subs = next(
|
|
147
|
+
a for a in run._actions if isinstance(a, argparse._SubParsersAction)
|
|
148
|
+
)
|
|
149
|
+
helps = {pa.dest: pa.help for pa in run_subs._choices_actions}
|
|
150
|
+
for name in ("phase1", "phase3"):
|
|
151
|
+
text = helps[name]
|
|
152
|
+
self.assertIn("Exit codes", text, name)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestHelpJson(unittest.TestCase):
|
|
156
|
+
def test_valid_complete_deterministic(self):
|
|
157
|
+
payload_text = self._dump()
|
|
158
|
+
data = json.loads(payload_text)
|
|
159
|
+
self.assertEqual(data["tool"], "bindery")
|
|
160
|
+
self.assertEqual(data["version"], __version__)
|
|
161
|
+
self.assertEqual(
|
|
162
|
+
[s["name"] for s in data["subcommands"]],
|
|
163
|
+
["repair", "doctor", "audit", "library", "run"],
|
|
164
|
+
)
|
|
165
|
+
run = next(s for s in data["subcommands"] if s["name"] == "run")
|
|
166
|
+
self.assertEqual([s["name"] for s in run["slices"]], ["phase1", "phase3"])
|
|
167
|
+
self.assertEqual(
|
|
168
|
+
[g["group"] for g in data["repair_reference"]],
|
|
169
|
+
list(cli._REPAIR_FLAG_TABLE),
|
|
170
|
+
)
|
|
171
|
+
self.assertEqual(
|
|
172
|
+
data["calibre_gates"][0:2],
|
|
173
|
+
["audit --tag", "library --apply --install-to-calibre"],
|
|
174
|
+
)
|
|
175
|
+
# the third door is phase3, guarded transitively through library
|
|
176
|
+
self.assertIn("run phase3", data["calibre_gates"][2])
|
|
177
|
+
# the contract now carries the per-verb truth
|
|
178
|
+
self.assertEqual(data["exit_contract"]["per_verb"]["repair"][-7:], "never 2")
|
|
179
|
+
self.assertIn("1 findings", data["exit_contract"]["per_verb"]["audit"])
|
|
180
|
+
self.assertIn("3", data["exit_contract"])
|
|
181
|
+
self.assertEqual(payload_text, self._dump())
|
|
182
|
+
|
|
183
|
+
def _dump(self):
|
|
184
|
+
out = io.StringIO()
|
|
185
|
+
with contextlib.redirect_stdout(out):
|
|
186
|
+
with self.assertRaises(SystemExit) as caught:
|
|
187
|
+
cli.main(["--help-json"])
|
|
188
|
+
self.assertEqual(caught.exception.code, 0)
|
|
189
|
+
return out.getvalue()
|
|
190
|
+
|
|
191
|
+
def test_covers_every_subcommand_flag_dest(self):
|
|
192
|
+
payload_text = self._dump()
|
|
193
|
+
parser = cli.build_parser()
|
|
194
|
+
subparsers = next(
|
|
195
|
+
a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
|
|
196
|
+
)
|
|
197
|
+
for _name, sub in subparsers.choices.items():
|
|
198
|
+
for group in sub._action_groups:
|
|
199
|
+
for action in group._group_actions:
|
|
200
|
+
if action.dest == "help":
|
|
201
|
+
continue
|
|
202
|
+
for opt in action.option_strings:
|
|
203
|
+
self.assertIn(f'"{opt}"', payload_text, opt)
|
|
204
|
+
|
|
205
|
+
def test_exit_contract_present(self):
|
|
206
|
+
data = json.loads(self._dump())
|
|
207
|
+
self.assertIn("0", data["exit_contract"])
|
|
208
|
+
self.assertIn("2", data["exit_contract"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|