bindery-cli 0.48.1__tar.gz → 0.49.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.48.1/CLAUDE.md → bindery_cli-0.49.0/AGENTS.md +12 -3
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/PKG-INFO +27 -11
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/README.md +26 -10
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/patchnotes.md +70 -1
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/pyproject.toml +1 -1
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/roadmap.md +1 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/spec.md +15 -27
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/__init__.py +5 -5
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/cli.py +52 -26
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/validate.py +2 -1
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_cli.py +73 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_version.py +11 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/uv.lock +1 -1
- bindery_cli-0.48.1/AGENTS.md +0 -1
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/.github/FUNDING.yml +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/.gitignore +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/LICENSE +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/logo.svg +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/plugin/__init__.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/project.done +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/run_tests.sh +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/epub.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/transforms.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_comment_hyphens.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_cover_tocdrift.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_epub.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_flags_real_core.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_flags_wiring.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_help.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_library.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_matcher_hardening.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_plugin.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_space_renames.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_transforms.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_watermark.py +0 -0
- {bindery_cli-0.48.1 → bindery_cli-0.49.0}/tests/test_xml_hardening.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AGENTS.md (bindery-cli)
|
|
2
2
|
|
|
3
3
|
Per-project guidance. Overrides the global file where they conflict.
|
|
4
4
|
|
|
@@ -120,13 +120,13 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
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
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
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.
|
|
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. Since v0.48.0 the metadata.db doors are enforced: `_require_closed_calibre` (pgrep fail-closed, exit 1) guards `audit --tag` and `library --apply --install-to-calibre` (run phase3 transitively), and `--help-json` (a custom argparse Action like --help-repairs, never colored) dumps the per-verb exit contract and the door list; since v0.48.1 the per-verb exit contracts tell the code's truth (repair: 0 probed/written incl. partial / 1 trouble, never 2; audit: 0/1/2 plus 3 for findings+write-failure; library and the run slices: the unified 0/2/1); since v0.49.0 the single-file verb's no-output form is a probe (the gate runs, the record's `output` is null and `applied` false, nothing is written anywhere; the old implicit `(repaired).epub` sibling was issue #7's import litter in a vetted tree, and writing now needs an explicit DST). `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.
|
|
124
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.
|
|
125
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.
|
|
126
126
|
|
|
127
127
|
## Conventions
|
|
128
128
|
|
|
129
|
-
-
|
|
129
|
+
- Python style follows the global conventions (type hints, `from __future__ import annotations`, ruff for lint and format; `~/.zcode/AGENTS.md`).
|
|
130
130
|
- `./run_tests.sh` runs the suite in the project venv. Since the 3.12 floor, `uv run --python X` REBUILDS `.venv` for interpreter X, and the `python_version >= '3.14'` markers then gate vir-tui/cquarry OUT of it below 3.14 (the suite goes red with ModuleNotFoundError: cquarry). After interpreter experiments, rebuild with `uv run --python 3.14` before running the suite again.
|
|
131
131
|
- `VERSION` lives in `src/bindery/__init__.py`, mirrored in `pyproject.toml`. Bump both.
|
|
132
132
|
- Run tests with `./run_tests.sh`.
|
|
@@ -136,3 +136,12 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
136
136
|
The library is real data. The loop is always: dry run on `/tmp` copies, inspect the
|
|
137
137
|
report, then apply with backups. epubcheck is the oracle; a repaired book that still has
|
|
138
138
|
fatals is `partial` and must be left for manual work, never auto-applied.
|
|
139
|
+
|
|
140
|
+
## Release flow
|
|
141
|
+
|
|
142
|
+
Commit and push verified work (the standing push grant in the global file; force-pushes
|
|
143
|
+
still need approval), wait for CI to go green, then tag: annotated `vX.Y.Z` at the release
|
|
144
|
+
commit, message = that release's patchnotes entry verbatim (`--cleanup=verbatim`; the full
|
|
145
|
+
tag procedure lives in `~/.zcode/AGENTS.md`). The tag drives `publish.yml`: the PyPI push
|
|
146
|
+
plus the Bindery Repair plugin zip attached to the GitHub release. Version carriers:
|
|
147
|
+
`src/bindery/__init__.py`'s VERSION and `pyproject.toml`; bump both.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.49.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
|
|
@@ -206,7 +206,9 @@ The sections below take each in turn.
|
|
|
206
206
|
Help is two-level: `bindery repair --help` (or `library --help`) lists the
|
|
207
207
|
shared repair flags grouped with one-line descriptions, and
|
|
208
208
|
`bindery repair --help-repairs` prints the full reference carrying every
|
|
209
|
-
flag's long rationale
|
|
209
|
+
flag's long rationale, and `bindery --help-json` dumps the complete
|
|
210
|
+
surface (every subcommand and flag, the repair reference, the per-verb
|
|
211
|
+
exit contract, the Calibre gates) as machine-readable JSON. On a terminal both are ANSI-colored with the same
|
|
210
212
|
theme Python 3.14's argparse uses; piped output stays plain, and `NO_COLOR`,
|
|
211
213
|
`FORCE_COLOR`, and `TERM=dumb` are honored exactly as `python --help` honors
|
|
212
214
|
them.
|
|
@@ -236,7 +238,11 @@ bindery audit monolithic ~/docs/Calibre\ Library
|
|
|
236
238
|
# Spot-check completeness: prose coverage, first/middle/last excerpts, trailing ToC
|
|
237
239
|
bindery audit completeness ~/docs/Calibre\ Library
|
|
238
240
|
|
|
239
|
-
#
|
|
241
|
+
# Cover-art quality and ToC-drift audits (advisory; never move the exit code)
|
|
242
|
+
bindery audit cover ~/docs/Calibre\ Library
|
|
243
|
+
bindery audit tocdrift ~/docs/Calibre\ Library
|
|
244
|
+
|
|
245
|
+
# Run all audits and report to the console (--json FILE for machine output)
|
|
240
246
|
cd ~/docs/Calibre\ Library && bindery audit all
|
|
241
247
|
```
|
|
242
248
|
|
|
@@ -265,7 +271,7 @@ With `--id`, `--json` accepts exactly one book id (each single-book run writes t
|
|
|
265
271
|
|
|
266
272
|
**Archive integrity:** every audit fully reads each archive entry (CRC + decompression), so a damaged download is reported CORRUPT, with the first broken entry named, instead of being mislabeled EMPTY by `emptytext`. `library --sweep` splits its `unreadable` bucket into `not_a_zip` / `truncated` / `encrypted` / `corrupt_entry`, so the right disease is visible without leaving the sweep.
|
|
267
273
|
|
|
268
|
-
### Tagging flagged books (opt-in)
|
|
274
|
+
### Tagging flagged books (opt-in; the write is refused while Calibre runs)
|
|
269
275
|
|
|
270
276
|
By default an audit writes nothing. With `--tag TAG` (library mode only), every book the audit
|
|
271
277
|
flags is tagged in `metadata.db` through cquarry's trigger-safe write module, useful for piping
|
|
@@ -276,17 +282,23 @@ cd ~/docs/Calibre\ Library && bindery audit content --tag "Audit Flagged"
|
|
|
276
282
|
```
|
|
277
283
|
|
|
278
284
|
The audit itself stays read-only; only the final tagging pass writes, it skips books that already
|
|
279
|
-
carry the tag, and THIN emptytext advisories are never tagged.
|
|
280
|
-
|
|
285
|
+
carry the tag, and THIN emptytext advisories are never tagged. The write is REFUSED with exit 1
|
|
286
|
+
while Calibre is running (a fail-closed pgrep guard: close Calibre and re-run). Tagged books are recorded in Calibre's `metadata_dirtied` queue, so the
|
|
281
287
|
desktop app regenerates their sidecar `.opf`s (and re-pushes metadata to wireless readers) on its
|
|
282
288
|
next startup: no manual resync needed.
|
|
283
289
|
|
|
284
290
|
|
|
285
|
-
|
|
291
|
+
## Repairing a book
|
|
292
|
+
|
|
293
|
+
Repair one book to a new file (gated; writes only if it is an improvement). Without an
|
|
294
|
+
output path the verb is a probe: the gate runs and the report says what would change,
|
|
295
|
+
and nothing is written anywhere, so probing a file can never litter the directory
|
|
296
|
+
under vetting:
|
|
286
297
|
|
|
287
298
|
```sh
|
|
288
|
-
bindery repair broken.epub #
|
|
289
|
-
bindery repair broken.epub
|
|
299
|
+
bindery repair broken.epub # probe: report only, nothing written
|
|
300
|
+
bindery repair broken.epub --all --json report.json # probe with a machine-readable record
|
|
301
|
+
bindery repair broken.epub fixed.epub # rescue form: write the repaired file
|
|
290
302
|
bindery repair scanned.epub --strip-pagination # also remove baked-in page numbers
|
|
291
303
|
bindery repair stubs.epub --strip-stub-docs # drop repeated placeholder chapters
|
|
292
304
|
bindery repair broken.epub fixed.epub --json report.json # machine-readable record
|
|
@@ -295,7 +307,11 @@ bindery repair broken.epub fixed.epub --json report.json # machine-readable rec
|
|
|
295
307
|
`repair --json FILE` writes one record in the `library --json` per-book
|
|
296
308
|
vocabulary (`status`, `applied`, `before`/`after` counts, the fix `summary`),
|
|
297
309
|
on every processing outcome including nochange and reject: a calling agent
|
|
298
|
-
gets the same facts the console lines carry.
|
|
310
|
+
gets the same facts the console lines carry. On a probe the record's
|
|
311
|
+
`output` is null and `applied` stays false; the counts and the fix
|
|
312
|
+
breakdown still describe the gate-accepted projection.
|
|
313
|
+
|
|
314
|
+
## Sweeping a library
|
|
299
315
|
|
|
300
316
|
Scan a Calibre library and see what would be fixed, writing nothing:
|
|
301
317
|
|
|
@@ -328,7 +344,7 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
|
|
|
328
344
|
- `--all` automatically turns on all opt-in non-fatal fixes and lossy strips (pagination, watermarks, bad attributes, unknown entities, image alt tags, etc.) in a single run.
|
|
329
345
|
- Only the `.epub` is replaced. `metadata.opf`, `cover.jpg`, and `metadata.db` are left for Calibre's Quality Check sync to reconcile.
|
|
330
346
|
- A per-book progress line goes to stderr (stdout stays a clean report); `--quiet` suppresses it. A corrupt or unreadable book is reported and skipped, never aborting the sweep.
|
|
331
|
-
- Exit codes for `
|
|
347
|
+
- Exit codes, per verb (for scripts and cron; `--help-json`'s `exit_contract` carries this machine-side): `repair` exits 0 when clean, probed, OR written (a probe writes nothing and exits 0; a PARTIAL repair writes its best output and exits 0; the `--json` record's status says so) and 1 on trouble (gate-rejected, epubcheck failure, unreadable input) or invocation problems (bad args, an existing output without `--force`); it never exits 2. `library`, `run phase1`, and `run phase3` use the unified contract: 0 clean, 2 when any book was flagged, rejected, unreadable, errored, or partial, 1 for invocation problems. `audit` runs the inverse contract: 0 clean, 1 when any book was flagged (a flag is the audit's trouble), 2 for its own usage errors, 3 when findings coexist with a failed `--json`/`--tag` write. `audit --tag` and `library --apply --install-to-calibre` are lock-class doors: they are REFUSED with exit 1 while Calibre is running. Argparse-level misuse exits 2 everywhere before any validation runs.
|
|
332
348
|
- A `partial` book (improved but still unable to open) is reported for manual follow-up and fails the run with exit 2, in `library`, `run phase1`, and `run phase3` alike: a book that still needs a human is trouble.
|
|
333
349
|
- `repair` refuses to overwrite an existing output file unless `--force` is given.
|
|
334
350
|
|
|
@@ -179,7 +179,9 @@ The sections below take each in turn.
|
|
|
179
179
|
Help is two-level: `bindery repair --help` (or `library --help`) lists the
|
|
180
180
|
shared repair flags grouped with one-line descriptions, and
|
|
181
181
|
`bindery repair --help-repairs` prints the full reference carrying every
|
|
182
|
-
flag's long rationale
|
|
182
|
+
flag's long rationale, and `bindery --help-json` dumps the complete
|
|
183
|
+
surface (every subcommand and flag, the repair reference, the per-verb
|
|
184
|
+
exit contract, the Calibre gates) as machine-readable JSON. On a terminal both are ANSI-colored with the same
|
|
183
185
|
theme Python 3.14's argparse uses; piped output stays plain, and `NO_COLOR`,
|
|
184
186
|
`FORCE_COLOR`, and `TERM=dumb` are honored exactly as `python --help` honors
|
|
185
187
|
them.
|
|
@@ -209,7 +211,11 @@ bindery audit monolithic ~/docs/Calibre\ Library
|
|
|
209
211
|
# Spot-check completeness: prose coverage, first/middle/last excerpts, trailing ToC
|
|
210
212
|
bindery audit completeness ~/docs/Calibre\ Library
|
|
211
213
|
|
|
212
|
-
#
|
|
214
|
+
# Cover-art quality and ToC-drift audits (advisory; never move the exit code)
|
|
215
|
+
bindery audit cover ~/docs/Calibre\ Library
|
|
216
|
+
bindery audit tocdrift ~/docs/Calibre\ Library
|
|
217
|
+
|
|
218
|
+
# Run all audits and report to the console (--json FILE for machine output)
|
|
213
219
|
cd ~/docs/Calibre\ Library && bindery audit all
|
|
214
220
|
```
|
|
215
221
|
|
|
@@ -238,7 +244,7 @@ With `--id`, `--json` accepts exactly one book id (each single-book run writes t
|
|
|
238
244
|
|
|
239
245
|
**Archive integrity:** every audit fully reads each archive entry (CRC + decompression), so a damaged download is reported CORRUPT, with the first broken entry named, instead of being mislabeled EMPTY by `emptytext`. `library --sweep` splits its `unreadable` bucket into `not_a_zip` / `truncated` / `encrypted` / `corrupt_entry`, so the right disease is visible without leaving the sweep.
|
|
240
246
|
|
|
241
|
-
### Tagging flagged books (opt-in)
|
|
247
|
+
### Tagging flagged books (opt-in; the write is refused while Calibre runs)
|
|
242
248
|
|
|
243
249
|
By default an audit writes nothing. With `--tag TAG` (library mode only), every book the audit
|
|
244
250
|
flags is tagged in `metadata.db` through cquarry's trigger-safe write module, useful for piping
|
|
@@ -249,17 +255,23 @@ cd ~/docs/Calibre\ Library && bindery audit content --tag "Audit Flagged"
|
|
|
249
255
|
```
|
|
250
256
|
|
|
251
257
|
The audit itself stays read-only; only the final tagging pass writes, it skips books that already
|
|
252
|
-
carry the tag, and THIN emptytext advisories are never tagged.
|
|
253
|
-
|
|
258
|
+
carry the tag, and THIN emptytext advisories are never tagged. The write is REFUSED with exit 1
|
|
259
|
+
while Calibre is running (a fail-closed pgrep guard: close Calibre and re-run). Tagged books are recorded in Calibre's `metadata_dirtied` queue, so the
|
|
254
260
|
desktop app regenerates their sidecar `.opf`s (and re-pushes metadata to wireless readers) on its
|
|
255
261
|
next startup: no manual resync needed.
|
|
256
262
|
|
|
257
263
|
|
|
258
|
-
|
|
264
|
+
## Repairing a book
|
|
265
|
+
|
|
266
|
+
Repair one book to a new file (gated; writes only if it is an improvement). Without an
|
|
267
|
+
output path the verb is a probe: the gate runs and the report says what would change,
|
|
268
|
+
and nothing is written anywhere, so probing a file can never litter the directory
|
|
269
|
+
under vetting:
|
|
259
270
|
|
|
260
271
|
```sh
|
|
261
|
-
bindery repair broken.epub #
|
|
262
|
-
bindery repair broken.epub
|
|
272
|
+
bindery repair broken.epub # probe: report only, nothing written
|
|
273
|
+
bindery repair broken.epub --all --json report.json # probe with a machine-readable record
|
|
274
|
+
bindery repair broken.epub fixed.epub # rescue form: write the repaired file
|
|
263
275
|
bindery repair scanned.epub --strip-pagination # also remove baked-in page numbers
|
|
264
276
|
bindery repair stubs.epub --strip-stub-docs # drop repeated placeholder chapters
|
|
265
277
|
bindery repair broken.epub fixed.epub --json report.json # machine-readable record
|
|
@@ -268,7 +280,11 @@ bindery repair broken.epub fixed.epub --json report.json # machine-readable rec
|
|
|
268
280
|
`repair --json FILE` writes one record in the `library --json` per-book
|
|
269
281
|
vocabulary (`status`, `applied`, `before`/`after` counts, the fix `summary`),
|
|
270
282
|
on every processing outcome including nochange and reject: a calling agent
|
|
271
|
-
gets the same facts the console lines carry.
|
|
283
|
+
gets the same facts the console lines carry. On a probe the record's
|
|
284
|
+
`output` is null and `applied` stays false; the counts and the fix
|
|
285
|
+
breakdown still describe the gate-accepted projection.
|
|
286
|
+
|
|
287
|
+
## Sweeping a library
|
|
272
288
|
|
|
273
289
|
Scan a Calibre library and see what would be fixed, writing nothing:
|
|
274
290
|
|
|
@@ -301,7 +317,7 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
|
|
|
301
317
|
- `--all` automatically turns on all opt-in non-fatal fixes and lossy strips (pagination, watermarks, bad attributes, unknown entities, image alt tags, etc.) in a single run.
|
|
302
318
|
- Only the `.epub` is replaced. `metadata.opf`, `cover.jpg`, and `metadata.db` are left for Calibre's Quality Check sync to reconcile.
|
|
303
319
|
- A per-book progress line goes to stderr (stdout stays a clean report); `--quiet` suppresses it. A corrupt or unreadable book is reported and skipped, never aborting the sweep.
|
|
304
|
-
- Exit codes for `
|
|
320
|
+
- Exit codes, per verb (for scripts and cron; `--help-json`'s `exit_contract` carries this machine-side): `repair` exits 0 when clean, probed, OR written (a probe writes nothing and exits 0; a PARTIAL repair writes its best output and exits 0; the `--json` record's status says so) and 1 on trouble (gate-rejected, epubcheck failure, unreadable input) or invocation problems (bad args, an existing output without `--force`); it never exits 2. `library`, `run phase1`, and `run phase3` use the unified contract: 0 clean, 2 when any book was flagged, rejected, unreadable, errored, or partial, 1 for invocation problems. `audit` runs the inverse contract: 0 clean, 1 when any book was flagged (a flag is the audit's trouble), 2 for its own usage errors, 3 when findings coexist with a failed `--json`/`--tag` write. `audit --tag` and `library --apply --install-to-calibre` are lock-class doors: they are REFUSED with exit 1 while Calibre is running. Argparse-level misuse exits 2 everywhere before any validation runs.
|
|
305
321
|
- A `partial` book (improved but still unable to open) is reported for manual follow-up and fails the run with exit 2, in `library`, `run phase1`, and `run phase3` alike: a book that still needs a human is trouble.
|
|
306
322
|
- `repair` refuses to overwrite an existing output file unless `--force` is given.
|
|
307
323
|
|
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
2
|
|
|
3
|
+
## v0.49.0 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
### The single-file repair probe writes nothing (issue #7)
|
|
6
|
+
|
|
7
|
+
Found live in a phase-1 vetting run: `bindery repair <file> --all --json
|
|
8
|
+
report.json`, the acquisition pipeline's documented single-candidate
|
|
9
|
+
probe, wrote its full repaired output as an 11MB `...(repaired).epub`
|
|
10
|
+
sibling inside the vetted tree, where anything beside the files gets
|
|
11
|
+
inventoried: import litter by the pipeline's own rule, carrying a name
|
|
12
|
+
only the `(repaired)` suffix apart from the original, so a later driver
|
|
13
|
+
pointed at the pre-probe filename list either missed it or
|
|
14
|
+
double-handled it.
|
|
15
|
+
|
|
16
|
+
- **No output path is now a probe**: the epubcheck gate runs, the
|
|
17
|
+
console lines and the `--json` record carry the full projection
|
|
18
|
+
(status, before/after counts, the structured fix breakdown), and
|
|
19
|
+
nothing is written anywhere; the repaired bytes stay in the scratch
|
|
20
|
+
workdir and vanish. On a probe the record's `output` is null and
|
|
21
|
+
`applied` stays false.
|
|
22
|
+
- **Writing needs an explicit destination** (`bindery repair book.epub
|
|
23
|
+
out.epub`), and that rescue form is byte-for-byte what it was: same
|
|
24
|
+
gate, same copy-the-accepted-bytes rule, same `--force` and
|
|
25
|
+
refuse-to-overwrite-the-input guards.
|
|
26
|
+
- The probe's console verdict is the record's own status vocabulary
|
|
27
|
+
(`accept:`/`equal:`/`unvalidated:`; partial keeps its established
|
|
28
|
+
PARTIAL phrasing), followed by an explicit "probe: nothing written"
|
|
29
|
+
line; a probe that would be partial still exits 0 with the record's
|
|
30
|
+
status carrying the verdict.
|
|
31
|
+
- The repair `--help` text (new probe example, the output positional's
|
|
32
|
+
no-write meaning) and `--help-json`'s exit contract tell the new
|
|
33
|
+
truth; README and spec.md document both forms. The exit contract
|
|
34
|
+
itself is unchanged: 0 clean/probed/written including partial, 1
|
|
35
|
+
trouble, never 2.
|
|
36
|
+
- Three pins added (643 total): the probe writes nothing on the accept
|
|
37
|
+
and partial paths, and the `equal` noop verdict is carried honestly
|
|
38
|
+
rather than dressed up as repairable.
|
|
39
|
+
|
|
40
|
+
## v0.48.2 (2026-10-06)
|
|
41
|
+
|
|
42
|
+
### The 12-agent docs audit: README/spec/CLAUDE tell the v0.48 truth
|
|
43
|
+
|
|
44
|
+
v0.48.0/v0.48.1 truthed the help and `--help-json` but touched only
|
|
45
|
+
cli.py; the four-lens documentation audit converged on that lag. This
|
|
46
|
+
propagates it (docs and two docstrings only, no behavior changes):
|
|
47
|
+
|
|
48
|
+
- **README's exit-contract bullet taught repair's dead contract**
|
|
49
|
+
(trouble=2, usage=1) and omitted partial-writes-at-0, audit's exit 3,
|
|
50
|
+
and the lock-class refusals: it is per-verb now, mirroring
|
|
51
|
+
`--help-json`'s exit_contract block.
|
|
52
|
+
- **The enforced closed-Calibre gate is stated where trust is earned**:
|
|
53
|
+
README's tagging section (advice became enforcement) and the
|
|
54
|
+
install-to-calibre story name the three doors and exit 1; spec's
|
|
55
|
+
Library replacement, Native installation, and audit sections name
|
|
56
|
+
their refusals; CLAUDE.md carries the v0.48 pair
|
|
57
|
+
(`_require_closed_calibre`, `--help-json`) plus the per-verb exit
|
|
58
|
+
truth.
|
|
59
|
+
- spec's audit exit vocabulary gains the exit-3 class and the repair
|
|
60
|
+
carve-out is named where the inverse shape is attributed; the
|
|
61
|
+
partial-repair rule gets repair's writes-the-output exception;
|
|
62
|
+
spec's plugin config list gains `max_log_mb`.
|
|
63
|
+
- README: Repairing a book / Sweeping a library section headers (the
|
|
64
|
+
usage docs were buried headerless inside the tagging subsection),
|
|
65
|
+
cover/tocdrift audit examples, `--help-json` in the help paragraph.
|
|
66
|
+
- Two stale code docstrings the audit flagged: `__init__` (four lossy
|
|
67
|
+
strips, eight analyzers) and validate.py's `no_worse` (stub strips
|
|
68
|
+
and the rename half included).
|
|
69
|
+
- One pin added (640 total): the newest patchnotes heading must carry
|
|
70
|
+
VERSION.
|
|
71
|
+
|
|
3
72
|
## v0.48.1 (2026-10-06)
|
|
4
73
|
|
|
5
74
|
### The 12-agent audit: the exit contracts tell the truth
|
|
@@ -34,7 +103,7 @@ silences:
|
|
|
34
103
|
- One code fix: the no-backup lossy warning now covers
|
|
35
104
|
--strip-stub-docs (it warned for its three siblings only).
|
|
36
105
|
|
|
37
|
-
|
|
106
|
+
3 help pins updated to the new truths (639 total, unchanged).
|
|
38
107
|
|
|
39
108
|
## v0.48.0 (2026-10-06)
|
|
40
109
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.49.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 = [
|
|
@@ -42,6 +42,7 @@ The plugin vendors the core and never gates (the epubcheck latency ruling).
|
|
|
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
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 |
|
|
45
|
+
| 0.49 | The probe form (issue #7): single-file repair without an output path runs the gate and writes nothing; writing needs an explicit DST | v0.49.0 |
|
|
45
46
|
|
|
46
47
|
## Open work
|
|
47
48
|
|
|
@@ -433,32 +433,7 @@ book can never be auto-applied through the lossy path.
|
|
|
433
433
|
|
|
434
434
|
For a Calibre library (`Author/Title (id)/Title - Author.epub`):
|
|
435
435
|
|
|
436
|
-
|
|
437
|
-
- Replacement is **atomic**: the repaired bytes are written to a temp file in the same
|
|
438
|
-
directory, fsynced, then `os.replace()`d over the original. The filename and path
|
|
439
|
-
Calibre expects never change; no partial file is ever visible.
|
|
440
|
-
- The original file mode is preserved. Only the `.epub` is touched; `metadata.opf`,
|
|
441
|
-
`cover.jpg`, and `metadata.db` are left for Calibre's Quality Check sync.
|
|
442
|
-
- Writing requires `--apply` (default is a dry run). A backup is taken first when
|
|
443
|
-
`--backup DIR` or `--backup-inplace` is given.
|
|
444
|
-
- A book that cannot be read at all (not a zip, truncated, encrypted entries) is
|
|
445
|
-
reported and counted as `unreadable`; it never aborts the rest of the sweep.
|
|
446
|
-
- The `library` exit code is 0 for a clean sweep, 1 for a usage error, and 2 when any
|
|
447
|
-
book was rejected, unreadable, or failed epubcheck, so scripts can detect trouble.
|
|
448
|
-
Argparse-level misuse (unknown flag, malformed argument) exits 2 before the tool's
|
|
449
|
-
own validation runs; the tool's own usage validations exit 1. A `partial` book
|
|
450
|
-
(improved but still fatal) is reported for manual follow-up and counts as trouble:
|
|
451
|
-
`library`, `run phase1`, and `run phase3` all exit 2 on it (unified 2026-09-10).
|
|
452
|
-
The `audit` verb is the deliberate exception, its own contract in the inverse
|
|
453
|
-
shape: 0 clean, 1 when any book was flagged (a flag is the audit's trouble, so it
|
|
454
|
-
takes the trouble slot), 2 for its own usage errors (a path that is not a
|
|
455
|
-
directory, a directory with no EPUBs, an unknown book id) and for tagging-setup
|
|
456
|
-
failure.
|
|
457
|
-
- With `--sweep`, `--workers N` runs the candidate-selection epubcheck pass through N
|
|
458
|
-
concurrent workers (default 1: serial, unchanged). Books are checked in windows of N
|
|
459
|
-
consumed in input order, so the candidate set and the before-measurements are identical
|
|
460
|
-
to the serial sweep; the repair phase is never parallel (the shared workdir and the
|
|
461
|
-
atomic-replacement contract live there).
|
|
436
|
+
`--apply` is a filesystem write door and the install door is a metadata.db write door: `--apply --install-to-calibre` is REFUSED with exit 1 while Calibre is running (a fail-closed pgrep guard). `--backup-keep N` (minimum 2) caps the ring of same-named per-book backups.
|
|
462
437
|
|
|
463
438
|
### Native format installation (`--install-to-calibre`)
|
|
464
439
|
Optionally, bindery-cli installs the repaired EPUB as the book's format through cquarry's write module (`WritableCalibreDB`): the file is placed atomically (an in-place replace over the catalogued file when one exists, same path and `data.name`; a fresh placement under the repaired file's stem otherwise) and the `data` row follows through `set_format` (cquarry 1.17's sanctioned remove+add in one transaction), keeping the size truthful and queuing the book in `metadata_dirtied` so Calibre regenerates its sidecar .opf. The external `calibredb` CLI is no longer used (the v0.23.1 `--replace` crash class is gone with it). It automatically falls back to atomic filesystem replacement if a valid Calibre ID cannot be extracted, and a database failure degrades to the in-place save with a warning rather than losing the repair.
|
|
@@ -472,6 +447,8 @@ wrote the stray's size over the catalogued entry, the 2026-09-08 stray-size inci
|
|
|
472
447
|
and a stale `(N)` directory whose book no longer exists saves in place instead of
|
|
473
448
|
crashing the sweep.
|
|
474
449
|
|
|
450
|
+
This door is lock-class: `--install-to-calibre` is REFUSED with exit 1 while Calibre is running (a fail-closed pgrep guard; close Calibre and re-run).
|
|
451
|
+
|
|
475
452
|
## Audit subcommand (read-only)
|
|
476
453
|
|
|
477
454
|
`bindery audit {content,pagenumbers,emptytext,ocr,monolithic,completeness,cover,tocdrift,all} [PATH] [--max-doc-chars N]
|
|
@@ -518,7 +495,7 @@ storage-layout logic is not duplicated here. The scan itself still writes nothin
|
|
|
518
495
|
separate trigger-safe write module (it registers Calibre's `title_sort`/`uuid4` SQL functions,
|
|
519
496
|
bumps `books.last_modified`, and cleans link tables before tag deletion). THIN emptytext
|
|
520
497
|
advisories stay untagged; already-tagged books are skipped; a missing file is a scan error, not a
|
|
521
|
-
silent skip.
|
|
498
|
+
silent skip. The tag write is lock-class: REFUSED with exit 1 while Calibre is running.
|
|
522
499
|
|
|
523
500
|
`--json FILE` (v0.29.0) writes the same verdicts machine-readably, in the `library --json`
|
|
524
501
|
shape: one record per file with a `status` (`clean`, `problem`, or `error`) and per-analyzer
|
|
@@ -582,6 +559,16 @@ half of the same information: consumers narrow lossy consent by fix class
|
|
|
582
559
|
as data, never by substring-matching the rendered summary (v0.45.0; the
|
|
583
560
|
phase1 payload inherits them through its per-book repair records).
|
|
584
561
|
|
|
562
|
+
DST is optional, and the two forms have different write contracts. With a
|
|
563
|
+
DST the verb is the rescue form: the gate-accepted bytes are written there
|
|
564
|
+
(the input is never overwritten; an existing DST needs `--force`). Without
|
|
565
|
+
a DST the verb is a probe: the gate runs and the record carries the
|
|
566
|
+
projection (`output` null, `applied` false), and nothing is written
|
|
567
|
+
anywhere, so probing a file in a vetted tree cannot litter it with a
|
|
568
|
+
`(repaired).epub` sibling (the v0.48.2-and-before default, retired in
|
|
569
|
+
v0.49.0; a probe that would be partial still exits 0 with `status`
|
|
570
|
+
carrying the verdict).
|
|
571
|
+
|
|
585
572
|
## The Calibre plugin (Bindery Repair)
|
|
586
573
|
|
|
587
574
|
Each release vendors the repair core into a Calibre plugin zip,
|
|
@@ -630,6 +617,7 @@ Behavior contract:
|
|
|
630
617
|
the user's epubcheck binary under the `no_worse` bar and refused on a
|
|
631
618
|
regression; an unanswered measurement never refuses. The mode is off by
|
|
632
619
|
default (the latency ruling stands; the default pass is safe ungated).
|
|
620
|
+
- **`max_log_mb`** (default 2): the plugin log's size cap in megabytes, one `.old` generation kept on rotation; `0` disables logging.
|
|
633
621
|
|
|
634
622
|
## Out of scope (non-goals)
|
|
635
623
|
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
"""bindery-cli: deterministic, epubcheck-gated EPUB repair and content auditing.
|
|
2
2
|
|
|
3
3
|
The repair side: an always-on core of five well-formedness fixes plus the NCX
|
|
4
|
-
pipeline and the mimetype fix, with the structural repairs, the
|
|
5
|
-
strips (page numbers, broken tags, watermarks), and the safe opt-ins behind
|
|
6
|
-
explicit flags. Around it: the read-only `audit` verb (
|
|
7
|
-
analyzers), the `library` sweep with atomic in-place replacement in a Calibre
|
|
4
|
+
pipeline and the mimetype fix, with the structural repairs, the four lossy
|
|
5
|
+
strips (page numbers, broken tags, watermarks, stub documents), and the safe opt-ins behind
|
|
6
|
+
explicit flags. Around it: the read-only `audit` verb (eight body-text and
|
|
7
|
+
structural analyzers), the `library` sweep with atomic in-place replacement in a Calibre
|
|
8
8
|
library, the `run phase1`/`phase3` acquisition slices, and the Bindery Repair
|
|
9
9
|
Calibre plugin built from this core.
|
|
10
10
|
|
|
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.49.0"
|
|
15
15
|
__version__ = VERSION
|
|
16
16
|
|
|
17
17
|
from .epub import RepairReport, repair_epub # noqa: E402
|
|
@@ -420,7 +420,7 @@ class _HelpJsonAction(argparse.Action):
|
|
|
420
420
|
"tool": "bindery",
|
|
421
421
|
"version": __version__,
|
|
422
422
|
"exit_contract": {
|
|
423
|
-
"0": "clean (repair: also written, including partial)",
|
|
423
|
+
"0": "clean (repair: also probed or written, including partial)",
|
|
424
424
|
"1": "library/run slices: trouble found; repair: trouble "
|
|
425
425
|
"(refused/failed/unreadable) or invocation problem; audit: "
|
|
426
426
|
"FINDINGS (the inverse contract, per spec.md); "
|
|
@@ -432,7 +432,8 @@ class _HelpJsonAction(argparse.Action):
|
|
|
432
432
|
"3": "audit: findings PLUS a failed --json/--tag write",
|
|
433
433
|
"130": "interrupted",
|
|
434
434
|
"per_verb": {
|
|
435
|
-
"repair": "0 clean/written (
|
|
435
|
+
"repair": "0 clean/probed/written (a probe writes "
|
|
436
|
+
"nothing, not even the sibling; partial writes, exit 0); "
|
|
436
437
|
"1 trouble or invocation problem; never 2",
|
|
437
438
|
"audit": "0 clean; 1 findings; 2 usage/environment; "
|
|
438
439
|
"3 findings + failed report write",
|
|
@@ -938,20 +939,21 @@ def run_repair(args) -> int:
|
|
|
938
939
|
if not src.is_file():
|
|
939
940
|
print(f"error: no such file: {src}", file=sys.stderr)
|
|
940
941
|
return 1
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
)
|
|
946
|
-
if dst
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
942
|
+
# No output path is the probe form (issue #7): the gate runs and the
|
|
943
|
+
# report carries the projection, but nothing is written; the old
|
|
944
|
+
# implicit "(repaired).epub" sibling was import litter in a vetted tree.
|
|
945
|
+
# Writing needs an explicit destination.
|
|
946
|
+
dst = Path(args.output).expanduser() if args.output else None
|
|
947
|
+
if dst is not None:
|
|
948
|
+
if dst.resolve() == src.resolve():
|
|
949
|
+
print("error: refusing to overwrite the input in place", file=sys.stderr)
|
|
950
|
+
return 1
|
|
951
|
+
if dst.exists() and not args.force:
|
|
952
|
+
print(
|
|
953
|
+
f"error: output exists: {dst} (pass --force to overwrite)",
|
|
954
|
+
file=sys.stderr,
|
|
955
|
+
)
|
|
956
|
+
return 1
|
|
955
957
|
|
|
956
958
|
def emit(record: dict) -> None:
|
|
957
959
|
if getattr(args, "json_path", None):
|
|
@@ -974,7 +976,7 @@ def run_repair(args) -> int:
|
|
|
974
976
|
{
|
|
975
977
|
"mode": "repair",
|
|
976
978
|
"path": str(src),
|
|
977
|
-
"output": str(dst),
|
|
979
|
+
"output": str(dst) if dst else None,
|
|
978
980
|
"status": "error",
|
|
979
981
|
"applied": False,
|
|
980
982
|
"before": None,
|
|
@@ -990,7 +992,7 @@ def run_repair(args) -> int:
|
|
|
990
992
|
base_record = {
|
|
991
993
|
"mode": "repair",
|
|
992
994
|
"path": str(src),
|
|
993
|
-
"output": str(dst),
|
|
995
|
+
"output": str(dst) if dst else None,
|
|
994
996
|
"status": o.status,
|
|
995
997
|
"applied": False,
|
|
996
998
|
"before": _counts_dict(o.before),
|
|
@@ -1017,13 +1019,28 @@ def run_repair(args) -> int:
|
|
|
1017
1019
|
)
|
|
1018
1020
|
emit(base_record)
|
|
1019
1021
|
return 1
|
|
1022
|
+
ba = f"{o.before} -> {o.after} " if o.before else ""
|
|
1023
|
+
if dst is None:
|
|
1024
|
+
# Probe: the accepted bytes stay in the scratch workdir; the JSON
|
|
1025
|
+
# record and these console lines are the whole deliverable. The
|
|
1026
|
+
# verdict word is the record's own status vocabulary.
|
|
1027
|
+
verdict = (
|
|
1028
|
+
"PARTIAL (still has fatals; needs manual work)"
|
|
1029
|
+
if o.status == "partial"
|
|
1030
|
+
else o.status
|
|
1031
|
+
)
|
|
1032
|
+
print(
|
|
1033
|
+
f"{verdict}: {ba}{o.summary}\n"
|
|
1034
|
+
"probe: nothing written (pass an output path to write the repaired file)"
|
|
1035
|
+
)
|
|
1036
|
+
emit(base_record)
|
|
1037
|
+
return 0
|
|
1020
1038
|
# Copy the exact bytes the gate accepted. Re-repairing src here would silently
|
|
1021
1039
|
# drop the opt-in flags (--fix-ids, --reserialize, --strip-bad-attrs) and write
|
|
1022
1040
|
# a file that differs from the one epubcheck validated.
|
|
1023
1041
|
shutil.copyfile(work / "repaired.epub", dst)
|
|
1024
1042
|
base_record["applied"] = True
|
|
1025
1043
|
emit(base_record)
|
|
1026
|
-
ba = f"{o.before} -> {o.after} " if o.before else ""
|
|
1027
1044
|
if o.status == "partial":
|
|
1028
1045
|
# The file is a real improvement and worth writing, but calling it
|
|
1029
1046
|
# "repaired" would read as fixed; it still will not open.
|
|
@@ -2078,13 +2095,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2078
2095
|
|
|
2079
2096
|
r = sub.add_parser(
|
|
2080
2097
|
"repair",
|
|
2081
|
-
help="repair a single EPUB
|
|
2098
|
+
help="repair a single EPUB (a probe when no output path is given)",
|
|
2082
2099
|
usage="bindery repair [options] path [output]",
|
|
2083
|
-
description="Repair one EPUB
|
|
2084
|
-
"
|
|
2085
|
-
"
|
|
2086
|
-
"
|
|
2087
|
-
"
|
|
2100
|
+
description="Repair one EPUB with the epubcheck-gated core pass: the "
|
|
2101
|
+
"five well-formedness fixes, the NCX pipeline, and the mimetype fix "
|
|
2102
|
+
"run always; every other repair is opt-in (grouped below) and "
|
|
2103
|
+
"applied only when epubcheck confirms the result improved. Without "
|
|
2104
|
+
"an output path the verb is a probe: the gate runs and the report "
|
|
2105
|
+
"says what would change, and nothing is written anywhere. Never "
|
|
2106
|
+
"prompts. Exit codes: 0 clean, probed, or written (a PARTIAL repair "
|
|
2088
2107
|
"writes its best output and exits 0; the --json record's status "
|
|
2089
2108
|
"says so), 1 trouble (repair refused by the gate, epubcheck "
|
|
2090
2109
|
"failure, or unreadable input) or invocation problem (bad args, "
|
|
@@ -2093,12 +2112,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2093
2112
|
epilog="examples:\n"
|
|
2094
2113
|
" bindery repair book.epub out.epub\n"
|
|
2095
2114
|
" bindery repair book.epub out.epub --all\n"
|
|
2115
|
+
" bindery repair book.epub --all --json report.json "
|
|
2116
|
+
"# probe: report only, nothing written\n"
|
|
2096
2117
|
" bindery repair book.epub out.epub --fix-svg-dup-ids "
|
|
2097
2118
|
"--fix-cdata-terminator\n",
|
|
2098
2119
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
2099
2120
|
)
|
|
2100
2121
|
r.add_argument("path")
|
|
2101
|
-
r.add_argument(
|
|
2122
|
+
r.add_argument(
|
|
2123
|
+
"output",
|
|
2124
|
+
nargs="?",
|
|
2125
|
+
help="destination for the repaired file; omit it to probe "
|
|
2126
|
+
"(report only, nothing written)",
|
|
2127
|
+
)
|
|
2102
2128
|
rc = r.add_argument_group("run control")
|
|
2103
2129
|
rc.add_argument(
|
|
2104
2130
|
"--force",
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
epubcheck is the external oracle. The `gate` demands measured improvement
|
|
4
4
|
(fewer fatals, or fewer errors on a clean book) and `no_worse` accepts the
|
|
5
|
-
lossy strips
|
|
5
|
+
lossy strips (including stub-document strips), the cover wiring repairs, and
|
|
6
|
+
the space-entry renames when nothing regressed. There is no degrade
|
|
6
7
|
path: if epubcheck is not installed, callers refuse (exit 1, "epubcheck not
|
|
7
8
|
found") unless the user explicitly passes --no-validate, which skips the gate
|
|
8
9
|
and trusts the RepairReport alone.
|
|
@@ -184,6 +184,79 @@ class TestRepairRefusesOverwrite(unittest.TestCase):
|
|
|
184
184
|
self.assertNotEqual(dst.read_bytes(), b"precious")
|
|
185
185
|
|
|
186
186
|
|
|
187
|
+
class TestRepairProbeForm(unittest.TestCase):
|
|
188
|
+
"""repair without an output path is a probe (issue #7): the gate runs,
|
|
189
|
+
the record carries the projection, and nothing is written anywhere; the
|
|
190
|
+
old implicit "(repaired).epub" sibling was import litter in a vetted
|
|
191
|
+
tree."""
|
|
192
|
+
|
|
193
|
+
def test_accept_probe_writes_nothing(self):
|
|
194
|
+
with tempfile.TemporaryDirectory() as td:
|
|
195
|
+
src = Path(td) / "in.epub"
|
|
196
|
+
build(src)
|
|
197
|
+
jout = Path(td) / "probe.json"
|
|
198
|
+
results = [CheckResult(1, 0, 0), CheckResult(0, 0, 0)]
|
|
199
|
+
out = io.StringIO()
|
|
200
|
+
with (
|
|
201
|
+
mock.patch("bindery.cli.run_epubcheck", side_effect=results),
|
|
202
|
+
redirect_stdout(out),
|
|
203
|
+
):
|
|
204
|
+
rc = main(["repair", str(src), "--json", str(jout)])
|
|
205
|
+
data = json.loads(jout.read_text())
|
|
206
|
+
leftovers = sorted(p.name for p in Path(td).iterdir())
|
|
207
|
+
self.assertEqual(rc, 0)
|
|
208
|
+
self.assertEqual(leftovers, ["in.epub", "probe.json"])
|
|
209
|
+
self.assertEqual(data["status"], "accept")
|
|
210
|
+
self.assertFalse(data["applied"])
|
|
211
|
+
self.assertIsNone(data["output"])
|
|
212
|
+
self.assertEqual(data["after"]["fatals"], 0)
|
|
213
|
+
self.assertIn("accept:", out.getvalue())
|
|
214
|
+
self.assertIn("probe: nothing written", out.getvalue())
|
|
215
|
+
|
|
216
|
+
def test_equal_probe_reports_the_noop_and_writes_nothing(self):
|
|
217
|
+
# 'equal' is the gate's noop verdict (accepted, no measurable gain);
|
|
218
|
+
# the probe must carry that status, not dress it up as repairable.
|
|
219
|
+
with tempfile.TemporaryDirectory() as td:
|
|
220
|
+
src = Path(td) / "in.epub"
|
|
221
|
+
build(src)
|
|
222
|
+
report = RepairReport(fixes={"fix_named_entities": 1})
|
|
223
|
+
out = io.StringIO()
|
|
224
|
+
with (
|
|
225
|
+
mock.patch("bindery.cli.repair_epub", return_value=report),
|
|
226
|
+
mock.patch(
|
|
227
|
+
"bindery.cli.run_epubcheck",
|
|
228
|
+
side_effect=[CheckResult(0, 0, 0), CheckResult(0, 0, 0)],
|
|
229
|
+
),
|
|
230
|
+
redirect_stdout(out),
|
|
231
|
+
):
|
|
232
|
+
rc = main(["repair", str(src)])
|
|
233
|
+
leftovers = sorted(p.name for p in Path(td).iterdir())
|
|
234
|
+
self.assertEqual(rc, 0)
|
|
235
|
+
self.assertEqual(leftovers, ["in.epub"])
|
|
236
|
+
self.assertIn("equal:", out.getvalue())
|
|
237
|
+
self.assertIn("probe: nothing written", out.getvalue())
|
|
238
|
+
|
|
239
|
+
def test_partial_probe_reports_and_writes_nothing(self):
|
|
240
|
+
# A probe that would be partial is still exit 0 (the verb's own
|
|
241
|
+
# contract: partial is the record's status, not a failure); the
|
|
242
|
+
# point here is that the "best output" is NOT written.
|
|
243
|
+
with tempfile.TemporaryDirectory() as td:
|
|
244
|
+
src = Path(td) / "in.epub"
|
|
245
|
+
build(src)
|
|
246
|
+
results = [CheckResult(3, 0, 0), CheckResult(1, 0, 0)]
|
|
247
|
+
out = io.StringIO()
|
|
248
|
+
with (
|
|
249
|
+
mock.patch("bindery.cli.run_epubcheck", side_effect=results),
|
|
250
|
+
redirect_stdout(out),
|
|
251
|
+
):
|
|
252
|
+
rc = main(["repair", str(src)])
|
|
253
|
+
leftovers = sorted(p.name for p in Path(td).iterdir())
|
|
254
|
+
self.assertEqual(rc, 0)
|
|
255
|
+
self.assertEqual(leftovers, ["in.epub"])
|
|
256
|
+
self.assertIn("PARTIAL", out.getvalue())
|
|
257
|
+
self.assertIn("probe: nothing written", out.getvalue())
|
|
258
|
+
|
|
259
|
+
|
|
187
260
|
class TestRepairWritesGatedBytes(unittest.TestCase):
|
|
188
261
|
@unittest.skipUnless(HAVE_HTML5LIB, "html5lib not installed")
|
|
189
262
|
def test_opt_in_flags_reach_the_output_file(self):
|
|
@@ -111,3 +111,14 @@ class TestSyntaxFloor(unittest.TestCase):
|
|
|
111
111
|
# the tree has dozens of modules; a broken path glob must not
|
|
112
112
|
# let the guard pass vacuously
|
|
113
113
|
self.assertGreaterEqual(checked, 20)
|
|
114
|
+
|
|
115
|
+
def test_patchnotes_newest_heading_matches(self):
|
|
116
|
+
# the docs-audit lesson: the release log's newest heading is a
|
|
117
|
+
# version carrier like any other
|
|
118
|
+
patchnotes = (
|
|
119
|
+
Path(__file__).resolve().parent.parent / "patchnotes.md"
|
|
120
|
+
).read_text()
|
|
121
|
+
heading = next(
|
|
122
|
+
line.strip() for line in patchnotes.splitlines() if line.startswith("## v")
|
|
123
|
+
)
|
|
124
|
+
self.assertIn(CODE_VERSION, heading)
|
bindery_cli-0.48.1/AGENTS.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
CLAUDE.md
|
|
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
|