bindery-cli 0.39.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.39.0 → bindery_cli-0.40.0}/CLAUDE.md +10 -5
  3. bindery_cli-0.39.0/README.md → bindery_cli-0.40.0/PKG-INFO +49 -1
  4. bindery_cli-0.39.0/PKG-INFO → bindery_cli-0.40.0/README.md +25 -23
  5. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/patchnotes.md +48 -0
  6. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/plugin/__init__.py +10 -3
  7. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/pyproject.toml +19 -4
  8. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/roadmap.md +77 -5
  9. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/__init__.py +1 -1
  10. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/audit.py +25 -10
  11. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/cli.py +14 -0
  12. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/epub.py +22 -11
  13. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/validate.py +2 -2
  14. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_cli.py +21 -0
  15. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_epub.py +46 -0
  16. bindery_cli-0.40.0/tests/test_version.py +78 -0
  17. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/uv.lock +10 -6
  18. bindery_cli-0.39.0/.github/workflows/ci.yml +0 -25
  19. bindery_cli-0.39.0/tests/test_version.py +0 -39
  20. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/.github/workflows/publish.yml +0 -0
  21. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/.gitignore +0 -0
  22. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/AGENTS.md +0 -0
  23. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/LICENSE +0 -0
  24. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/REPORT-12-Sept.md +0 -0
  25. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/docs/screenshots/library-sweep.png +0 -0
  26. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/logo.svg +0 -0
  27. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/run_tests.sh +0 -0
  28. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/scripts/FastSweep.java +0 -0
  29. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/scripts/build_plugin.py +0 -0
  30. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/scripts/fast_sweep.py +0 -0
  31. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/spec.md +0 -0
  32. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/__main__.py +0 -0
  33. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/library.py +0 -0
  34. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/pagination.py +0 -0
  35. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/reserialize.py +0 -0
  36. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/transforms.py +0 -0
  37. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/src/bindery/watermark.py +0 -0
  38. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  39. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/testing_facility/top500candidates/REPORT.md +0 -0
  40. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_audit.py +0 -0
  41. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_library.py +0 -0
  42. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_pagination.py +0 -0
  43. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_plugin.py +0 -0
  44. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_reserialize.py +0 -0
  45. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_transforms.py +0 -0
  46. {bindery_cli-0.39.0 → bindery_cli-0.40.0}/tests/test_validate.py +0 -0
  47. {bindery_cli-0.39.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
@@ -80,17 +84,18 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
80
84
  - `src/bindery/pagination.py`: the opt-in lossy page-number strip (runhead detection, page-layer decision, block-centric removal/merge, safety nets).
81
85
  - `src/bindery/watermark.py`: the opt-in lossy watermark strip (anchored and anchorless signature removal).
82
86
  - `src/bindery/reserialize.py`: structural repair via `html5lib`.
83
- - `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).
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).
84
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).
85
89
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
86
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.
87
- - `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).
88
- - `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.
89
- - `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).
90
94
 
91
95
  ## Conventions
92
96
 
93
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.
94
99
  - `VERSION` lives in `src/bindery/__init__.py`, mirrored in `pyproject.toml`. Bump both.
95
100
  - Run tests with `./run_tests.sh`.
