bindery-cli 0.47.1__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.
Files changed (52) hide show
  1. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/CLAUDE.md +3 -1
  2. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/PKG-INFO +9 -1
  3. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/README.md +8 -0
  4. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/patchnotes.md +56 -0
  5. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/pyproject.toml +1 -1
  6. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/roadmap.md +1 -0
  7. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/__init__.py +1 -1
  8. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/cli.py +291 -41
  9. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_cli.py +48 -0
  10. bindery_cli-0.48.0/tests/test_help.py +198 -0
  11. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/uv.lock +1 -1
  12. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/.github/FUNDING.yml +0 -0
  13. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/.github/workflows/ci.yml +0 -0
  14. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/.github/workflows/publish.yml +0 -0
  15. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/.gitignore +0 -0
  16. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/AGENTS.md +0 -0
  17. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/LICENSE +0 -0
  18. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/docs/screenshots/library-sweep.png +0 -0
  19. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/logo.svg +0 -0
  20. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/plugin/__init__.py +0 -0
  21. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/project.done +0 -0
  22. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/run_tests.sh +0 -0
  23. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/scripts/FastSweep.java +0 -0
  24. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/scripts/build_plugin.py +0 -0
  25. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/scripts/fast_sweep.py +0 -0
  26. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/spec.md +0 -0
  27. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/__main__.py +0 -0
  28. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/audit.py +0 -0
  29. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/epub.py +0 -0
  30. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/library.py +0 -0
  31. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/pagination.py +0 -0
  32. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/reserialize.py +0 -0
  33. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/transforms.py +0 -0
  34. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/validate.py +0 -0
  35. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/watermark.py +0 -0
  36. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_audit.py +0 -0
  37. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_comment_hyphens.py +0 -0
  38. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_cover_tocdrift.py +0 -0
  39. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_epub.py +0 -0
  40. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_flags_real_core.py +0 -0
  41. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_flags_wiring.py +0 -0
  42. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_library.py +0 -0
  43. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_matcher_hardening.py +0 -0
  44. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_pagination.py +0 -0
  45. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_plugin.py +0 -0
  46. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_reserialize.py +0 -0
  47. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_space_renames.py +0 -0
  48. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_transforms.py +0 -0
  49. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_validate.py +0 -0
  50. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_version.py +0 -0
  51. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_watermark.py +0 -0
  52. {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_xml_hardening.py +0 -0
@@ -118,7 +118,9 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
118
118
  - `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan). Since v0.46.0 the opt-in selection is the `RepairFlags` dataclass (one field per flag, all off by default; the all-off instance IS the default pass): `repair_epub(src, dst, flags)` and `process_book(..., flags)` take one object instead of a ~25-kwarg signature, the EPUB2-targeted fixes' version gate swaps in a `dataclasses.replace` copy (never mutating the caller's instance), and the plugin's bare `repair_epub(src, dst)` call constructs exactly the all-off default pass. Since v0.38.0/v0.39.0 (Phase 16): `generate_container` + the `--fix-container` gateway (insert after mimetype when missing, replace in-loop when stale; constant-epoch timestamp keeps bytes deterministic), `prune_dangling_edges` (prune_missing_manifest_items returns the pruned ids and the edges that pointed at them (spine@toc, media-overlay, fallback, EPUB2 cover meta) are rewritten so pruning cannot manufacture the regression the gate rejects), and `fix_manifest_media_types` (extension map + magic-byte confirmation via a `peek` callable over the open source zip; jpg/png/gif only), and `fix_cover_meta` (the ruling's deterministic half: dangling EPUB2 cover meta re-pointed from the guide's cover reference when exactly one manifest item carries that file, removed otherwise; accepted under no_worse like the lossy strips because cover wiring is invisible to epubcheck).
119
119
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
120
120
  - `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched: never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book. Since 2026-09-15 `make_backup` takes the opt-in `--backup-keep N` ring: at most N backup files per book, the author original `.bak` never deleted, newest content at the highest name; default remains an unbounded rotation.
121
- - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; `repair --json` emits the library per-book record vocabulary (2026-09-15; since v0.45.0 every per-book record, the phase1 payload included, also carries the structured fix breakdown: the `fixes` dict plus `ncx_uid_synced`/`watermark_refusals` off the `Outcome`, and `_phase1_decisions` reads that data instead of substring-matching the rendered `summary`, which stays for human eyes; CalibreQuarry's lossy-consent mirror is the waiting consumer, floor `bindery>=0.45.0`); since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2; since 2026-09-15 the summary sums applied/equal states only and carries rejected projections as `rejected_projection`). Plus `bindery doctor` (2026-09-15): the environment self-check that imports none of the VirInvictus stack, never raises, and always exits 0. Both run verbs take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. Since v0.46.0 `cli._flags_from_args(args)` is the one place the argparse names map onto `RepairFlags` fields (each `or --all`), shared by `run_library` and `run_repair`; the old per-verb ~25-kwarg blocks are gone. Since v0.47.1 the help is two-level and the repair-flag inventory is one table: `_REPAIR_FLAG_TABLE` (dest + one-line short help + long reference text) registers the compact grouped `--help` (argparse argument groups, hand-short usage line) and `--help-repairs` renders the long descriptions (a custom argparse Action, deliberately NOT store_true so the wiring test's selection count ignores it); adding a flag means adding a table row. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract). Since v0.40.0 `main()` guards the VirInvictus stack: a `ModuleNotFoundError` naming `vir_tui`/`cquarry` (marker-gated to 3.14+ on the 3.12 floor) becomes one stderr line and trouble exit 2 instead of a traceback; anything else missing still raises.
121
+ - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; `repair --json` emits the library per-book record vocabulary (2026-09-15; since v0.45.0 every per-book record, the phase1 payload included, also carries the structured fix breakdown: the `fixes` dict plus `ncx_uid_synced`/`watermark_refusals` off the `Outcome`, and `_phase1_decisions` reads that data instead of substring-matching the rendered `summary`, which stays for human eyes; CalibreQuarry's lossy-consent mirror is the waiting consumer, floor `bindery>=0.45.0`); since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2; since 2026-09-15 the summary sums applied/equal states only and carries rejected projections as `rejected_projection`). Plus `bindery doctor` (2026-09-15): the environment self-check that imports none of the VirInvictus stack, never raises, and always exits 0. Both run verbs take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. Since v0.46.0 `cli._flags_from_args(args)` is the one place the argparse names map onto `RepairFlags` fields (each `or --all`), shared by `run_library` and `run_repair`; the old per-verb ~25-kwarg blocks are gone. Since v0.47.1 the help is two-level (the --help-repairs reference is
122
+ custom-rendered and colors itself with the CPython 3.14 argparse theme,
123
+ gated by argparse's own _colorize detection so the levels always agree) and the repair-flag inventory is one table: `_REPAIR_FLAG_TABLE` (dest + one-line short help + long reference text) registers the compact grouped `--help` (argparse argument groups, hand-short usage line) and `--help-repairs` renders the long descriptions (a custom argparse Action, deliberately NOT store_true so the wiring test's selection count ignores it); adding a flag means adding a table row. `library --workers N` (v0.30.0) parallelizes only the sweep's candidate pass, in windows of N consumed in order so `--limit` stays lazy; the repair phase is never parallel (shared workdir, atomic-replacement contract). Since v0.40.0 `main()` guards the VirInvictus stack: a `ModuleNotFoundError` naming `vir_tui`/`cquarry` (marker-gated to 3.14+ on the 3.12 floor) becomes one stderr line and trouble exit 2 instead of a traceback; anything else missing still raises.
122
124
  - `plugin/__init__.py` + `scripts/build_plugin.py`: the Bindery Repair Calibre plugin (v0.37.0; identity `Bindery Repair` / `bindery_repair`, approved and built per the scoped spec in roadmap.md Phase 3). The builder vendors `transforms.py`, `epub.py`, `pagination.py`, `watermark.py`, `reserialize.py` byte-identically into the zip (the zip root is a package, so their relative imports resolve unchanged; the suite's drift test pins the equality) with the version tuple substituted from the single-source VERSION; `publish.yml` attaches `BinderyRepair-v<VERSION>.zip` to each release. The plugin runs ONLY the default pass (epubcheck cannot gate inside Calibre), never raises, is byte-idempotent (zero fixes returns the original path), testzip()-verifies its output, logs one line per book, and takes JSON config via `site_customization` (`log`, `log_path`, `max_size_mb` default 150, `max_log_mb` rotation cap default 2, `epubcheck_path` experimental validation default OFF). Load it in tests through the `tests/test_plugin.py` stubs: the calibre namespace is installed in sys.modules and the package loads with a two-dir `__path__` (plugin dir first, then src/bindery) exactly like calibre's zipplugin loader shape. **Plugin/interpreter compatibility (v0.40.0):** v0.39.0 shipped PEP 758 unparenthesized except tuples, and the plugin died with a bare SyntaxError on every released Calibre (7.x and the whole 8.x series embed Python 3.11, 9.0+ embeds 3.14; from calibre's bypy sources.json, checked against the upstream clone). The core is now grammar-pinned: parenthesized except tuples everywhere, `ruff`'s `target-version = "py312"` pin (inference from requires-python would rewrite the parens back off), `tests/test_version.py`'s parse guard, and CI's `plugin-compat` job byte-compiling the built zip under 3.11/3.12/3.13/3.14. `minimum_calibre_version` is (7, 0, 0): the oldest series whose interpreter CI covers, replacing the hollow (2, 0, 0); `supported_platforms` stays `["linux"]` (developed and tested on Linux only). Any new syntax in the vendored modules must clear the guard before it ships.
123
125
  - `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic/completeness, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, the version pin and the syntax-floor grammar guard, and the Calibre plugin (build drift + run behavior under calibre stubs). Since the v0.46.0 follow-ups: `tests/test_flags_wiring.py` reflectively pins the RepairFlags inventory to the argparse dests and `_flags_from_args` mapping (a flag added without a field, a field without a flag, or a remapped line fails there), the all-off defaults, and the version gate's caller-immutability contract (test_epub's `TestVersionGateKeepsFlagsIntact`); `tests/test_comment_hyphens.py` carries the RSC-016 fix; `tests/test_flags_real_core.py` drives every repair flag from argv through the unmocked core (--no-validate; the gate itself stays pinned by the mocked-oracle tests and runs for real wherever epubcheck exists). All three ride CI's core-compat 3.12/3.13 leg; the hand-listed module list there must gain an entry for every new stack-free test module.
124
126
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.47.1
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
@@ -203,6 +203,14 @@ vetting, phase-3 scoped post-import repair) for a calling agent, and `bindery do
203
203
  checks the installation (see Install).
204
204
  The sections below take each in turn.
205
205
 
206
+ Help is two-level: `bindery repair --help` (or `library --help`) lists the
207
+ shared repair flags grouped with one-line descriptions, and
208
+ `bindery repair --help-repairs` prints the full reference carrying every
209
+ flag's long rationale. On a terminal both are ANSI-colored with the same
210
+ theme Python 3.14's argparse uses; piped output stays plain, and `NO_COLOR`,
211
+ `FORCE_COLOR`, and `TERM=dumb` are honored exactly as `python --help` honors
212
+ them.
213
+
206
214
  ## Auditing
207
215
 
208
216
  bindery-cli's auditing tool inspects EPUB body text for non-schema flaws that epubcheck cannot catch. It extracts and analyzes the visible text to detect content issues, producing console reports that can be used to filter your library or feed into `bindery repair`.
@@ -176,6 +176,14 @@ vetting, phase-3 scoped post-import repair) for a calling agent, and `bindery do
176
176
  checks the installation (see Install).
177
177
  The sections below take each in turn.
178
178
 
179
+ Help is two-level: `bindery repair --help` (or `library --help`) lists the
180
+ shared repair flags grouped with one-line descriptions, and
181
+ `bindery repair --help-repairs` prints the full reference carrying every
182
+ flag's long rationale. On a terminal both are ANSI-colored with the same
183
+ theme Python 3.14's argparse uses; piped output stays plain, and `NO_COLOR`,
184
+ `FORCE_COLOR`, and `TERM=dumb` are honored exactly as `python --help` honors
185
+ them.
186
+
179
187
  ## Auditing
180
188
 
181
189
  bindery-cli's auditing tool inspects EPUB body text for non-schema flaws that epubcheck cannot catch. It extracts and analyzes the visible text to detect content issues, producing console reports that can be used to filter your library or feed into `bindery repair`.
@@ -1,5 +1,61 @@
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
+
33
+ ## v0.47.2 (2026-10-06)
34
+
35
+ ### The --help-repairs reference keeps argparse's color
36
+
37
+ The lattice-music lesson, landed here the same day: Python 3.14's argparse
38
+ colors `-h` on terminals by itself, so the argparse-rendered surfaces (the
39
+ top-level help and `-h` for every verb) kept their theme through the
40
+ v0.47.1 restructure untouched. The one gap was the page v0.47.1 added: the
41
+ custom-rendered `--help-repairs` reference printed plain, silently losing
42
+ the colorization every other surface has (the exact regression
43
+ lattice-music repaired in its 6b88a52).
44
+
45
+ * The reference now paints with the exact CPython 3.14 default theme
46
+ (bold blue headings, magenta prog, bold cyan long options; read off real
47
+ 3.14 output, so the page reads like the argparse-rendered `-h` beside
48
+ it) through a single layout path whose disabled style emits empty codes.
49
+ * The color gate IS argparse's own detection (`_colorize.can_colorize` on
50
+ 3.14+; plain False below 3.14, where argparse has no colorization to
51
+ match), so the two help levels can never disagree: verified under a pty
52
+ that `--help-repairs` carries the same code vocabulary as `-h` and that
53
+ `TERM=dumb` and `NO_COLOR` silence both, and that piped output stays
54
+ plain for README embedding and tests.
55
+ * Four new tests: the theme codes, the strip invariant (colored, codes
56
+ stripped == plain, byte for byte), pipe safety, and gate tracking.
57
+ TestHelpLayout is now nine; suite: 624.
58
+
3
59
  ## v0.47.1 (2026-10-06)
4
60
 
5
61
  ### The two-level help: compact grouped --help, full --help-repairs reference
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.47.1"
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.47.1"
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)
@@ -1642,6 +1789,115 @@ _REPAIR_FLAG_TABLE: dict[str, list[tuple[str, str, str]]] = {
1642
1789
  }
1643
1790
 
1644
1791
 
1792
+ # The --help-repairs page is custom-rendered (not argparse output), so it
1793
+ # paints itself with the exact CPython 3.14 argparse default theme
1794
+ # (ColorfulTheme: bold blue headings, magenta prog, bold cyan long options
1795
+ # -- read off real 3.14 output, so the page reads like the argparse-rendered
1796
+ # -h beside it). This is the lattice-music lesson (6b88a52 there): a custom
1797
+ # help renderer that prints plain silently loses the colorization argparse
1798
+ # gives every other surface.
1799
+ _THEME = {
1800
+ "heading": "\x1b[1;34m",
1801
+ "prog": "\x1b[35m",
1802
+ "long_option": "\x1b[1;36m",
1803
+ "reset": "\x1b[0m",
1804
+ }
1805
+
1806
+
1807
+ def _can_color_help() -> bool:
1808
+ """True exactly when argparse itself would colorize -h here: CPython
1809
+ 3.14's own detection (_colorize.can_colorize: PYTHON_COLORS, NO_COLOR,
1810
+ FORCE_COLOR, TERM=dumb, isatty), so the two help levels can never
1811
+ disagree about color. Plain False below 3.14, where argparse has no
1812
+ colorization to match."""
1813
+ if sys.version_info < (3, 14):
1814
+ return False
1815
+ from _colorize import can_colorize
1816
+
1817
+ return can_colorize()
1818
+
1819
+
1820
+ def _repair_flag_reference(color: bool | None = None) -> str:
1821
+ """The full repair-flag reference: the _REPAIR_FLAG_TABLE rows with
1822
+ their long descriptions. `color=None` decides by the same gate argparse
1823
+ uses; the disabled style emits empty codes, so one layout path serves
1824
+ both faces and the strip invariant (colored, codes stripped == plain)
1825
+ holds by construction."""
1826
+ if color is None:
1827
+ color = _can_color_help()
1828
+ if color:
1829
+ heading, prog, option, reset = (
1830
+ _THEME["heading"],
1831
+ _THEME["prog"],
1832
+ _THEME["long_option"],
1833
+ _THEME["reset"],
1834
+ )
1835
+ else:
1836
+ heading = prog = option = reset = ""
1837
+ lines = [
1838
+ prog
1839
+ + "bindery"
1840
+ + reset
1841
+ + " repair-flag reference (shared by `bindery repair` and "
1842
+ "`bindery library`)",
1843
+ "",
1844
+ "The always-on core pass is exactly five well-formedness fixes, the NCX",
1845
+ "pipeline, and the mimetype fix. Everything below is opt-in, and every",
1846
+ "repair is epubcheck-gated: applied only when the measured result improved",
1847
+ "(lossy strips: only when it did not get worse). Library mode is dry-run",
1848
+ "by default; `--all` enables every flag below at once. The README and",
1849
+ "spec.md carry the full rationale for each repair.",
1850
+ ]
1851
+ for title, rows in _REPAIR_FLAG_TABLE.items():
1852
+ lines.append("")
1853
+ lines.append(heading + title + reset)
1854
+ lines.append("")
1855
+ for dest, _short, long_help in rows:
1856
+ lines.append(" " + option + "--" + dest.replace("_", "-") + reset)
1857
+ for para in long_help.split("\n"):
1858
+ lines.extend(
1859
+ textwrap.wrap(
1860
+ para,
1861
+ width=88,
1862
+ initial_indent=" ",
1863
+ subsequent_indent=" ",
1864
+ )
1865
+ or [" "]
1866
+ )
1867
+ return "\n".join(lines)
1868
+
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
+
1645
1901
  class _HelpRepairsAction(argparse.Action):
1646
1902
  """The second help level: print the full repair-flag reference and exit.
1647
1903
 
@@ -1665,35 +1921,7 @@ class _HelpRepairsAction(argparse.Action):
1665
1921
  )
1666
1922
 
1667
1923
  def __call__(self, parser, namespace, values, option_string=None):
1668
- lines = [
1669
- "bindery repair-flag reference (shared by `bindery repair` and "
1670
- "`bindery library`)",
1671
- "",
1672
- "The always-on core pass is exactly five well-formedness fixes, the NCX",
1673
- "pipeline, and the mimetype fix. Everything below is opt-in, and every",
1674
- "repair is epubcheck-gated: applied only when the measured result improved",
1675
- "(lossy strips: only when it did not get worse). Library mode is dry-run",
1676
- "by default; `--all` enables every flag below at once. The README and",
1677
- "spec.md carry the full rationale for each repair.",
1678
- ]
1679
- for title, rows in _REPAIR_FLAG_TABLE.items():
1680
- lines.append("")
1681
- lines.append(title)
1682
- lines.append("")
1683
- for dest, _short, long_help in rows:
1684
- option = "--" + dest.replace("_", "-")
1685
- lines.append(f" {option}")
1686
- for para in long_help.split("\n"):
1687
- lines.extend(
1688
- textwrap.wrap(
1689
- para,
1690
- width=88,
1691
- initial_indent=" ",
1692
- subsequent_indent=" ",
1693
- )
1694
- or [" "]
1695
- )
1696
- print("\n".join(lines))
1924
+ print(_repair_flag_reference())
1697
1925
  parser.exit(0)
1698
1926
 
1699
1927
 
@@ -1731,6 +1959,10 @@ def _add_repair_flags(p: argparse.ArgumentParser) -> None:
1731
1959
 
1732
1960
 
1733
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
1734
1966
  selected = list(ALL) if args.mode == "all" else [args.mode]
1735
1967
  max_doc = args.max_doc_chars
1736
1968
  if args.min_chars > args.thin_chars:
@@ -1803,6 +2035,12 @@ def build_parser() -> argparse.ArgumentParser:
1803
2035
  formatter_class=argparse.RawDescriptionHelpFormatter,
1804
2036
  )
1805
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
+ )
1806
2044
  sub = ap.add_subparsers(dest="cmd", required=True)
1807
2045
 
1808
2046
  r = sub.add_parser(
@@ -1812,7 +2050,10 @@ def build_parser() -> argparse.ArgumentParser:
1812
2050
  description="Repair one EPUB into a new file with the epubcheck-gated "
1813
2051
  "core pass: the five well-formedness fixes, the NCX pipeline, and the "
1814
2052
  "mimetype fix run always; every other repair is opt-in (grouped "
1815
- "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).",
1816
2057
  epilog="examples:\n"
1817
2058
  " bindery repair book.epub out.epub\n"
1818
2059
  " bindery repair book.epub out.epub --all\n"
@@ -1832,8 +2073,9 @@ def build_parser() -> argparse.ArgumentParser:
1832
2073
  "--json",
1833
2074
  dest="json_path",
1834
2075
  metavar="FILE",
1835
- help="write a machine-readable report (status, before/after counts, "
1836
- "fix summary) in the library --json per-book shape",
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)",
1837
2079
  )
1838
2080
  _add_repair_flags(r)
1839
2081
  r.set_defaults(func=run_repair)
@@ -1920,7 +2162,11 @@ def build_parser() -> argparse.ArgumentParser:
1920
2162
  description="Scan a Calibre library tree for repair candidates and "
1921
2163
  "repair them in place. Dry run by default; --apply atomically "
1922
2164
  "replaces accepted books. Shares the repair-flag set with `bindery "
1923
- "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).",
1924
2170
  epilog="examples:\n"
1925
2171
  " bindery library ~/docs/Calibre\\ Library --sweep\n"
1926
2172
  " bindery library ~/docs/Calibre\\ Library --id 1234 --apply "
@@ -1938,7 +2184,8 @@ def build_parser() -> argparse.ArgumentParser:
1938
2184
  "--only",
1939
2185
  choices=("fatals", "ncx", "all"),
1940
2186
  default="all",
1941
- help="restrict to books with fatals, NCX-001 mismatch, or all (default)",
2187
+ help="restrict to books with fatals, the NCX-001 mismatch "
2188
+ "(toc.ncx dtb:uid != the OPF unique-identifier), or all (default)",
1942
2189
  )
1943
2190
  rc.add_argument(
1944
2191
  "--limit", type=int, help="process at most N candidates (for sampling)"
@@ -1971,9 +2218,9 @@ def build_parser() -> argparse.ArgumentParser:
1971
2218
  "--id",
1972
2219
  default="",
1973
2220
  metavar="IDS",
1974
- help="comma-separated Calibre book ids to scope the sweep to "
1975
- "(resolved via cquarry's get_format_path; mutually exclusive with "
1976
- "--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)",
1977
2224
  )
1978
2225
  bk = lib.add_argument_group("backups")
1979
2226
  bk.add_argument("--backup", help="directory to mirror backups into before --apply")
@@ -1994,7 +2241,7 @@ def build_parser() -> argparse.ArgumentParser:
1994
2241
  out.add_argument(
1995
2242
  "--json",
1996
2243
  metavar="FILE",
1997
- 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",
1998
2245
  )
1999
2246
  out.add_argument(
2000
2247
  "--manual-list",
@@ -2005,7 +2252,7 @@ def build_parser() -> argparse.ArgumentParser:
2005
2252
  out.add_argument(
2006
2253
  "--install-to-calibre",
2007
2254
  action="store_true",
2008
- 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 "
2009
2256
  "through cquarry's write module (remove + add in one transaction) "
2010
2257
  "instead of a bare filesystem replace",
2011
2258
  )
@@ -2024,7 +2271,9 @@ def build_parser() -> argparse.ArgumentParser:
2024
2271
  "phase1",
2025
2272
  help="the EPUB pre-import vetting slice over a directory of loose "
2026
2273
  "files: corruption sweep, epubcheck, content battery, monolithic, "
2027
- "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",
2028
2277
  )
2029
2278
  p1.add_argument("path")
2030
2279
  p1.add_argument(
@@ -2060,7 +2309,8 @@ def build_parser() -> argparse.ArgumentParser:
2060
2309
  help="the post-import scoped repair sweep: library --id <ids> --sweep "
2061
2310
  "--only all --apply --all --install-to-calibre with a pre/post "
2062
2311
  "summary. Refuses unscoped library-wide sweeps (exit 2); run from "
2063
- "the library directory",
2312
+ "the library directory. Exit codes follow library's: 0 clean, "
2313
+ "2 trouble, 1 invocation problem",
2064
2314
  )
2065
2315
  p3.add_argument(
2066
2316
  "--ids",
@@ -1866,6 +1866,54 @@ class TestHelpLayout(unittest.TestCase):
1866
1866
  self.assertIn("XML NameChar set", text, verb)
1867
1867
  self.assertIn("no-worse", text, verb)
1868
1868
 
1869
+ def test_help_repairs_theme_codes_when_colored(self):
1870
+ from bindery import cli
1871
+
1872
+ text = cli._repair_flag_reference(color=True)
1873
+ # the exact CPython 3.14 argparse theme: heading blue, prog magenta,
1874
+ # long-option cyan
1875
+ self.assertIn("\x1b[1;34m", text)
1876
+ self.assertIn("\x1b[35mbindery\x1b[0m", text)
1877
+ self.assertIn("\x1b[1;36m--fix-ids\x1b[0m", text)
1878
+ self.assertIn("\x1b[1;34mlossy strips", text)
1879
+
1880
+ def test_help_repairs_strip_invariant(self):
1881
+ # colored, codes stripped == plain, byte for byte: one layout path
1882
+ # serves both faces (the lattice-music invariant)
1883
+ import re
1884
+
1885
+ from bindery import cli
1886
+
1887
+ colored = cli._repair_flag_reference(color=True)
1888
+ plain = cli._repair_flag_reference(color=False)
1889
+ self.assertEqual(re.sub(r"\x1b\[[0-9;]*m", "", colored), plain)
1890
+
1891
+ def test_help_repairs_piped_output_is_plain(self):
1892
+ # a pipe (tests, README embedding) never carries codes
1893
+ for verb in ("repair", "library"):
1894
+ buf = io.StringIO()
1895
+ with self.assertRaises(SystemExit), contextlib.redirect_stdout(buf):
1896
+ main([verb, "--help-repairs"])
1897
+ self.assertNotIn("\x1b[", buf.getvalue(), verb)
1898
+
1899
+ def test_help_gate_tracks_argparse(self):
1900
+ # the gate IS argparse's own detection on 3.14+ (so -h and
1901
+ # --help-repairs can never disagree); plain False below, where
1902
+ # argparse has no colorization
1903
+ import os
1904
+
1905
+ from bindery import cli
1906
+
1907
+ if sys.version_info < (3, 14):
1908
+ self.assertFalse(cli._can_color_help())
1909
+ return
1910
+ from _colorize import can_colorize
1911
+
1912
+ self.assertEqual(cli._can_color_help(), can_colorize())
1913
+ # NO_COLOR suppresses on every interpreter
1914
+ with mock.patch.dict(os.environ, {"NO_COLOR": "1"}):
1915
+ self.assertFalse(cli._can_color_help())
1916
+
1869
1917
  def test_help_repairs_is_not_a_selection_flag(self):
1870
1918
  # _HelpRepairsAction is deliberately not a store_true: the wiring
1871
1919
  # test counts store_true dests as repair selections
@@ -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"])
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "bindery-cli"
11
- version = "0.47.1"
11
+ version = "0.48.0"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "cquarry", marker = "python_full_version >= '3.14'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes