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.
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/CLAUDE.md +3 -1
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/PKG-INFO +9 -1
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/README.md +8 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/patchnotes.md +56 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/pyproject.toml +1 -1
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/roadmap.md +1 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/cli.py +291 -41
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_cli.py +48 -0
- bindery_cli-0.48.0/tests/test_help.py +198 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/uv.lock +1 -1
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/.github/FUNDING.yml +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/.gitignore +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/AGENTS.md +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/LICENSE +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/logo.svg +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/plugin/__init__.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/project.done +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/run_tests.sh +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/spec.md +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/epub.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/transforms.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_comment_hyphens.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_cover_tocdrift.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_epub.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_flags_real_core.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_flags_wiring.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_library.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_matcher_hardening.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_plugin.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_space_renames.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_transforms.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_version.py +0 -0
- {bindery_cli-0.47.1 → bindery_cli-0.48.0}/tests/test_watermark.py +0 -0
- {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
|
|
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.
|
|
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.
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
|
1836
|
-
"
|
|
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
|
|
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
|
|
1975
|
-
"(resolved via cquarry's get_format_path;
|
|
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"])
|
|
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
|