96
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>
@@ -69,7 +93,19 @@ The lossy modes (`--strip-pagination`, `--strip-broken-tags`, and `--strip-water
69
93
 
70
94
  ## Install
71
95
 
72
- 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.
73
109
 
74
110
  ```sh
75
111
  uv tool install bindery-cli # core tools
@@ -86,6 +122,18 @@ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
86
122
  strips stay CLI-only, because their acceptance is the epubcheck gate and
87
123
  epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
88
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
+
89
137
  Behavior and guarantees:
90
138
 
91
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.39.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>
@@ -91,7 +69,19 @@ The lossy modes (`--strip-pagination`, `--strip-broken-tags`, and `--strip-water
91
69
 
92
70
  ## Install
93
71
 
94
- 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.
95
85
 
96
86
  ```sh
97
87
  uv tool install bindery-cli # core tools
@@ -108,6 +98,18 @@ well-formedness fixes plus the NCX pipeline. The structural repairs and lossy
108
98
  strips stay CLI-only, because their acceptance is the epubcheck gate and
109
99
  epubcheck cannot run inside Calibre; nothing runs ungated in the plugin, ever.
110
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
+
111
113
  Behavior and guarantees:
112
114
 
113
115
  - `run()` never raises and never touches the original file or `metadata.db`: a
@@ -1,4 +1,52 @@
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
+
2
50
  ## v0.39.0 (2026-09-12)
3
51
 
4
52
  ### The cover-wiring repair (`--fix-cover`) and the executed rulings
@@ -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.39.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
@@ -1408,7 +1408,7 @@ everything else is backlog, not danger.*
1408
1408
 
1409
1409
  *(SHIPPED 2026-09-12 as v0.36.0: `audit completeness` rides the existing
1410
1410
  single decompression pass; the Redwall-shaped regression tests landed
1411
- first (4021ce7), the analyzer second (e6156c0). Verified against the
1411
+ first (ed2589d), the analyzer second (da0d2c7). Verified against the
1412
1412
  real fixtures: Lord Brocktree's trailing ToC is caught with the Epilogue
1413
1413
  sampled as the last prose doc, The Long Patrol reads exactly 55/57
1414
1414
  prose docs through its percent-encoded hrefs, and Mattimeo's split doc
@@ -1486,7 +1486,7 @@ an opt-in structural repair under the existing gate contract.
1486
1486
  - [x] **`--fix-container`**: generate META-INF/container.xml at the
1487
1487
  located OPF (model: upstream calibre's initialize_container).
1488
1488
  Gateway repair, effort S, HIGH value.
1489
- *(shipped v0.38.0, dac7835: fires when the container is missing,
1489
+ *(shipped v0.38.0, fa65c3a: fires when the container is missing,
1490
1490
  unparseable, or names a file the archive does not contain; the
1491
1491
  generated entry carries the constant epoch so repairs stay
1492
1492
  byte-deterministic. Prevalence: 0 books in-library (5,228/5,228 have
@@ -1506,7 +1506,7 @@ an opt-in structural repair under the existing gate contract.
1506
1506
  - [x] **Audit distinction: OBFUSCATED vs DRM** in audit.py (today the
1507
1507
  ENCRYPTED verdict keys on unreadable entries and implies DRM where
1508
1508
  publisher obfuscation is benign). S, rides the box above.
1509
- *(shipped v0.38.0, dac7835: readable obfuscation entries are the
1509
+ *(shipped v0.38.0, fa65c3a: readable obfuscation entries are the
1510
1510
  OBFUSCATED advisory (problem-False, exit-neutral; 86 library books);
1511
1511
  an unreadable obfuscation entry is CORRUPT (broken font, re-source),
1512
1512
  and only non-obfuscation algorithms give the ENCRYPTED skip.)*
@@ -1514,13 +1514,13 @@ an opt-in structural repair under the existing gate contract.
1514
1514
  rewrite `spine@toc`, `item@media-overlay`, `item@fallback`, and EPUB2
1515
1515
  cover-meta edges when their targets are pruned (machinery exists in
1516
1516
  fix_manifest_ids). Prevents manufacturing gate regressions. S/M.
1517
- *(shipped v0.38.0, dac7835: prune_missing_manifest_items returns the
1517
+ *(shipped v0.38.0, fa65c3a: prune_missing_manifest_items returns the
1518
1518
  pruned ids; prune_dangling_edges removes the optional attributes and
1519
1519
  a cover meta whose payload died. RSC-007 backs it at 14,156
1520
1520
  occurrences in 600 books.)*
1521
1521
  - [x] **Manifest media-type normalization**: sniff magic bytes /
1522
1522
  extension map; attribute-only. S.
1523
- *(shipped v0.38.0, dac7835 as `--fix-media-types`: OPF-029 class,
1523
+ *(shipped v0.38.0, fa65c3a as `--fix-media-types`: OPF-029 class,
1524
1524
  275 occurrences in 15 books; jpg/png/gif only, fired only when the
1525
1525
  magic bytes confirm the extension. Oracle-verified on the real
1526
1526
  Arthur sample: nochange without the flag, accept with 9 normalized.)*
@@ -1610,3 +1610,75 @@ The release-tag backlog is settled, forward-only:
1610
1610
  files) are stripped from all history via git filter-repo, main and
1611
1611
  all 14 tags force-pushed. The tags' verbatim messages survived the
1612
1612
  rewrite; PyPI was untouched throughout.
1613
+
1614
+ ## New findings 2026-09-12 late (six-lens full audit; detail: audit/FULL-AUDIT-2026-09-12.md, Wave 15)
1615
+
1616
+ Lane 2026-09-13 shipped v0.40.0 from this section. Correction to the Wave 15
1617
+ text: released Calibre embeds Python 3.11 through the whole 8.x series
1618
+ (7.0.0 = 3.11.5, 8.16.2 = 3.11.14) and 3.14 from 9.0 (9.0.0 = 3.14.2), per
1619
+ calibre's own bypy sources.json checked against the upstream clone; the
1620
+ "Calibre 9.x embeds 3.13" line was imprecise, and no released Calibre embeds
1621
+ 3.12 or 3.13 at all. CI now byte-compiles the shipped plugin zip under all
1622
+ four interpreter families.
1623
+
1624
+ - [x] **HIGH: the Calibre plugin cannot load on mainstream Calibre**
1625
+ (SHIPPED v0.40.0: bab3bdc + bb31308). plugin/__init__.py:218/228 and
1626
+ the vendored epub.py:948/1286 used PEP 758 unparenthesized except
1627
+ tuples - SyntaxError on every released Calibre (see the correction
1628
+ above); validate.py:75/398 carried the same syntax for the CLI. All
1629
+ six sites parenthesized; ruff's `target-version = "py312"` pin keeps
1630
+ `ruff format` from re-stripping the parens (inference from a 3.14
1631
+ requires-python rewrote them); CI gained the `plugin-compat` job
1632
+ (build the zip, extract, `compileall` the zip + src under
1633
+ 3.11/3.12/3.13/3.14); `tests/test_version.py` gained the
1634
+ syntax-floor grammar guard so the class fails a test, not a Calibre
1635
+ install. Honest metadata: `minimum_calibre_version` is (7, 0, 0)
1636
+ (the oldest series whose embedded interpreter CI covers; the hollow
1637
+ (2, 0, 0) is gone) and the README plugin section documents platforms
1638
+ (Linux-tested) and the verification story.
1639
+ - [ ] **Analyzer robustness on untrusted input:** spine_integrity
1640
+ IndexErrors on a spineless EPUB (nums[0] with an empty list) and
1641
+ the call sites sit OUTSIDE the per-book try - one malformed book
1642
+ aborts a whole run; load_book raises NameError (encrypted_names
1643
+ used before assignment) on a corrupt container.xml; DRM books get
1644
+ an EMPTY re-source verdict alongside the ENCRYPTED skip (extend the
1645
+ emptytext gate to encrypted_drm_n). (Not in the 09-13 lane; still
1646
+ open.)
1647
+ - [x] **Repair-edge fixes** (SHIPPED v0.40.0: 2473bf8).
1648
+ generate_container XML-escapes the book-controlled OPF path into its
1649
+ double-quoted attribute and BOTH regex readers unescape before
1650
+ comparing against zip names (the round trip is regression-tested
1651
+ with a "Tom & Jerry.opf" fixture, including the second-repair
1652
+ idempotence the staleness check depends on); the two remaining
1653
+ FRAGMENT em-dash verdict lines match the colon style; fix_cover_meta's
1654
+ dead `present` parameter is removed (the function deliberately
1655
+ consults only OPF text, never archive membership).
1656
+ - [x] **Docs sweep, plugin-platforms half** (SHIPPED v0.40.0: bb31308):
1657
+ the plugin is no longer undocumented; the README states supported
1658
+ platforms and the minimum Calibre version. The rest stays open:
1659
+ README's audit CSV claim, the repair_epub docstring's three missing
1660
+ flags, the audit.py module-docstring contradictions, the
1661
+ roman-numeral duplication, the sys.path.insert pollution.
1662
+ - [ ] **Blitz candidates:** the audit-refresh deep-dive (per-class deltas
1663
+ vs the 09-12 baselines; first-time counts for the two C-ruled
1664
+ classes); a cover advisory analyzer completing the hybrid ruling;
1665
+ --encode-url-spaces entry rename + reference rewrite (PKG-010, 280
1666
+ books); plugin log rotation.
1667
+ - [ ] **GitHub presentation (workspace batch):** description rewrite
1668
+ (leads with deterministic epubcheck-gated repair); 7 topics swapped
1669
+ (add epub3/calibre-plugin/python-cli/ebook-audit); codex page still
1670
+ says calibredb; wiki off.
1671
+
1672
+ - [x] **DECIDED 2026-09-13: requires-python drops to ~3.12** (decision
1673
+ 63) - EXECUTED v0.40.0 the same day with one refinement Brandon
1674
+ ruled on live: every cquarry (1.9.0-1.21.0) and vir-tui (2.2.0/2.3.0)
1675
+ on PyPI declares requires-python >=3.14, so a bare floor drop would
1676
+ have failed pip resolution for exactly the strangers it invites.
1677
+ Decided shape (asked, markers + guard): the VirInvictus pins carry
1678
+ `; python_version >= '3.14'` markers (3.14 installs byte-identical
1679
+ to v0.39.0), 3.12/3.13 installs run the stack-free repair core,
1680
+ audit.py loads its renderer through the `_LazyUI` proxy, cli.py's
1681
+ dispatch guard turns the missing stack into one stderr line + exit
1682
+ 2, and CI's `core-compat` job runs the stack-free test modules on
1683
+ real 3.12/3.13 (258 tests per leg, verified locally under
1684
+ 3.12.14/3.13.15). Suite 445, ruff 0.16.2 clean.
@@ -4,7 +4,7 @@ strip), epubcheck-gated.
4
4
  Single source of truth for the version. pyproject.toml must match VERSION below.
5
5
  """
6
6
 
7
- VERSION = "0.39.0"
7
+ VERSION = "0.40.0"
8
8
  __version__ = VERSION
9
9
 
10
10
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -71,7 +71,22 @@ from pathlib import Path
71
71
  from xml.etree import ElementTree as ET
72
72
 
73
73
  sys.path.insert(0, str(Path(__file__).parent))
74
- import vir_tui as ui
74
+
75
+
76
+ class _LazyUI:
77
+ # vir_tui rides the Python 3.14 VirInvictus stack, marker-gated out of
78
+ # 3.12/3.13 installs since the v0.40.0 floor drop. A module-level import
79
+ # would make the whole package unimportable there (and LOAD_GLOBAL never
80
+ # consults module __getattr__), so this proxy defers the import to the
81
+ # first render call, where cli.py's dispatch guard can answer cleanly.
82
+ def __getattr__(self, name: str):
83
+ import vir_tui
84
+
85
+ return getattr(vir_tui, name)
86
+
87
+
88
+ ui = _LazyUI()
89
+
75
90
 
76
91
  # ----------------------------------------------------------------------------
77
92
  # Shared scaffolding
@@ -1349,18 +1364,18 @@ def _corrupt_verdict(r: dict) -> tuple[bool, str, list[str]]:
1349
1364
  "CORRUPT",
1350
1365
  [
1351
1366
  f"unreadable obfuscated font(s): {r['encrypted_n']} "
1352
- f"(first: {r['encrypted_first']}) — broken, not DRM; re-source"
1367
+ f"(first: {r['encrypted_first']}): broken, not DRM; re-source"
1353
1368
  ],
1354
1369
  )
1355
1370
  if r.get("encrypted_n"):
1356
1371
  lines = []
1357
1372
  if r.get("n"):
1358
1373
  lines.append(
1359
- f"corrupt:{r['n']} (first: {r['first']}) — damaged archive; re-source"
1374
+ f"corrupt:{r['n']} (first: {r['first']}): damaged archive; re-source"
1360
1375
  )
1361
1376
  lines.append(
1362
1377
  f"encrypted:{r['encrypted_drm_n']} (first: {r['encrypted_first']})"
1363
- " — DRM-protected; not repairable, skip"
1378
+ ": DRM-protected; not repairable, skip"
1364
1379
  )
1365
1380
  return (
1366
1381
  True,
@@ -1368,7 +1383,7 @@ def _corrupt_verdict(r: dict) -> tuple[bool, str, list[str]]:
1368
1383
  lines,
1369
1384
  )
1370
1385
  if r.get("n"):
1371
- lines = [f"corrupt:{r['n']} (first: {r['first']}) — damaged archive; re-source"]
1386
+ lines = [f"corrupt:{r['n']} (first: {r['first']}): damaged archive; re-source"]
1372
1387
  if r.get("dup_entries"):
1373
1388
  lines.append(
1374
1389
  f"duplicate entries: {r['dup_entries']} (zip resolves last-wins; "
@@ -1385,7 +1400,7 @@ def _corrupt_verdict(r: dict) -> tuple[bool, str, list[str]]:
1385
1400
  "OBFUSCATED",
1386
1401
  [
1387
1402
  f"obfuscated font(s): {r['obfuscated_n']} "
1388
- f"(first: {r['obfuscated_first']}) — publisher embedding, "
1403
+ f"(first: {r['obfuscated_first']}): publisher embedding, "
1389
1404
  "readable; benign"
1390
1405
  ],
1391
1406
  )
@@ -1432,7 +1447,7 @@ def _spine_verdict(r: dict) -> tuple[bool, str, list[str]]:
1432
1447
  return (
1433
1448
  True,
1434
1449
  "FRAGMENT",
1435
- [f"toc {r['refs']} refs, {r['absent']} absent — span broken"],
1450
+ [f"toc {r['refs']} refs, {r['absent']} absent: span broken"],
1436
1451
  )
1437
1452
  if r["class"] == "convention":
1438
1453
  return (
@@ -1852,7 +1867,7 @@ def _corrupt_sections(hits) -> int:
1852
1867
  print(f"{RED}{BOLD}CORRUPT ARCHIVES ({len(hits)}){RESET}")
1853
1868
  for book_id, title, tag, n, first in sorted(hits):
1854
1869
  print(f" {RED}#{book_id}{RESET} [{tag}] {title}")
1855
- print(f" corrupt:{n} (first: {first}) — damaged archive; re-source")
1870
+ print(f" corrupt:{n} (first: {first}): damaged archive; re-source")
1856
1871
  print()
1857
1872
  print(
1858
1873
  f"{RED}{BOLD}archive FOUND{RESET}: {len(hits)} file(s) need re-sourcing "
@@ -1876,7 +1891,7 @@ def _spine_sections(hits, advisory) -> int:
1876
1891
  print(f"{RED}{BOLD}SPINE FRAGMENTS ({len(hits)}){RESET}")
1877
1892
  for book_id, title, tag, r in sorted(hits):
1878
1893
  print(f" {RED}#{book_id}{RESET} [{tag}] {title}")
1879
- print(f" toc {r['refs']} refs, {r['absent']} absent — span broken")
1894
+ print(f" toc {r['refs']} refs, {r['absent']} absent: span broken")
1880
1895
  print()
1881
1896
  print(
1882
1897
  f"{RED}{BOLD}spine FOUND{RESET}: {len(hits)} file(s) are fragments "
@@ -1921,7 +1936,7 @@ def _obfuscated_sections(hits) -> int:
1921
1936
  print(f" {YELLOW}#{book_id}{RESET} [{tag}] {title}")
1922
1937
  print(
1923
1938
  f" obfuscated font(s): {r['obfuscated_n']} "
1924
- f"(first: {r['obfuscated_first']}) — readable; benign"
1939
+ f"(first: {r['obfuscated_first']}): readable; benign"
1925
1940
  )
1926
1941
  print()
1927
1942
  return 0
@@ -1623,6 +1623,20 @@ def main(argv: list[str] | None = None) -> int:
1623
1623
  args = build_parser().parse_args(argv)
1624
1624
  try:
1625
1625
  return args.func(args)
1626
+ except ModuleNotFoundError as e:
1627
+ # The VirInvictus stack (vir-tui, cquarry) is marker-gated to Python
1628
+ # 3.14+ since the v0.40.0 floor drop: a 3.12/3.13 install runs the
1629
+ # single-book repair core, and the audit/library surfaces say what
1630
+ # they need instead of dying with a traceback. Anything else missing
1631
+ # is a real bug and still raises.
1632
+ if e.name not in ("vir_tui", "cquarry"):
1633
+ raise
1634
+ print(
1635
+ f"this command needs the {e.name} library, which currently "
1636
+ "installs only on Python 3.14+; run it under Python 3.14",
1637
+ file=sys.stderr,
1638
+ )
1639
+ return 2
1626
1640
  except KeyboardInterrupt:
1627
1641
  # A library run can take a long time; end a Ctrl-C cleanly instead of with a
1628
1642
  # traceback. In-flight work is safe: the original is only ever touched by the
@@ -15,6 +15,8 @@ import zipfile
15
15
  from dataclasses import dataclass, field
16
16
  from pathlib import Path
17
17
  from urllib.parse import unquote
18
+ from xml.sax.saxutils import escape as _xml_escape
19
+ from xml.sax.saxutils import unescape as _xml_unescape
18
20
 
19
21
  from .pagination import collect_runheads, detect_page_layer, strip_pagination_doc
20
22
  from .reserialize import reserialize_if_broken
@@ -63,6 +65,12 @@ _ITEM_ID_RE = re.compile(
63
65
  )
64
66
  _ROOTFILE_RE = re.compile(r"full-path=([\"'])((?:(?!\1).)+)\1")
65
67
 
68
+ # The inverse pair for the container.xml rootfile attribute: generate_container
69
+ # escapes the book-controlled OPF path into its double-quoted attribute, and
70
+ # every regex-based reader of that attribute unescapes before comparing against
71
+ # zip names (which are raw). Default entities plus the quote forms.
72
+ _XML_ATTR_ENTITIES = {"&quot;": '"', "&apos;": "'"}
73
+
66
74
 
67
75
  def _locate_opf(z: zipfile.ZipFile) -> str | None:
68
76
  """The package document path, from META-INF/container.xml when possible.
@@ -76,8 +84,8 @@ def _locate_opf(z: zipfile.ZipFile) -> str | None:
76
84
  except KeyError:
77
85
  container = ""
78
86
  m = _ROOTFILE_RE.search(container)
79
- if m and m.group(2) in z.namelist():
80
- return m.group(2)
87
+ if m and _xml_unescape(m.group(2), _XML_ATTR_ENTITIES) in z.namelist():
88
+ return _xml_unescape(m.group(2), _XML_ATTR_ENTITIES)
81
89
  return next((n for n in z.namelist() if n.lower().endswith(".opf")), None)
82
90
 
83
91
 
@@ -275,14 +283,18 @@ def generate_container(opf_path: str) -> str:
275
283
  Byte-deterministic: the same OPF path always yields the same bytes (the
276
284
  model is upstream calibre's initialize_container). The entry itself is
277
285
  written with the constant epoch timestamp, so repairing one book twice
278
- is byte-identical.
286
+ is byte-identical. The path is book-controlled (it is a zip entry name)
287
+ and lands in a double-quoted XML attribute, so it is escaped: without
288
+ this, an OPF named like ``Tom & Jerry.opf`` would install a fresh
289
+ fatal into a book the repair just fixed.
279
290
  """
291
+ safe_path = _xml_escape(opf_path, {'"': "&quot;"})
280
292
  return (
281
293
  '<?xml version="1.0" encoding="UTF-8"?>\n'
282
294
  '<container version="1.0" '
283
295
  'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n'
284
296
  " <rootfiles>\n"
285
- f' <rootfile full-path="{opf_path}" '
297
+ f' <rootfile full-path="{safe_path}" '
286
298
  'media-type="application/oebps-package+xml"/>\n'
287
299
  " </rootfiles>\n"
288
300
  "</container>\n"
@@ -309,9 +321,7 @@ _GUIDE_REF_RE = re.compile(
309
321
  )
310
322
 
311
323
 
312
- def fix_cover_meta(
313
- opf_text: str, opf_dir: str, present: frozenset[str]
314
- ) -> tuple[str, dict[str, int]]:
324
+ def fix_cover_meta(opf_text: str, opf_dir: str) -> tuple[str, dict[str, int]]:
315
325
  """Repair the EPUB2 cover-meta wiring (the cover-wiring ruling's
316
326
  deterministic half; hybrid scope, 2026-09-12).
317
327
 
@@ -945,7 +955,7 @@ def ncx_uid_mismatch(src: Path) -> bool:
945
955
  text = z.read(ncx).decode("utf-8", "replace")
946
956
  m = _DTB_UID_RE.search(text) or _DTB_UID_RE_REV.search(text)
947
957
  return bool(m and m.group(3) != uid)
948
- except zipfile.BadZipFile, OSError, RuntimeError:
958
+ except (zipfile.BadZipFile, OSError, RuntimeError):
949
959
  # RuntimeError covers zip-encrypted entries, which raise out of
950
960
  # z.read: a DRM'd book is not a candidate, not a crash.
951
961
  return False
@@ -1154,7 +1164,8 @@ def repair_epub(
1154
1164
  cm = _ROOTFILE_RE.search(
1155
1165
  zin.read("META-INF/container.xml").decode("utf-8", "replace")
1156
1166
  )
1157
- if not (cm and cm.group(2) in zin.namelist()):
1167
+ rootfile = _xml_unescape(cm.group(2), _XML_ATTR_ENTITIES) if cm else None
1168
+ if not (rootfile and rootfile in zin.namelist()):
1158
1169
  container_bytes = generate_container(opf).encode("utf-8")
1159
1170
  if container_bytes is not None and "META-INF/container.xml" not in (
1160
1171
  zin.namelist()
@@ -1283,7 +1294,7 @@ def repair_epub(
1283
1294
  try:
1284
1295
  with zin.open(resolved) as f:
1285
1296
  return f.read(8)
1286
- except KeyError, OSError, RuntimeError:
1297
+ except (KeyError, OSError, RuntimeError):
1287
1298
  return None
1288
1299
 
1289
1300
  text, n = fix_manifest_media_types(text, opf_dir, peek)
@@ -1291,7 +1302,7 @@ def repair_epub(
1291
1302
  report.add({"media_types_normalized": n})
1292
1303
  opf_changed = True
1293
1304
  if fix_cover:
1294
- text, ccounts = fix_cover_meta(text, opf_dir, present)
1305
+ text, ccounts = fix_cover_meta(text, opf_dir)
1295
1306
  if ccounts:
1296
1307
  report.add(ccounts)
1297
1308
  opf_changed = True
@@ -72,7 +72,7 @@ def _counts_from_json(stdout: str) -> CheckResult | None:
72
72
  return CheckResult(
73
73
  int(checker["nFatal"]), int(checker["nError"]), int(checker["nWarning"])
74
74
  )
75
- except KeyError, TypeError, ValueError:
75
+ except (KeyError, TypeError, ValueError):
76
76
  return None
77
77
 
78
78
 
@@ -395,7 +395,7 @@ def run_epubcheck(path: Path, timeout: int = 300) -> CheckResult | None:
395
395
  env=_english_locale_env(),
396
396
  check=False,
397
397
  )
398
- except FileNotFoundError, subprocess.TimeoutExpired:
398
+ except (FileNotFoundError, subprocess.TimeoutExpired):
399
399
  return None
400
400
  result = _counts_from_json(out.stdout)
401
401
  if result is not None:
@@ -1385,3 +1385,24 @@ class TestCoverGate(unittest.TestCase):
1385
1385
  def test_regression_rejects(self):
1386
1386
  o = self._cover_verdict(CheckResult(0, 0, 0), CheckResult(0, 1, 0))
1387
1387
  self.assertEqual(o.status, "reject")
1388
+
1389
+
1390
+ class TestStackFreeFloorGuard(unittest.TestCase):
1391
+ """Since the v0.40.0 floor drop the VirInvictus stack (vir-tui, cquarry)
1392
+ is marker-gated out of 3.12/3.13 installs; the audit/library surfaces
1393
+ must say so cleanly (trouble exit, one stderr line) instead of dying
1394
+ with a traceback. Poisoning sys.modules stands in for the absent
1395
+ library whatever the dev machine has installed."""
1396
+
1397
+ def test_missing_stack_is_a_clean_trouble_exit(self):
1398
+ with tempfile.TemporaryDirectory() as td:
1399
+ # any .epub name reaches the audit scan's first ui render, which
1400
+ # is the first point the gated library would load
1401
+ (Path(td) / "x.epub").write_bytes(b"not a zip")
1402
+ out, err = io.StringIO(), io.StringIO()
1403
+ with mock.patch.dict(sys.modules, {"vir_tui": None}):
1404
+ with redirect_stdout(out), redirect_stderr(err):
1405
+ rc = main(["audit", "content", td])
1406
+ self.assertEqual(rc, 2)
1407
+ self.assertIn("installs only on Python 3.14+", err.getvalue())
1408
+ self.assertIn("vir_tui", err.getvalue())
@@ -6,12 +6,15 @@ import unittest
6
6
  import warnings
7
7
  import zipfile
8
8
  from pathlib import Path
9
+ from xml.etree import ElementTree as ET
9
10
 
10
11
  from bindery.epub import (
12
+ _locate_opf,
11
13
  downgrade_epub3_tags,
12
14
  fix_manifest_ids,
13
15
  fix_ncx_ids,
14
16
  fix_pagelist_class,
17
+ generate_container,
15
18
  ncx_uid_mismatch,
16
19
  opf_unique_id,
17
20
  package_version,
@@ -1325,6 +1328,49 @@ class TestFixContainer(unittest.TestCase):
1325
1328
  report = repair_epub(once, twice, fix_container=True)
1326
1329
  self.assertNotIn("container_generated", report.fixes)
1327
1330
 
1331
+ def test_generate_container_escapes_xml_specials(self):
1332
+ """An OPF entry name is book-controlled and lands in a double-quoted
1333
+ XML attribute; an unescaped '&' or quote would install a fresh fatal
1334
+ into a book the repair just fixed (six-lens audit, 2026-09-12)."""
1335
+ for name in ("Tom & Jerry.opf", 'weird"name.opf', "a<b>c.opf", "plain.opf"):
1336
+ container = generate_container(name)
1337
+ root = ET.fromstring(container)
1338
+ rootfile = root.find(
1339
+ ".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile"
1340
+ )
1341
+ self.assertEqual(rootfile.get("full-path"), name)
1342
+
1343
+ def test_ampersand_opf_name_round_trips_through_the_generated_container(self):
1344
+ with tempfile.TemporaryDirectory() as td:
1345
+ src = Path(td) / "in.epub"
1346
+ once, twice = Path(td) / "a.epub", Path(td) / "b.epub"
1347
+ opf_name = "OEBPS/Tom & Jerry.opf"
1348
+ self._build(
1349
+ src,
1350
+ {
1351
+ "mimetype": "application/epub+zip",
1352
+ opf_name: self.OPF,
1353
+ "OEBPS/c1.xhtml": CONTENT,
1354
+ },
1355
+ )
1356
+ report = repair_epub(src, once, fix_container=True)
1357
+ self.assertEqual(report.fixes.get("container_generated"), 1)
1358
+ with zipfile.ZipFile(once) as z:
1359
+ self.assertIsNone(z.testzip())
1360
+ container = z.read("META-INF/container.xml").decode("utf-8")
1361
+ root = ET.fromstring(container)
1362
+ rootfile = root.find(
1363
+ ".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile"
1364
+ )
1365
+ self.assertEqual(rootfile.get("full-path"), opf_name)
1366
+ # the escaping reader: the locator resolves the escaped
1367
+ # attribute back to the raw zip entry, not the first-.opf
1368
+ # fallback (which a stray duplicate .opf could poison)
1369
+ self.assertEqual(_locate_opf(z), opf_name)
1370
+ # and the escaped container reads back as healthy, not stale
1371
+ report = repair_epub(once, twice, fix_container=True)
1372
+ self.assertNotIn("container_generated", report.fixes)
1373
+
1328
1374
 
1329
1375
  class TestPruneDanglingEdges(unittest.TestCase):
1330
1376
  """Phase 16 B2: pruning an item must rewrite the edges that point at it
@@ -0,0 +1,78 @@
1
+ """Tests to ensure version numbers are synchronized across the repository.
2
+
3
+ v0.19.1 exists because `__init__.py` drifted to 0.18.0 while pyproject said
4
+ 0.19.0; this pin is the Phase 7 guard so that class of drift fails a test
5
+ instead of shipping.
6
+ """
7
+
8
+ import ast
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ from bindery import VERSION as CODE_VERSION
13
+
14
+
15
+ class TestVersionSync(unittest.TestCase):
16
+ def test_versions_match(self):
17
+ """Ensure pyproject.toml's version mirrors the code's VERSION.
18
+
19
+ `__init__.py` is the declared single source (there is no VERSION
20
+ file by design); pyproject.toml must repeat it exactly.
21
+ """
22
+ root_dir = Path(__file__).parent.parent
23
+
24
+ pyproject_path = root_dir / "pyproject.toml"
25
+ pyproject_version = None
26
+ if pyproject_path.exists():
27
+ with open(pyproject_path, encoding="utf-8") as f:
28
+ for line in f:
29
+ if line.startswith("version = "):
30
+ pyproject_version = line.split("=")[1].strip().strip('"')
31
+ break
32
+
33
+ self.assertIsNotNone(
34
+ pyproject_version, "Could not find version in pyproject.toml"
35
+ )
36
+ self.assertEqual(
37
+ pyproject_version,
38
+ CODE_VERSION,
39
+ f"pyproject.toml ({pyproject_version}) does not match code VERSION ({CODE_VERSION})",
40
+ )
41
+
42
+
43
+ class TestSyntaxFloor(unittest.TestCase):
44
+ """The Python floor is a package contract, enforced by parse, not trust.
45
+
46
+ v0.39.0 shipped the PEP 758 unparenthesized except form; the Calibre
47
+ plugin vendors those modules, so the plugin failed to load with a bare
48
+ SyntaxError on every released Calibre (7.x/8.x embed Python 3.11, 9.x
49
+ embeds 3.14) and CI could not see it because the suite only ever ran
50
+ under 3.14. This guard parses every module in the repo's Python roots
51
+ under a pinned grammar level, so interpreter-only syntax fails a test
52
+ instead of shipping. The pinned level is a deliberate contract point:
53
+ update it together with pyproject's requires-python and the CI
54
+ interpreter legs.
55
+ """
56
+
57
+ GRAMMAR_FLOOR = (3, 12)
58
+ ROOTS = ("src", "plugin", "tests", "scripts")
59
+
60
+ def test_tree_parses_under_the_declared_grammar(self):
61
+ repo = Path(__file__).parent.parent
62
+ checked = 0
63
+ for root in self.ROOTS:
64
+ for path in sorted((repo / root).rglob("*.py")):
65
+ if "__pycache__" in path.parts:
66
+ continue
67
+ try:
68
+ ast.parse(
69
+ path.read_text(encoding="utf-8"),
70
+ filename=str(path),
71
+ feature_version=self.GRAMMAR_FLOOR,
72
+ )
73
+ except SyntaxError as e:
74
+ self.fail(f"{path.relative_to(repo)}: {e}")
75
+ checked += 1
76
+ # the tree has dozens of modules; a broken path glob must not
77
+ # let the guard pass vacuously
78
+ self.assertGreaterEqual(checked, 20)
@@ -1,15 +1,19 @@
1
1
  version = 1
2
2
  revision = 3
3
- requires-python = ">=3.14"
3
+ requires-python = ">=3.12"
4
+ resolution-markers = [
5
+ "python_full_version >= '3.14'",
6
+ "python_full_version < '3.14'",
7
+ ]
4
8
 
5
9
  [[package]]
6
10
  name = "bindery-cli"
7
- version = "0.39.0"
11
+ version = "0.40.0"
8
12
  source = { editable = "." }
9
13
  dependencies = [
10
- { name = "cquarry" },
14
+ { name = "cquarry", marker = "python_full_version >= '3.14'" },
11
15
  { name = "tqdm" },
12
- { name = "vir-tui" },
16
+ { name = "vir-tui", marker = "python_full_version >= '3.14'" },
13
17
  ]
14
18
 
15
19
  [package.optional-dependencies]
@@ -19,10 +23,10 @@ reserialize = [
19
23
 
20
24
  [package.metadata]
21
25
  requires-dist = [
22
- { name = "cquarry", specifier = ">=1.19.0" },
26
+ { name = "cquarry", marker = "python_full_version >= '3.14'", specifier = ">=1.19.0" },
23
27
  { name = "html5lib", marker = "extra == 'reserialize'", specifier = ">=1.1" },
24
28
  { name = "tqdm" },
25
- { name = "vir-tui", specifier = ">=2.2.0" },
29
+ { name = "vir-tui", marker = "python_full_version >= '3.14'", specifier = ">=2.2.0" },
26
30
  ]
27
31
  provides-extras = ["reserialize"]
28
32
 
@@ -1,25 +0,0 @@
1
- name: CI
2
- on:
3
- push:
4
- branches: [main]
5
- pull_request:
6
- permissions:
7
- contents: read
8
- jobs:
9
- test:
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
- # html5lib is the project's declared optional `reserialize` extra (core stays
17
- # stdlib-only); installing it here lets CI exercise the --reserialize tests
18
- # instead of skipping them. ruff is a dev-only linter.
19
- - run: pip install . html5lib ruff==0.16.2
20
- # The rule set lives in pyproject.toml, so these mean the same thing locally.
21
- # Checking `.` rather than named directories keeps a new top-level script from
22
- # silently escaping lint.
23
- - run: ruff check .
24
- - run: ruff format --check .
25
- - run: PYTHONPATH=src python -m unittest discover -s tests
@@ -1,39 +0,0 @@
1
- """Tests to ensure version numbers are synchronized across the repository.
2
-
3
- v0.19.1 exists because `__init__.py` drifted to 0.18.0 while pyproject said
4
- 0.19.0; this pin is the Phase 7 guard so that class of drift fails a test
5
- instead of shipping.
6
- """
7
-
8
- import unittest
9
- from pathlib import Path
10
-
11
- from bindery import VERSION as CODE_VERSION
12
-
13
-
14
- class TestVersionSync(unittest.TestCase):
15
- def test_versions_match(self):
16
- """Ensure pyproject.toml's version mirrors the code's VERSION.
17
-
18
- `__init__.py` is the declared single source (there is no VERSION
19
- file by design); pyproject.toml must repeat it exactly.
20
- """
21
- root_dir = Path(__file__).parent.parent
22
-
23
- pyproject_path = root_dir / "pyproject.toml"
24
- pyproject_version = None
25
- if pyproject_path.exists():
26
- with open(pyproject_path, encoding="utf-8") as f:
27
- for line in f:
28
- if line.startswith("version = "):
29
- pyproject_version = line.split("=")[1].strip().strip('"')
30
- break
31
-
32
- self.assertIsNotNone(
33
- pyproject_version, "Could not find version in pyproject.toml"
34
- )
35
- self.assertEqual(
36
- pyproject_version,
37
- CODE_VERSION,
38
- f"pyproject.toml ({pyproject_version}) does not match code VERSION ({CODE_VERSION})",
39
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes