bindery-cli 0.38.0__tar.gz → 0.40.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. bindery_cli-0.40.0/.github/workflows/ci.yml +82 -0
  2. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/CLAUDE.md +15 -9
  3. bindery_cli-0.38.0/README.md → bindery_cli-0.40.0/PKG-INFO +50 -1
  4. bindery_cli-0.38.0/PKG-INFO → bindery_cli-0.40.0/README.md +26 -23
  5. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/patchnotes.md +86 -0
  6. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/plugin/__init__.py +10 -3
  7. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/pyproject.toml +19 -4
  8. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/roadmap.md +106 -20
  9. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/spec.md +13 -3
  10. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/__init__.py +1 -1
  11. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/audit.py +25 -10
  12. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/cli.py +41 -0
  13. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/epub.py +98 -7
  14. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/validate.py +2 -2
  15. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_cli.py +50 -0
  16. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_epub.py +179 -0
  17. bindery_cli-0.40.0/tests/test_version.py +78 -0
  18. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/uv.lock +10 -6
  19. bindery_cli-0.38.0/.github/workflows/ci.yml +0 -25
  20. bindery_cli-0.38.0/tests/test_version.py +0 -39
  21. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/.github/workflows/publish.yml +0 -0
  22. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/.gitignore +0 -0
  23. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/AGENTS.md +0 -0
  24. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/LICENSE +0 -0
  25. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/REPORT-12-Sept.md +0 -0
  26. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/docs/screenshots/library-sweep.png +0 -0
  27. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/logo.svg +0 -0
  28. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/run_tests.sh +0 -0
  29. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/scripts/FastSweep.java +0 -0
  30. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/scripts/build_plugin.py +0 -0
  31. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/scripts/fast_sweep.py +0 -0
  32. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/__main__.py +0 -0
  33. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/library.py +0 -0
  34. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/pagination.py +0 -0
  35. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/reserialize.py +0 -0
  36. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/transforms.py +0 -0
  37. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/src/bindery/watermark.py +0 -0
  38. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  39. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/testing_facility/top500candidates/REPORT.md +0 -0
  40. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_audit.py +0 -0
  41. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_library.py +0 -0
  42. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_pagination.py +0 -0
  43. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_plugin.py +0 -0
  44. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_reserialize.py +0 -0
  45. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_transforms.py +0 -0
  46. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_validate.py +0 -0
  47. {bindery_cli-0.38.0 → bindery_cli-0.40.0}/tests/test_watermark.py +0 -0
@@ -0,0 +1,82 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ permissions:
7
+ contents: read
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v5
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.14"
16
+ # ruff is a dev-only linter; the rule set lives in pyproject.toml, so
17
+ # these mean the same thing locally. Checking `.` rather than named
18
+ # directories keeps a new top-level script from silently escaping lint.
19
+ - run: pip install ruff==0.16.2
20
+ - run: ruff check .
21
+ - run: ruff format --check .
22
+ test:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v5
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.14"
29
+ # html5lib is the project's declared optional `reserialize` extra (core
30
+ # stays stdlib-only); installing it here lets CI exercise the
31
+ # --reserialize tests instead of skipping them.
32
+ - run: pip install . html5lib
33
+ - run: PYTHONPATH=src python -m unittest discover -s tests
34
+ core-compat:
35
+ name: Run the stack-free suite on the 3.12/3.13 floor
36
+ runs-on: ubuntu-latest
37
+ strategy:
38
+ fail-fast: false
39
+ matrix:
40
+ # The install floor (decision 63): vir-tui/cquarry are marker-gated
41
+ # to 3.14 there, so these legs install the core (tqdm only) and run
42
+ # exactly the test modules that never touch the gated stack: the
43
+ # repair core, the plugin under calibre stubs, validate, and the
44
+ # version/grammar pins. audit/library/CLI-stack tests stay on the
45
+ # 3.14 leg above.
46
+ python-version: ["3.12", "3.13"]
47
+ steps:
48
+ - uses: actions/checkout@v5
49
+ - uses: actions/setup-python@v5
50
+ with:
51
+ python-version: ${{ matrix.python-version }}
52
+ - run: pip install . html5lib
53
+ - run: >
54
+ PYTHONPATH=src python -m unittest
55
+ tests.test_transforms tests.test_epub tests.test_pagination
56
+ tests.test_watermark tests.test_reserialize tests.test_version
57
+ tests.test_plugin tests.test_validate
58
+ plugin-compat:
59
+ name: Parse-check the plugin zip under the interpreters Calibre embeds
60
+ runs-on: ubuntu-latest
61
+ strategy:
62
+ fail-fast: false
63
+ matrix:
64
+ # v0.39.0 shipped 3.14-only syntax and the plugin failed to load with
65
+ # a bare SyntaxError on every released Calibre. The embedded
66
+ # interpreters are 3.11 (7.x and the whole 8.x series) and 3.14
67
+ # (9.0+); 3.12/3.13 bracket the gap and match the CLI's grammar
68
+ # floor (tests/test_version.py). The plugin cannot be imported
69
+ # without Calibre, but the shipped zip can be extracted and
70
+ # byte-compiled, which catches exactly this class.
71
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
72
+ steps:
73
+ - uses: actions/checkout@v5
74
+ - uses: actions/setup-python@v5
75
+ with:
76
+ python-version: ${{ matrix.python-version }}
77
+ - name: Build the plugin zip from this tree
78
+ run: python scripts/build_plugin.py
79
+ - name: Byte-compile the zip contents and the CLI sources
80
+ run: |
81
+ python -m zipfile -e dist/BinderyRepair-*.zip plugin_pkg
82
+ python -m compileall -q plugin_pkg src
@@ -15,7 +15,11 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
15
15
  - **Minimal Dependencies.** Runtime deps are exactly: `tqdm` (progress/output),
16
16
  `vir-tui` (shared TUI rendering) and `cquarry` (read-only Calibre `metadata.db` access,
17
17
  adopted in v0.16.0), pinned as PyPI ranges in pyproject.toml (`vir-tui>=2.2.0`,
18
- `cquarry>=1.9.0`); `uv.lock` records the resolved versions. `html5lib` remains the one approved
18
+ `cquarry>=1.19.0`); `uv.lock` records the resolved versions. Since v0.40.0 both
19
+ VirInvictus pins carry `; python_version >= '3.14'` markers (every release they
20
+ ship declares requires-python >=3.14) and the package floor is 3.12: a 3.12/3.13
21
+ install runs the stack-free repair core, and the audit/library surfaces degrade
22
+ through cli.py's dispatch guard instead of failing resolution. `html5lib` remains the one approved
19
23
  heavy-parsing exception (used only for the `--reserialize` fix, imported lazily so every
20
24
  other mode runs without it). Tests use the standard `unittest` framework. epubcheck is an
21
25
  external CLI dependency expected on PATH; `--install-to-calibre` needs no external
@@ -30,7 +34,8 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
30
34
  `--fix-page-map`, `--strip-epub3-attrs`, `--downgrade-epub3-tags`,
31
35
  `--unwrap-block-in-inline`, `--strip-invalid-value`, `--unwrap-illegal-tags`,
32
36
  `--prune-missing-resources`, `--strip-broken-anchors`, `--encode-url-spaces`,
33
- `--fix-container`, `--fix-media-types`; transforms.py, threaded through epub.py):
37
+ `--fix-container`, `--fix-media-types`, `--fix-cover`; transforms.py, threaded
38
+ through epub.py):
34
39
  they alter markup structure or fabricate minimal content. v0.14–v0.16 ran these unconditionally, which broke this rule;
35
40
  v0.17.0 restored it. `--unwrap-illegal-tags` additionally protects any illegal-tag
36
41
  name that an EPUB stylesheet styles as an element selector (`css_protected_tags`,
@@ -57,8 +62,8 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
57
62
  `--escape-unknown-entities`) plus `--encode-url-spaces`, which repair without
58
63
  altering visible markup; the "twelve structural repairs and three lossy strips"
59
64
  phrasing elsewhere in these docs was never the complete inventory, and the
60
- Phase 16 package repairs (`--fix-container`, `--fix-media-types`) took the
61
- structural count to fourteen (v0.38.0).
65
+ Phase 16 package repairs (`--fix-container`, `--fix-media-types`, `--fix-cover`)
66
+ took the structural count to fifteen (v0.38.0 + v0.39.0).
62
67
  - **The gate is the safety contract.** Never apply a repair epubcheck has not accepted.
63
68
  Respect the two-mode logic in `validate.gate` (fatal-fixing tolerates error unmasking;
64
69
  error-cleanup does not). The lossy strips (`--strip-pagination`, `--strip-broken-tags`,
@@ -79,17 +84,18 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
79
84
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
80
85
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
81
86
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
82
- - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis — a damaged entry reports its own CORRUPT verdict instead of feeding emptytext — and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry — do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (`OBFUSCATION_ALGOS`: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED).
83
- - `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.38.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).
87
+ - `src/bindery/audit.py`: read-only body-text audits (`content`, `pagenumbers`, `emptytext`, `ocr`, `monolithic` since v0.21.0 with `--max-doc-chars N`, and `completeness` since v0.36.0) behind the `audit` subcommand (v0.15.0). Since v0.18.0 library mode resolves EPUB paths through `cquarry.get_format_path()` and can apply a tag to flagged books via `cquarry.write.WritableCalibreDB` (only with explicit `--tag`; the only sanctioned write path). Since v0.19.0 `audit --id BOOK_ID` audits a single library book through cquarry's single-entity `get_book()` fetch (no library-wide cache; supports `--tag`; incompatible with the directory argument; comma lists since v0.23.0). Since v0.22.0 every archive entry is fully read (CRC + decompression) before analysis — a damaged entry reports its own CORRUPT verdict instead of feeding emptytext — and manifest/NCX references to absent files classify as `convention` (bloated ToC, consecutive span) or `fragment` (broken span). Hazard: the scan loop reuses `tag` as its per-book display column, so the `--tag` argument is captured as `audit_tag` at function entry — do not collapse them again. Since v0.29.0 `audit --json FILE` writes per-file analyzer verdicts in the `library --json` shape (directory, library, and single-book modes; with `--id`, exactly one id; emptytext omitted from a record when the archive verdict owns the body-text story). Since v0.36.0 the completeness analyzer (Phase 15) reports the phase-1 spot-check per book: spine/prose-doc counts (prose = >= 400 visible chars), first/middle/last prose-doc opening/closing excerpts, trailing-ToC classification (`_trailing_toc`: short link lines dominate, no paragraph-length blocks), and the unreadable fraction. It is ADVISORY by contract (problem always False; ADVISORY on a trailing ToC or unreadable fraction >= 10%), never moves the exit code, and is skipped like emptytext when the archive verdict owns the book. Since v0.38.0 the archive verdict distinguishes font obfuscation from DRM (`OBFUSCATION_ALGOS`: IDPF 2008/embedding + both Adobe URIs; readable obfuscated entries are the benign OBFUSCATED advisory, an unreadable one is CORRUPT, and only non-obfuscation algorithms give ENCRYPTED). Since v0.40.0 the vir-tui console renderer loads through the module-level `_LazyUI` proxy (a plain `import vir_tui as ui` at module level would make 3.12/3.13 installs unimportable now that the stack is marker-gated; LOAD_GLOBAL never consults module `__getattr__`, so a proxy object is the lazy shape that works).
88
+ - `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.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).
84
89
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
85
90
  - `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.
86
- - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; 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). Both 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. `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).
87
- - `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, `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.
88
- - `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 Calibre plugin (build drift + run behavior under calibre stubs).
91
+ - `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; 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). Both 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. `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.
92
+ - `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, `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.
93
+ - `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).
89
94
 
90
95
  ## Conventions
91
96
 
92
97
  - Type hints, `from __future__ import annotations`, ruff for lint and format.
98
+ - `./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.
93
99
  - `VERSION` lives in `src/bindery/__init__.py`, mirrored in `pyproject.toml`. Bump both.
94
100
  - Run tests with `./run_tests.sh`.
95
101
 
@@ -1,3 +1,27 @@
1
+ Metadata-Version: 2.5
2
+ Name: bindery-cli
3
+ Version: 0.40.0
4
+ Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
5
+ Author: vrnvctss
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: calibre,ebook,epub,epubcheck,html-repair,repair,watermarks
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: cquarry>=1.19.0; python_version >= '3.14'
19
+ Requires-Dist: tqdm
20
+ Requires-Dist: vir-tui>=2.2.0; python_version >= '3.14'
21
+ Provides-Extra: reserialize
22
+ Requires-Dist: html5lib>=1.1; extra == 'reserialize'
23
+ Description-Content-Type: text/markdown
24
+
1
25
  <div align="center">
2
26
  <img src="logo.svg" width="96" height="96" alt="bindery-cli logo"/>
3
27
  <h1>bindery-cli</h1>
@@ -43,6 +67,7 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
43
67
  - **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file.
44
68
  - **`--fix-container`**: generates `META-INF/container.xml` at the located OPF when the container is missing or names a file the archive does not contain. This is the gateway defect: epubcheck stays fatal while the OPF is unfindable, so no repair can be gate-accepted on such a book until the container exists.
45
69
  - **`--fix-media-types`**: normalizes wrong manifest media-type declarations (OPF-029, e.g. a jpg stamped `image/png` by an aggregator). Attribute-only and quote-preserving; it fires only when the file's magic bytes confirm the extension, so an honestly-declared misnamed file is never made worse.
70
+ - **`--fix-cover`**: repairs dangling EPUB2 cover wiring. A `<meta name="cover">` whose content names no manifest item is re-pointed when the OPF guide's own cover reference names an existing item, and removed when nothing identifies it; EPUB3 `properties="cover-image"` is deliberately audit-only (guessing which image is the cover is not deterministic). Cover wiring is invisible to epubcheck, so cover-only repairs are accepted under the same no-worse bar the lossy strips use.
46
71
 
47
72
 
48
73
  Three opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
@@ -68,7 +93,19 @@ The lossy modes (`--strip-pagination`, `--strip-broken-tags`, and `--strip-water
68
93
 
69
94
  ## Install
70
95
 
71
- Python 3.14+, plus epubcheck on `PATH` for the gate. Dependencies: `tqdm` for progress output, plus the VirInvictus libraries `vir-tui` (TUI rendering) and `cquarry` (read-only Calibre database access); `html5lib` remains an optional extra, needed only for `--reserialize`.
96
+ Python 3.12+, plus epubcheck on `PATH` for the gate. Dependencies: `tqdm` for
97
+ progress output, plus the VirInvictus libraries `vir-tui` (TUI rendering) and
98
+ `cquarry` (read-only Calibre database access), which install on Python 3.14+
99
+ only; `html5lib` remains an optional extra, needed only for `--reserialize`.
100
+
101
+ The floor is two-tier by those markers: on Python 3.14 everything runs. On
102
+ 3.12/3.13 the install carries the repair core only, which is the tool's core
103
+ job: `bindery repair` on a single EPUB works in full, while `bindery audit`,
104
+ `bindery library` and `bindery run` print a one-line note that the command
105
+ needs the Python 3.14 stack instead of dying in a traceback. (Every vir-tui
106
+ and cquarry release on PyPI currently declares `requires-python >=3.14`, so
107
+ the stack itself cannot follow the floor down until they move.) CI runs the
108
+ stack-free test modules on real 3.12 and 3.13 interpreters.
72
109
 
73
110
  ```sh
74
111
  uv tool install bindery-cli # core tools
@@ -85,6 +122,18 @@ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
85
122
  strips stay CLI-only, because their acceptance is the epubcheck gate and
86
123
  epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
87
124
 
125
+ Compatibility: the plugin declares a minimum Calibre of **7.0** and
126
+ `supported_platforms` is Linux. Both are honest floors, not boastful ones:
127
+ released Calibre embeds Python 3.11 through the whole 8.x series and 3.14 from
128
+ 9.0 on, and CI byte-compiles the shipped plugin zip under Python 3.11, 3.12,
129
+ 3.13 and 3.14, so the plugin parses on every current Calibre series; 7.x is
130
+ the oldest whose interpreter CI covers, 2.x-6.x embed interpreters nothing
131
+ verifies. The Calibre API surface the plugin uses is the long-stable
132
+ `FileTypePlugin` shape, exercised against test stubs only; the plugin is
133
+ developed and tested on Linux (v0.39.0 shipped the 3.14-only except syntax
134
+ and silently failed to load on every released Calibre, which is the failure
135
+ this floor and CI now guard against).
136
+
88
137
  Behavior and guarantees:
89
138
 
90
139
  - `run()` never raises and never touches the original file or `metadata.db`: a
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: bindery-cli
3
- Version: 0.38.0
4
- Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
5
- Author: vrnvctss
6
- License: MIT
7
- License-File: LICENSE
8
- Keywords: calibre,ebook,epub,epubcheck,html-repair,repair,watermarks
9
- Classifier: Development Status :: 5 - Production/Stable
10
- Classifier: Environment :: Console
11
- Classifier: Intended Audience :: End Users/Desktop
12
- Classifier: License :: OSI Approved :: MIT License
13
- Classifier: Programming Language :: Python :: 3.14
14
- Classifier: Topic :: Utilities
15
- Requires-Python: >=3.14
16
- Requires-Dist: cquarry>=1.19.0
17
- Requires-Dist: tqdm
18
- Requires-Dist: vir-tui>=2.2.0
19
- Provides-Extra: reserialize
20
- Requires-Dist: html5lib>=1.1; extra == 'reserialize'
21
- Description-Content-Type: text/markdown
22
-
23
1
  <div align="center">
24
2
  <img src="logo.svg" width="96" height="96" alt="bindery-cli logo"/>
25
3
  <h1>bindery-cli</h1>
@@ -65,6 +43,7 @@ Everything below is off until its flag is passed (or all at once via `--all`). T
65
43
  - **`--encode-url-spaces`**: percent-encodes raw spaces in `src`/`href` attribute values across the package (OPF manifest, NCX, content documents). A URL with a literal space is not a valid URL (RSC-020) and unresolvable on strict readers; the encoded form denotes the same file.
66
44
  - **`--fix-container`**: generates `META-INF/container.xml` at the located OPF when the container is missing or names a file the archive does not contain. This is the gateway defect: epubcheck stays fatal while the OPF is unfindable, so no repair can be gate-accepted on such a book until the container exists.
67
45
  - **`--fix-media-types`**: normalizes wrong manifest media-type declarations (OPF-029, e.g. a jpg stamped `image/png` by an aggregator). Attribute-only and quote-preserving; it fires only when the file's magic bytes confirm the extension, so an honestly-declared misnamed file is never made worse.
46
+ - **`--fix-cover`**: repairs dangling EPUB2 cover wiring. A `<meta name="cover">` whose content names no manifest item is re-pointed when the OPF guide's own cover reference names an existing item, and removed when nothing identifies it; EPUB3 `properties="cover-image"` is deliberately audit-only (guessing which image is the cover is not deterministic). Cover wiring is invisible to epubcheck, so cover-only repairs are accepted under the same no-worse bar the lossy strips use.
68
47
 
69
48
 
70
49
  Three opt-in fixes are **lossy** and stand apart from the semantics-preserving rest:
@@ -90,7 +69,19 @@ The lossy modes (`--strip-pagination`, `--strip-broken-tags`, and `--strip-water
90
69
 
91
70
  ## Install
92
71
 
93
- Python 3.14+, plus epubcheck on `PATH` for the gate. Dependencies: `tqdm` for progress output, plus the VirInvictus libraries `vir-tui` (TUI rendering) and `cquarry` (read-only Calibre database access); `html5lib` remains an optional extra, needed only for `--reserialize`.
72
+ Python 3.12+, plus epubcheck on `PATH` for the gate. Dependencies: `tqdm` for
73
+ progress output, plus the VirInvictus libraries `vir-tui` (TUI rendering) and
74
+ `cquarry` (read-only Calibre database access), which install on Python 3.14+
75
+ only; `html5lib` remains an optional extra, needed only for `--reserialize`.
76
+
77
+ The floor is two-tier by those markers: on Python 3.14 everything runs. On
78
+ 3.12/3.13 the install carries the repair core only, which is the tool's core
79
+ job: `bindery repair` on a single EPUB works in full, while `bindery audit`,
80
+ `bindery library` and `bindery run` print a one-line note that the command
81
+ needs the Python 3.14 stack instead of dying in a traceback. (Every vir-tui
82
+ and cquarry release on PyPI currently declares `requires-python >=3.14`, so
83
+ the stack itself cannot follow the floor down until they move.) CI runs the
84
+ stack-free test modules on real 3.12 and 3.13 interpreters.
94
85
 
95
86
  ```sh
96
87
  uv tool install bindery-cli # core tools
@@ -107,6 +98,18 @@ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
107
98
  strips stay CLI-only, because their acceptance is the epubcheck gate and
108
99
  epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
109
100
 
101
+ Compatibility: the plugin declares a minimum Calibre of **7.0** and
102
+ `supported_platforms` is Linux. Both are honest floors, not boastful ones:
103
+ released Calibre embeds Python 3.11 through the whole 8.x series and 3.14 from
104
+ 9.0 on, and CI byte-compiles the shipped plugin zip under Python 3.11, 3.12,
105
+ 3.13 and 3.14, so the plugin parses on every current Calibre series; 7.x is
106
+ the oldest whose interpreter CI covers, 2.x-6.x embed interpreters nothing
107
+ verifies. The Calibre API surface the plugin uses is the long-stable
108
+ `FileTypePlugin` shape, exercised against test stubs only; the plugin is
109
+ developed and tested on Linux (v0.39.0 shipped the 3.14-only except syntax
110
+ and silently failed to load on every released Calibre, which is the failure
111
+ this floor and CI now guard against).
112
+
110
113
  Behavior and guarantees:
111
114
 
112
115
  - `run()` never raises and never touches the original file or `metadata.db`: a
@@ -1,4 +1,90 @@
1
1
  # bindery-cli Patch Notes
2
+ ## v0.40.0 (2026-09-13)
3
+
4
+ ### The plugin loads on Calibre again: the Python floor drops to 3.12
5
+
6
+ - **The plugin compatibility fix.** v0.39.0 shipped PEP 758
7
+ unparenthesized except tuples, which are a SyntaxError on every Python
8
+ before 3.14, and the Bindery Repair plugin failed to load on every
9
+ released Calibre with a bare SyntaxError, silently. All six sites are
10
+ parenthesized now (plugin/__init__.py, the vendored epub.py copies,
11
+ validate.py), which is valid on every Python 3 and what the plugin
12
+ needed all along.
13
+ - **The compatibility matrix CI actually verifies.** Released Calibre
14
+ embeds Python 3.11 through the whole 8.x series (7.0.0 = 3.11.5,
15
+ 8.16.2 = 3.11.14) and 3.14 from 9.0 (9.0.0 = 3.14.2), per calibre's own
16
+ build manifests. CI gained a `plugin-compat` job that builds the plugin
17
+ zip the way the release does, extracts it, and byte-compiles the zip
18
+ contents plus the CLI sources under 3.11, 3.12, 3.13 and 3.14, so the
19
+ class of failure that shipped in v0.39.0 is now a red build instead of
20
+ a silent plugin. `ruff`'s `target-version = "py312"` pin keeps the
21
+ formatter from re-stripping the parens, and a new grammar-floor guard
22
+ in the version tests parses the whole tree under the 3.12 grammar.
23
+ - **Honest plugin metadata.** `minimum_calibre_version` is (7, 0, 0), the
24
+ oldest series whose embedded interpreter CI actually verifies,
25
+ replacing the hollow (2, 0, 0). The README plugin section now
26
+ documents platform support (developed and tested on Linux) and the
27
+ exact verification level behind the floor.
28
+ - **requires-python drops to >=3.12, with one honest refinement.** Every
29
+ cquarry (1.9.0-1.21.0) and vir-tui (2.2.0/2.3.0) release on PyPI
30
+ declares requires-python >=3.14, so a bare floor drop would have made
31
+ `pip install` fail on 3.12/3.13 in dependency resolution for exactly
32
+ the strangers the floor is meant to invite. The VirInvictus pins
33
+ instead carry `; python_version >= '3.14'` markers: Python 3.14
34
+ installs are byte-identical to v0.39.0, and 3.12/3.13 installs get the
35
+ stack-free repair core, where `bindery repair` (the one broken EPUB, no
36
+ Calibre) works in full. `bindery audit`, `bindery library` and
37
+ `bindery run` print a one-line note that the command needs the Python
38
+ 3.14 stack instead of a traceback (a lazy renderer proxy in the audit
39
+ module and a dispatch guard in the CLI). CI runs the stack-free test
40
+ modules on real 3.12 and 3.13 interpreters (258 tests per leg).
41
+ - **Repair-edge riders from the six-lens audit.** `--fix-container`
42
+ XML-escapes the book-controlled OPF path when it writes
43
+ container.xml and unescapes at both readers, so a book with an OPF
44
+ named like `Tom & Jerry.opf` does not gain a fresh fatal while being
45
+ repaired, and the escaped form round-trips (regression-tested,
46
+ including second-repair idempotence). The last two FRAGMENT em-dash
47
+ verdict lines match the colon style. `fix_cover_meta` loses its dead
48
+ `present` parameter.
49
+
50
+ ## v0.39.0 (2026-09-12)
51
+
52
+ ### The cover-wiring repair (`--fix-cover`) and the executed rulings
53
+
54
+ - **`--fix-cover` repairs dangling EPUB2 cover wiring.** A
55
+ `<meta name="cover">` whose content names no manifest item is
56
+ re-pointed when the OPF guide's own cover reference resolves to
57
+ exactly one manifest item (the producer's statement, so nothing is
58
+ guessed), and removed when nothing identifies the item. A meta whose
59
+ item exists is never touched: an absent cover file belongs to
60
+ `--prune-missing-resources` and its edge completion.
61
+ - **Gate treatment:** cover wiring is invisible to epubcheck, so a
62
+ cover-only repair always answered 'equal' from the improvement gate.
63
+ Cover repairs now ride the same no_worse acceptance the lossy strips
64
+ use, with the partial rule intact: a regression rejects, a
65
+ still-fatal book stays partial and is never auto-applied. Verified
66
+ on a real dangling-meta book from the library: re-pointed 1,
67
+ accepted.
68
+ - **Scope by ruling (the hybrid):** the EPUB3
69
+ `properties="cover-image"` slice stays audit-only. Choosing which
70
+ image is the cover from filenames is exactly the non-determinism the
71
+ charter forbids, and no library book is fully unwired (measured:
72
+ 21 dangling EPUB2 metas, 3 absent cover files, 103 EPUB3 books
73
+ without the property, 0 with no wiring at all).
74
+ - **The metadata-validity carve-out is ruled: routed to
75
+ cquarry/CalibreQuarry.** bindery's side is closed: human-facing dc:
76
+ metadata stays a non-goal. The cross-repo handoff (a routed box in
77
+ CalibreQuarry's backlog naming the 51 OPF-085 invalid-UUID warnings
78
+ and the thin date/language tail) is filed for the next CQ lane.
79
+ - **The tag-policy force-push repairs are executed** on a researched
80
+ go: the v0.28.0 tag retargeted to the first green commit with its
81
+ title line restored (the historic `git tag -F` defect had stripped
82
+ it), the v0.33.0 tag message replaced with the corrected verbatim
83
+ entry, and the 10 test_facility EPUBs stripped from all history
84
+ (git filter-repo; main and all 14 tags force-pushed in one
85
+ publish.yml-disabled session; verbatim tag messages survived; PyPI
86
+ untouched). The roadmap's Tag policy section records the execution.
87
+
2
88
  ## v0.38.0 (2026-09-12)
3
89
 
4
90
  ### Phase 16: package-structure repairs
@@ -66,7 +66,14 @@ class BinderyRepair(FileTypePlugin):
66
66
  )
67
67
  author = "Brandon LaRocque"
68
68
  version = __PLUGIN_VERSION__
69
- minimum_calibre_version = (2, 0, 0)
69
+ # (7, 0, 0), not (2, 0, 0): the vendored core must at least PARSE under
70
+ # the interpreter the target Calibre embeds, and CI byte-compiles the
71
+ # built zip under 3.11/3.12/3.13/3.14. Released Calibre embeds 3.11
72
+ # through the whole 8.x series and 3.14 from 9.0 (bypy sources.json),
73
+ # so 7.x is the oldest series the compatibility job actually covers;
74
+ # 2.x-6.x embed interpreters nothing verifies and the claim was hollow.
75
+ # The syntax floor is pinned by tests/test_version.py.
76
+ minimum_calibre_version = (7, 0, 0)
70
77
  supported_platforms = ["linux"]
71
78
  type = "File type"
72
79
  file_types = {"epub"}
@@ -215,7 +222,7 @@ class BinderyRepair(FileTypePlugin):
215
222
  text=True,
216
223
  timeout=300,
217
224
  )
218
- except OSError, subprocess.TimeoutExpired:
225
+ except (OSError, subprocess.TimeoutExpired):
219
226
  return None
220
227
  try:
221
228
  data = json.loads(proc.stdout)
@@ -225,7 +232,7 @@ class BinderyRepair(FileTypePlugin):
225
232
  int(checker["nError"]),
226
233
  int(checker["nWarning"]),
227
234
  )
228
- except json.JSONDecodeError, KeyError, TypeError, ValueError:
235
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError):
229
236
  return None
230
237
 
231
238
  # ---- plugin housekeeping
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.38.0"
7
+ version = "0.40.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 = [
@@ -12,10 +12,12 @@ classifiers = [
12
12
  "Environment :: Console",
13
13
  "Intended Audience :: End Users/Desktop",
14
14
  "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
15
17
  "Programming Language :: Python :: 3.14",
16
18
  "Topic :: Utilities",
17
19
  ]
18
- requires-python = ">=3.14"
20
+ requires-python = ">=3.12"
19
21
  keywords = ["epub", "epubcheck", "calibre", "repair", "ebook", "html-repair", "watermarks"]
20
22
  license = { text = "MIT" }
21
23
  authors = [
@@ -24,10 +26,15 @@ authors = [
24
26
  # Core dependencies are minimal. html5lib is needed only for the opt-in --reserialize structural
25
27
  # repair; it is imported lazily, so every other mode works without it installed.
26
28
  # vir-tui and cquarry are pinned as PyPI ranges; uv.lock records the resolved versions.
29
+ # Both are marker-gated to 3.14+ because every release they ship declares
30
+ # requires-python >=3.14: without the markers a 3.12/3.13 install of this
31
+ # package would die in dependency resolution before `bindery repair` (which
32
+ # needs neither) could run. The audit/library surfaces degrade with a clean
33
+ # message (cli.py's dispatch guard) instead of installing at all.
27
34
  dependencies = [
28
35
  "tqdm",
29
- "vir-tui>=2.2.0",
30
- "cquarry>=1.19.0",
36
+ "vir-tui>=2.2.0; python_version >= '3.14'",
37
+ "cquarry>=1.19.0; python_version >= '3.14'",
31
38
  ]
32
39
 
33
40
  [project.optional-dependencies]
@@ -39,6 +46,14 @@ bindery = "bindery.cli:main"
39
46
  [tool.hatch.build.targets.wheel]
40
47
  packages = ["src/bindery"]
41
48
 
49
+ [tool.ruff]
50
+ # Pin the rewrite level below the house interpreter on purpose: the Calibre
51
+ # plugin vendors these modules into Calibre's embedded interpreters, so the
52
+ # parenthesized except form must survive `ruff format`. A target inferred
53
+ # from requires-python >=3.14 rewrites it to the PEP 758 bare form, which is
54
+ # a SyntaxError on every released Calibre (see tests/test_version.py).
55
+ target-version = "py312"
56
+
42
57
  [tool.ruff.lint]
43
58
  # Pinned explicitly so `ruff check` means the same thing locally and in CI. Without
44
59
  # this the result rode on whichever ruff was installed: 0.15 passed clean while 0.16