bindery-cli 0.47.2__tar.gz → 0.48.0__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.0}/PKG-INFO +1 -1
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/patchnotes.md +30 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/pyproject.toml +1 -1
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/roadmap.md +1 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/cli.py +212 -12
- bindery_cli-0.48.0/tests/test_help.py +198 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/uv.lock +1 -1
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/.github/FUNDING.yml +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/.gitignore +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/AGENTS.md +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/CLAUDE.md +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/LICENSE +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/README.md +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/logo.svg +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/plugin/__init__.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/project.done +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/run_tests.sh +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/spec.md +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/epub.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/transforms.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_cli.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_comment_hyphens.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_cover_tocdrift.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_epub.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_flags_real_core.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_flags_wiring.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_library.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_matcher_hardening.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_plugin.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_space_renames.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_transforms.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_version.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/tests/test_watermark.py +0 -0
- {bindery_cli-0.47.2 → bindery_cli-0.48.0}/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.0
|
|
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,35 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
2
|
|
|
3
|
+
## v0.48.0 (2026-10-06)
|
|
4
|
+
|
|
5
|
+
### The closed-Calibre guard, stated contracts, and --help-json
|
|
6
|
+
|
|
7
|
+
The AI-grokability probe (a fresh agent learning the CLI from help
|
|
8
|
+
alone, the same method CalibreQuarry ran) confirmed the repair-semantics
|
|
9
|
+
help is complete and found the cross-cutting contracts missing. Three
|
|
10
|
+
fixes:
|
|
11
|
+
|
|
12
|
+
- **The closed-Calibre gate is enforced, not just stated.** audit
|
|
13
|
+
--tag and library --apply --install-to-calibre both write
|
|
14
|
+
metadata.db, and nothing stopped that write under a live Calibre
|
|
15
|
+
(WritableCalibreDB waits on the sqlite lock and proceeds; a running
|
|
16
|
+
Calibre holds no persistent exclusive lock). Both doors now share a
|
|
17
|
+
fail-closed pgrep refusal (exit 1: an environment condition, not a
|
|
18
|
+
usage error), and --install-to-calibre's help says so.
|
|
19
|
+
- **The help states the contracts** the probe could not find: exit
|
|
20
|
+
codes on the repair, library, and run-slice pages (0 clean, 2
|
|
21
|
+
trouble, 1 invocation problem); neither mode ever prompts (--apply is
|
|
22
|
+
the only confirmation); an existing output without --force is exit 1;
|
|
23
|
+
NCX-001 defined (toc.ncx dtb:uid != the OPF unique-identifier); --id
|
|
24
|
+
works without --sweep; the repair and library --json envelopes name
|
|
25
|
+
their keys.
|
|
26
|
+
- **--help-json**: the complete surface (subcommands with every flag,
|
|
27
|
+
the run slices, the full repair reference, the exit contract, the
|
|
28
|
+
Calibre gates) as deterministic JSON, generated from the live parser
|
|
29
|
+
and _REPAIR_FLAG_TABLE, never colored.
|
|
30
|
+
|
|
31
|
+
15 new tests (639 total).
|
|
32
|
+
|
|
3
33
|
## v0.47.2 (2026-10-06)
|
|
4
34
|
|
|
5
35
|
### 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.0"
|
|
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.0"
|
|
15
15
|
__version__ = VERSION
|
|
16
16
|
|
|
17
17
|
from .epub import RepairReport, repair_epub # noqa: E402
|
|
@@ -395,8 +395,155 @@ 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",
|
|
424
|
+
"1": "invocation problem (bad args; repair: existing output "
|
|
425
|
+
"without --force); lock-class refusals (Calibre open at a "
|
|
426
|
+
"metadata.db door)",
|
|
427
|
+
"2": "trouble found (rejected/error/unreadable/partial "
|
|
428
|
+
"books; unscoped phase3 sweep); argparse usage errors",
|
|
429
|
+
"130": "interrupted",
|
|
430
|
+
},
|
|
431
|
+
"calibre_gates": [
|
|
432
|
+
"audit --tag",
|
|
433
|
+
"library --apply --install-to-calibre",
|
|
434
|
+
],
|
|
435
|
+
"subcommands": [],
|
|
436
|
+
"repair_reference": [
|
|
437
|
+
{
|
|
438
|
+
"group": title,
|
|
439
|
+
"flags": [
|
|
440
|
+
{"dest": dest, "summary": summary, "reference": long}
|
|
441
|
+
for dest, summary, long in rows
|
|
442
|
+
],
|
|
443
|
+
}
|
|
444
|
+
for title, rows in _REPAIR_FLAG_TABLE.items()
|
|
445
|
+
],
|
|
446
|
+
}
|
|
447
|
+
subparsers = next(
|
|
448
|
+
(a for a in parser._actions if isinstance(a, argparse._SubParsersAction)),
|
|
449
|
+
None,
|
|
450
|
+
)
|
|
451
|
+
sub_helps = (
|
|
452
|
+
{pa.dest: pa.help for pa in subparsers._choices_actions}
|
|
453
|
+
if subparsers
|
|
454
|
+
else {}
|
|
455
|
+
)
|
|
456
|
+
for name, sub in subparsers.choices.items() if subparsers else []:
|
|
457
|
+
|
|
458
|
+
def sub_flags(subparser):
|
|
459
|
+
out = []
|
|
460
|
+
for group in subparser._action_groups:
|
|
461
|
+
for action in group._group_actions:
|
|
462
|
+
if action.dest in ("help",):
|
|
463
|
+
continue
|
|
464
|
+
entry = {
|
|
465
|
+
"flags": list(action.option_strings) or [action.dest],
|
|
466
|
+
"dest": action.dest,
|
|
467
|
+
"help": " ".join((action.help or "").split()),
|
|
468
|
+
}
|
|
469
|
+
if action.metavar:
|
|
470
|
+
met = action.metavar
|
|
471
|
+
entry["metavar"] = (
|
|
472
|
+
" ".join(met) if isinstance(met, tuple) else met
|
|
473
|
+
)
|
|
474
|
+
if action.choices:
|
|
475
|
+
entry["choices"] = [str(c) for c in action.choices]
|
|
476
|
+
out.append(entry)
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
payload["subcommands"].append(
|
|
480
|
+
{
|
|
481
|
+
"name": name,
|
|
482
|
+
"help": " ".join((sub_helps.get(name) or "").split()),
|
|
483
|
+
"description": " ".join((sub.description or "").split()),
|
|
484
|
+
"flags": sub_flags(sub),
|
|
485
|
+
"flag_groups": [
|
|
486
|
+
{
|
|
487
|
+
"title": group.title,
|
|
488
|
+
"flags": [
|
|
489
|
+
" ".join((a.help or "").split())
|
|
490
|
+
for a in group._group_actions
|
|
491
|
+
if a.dest != "help"
|
|
492
|
+
],
|
|
493
|
+
}
|
|
494
|
+
for group in sub._action_groups
|
|
495
|
+
if group.title
|
|
496
|
+
],
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
# the run slices are one level deeper
|
|
500
|
+
run_choice = subparsers.choices.get("run") if subparsers else None
|
|
501
|
+
if run_choice is not None:
|
|
502
|
+
run_subs = next(
|
|
503
|
+
(
|
|
504
|
+
a
|
|
505
|
+
for a in run_choice._actions
|
|
506
|
+
if isinstance(a, argparse._SubParsersAction)
|
|
507
|
+
),
|
|
508
|
+
None,
|
|
509
|
+
)
|
|
510
|
+
run_entry = next(
|
|
511
|
+
(s for s in payload["subcommands"] if s["name"] == "run"),
|
|
512
|
+
None,
|
|
513
|
+
)
|
|
514
|
+
if (
|
|
515
|
+
run_choice is not None
|
|
516
|
+
and run_subs is not None
|
|
517
|
+
and run_entry is not None
|
|
518
|
+
):
|
|
519
|
+
slice_helps = {pa.dest: pa.help for pa in run_subs._choices_actions}
|
|
520
|
+
run_entry["slices"] = [
|
|
521
|
+
{
|
|
522
|
+
"name": name,
|
|
523
|
+
"help": " ".join((slice_helps.get(name) or "").split()),
|
|
524
|
+
"flags": [
|
|
525
|
+
{
|
|
526
|
+
"flags": list(a.option_strings) or [a.dest],
|
|
527
|
+
"dest": a.dest,
|
|
528
|
+
"help": " ".join((a.help or "").split()),
|
|
529
|
+
}
|
|
530
|
+
for group in sub._action_groups
|
|
531
|
+
for a in group._group_actions
|
|
532
|
+
if a.dest != "help"
|
|
533
|
+
],
|
|
534
|
+
}
|
|
535
|
+
for name, sub in run_subs.choices.items()
|
|
536
|
+
]
|
|
537
|
+
print(json.dumps(payload, indent=1, sort_keys=True))
|
|
538
|
+
raise SystemExit(0)
|
|
539
|
+
|
|
540
|
+
|
|
398
541
|
def run_library(args) -> int:
|
|
399
542
|
root = Path(args.path).expanduser()
|
|
543
|
+
if args.apply and args.install_to_calibre:
|
|
544
|
+
rc = _require_closed_calibre("--apply --install-to-calibre")
|
|
545
|
+
if rc:
|
|
546
|
+
return rc
|
|
400
547
|
# cquarry-backed id resolution for --install-to-calibre: one lazy map
|
|
401
548
|
# build per run, read-only against metadata.db.
|
|
402
549
|
id_resolver = CalibreIdResolver(root)
|
|
@@ -1720,6 +1867,37 @@ def _repair_flag_reference(color: bool | None = None) -> str:
|
|
|
1720
1867
|
return "\n".join(lines)
|
|
1721
1868
|
|
|
1722
1869
|
|
|
1870
|
+
def _calibre_running() -> bool:
|
|
1871
|
+
"""pgrep -x calibre. Fail-closed: this gates a metadata.db write, and
|
|
1872
|
+
an unrunnable or timed-out pgrep means "can't tell", so the safe
|
|
1873
|
+
answer is running (refusing costs a closed-Calibre re-run; guessing
|
|
1874
|
+
wrong writes underneath a live Calibre)."""
|
|
1875
|
+
try:
|
|
1876
|
+
return (
|
|
1877
|
+
subprocess.run(
|
|
1878
|
+
["pgrep", "-x", "calibre"], capture_output=True, timeout=10
|
|
1879
|
+
).returncode
|
|
1880
|
+
== 0
|
|
1881
|
+
)
|
|
1882
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1883
|
+
return True
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def _require_closed_calibre(door: str) -> int:
|
|
1887
|
+
"""The lock-class refusal shared by the metadata.db write doors
|
|
1888
|
+
(`audit --tag`, `library --apply --install-to-calibre`). Exit 1, not
|
|
1889
|
+
a usage error: an open Calibre is an environment condition, exactly
|
|
1890
|
+
like the companion tools' convention (usage problems exit 2)."""
|
|
1891
|
+
if _calibre_running():
|
|
1892
|
+
print(
|
|
1893
|
+
f"ERROR: Calibre is running; close it before {door} "
|
|
1894
|
+
"(the write goes through metadata.db).",
|
|
1895
|
+
file=sys.stderr,
|
|
1896
|
+
)
|
|
1897
|
+
return 1
|
|
1898
|
+
return 0
|
|
1899
|
+
|
|
1900
|
+
|
|
1723
1901
|
class _HelpRepairsAction(argparse.Action):
|
|
1724
1902
|
"""The second help level: print the full repair-flag reference and exit.
|
|
1725
1903
|
|
|
@@ -1781,6 +1959,10 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
|
|
|
1781
1959
|
|
|
1782
1960
|
|
|
1783
1961
|
def run_audit_cmd(args: argparse.Namespace) -> int:
|
|
1962
|
+
if getattr(args, "tag", None):
|
|
1963
|
+
rc = _require_closed_calibre("audit --tag")
|
|
1964
|
+
if rc:
|
|
1965
|
+
return rc
|
|
1784
1966
|
selected = list(ALL) if args.mode == "all" else [args.mode]
|
|
1785
1967
|
max_doc = args.max_doc_chars
|
|
1786
1968
|
if args.min_chars > args.thin_chars:
|
|
@@ -1853,6 +2035,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1853
2035
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1854
2036
|
)
|
|
1855
2037
|
ap.add_argument("--version", action="version", version=f"bindery {__version__}")
|
|
2038
|
+
ap.add_argument(
|
|
2039
|
+
"--help-json",
|
|
2040
|
+
action=_HelpJsonAction,
|
|
2041
|
+
help="dump the complete CLI surface (subcommands, flags, the "
|
|
2042
|
+
"repair reference, the exit contract) as JSON and exit",
|
|
2043
|
+
)
|
|
1856
2044
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1857
2045
|
|
|
1858
2046
|
r = sub.add_parser(
|
|
@@ -1862,7 +2050,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1862
2050
|
description="Repair one EPUB into a new file with the epubcheck-gated "
|
|
1863
2051
|
"core pass: the five well-formedness fixes, the NCX pipeline, and the "
|
|
1864
2052
|
"mimetype fix run always; every other repair is opt-in (grouped "
|
|
1865
|
-
"below) and applied only when epubcheck confirms the result improved."
|
|
2053
|
+
"below) and applied only when epubcheck confirms the result improved. "
|
|
2054
|
+
"Never prompts. Exit codes: 0 clean, 2 trouble found (repairs refused "
|
|
2055
|
+
"or failed; --json carries the records), 1 invocation problem (bad "
|
|
2056
|
+
"args, or an existing output without --force).",
|
|
1866
2057
|
epilog="examples:\n"
|
|
1867
2058
|
" bindery repair book.epub out.epub\n"
|
|
1868
2059
|
" bindery repair book.epub out.epub --all\n"
|
|
@@ -1882,8 +2073,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1882
2073
|
"--json",
|
|
1883
2074
|
dest="json_path",
|
|
1884
2075
|
metavar="FILE",
|
|
1885
|
-
help="write a machine-readable report
|
|
1886
|
-
"
|
|
2076
|
+
help="write a machine-readable report: one record per file with "
|
|
2077
|
+
"mode, path, output, status, before, after, and the per-fix "
|
|
2078
|
+
"summary (the library --json per-book shape)",
|
|
1887
2079
|
)
|
|
1888
2080
|
_add_repair_flags(r)
|
|
1889
2081
|
r.set_defaults(func=run_repair)
|
|
@@ -1970,7 +2162,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1970
2162
|
description="Scan a Calibre library tree for repair candidates and "
|
|
1971
2163
|
"repair them in place. Dry run by default; --apply atomically "
|
|
1972
2164
|
"replaces accepted books. Shares the repair-flag set with `bindery "
|
|
1973
|
-
"repair` (--all enables every opt-in)."
|
|
2165
|
+
"repair` (--all enables every opt-in). Never prompts: --apply is "
|
|
2166
|
+
"the only confirmation. Exit codes: 0 all clean, 2 any "
|
|
2167
|
+
"flagged/rejected/error/partial book, 1 invocation problem. "
|
|
2168
|
+
"--apply --install-to-calibre writes metadata.db and demands a "
|
|
2169
|
+
"closed Calibre (refused, exit 1, while it runs).",
|
|
1974
2170
|
epilog="examples:\n"
|
|
1975
2171
|
" bindery library ~/docs/Calibre\\ Library --sweep\n"
|
|
1976
2172
|
" bindery library ~/docs/Calibre\\ Library --id 1234 --apply "
|
|
@@ -1988,7 +2184,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1988
2184
|
"--only",
|
|
1989
2185
|
choices=("fatals", "ncx", "all"),
|
|
1990
2186
|
default="all",
|
|
1991
|
-
help="restrict to books with fatals, NCX-001 mismatch
|
|
2187
|
+
help="restrict to books with fatals, the NCX-001 mismatch "
|
|
2188
|
+
"(toc.ncx dtb:uid != the OPF unique-identifier), or all (default)",
|
|
1992
2189
|
)
|
|
1993
2190
|
rc.add_argument(
|
|
1994
2191
|
"--limit", type=int, help="process at most N candidates (for sampling)"
|
|
@@ -2021,9 +2218,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2021
2218
|
"--id",
|
|
2022
2219
|
default="",
|
|
2023
2220
|
metavar="IDS",
|
|
2024
|
-
help="comma-separated Calibre book ids to scope the
|
|
2025
|
-
"(resolved via cquarry's get_format_path;
|
|
2026
|
-
"--audit)",
|
|
2221
|
+
help="comma-separated Calibre book ids to scope the run to "
|
|
2222
|
+
"(resolved via cquarry's get_format_path; works without --sweep, "
|
|
2223
|
+
"since ids replace the tree walk; mutually exclusive with --audit)",
|
|
2027
2224
|
)
|
|
2028
2225
|
bk = lib.add_argument_group("backups")
|
|
2029
2226
|
bk.add_argument("--backup", help="directory to mirror backups into before --apply")
|
|
@@ -2044,7 +2241,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2044
2241
|
out.add_argument(
|
|
2045
2242
|
"--json",
|
|
2046
2243
|
metavar="FILE",
|
|
2047
|
-
help="write a machine-readable JSON report of the run to FILE",
|
|
2244
|
+
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",
|
|
2048
2245
|
)
|
|
2049
2246
|
out.add_argument(
|
|
2050
2247
|
"--manual-list",
|
|
@@ -2055,7 +2252,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2055
2252
|
out.add_argument(
|
|
2056
2253
|
"--install-to-calibre",
|
|
2057
2254
|
action="store_true",
|
|
2058
|
-
help="with --apply, re-register the format natively in metadata.db "
|
|
2255
|
+
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
2256
|
"through cquarry's write module (remove + add in one transaction) "
|
|
2060
2257
|
"instead of a bare filesystem replace",
|
|
2061
2258
|
)
|
|
@@ -2074,7 +2271,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2074
2271
|
"phase1",
|
|
2075
2272
|
help="the EPUB pre-import vetting slice over a directory of loose "
|
|
2076
2273
|
"files: corruption sweep, epubcheck, content battery, monolithic, "
|
|
2077
|
-
"watermark detection, repairability. Read-only without --apply-lossy"
|
|
2274
|
+
"watermark detection, repairability. Read-only without --apply-lossy. "
|
|
2275
|
+
"Exit codes: 0 clean, 2 trouble (reject/partial/error/unreadable "
|
|
2276
|
+
"books; decisions_needed in --json), 1 invocation problem",
|
|
2078
2277
|
)
|
|
2079
2278
|
p1.add_argument("path")
|
|
2080
2279
|
p1.add_argument(
|
|
@@ -2110,7 +2309,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2110
2309
|
help="the post-import scoped repair sweep: library --id <ids> --sweep "
|
|
2111
2310
|
"--only all --apply --all --install-to-calibre with a pre/post "
|
|
2112
2311
|
"summary. Refuses unscoped library-wide sweeps (exit 2); run from "
|
|
2113
|
-
"the library directory"
|
|
2312
|
+
"the library directory. Exit codes follow library's: 0 clean, "
|
|
2313
|
+
"2 trouble, 1 invocation problem",
|
|
2114
2314
|
)
|
|
2115
2315
|
p3.add_argument(
|
|
2116
2316
|
"--ids",
|
|
@@ -0,0 +1,198 @@
|
|
|
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("0 clean, 2 trouble", text)
|
|
108
|
+
self.assertIn("without --force", text)
|
|
109
|
+
|
|
110
|
+
def test_library_page_states_gate_and_codes(self):
|
|
111
|
+
text = self._sub(self._parser(), "library").description
|
|
112
|
+
self.assertIn("Never prompts", text)
|
|
113
|
+
self.assertIn("closed Calibre", text)
|
|
114
|
+
self.assertIn("0 all clean, 2 any", text)
|
|
115
|
+
|
|
116
|
+
def test_install_to_calibre_names_the_gate(self):
|
|
117
|
+
parser = self._parser()
|
|
118
|
+
lib = self._sub(parser, "library")
|
|
119
|
+
helps = " ".join(
|
|
120
|
+
a.help or "" for g in lib._action_groups for a in g._group_actions
|
|
121
|
+
)
|
|
122
|
+
self.assertIn("Calibre must be closed", helps)
|
|
123
|
+
|
|
124
|
+
def test_ncx001_is_defined(self):
|
|
125
|
+
parser = self._parser()
|
|
126
|
+
lib = self._sub(parser, "library")
|
|
127
|
+
helps = " ".join(
|
|
128
|
+
a.help or "" for g in lib._action_groups for a in g._group_actions
|
|
129
|
+
)
|
|
130
|
+
self.assertIn("dtb:uid", helps)
|
|
131
|
+
|
|
132
|
+
def test_library_id_works_without_sweep(self):
|
|
133
|
+
parser = self._parser()
|
|
134
|
+
lib = self._sub(parser, "library")
|
|
135
|
+
helps = " ".join(
|
|
136
|
+
a.help or "" for g in lib._action_groups for a in g._group_actions
|
|
137
|
+
)
|
|
138
|
+
self.assertIn("works without --sweep", helps)
|
|
139
|
+
|
|
140
|
+
def test_run_pages_carry_exit_codes(self):
|
|
141
|
+
run = self._sub(self._parser(), "run")
|
|
142
|
+
run_subs = next(
|
|
143
|
+
a for a in run._actions if isinstance(a, argparse._SubParsersAction)
|
|
144
|
+
)
|
|
145
|
+
helps = {pa.dest: pa.help for pa in run_subs._choices_actions}
|
|
146
|
+
for name in ("phase1", "phase3"):
|
|
147
|
+
text = helps[name]
|
|
148
|
+
self.assertIn("Exit codes", text, name)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestHelpJson(unittest.TestCase):
|
|
152
|
+
def test_valid_complete_deterministic(self):
|
|
153
|
+
payload_text = self._dump()
|
|
154
|
+
data = json.loads(payload_text)
|
|
155
|
+
self.assertEqual(data["tool"], "bindery")
|
|
156
|
+
self.assertEqual(data["version"], __version__)
|
|
157
|
+
self.assertEqual(
|
|
158
|
+
[s["name"] for s in data["subcommands"]],
|
|
159
|
+
["repair", "doctor", "audit", "library", "run"],
|
|
160
|
+
)
|
|
161
|
+
run = next(s for s in data["subcommands"] if s["name"] == "run")
|
|
162
|
+
self.assertEqual([s["name"] for s in run["slices"]], ["phase1", "phase3"])
|
|
163
|
+
self.assertEqual(
|
|
164
|
+
[g["group"] for g in data["repair_reference"]],
|
|
165
|
+
list(cli._REPAIR_FLAG_TABLE),
|
|
166
|
+
)
|
|
167
|
+
self.assertEqual(
|
|
168
|
+
data["calibre_gates"],
|
|
169
|
+
["audit --tag", "library --apply --install-to-calibre"],
|
|
170
|
+
)
|
|
171
|
+
self.assertEqual(payload_text, self._dump())
|
|
172
|
+
|
|
173
|
+
def _dump(self):
|
|
174
|
+
out = io.StringIO()
|
|
175
|
+
with contextlib.redirect_stdout(out):
|
|
176
|
+
with self.assertRaises(SystemExit) as caught:
|
|
177
|
+
cli.main(["--help-json"])
|
|
178
|
+
self.assertEqual(caught.exception.code, 0)
|
|
179
|
+
return out.getvalue()
|
|
180
|
+
|
|
181
|
+
def test_covers_every_subcommand_flag_dest(self):
|
|
182
|
+
payload_text = self._dump()
|
|
183
|
+
parser = cli.build_parser()
|
|
184
|
+
subparsers = next(
|
|
185
|
+
a for a in parser._actions if isinstance(a, argparse._SubParsersAction)
|
|
186
|
+
)
|
|
187
|
+
for _name, sub in subparsers.choices.items():
|
|
188
|
+
for group in sub._action_groups:
|
|
189
|
+
for action in group._group_actions:
|
|
190
|
+
if action.dest == "help":
|
|
191
|
+
continue
|
|
192
|
+
for opt in action.option_strings:
|
|
193
|
+
self.assertIn(f'"{opt}"', payload_text, opt)
|
|
194
|
+
|
|
195
|
+
def test_exit_contract_present(self):
|
|
196
|
+
data = json.loads(self._dump())
|
|
197
|
+
self.assertIn("0", data["exit_contract"])
|
|
198
|
+
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
|