bindery-cli 0.44.0__tar.gz → 0.45.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bindery_cli-0.45.0/.github/FUNDING.yml +2 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/.gitignore +1 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/CLAUDE.md +2 -2
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/PKG-INFO +1 -1
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/patchnotes.md +14 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/pyproject.toml +1 -1
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/roadmap.md +20 -1
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/spec.md +7 -2
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/cli.py +66 -10
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_cli.py +76 -2
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/uv.lock +4 -4
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/AGENTS.md +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/LICENSE +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/README.md +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/logo.svg +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/plugin/__init__.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/project.done +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/run_tests.sh +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/scripts/build_plugin.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/epub.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/transforms.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_cover_tocdrift.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_epub.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_library.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_matcher_hardening.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_plugin.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_space_renames.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_transforms.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_version.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_watermark.py +0 -0
- {bindery_cli-0.44.0 → bindery_cli-0.45.0}/tests/test_xml_hardening.py +0 -0
|
@@ -14,7 +14,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
14
14
|
|
|
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
|
-
adopted in v0.16.0), pinned as PyPI ranges in pyproject.toml (`vir-tui>=2.
|
|
17
|
+
adopted in v0.16.0), pinned as PyPI ranges in pyproject.toml (`vir-tui>=2.5.0`,
|
|
18
18
|
`cquarry>=1.19.0`); `uv.lock` records the resolved versions. Since v0.40.0 both
|
|
19
19
|
VirInvictus pins carry `; python_version >= '3.14'` markers (every release they
|
|
20
20
|
ship declares requires-python >=3.14) and the package floor is 3.12: a 3.12/3.13
|
|
@@ -96,7 +96,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
96
96
|
- `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).
|
|
97
97
|
- `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
|
|
98
98
|
- `src/bindery/library.py`: Calibre walk, atomic replace, backups, and native format installation. Since v0.19.0 `CalibreIdResolver` resolves the book id from `metadata.db` through cquarry (one lazy path→id map per run; since v0.20.0 the map comes from `CalibreDB.format_path_index()`, re-normalized with `resolve().lower()` for the resolver's symlinked-directory and case-insensitive matching); the `(id)` directory-name regex is only the no-catalog fallback. Since v0.24.0 `install_format()` places the repaired file atomically and updates the `data` row through `cquarry.write.WritableCalibreDB` (since v0.31.0 via `set_format`, cquarry 1.17's sanctioned remove+add in one transaction); the external `calibredb` CLI is gone. Since v0.32.0 a guessed id drives a row update only when metadata.db corroborates it (`_verify_guess`: books row exists, file in the book's own directory, catalogued `data.name` when a row exists); anything else saves in place and leaves the catalog untouched: never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book. Since 2026-09-15 `make_backup` takes the opt-in `--backup-keep N` ring: at most N backup files per book, the author original `.bak` never deleted, newest content at the highest name; default remains an unbounded rotation.
|
|
99
|
-
- `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; `repair --json` emits the library per-book record vocabulary (2026-09-15); since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2; since 2026-09-15 the summary sums applied/equal states only and carries rejected projections as `rejected_projection`). Plus `bindery doctor` (2026-09-15): the environment self-check that imports none of the VirInvictus stack, never raises, and always exits 0. Both run verbs take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. `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.
|
|
99
|
+
- `src/bindery/cli.py`: `repair` and `library` subcommands, including `--all` and `--install-to-calibre`; `repair --json` emits the library per-book record vocabulary (2026-09-15; since v0.45.0 every per-book record, the phase1 payload included, also carries the structured fix breakdown: the `fixes` dict plus `ncx_uid_synced`/`watermark_refusals` off the `Outcome`, and `_phase1_decisions` reads that data instead of substring-matching the rendered `summary`, which stays for human eyes; CalibreQuarry's lossy-consent mirror is the waiting consumer, floor `bindery>=0.45.0`); since v0.29.0 also the `run` verbs: `phase1` (pre-import vetting over loose files: the audit battery composed with one gated repair sweep; read-only until `--apply-lossy`, which IS the lossy-strip consent) and `phase3` (the scoped post-import apply step: `library --id --sweep --only all --apply --all --install-to-calibre` plus a pre/post summary; mechanically refuses unscoped sweeps with exit 2; since 2026-09-15 the summary sums applied/equal states only and carries rejected projections as `rejected_projection`). Plus `bindery doctor` (2026-09-15): the environment self-check that imports none of the VirInvictus stack, never raises, and always exits 0. Both run verbs take `--non-interactive` and surface open questions as `decisions_needed` in their JSON, never prompts. They drive the shipped `run_library` through the real parser, so no flag can drift between the verb and the subcommand it wraps. `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.
|
|
100
100
|
- `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.
|
|
101
101
|
- `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).
|
|
102
102
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.45.0
|
|
4
4
|
Summary: Audit EPUB body text and repair broken EPUBs with deterministic, epubcheck-gated fixes; atomic in-place replacement in a Calibre library.
|
|
5
5
|
Project-URL: Homepage, https://github.com/VirInvictus/bindery-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/VirInvictus/bindery-cli
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
|
+
## v0.45.0 (2026-09-21)
|
|
3
|
+
|
|
4
|
+
### The structured fix breakdown in the JSON records
|
|
5
|
+
|
|
6
|
+
* **Every per-book record now carries the fix breakdown as data, not only the rendered `summary` string** (the roadmap box recorded 2026-09-18 from the CalibreQuarry side): `library --json` and `repair --json` records, and the `run phase1`/`phase3` payloads built on them, expose the RepairReport's `fixes` dict plus `ncx_uid_synced` and `watermark_refusals`. Consumers that need to know whether a repair was lossy (CalibreQuarry's lossy-consent mirror) can read fix classes as data instead of substring-matching the flattened `"k:v, k:v"` summary; the summary itself stays rendered for human eyes, byte-identical to before. Purely additive: no existing key changed meaning.
|
|
7
|
+
* **`run phase1`'s own decisions read the data now**: `_phase1_decisions` narrows the `apply_lossy` and `manual_watermark_repair` questions through the structured fields instead of matching summary substrings, and a book whose apply failed mid-run (the ENOSPC-class path) still reports the candidate's real fixes instead of an empty breakdown.
|
|
8
|
+
* CalibreQuarry adoption is a deliberate floor bump: `bindery>=0.45.0`.
|
|
9
|
+
* Suite: 521 -> 524 tests (`TestStructuredFixRecord`, plus the apply-failure and watermark-refusal records updated to the structured shape).
|
|
10
|
+
## v0.44.1 (2026-09-18)
|
|
11
|
+
|
|
12
|
+
### Doctor names its interpreter
|
|
13
|
+
|
|
14
|
+
* **`bindery doctor` prints `sys.executable` on its python line**, in the epubcheck line's `version [path]` shape. The version-only line hid which environment was probed, so an html5lib "not installed" read as wrong when the module sat in the system interpreter: a uv tool venv (`include-system-site-packages = false`) genuinely cannot see system site-packages, and `find_spec` only ever consults the running interpreter. The path makes the install shape self-diagnosing.
|
|
15
|
+
* Repo housekeeping with no runtime effect: `FUNDING.yml` (GitHub Sponsors + Liberapay).
|
|
2
16
|
## v0.44.0 (2026-09-17)
|
|
3
17
|
|
|
4
18
|
### The wave-2 batch: PKG-010 entry renames, the cover and ToC-drift audit slices, and the roman grammar unified
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.45.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 = [
|
|
@@ -1950,7 +1950,7 @@ four interpreter families.
|
|
|
1950
1950
|
quote skips, well-formed byte-identity, prune spin over the
|
|
1951
1951
|
truncated-OPF shape). Never a third grammar copy.)*
|
|
1952
1952
|
|
|
1953
|
-
- [
|
|
1953
|
+
- [x] **`--encode-url-spaces` left an OPF-manifest RSC-020 FATAL standing
|
|
1954
1954
|
on the Theaetetus fixture** (observed 2026-09-16, classics wave
|
|
1955
1955
|
phase 1): the Hackett Aristotle *Theaetetus* EPUB (McDowell) arrived
|
|
1956
1956
|
with 270 raw-space zip entry names AND 270 raw-space hrefs in
|
|
@@ -1983,3 +1983,22 @@ four interpreter families.
|
|
|
1983
1983
|
such books at all — until then the book parses in lenient readers
|
|
1984
1984
|
but never passes epubcheck. Low frequency (one book in the wave),
|
|
1985
1985
|
noted with the fixture reference above.
|
|
1986
|
+
|
|
1987
|
+
- [x] **The `run phase1` report's repair records carry no structured fix
|
|
1988
|
+
breakdown** (observed 2026-09-18 from the CalibreQuarry side, after
|
|
1989
|
+
its lossy_consent box): the phase-1 JSON exposes only the rendered
|
|
1990
|
+
`summary` string (fix keys flattened to "k:v, k:v"), so the CQ
|
|
1991
|
+
mirror has to substring-match the gate's lossy keys
|
|
1992
|
+
(`stripped_pagination`, `stripped_broken_tags`,
|
|
1993
|
+
`stripped_watermarks`, `dropped_marker`, `stub_docs_dropped`) to
|
|
1994
|
+
narrow consent to real strips. Exposing the RepairReport's `fixes`
|
|
1995
|
+
dict per book (the `ncx_uid_synced`/`watermark_refusals` fields
|
|
1996
|
+
already exist as data) would turn the lossy/structural split into
|
|
1997
|
+
data instead of string matching, and CalibreQuarry would adopt it
|
|
1998
|
+
with a deliberate floor bump. DONE in v0.45.0: every per-book
|
|
1999
|
+
record (library --json, repair --json, and the phase1 payload built
|
|
2000
|
+
on it) carries `fixes` plus `ncx_uid_synced` and
|
|
2001
|
+
`watermark_refusals` as data; `_phase1_decisions` reads those
|
|
2002
|
+
fields and no longer substring-matches the summary (which stays
|
|
2003
|
+
rendered for human eyes). CalibreQuarry can floor-bump to
|
|
2004
|
+
`bindery>=0.45.0` and drop its substring matching.
|
|
@@ -509,8 +509,13 @@ crash), never raises, and always exits 0: a diagnosis is not a failure.
|
|
|
509
509
|
|
|
510
510
|
`bindery repair SRC [DST] [--json FILE]` writes its record in the
|
|
511
511
|
`library --json` per-book vocabulary (`status`, `applied`, `before`/`after`,
|
|
512
|
-
the fix `summary
|
|
513
|
-
|
|
512
|
+
the fix `summary`, and the structured fix breakdown: the `fixes` dict plus
|
|
513
|
+
`ncx_uid_synced` and `watermark_refusals`) on every processing outcome,
|
|
514
|
+
including nochange and reject; usage refusals (missing input, existing
|
|
515
|
+
output) precede any record. The structured fields are the machine-readable
|
|
516
|
+
half of the same information: consumers narrow lossy consent by fix class
|
|
517
|
+
as data, never by substring-matching the rendered summary (v0.45.0; the
|
|
518
|
+
phase1 payload inherits them through its per-book repair records).
|
|
514
519
|
|
|
515
520
|
## The Calibre plugin (Bindery Repair)
|
|
516
521
|
|
|
@@ -11,7 +11,7 @@ Calibre plugin built from this core.
|
|
|
11
11
|
Single source of truth for the version. pyproject.toml must match VERSION below.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "0.
|
|
14
|
+
VERSION = "0.45.0"
|
|
15
15
|
__version__ = VERSION
|
|
16
16
|
|
|
17
17
|
from .epub import RepairReport, repair_epub # noqa: E402
|
|
@@ -11,7 +11,7 @@ import subprocess
|
|
|
11
11
|
import sys
|
|
12
12
|
import tempfile
|
|
13
13
|
import zipfile
|
|
14
|
-
from dataclasses import dataclass
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
15
|
from itertools import islice
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
@@ -56,6 +56,13 @@ class Outcome:
|
|
|
56
56
|
before: CheckResult | None
|
|
57
57
|
after: CheckResult | None
|
|
58
58
|
summary: str
|
|
59
|
+
# The structured half of `summary`: the RepairReport's own data, so
|
|
60
|
+
# consumers (run phase1's decisions, CalibreQuarry's lossy-consent
|
|
61
|
+
# mirror) read fix classes as data instead of substring-matching the
|
|
62
|
+
# rendered string.
|
|
63
|
+
fixes: dict[str, int] = field(default_factory=dict)
|
|
64
|
+
ncx_uid_synced: bool = False
|
|
65
|
+
watermark_refusals: int = 0
|
|
59
66
|
|
|
60
67
|
|
|
61
68
|
def process_book(
|
|
@@ -133,7 +140,16 @@ def process_book(
|
|
|
133
140
|
)
|
|
134
141
|
|
|
135
142
|
if not validate:
|
|
136
|
-
return Outcome(
|
|
143
|
+
return Outcome(
|
|
144
|
+
epub,
|
|
145
|
+
"unvalidated",
|
|
146
|
+
None,
|
|
147
|
+
None,
|
|
148
|
+
summary,
|
|
149
|
+
fixes=report.fixes,
|
|
150
|
+
ncx_uid_synced=report.ncx_uid_synced,
|
|
151
|
+
watermark_refusals=report.watermark_refusals,
|
|
152
|
+
)
|
|
137
153
|
|
|
138
154
|
if before is None:
|
|
139
155
|
before = run_epubcheck(epub)
|
|
@@ -142,7 +158,16 @@ def process_book(
|
|
|
142
158
|
# Validation was requested but the oracle failed (crash, timeout, unparsable
|
|
143
159
|
# output). This is "error", not "unvalidated": the gate did not accept the
|
|
144
160
|
# repair, so it must never be applied. Only --no-validate skips the gate.
|
|
145
|
-
return Outcome(
|
|
161
|
+
return Outcome(
|
|
162
|
+
epub,
|
|
163
|
+
"error",
|
|
164
|
+
before,
|
|
165
|
+
after,
|
|
166
|
+
summary + " (epubcheck failed)",
|
|
167
|
+
fixes=report.fixes,
|
|
168
|
+
ncx_uid_synced=report.ncx_uid_synced,
|
|
169
|
+
watermark_refusals=report.watermark_refusals,
|
|
170
|
+
)
|
|
146
171
|
verdict = gate(before, after)
|
|
147
172
|
if (
|
|
148
173
|
report.fixes.get("stripped_pagination")
|
|
@@ -193,7 +218,16 @@ def process_book(
|
|
|
193
218
|
elif verdict == "noop":
|
|
194
219
|
summary += " (no measurable gain)"
|
|
195
220
|
status = "equal" if verdict == "noop" else verdict
|
|
196
|
-
return Outcome(
|
|
221
|
+
return Outcome(
|
|
222
|
+
epub,
|
|
223
|
+
status,
|
|
224
|
+
before,
|
|
225
|
+
after,
|
|
226
|
+
summary,
|
|
227
|
+
fixes=report.fixes,
|
|
228
|
+
ncx_uid_synced=report.ncx_uid_synced,
|
|
229
|
+
watermark_refusals=report.watermark_refusals,
|
|
230
|
+
)
|
|
197
231
|
|
|
198
232
|
|
|
199
233
|
def _load_audit(path: Path) -> dict[str, tuple[int, int, int]]:
|
|
@@ -660,8 +694,21 @@ def run_library(args) -> int:
|
|
|
660
694
|
# record per path.
|
|
661
695
|
errors += 1
|
|
662
696
|
records.pop()
|
|
697
|
+
# Forward o's structured fields: the repair itself
|
|
698
|
+
# happened (its fixes are facts about the candidate),
|
|
699
|
+
# and the record contract promises them on every
|
|
700
|
+
# outcome, apply-failed included.
|
|
663
701
|
records.append(
|
|
664
|
-
Outcome(
|
|
702
|
+
Outcome(
|
|
703
|
+
epub,
|
|
704
|
+
"error",
|
|
705
|
+
o.before,
|
|
706
|
+
o.after,
|
|
707
|
+
f"apply failed: {e}",
|
|
708
|
+
fixes=o.fixes,
|
|
709
|
+
ncx_uid_synced=o.ncx_uid_synced,
|
|
710
|
+
watermark_refusals=o.watermark_refusals,
|
|
711
|
+
)
|
|
665
712
|
)
|
|
666
713
|
tqdm.write(
|
|
667
714
|
f" ERROR {rel}\n apply failed: {e}; not applied"
|
|
@@ -742,6 +789,9 @@ def run_library(args) -> int:
|
|
|
742
789
|
"before": _counts_dict(o.before),
|
|
743
790
|
"after": _counts_dict(o.after),
|
|
744
791
|
"summary": o.summary,
|
|
792
|
+
"fixes": o.fixes,
|
|
793
|
+
"ncx_uid_synced": o.ncx_uid_synced,
|
|
794
|
+
"watermark_refusals": o.watermark_refusals,
|
|
745
795
|
"applied": o.epub in applied_paths,
|
|
746
796
|
}
|
|
747
797
|
for o in records
|
|
@@ -833,6 +883,9 @@ def run_repair(args) -> int:
|
|
|
833
883
|
"before": None,
|
|
834
884
|
"after": None,
|
|
835
885
|
"summary": "",
|
|
886
|
+
"fixes": {},
|
|
887
|
+
"ncx_uid_synced": False,
|
|
888
|
+
"watermark_refusals": 0,
|
|
836
889
|
"error": f"{type(e).__name__}: {e}",
|
|
837
890
|
}
|
|
838
891
|
)
|
|
@@ -846,6 +899,9 @@ def run_repair(args) -> int:
|
|
|
846
899
|
"before": _counts_dict(o.before),
|
|
847
900
|
"after": _counts_dict(o.after),
|
|
848
901
|
"summary": o.summary,
|
|
902
|
+
"fixes": o.fixes,
|
|
903
|
+
"ncx_uid_synced": o.ncx_uid_synced,
|
|
904
|
+
"watermark_refusals": o.watermark_refusals,
|
|
849
905
|
}
|
|
850
906
|
if o.status == "nochange":
|
|
851
907
|
print("no applicable fixes; nothing written.")
|
|
@@ -951,10 +1007,10 @@ def _phase1_decisions(books: list[dict], apply: bool) -> list[dict]:
|
|
|
951
1007
|
r = b["repair"]
|
|
952
1008
|
if r is None:
|
|
953
1009
|
continue
|
|
954
|
-
|
|
955
|
-
if "stripped_watermarks"
|
|
1010
|
+
fixes = r.get("fixes") or {}
|
|
1011
|
+
if fixes.get("stripped_watermarks") or fixes.get("dropped_marker"):
|
|
956
1012
|
watermarked.append(b["path"])
|
|
957
|
-
if "watermark_refusals"
|
|
1013
|
+
if r.get("watermark_refusals"):
|
|
958
1014
|
manual_wm.append(b["path"])
|
|
959
1015
|
if r["status"] in ("accept", "partial"):
|
|
960
1016
|
pending.append(b["path"])
|
|
@@ -974,7 +1030,7 @@ def _phase1_decisions(books: list[dict], apply: bool) -> list[dict]:
|
|
|
974
1030
|
else:
|
|
975
1031
|
for b in books:
|
|
976
1032
|
r = b["repair"]
|
|
977
|
-
if r is not None and
|
|
1033
|
+
if r is not None and r.get("watermark_refusals"):
|
|
978
1034
|
manual_wm.append(b["path"])
|
|
979
1035
|
if manual_wm:
|
|
980
1036
|
decisions.append(
|
|
@@ -1024,7 +1080,7 @@ def run_doctor(args) -> int:
|
|
|
1024
1080
|
print()
|
|
1025
1081
|
|
|
1026
1082
|
py = sys.version.split()[0]
|
|
1027
|
-
print(f"python: {py} (floor 3.12)")
|
|
1083
|
+
print(f"python: {py} (floor 3.12) [{sys.executable}]")
|
|
1028
1084
|
|
|
1029
1085
|
stack = []
|
|
1030
1086
|
for mod in ("vir_tui", "cquarry"):
|
|
@@ -196,8 +196,9 @@ class TestRepairWritesGatedBytes(unittest.TestCase):
|
|
|
196
196
|
class TestRepairJson(unittest.TestCase):
|
|
197
197
|
"""repair --json: the one machine-readable gap in the four-verb CLI. The
|
|
198
198
|
record speaks the library --json per-book vocabulary (status, applied,
|
|
199
|
-
before/after, summary
|
|
200
|
-
|
|
199
|
+
before/after, summary, the structured fixes/ncx_uid_synced/
|
|
200
|
+
watermark_refusals fields) and lands on every processing outcome,
|
|
201
|
+
including the refusals."""
|
|
201
202
|
|
|
202
203
|
def test_accepted_repair_writes_a_record(self):
|
|
203
204
|
with tempfile.TemporaryDirectory() as td:
|
|
@@ -274,6 +275,68 @@ class TestRepairJson(unittest.TestCase):
|
|
|
274
275
|
self.assertFalse(d2["applied"])
|
|
275
276
|
|
|
276
277
|
|
|
278
|
+
class TestStructuredFixRecord(unittest.TestCase):
|
|
279
|
+
"""The per-book records carry the fix breakdown as data, not only the
|
|
280
|
+
rendered summary string: the structured-fixes roadmap box (2026-09-18),
|
|
281
|
+
so consumers like CalibreQuarry's lossy-consent mirror and run phase1's
|
|
282
|
+
own decisions read fix classes instead of substring-matching prose."""
|
|
283
|
+
|
|
284
|
+
def test_library_record_carries_fix_breakdown(self):
|
|
285
|
+
with tempfile.TemporaryDirectory() as td:
|
|
286
|
+
root = Path(td)
|
|
287
|
+
_phase1_book(root / "a.epub", broken=False, real_fix=True)
|
|
288
|
+
jout = root / "lib.json"
|
|
289
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
|
290
|
+
rc = cli.run_library(
|
|
291
|
+
build_parser().parse_args(
|
|
292
|
+
["library", str(root), "--no-validate", "--json", str(jout)]
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
data = json.loads(jout.read_text())
|
|
296
|
+
self.assertEqual(rc, 0)
|
|
297
|
+
(rec,) = data["books"]
|
|
298
|
+
self.assertEqual(rec["status"], "unvalidated")
|
|
299
|
+
self.assertEqual(rec["fixes"], {"fix_ncx_playorder": 1})
|
|
300
|
+
self.assertFalse(rec["ncx_uid_synced"])
|
|
301
|
+
self.assertEqual(rec["watermark_refusals"], 0)
|
|
302
|
+
# the rendered summary stays for human eyes
|
|
303
|
+
self.assertIn("fix_ncx_playorder:1", rec["summary"])
|
|
304
|
+
|
|
305
|
+
def test_repair_json_record_carries_fix_breakdown(self):
|
|
306
|
+
with tempfile.TemporaryDirectory() as td:
|
|
307
|
+
src = Path(td) / "in.epub"
|
|
308
|
+
dst = Path(td) / "out.epub"
|
|
309
|
+
_phase1_book(src, broken=False, real_fix=True)
|
|
310
|
+
jout = Path(td) / "repair.json"
|
|
311
|
+
with redirect_stdout(io.StringIO()):
|
|
312
|
+
rc = main(
|
|
313
|
+
["repair", str(src), str(dst), "--no-validate", "--json", str(jout)]
|
|
314
|
+
)
|
|
315
|
+
data = json.loads(jout.read_text())
|
|
316
|
+
self.assertEqual(rc, 0)
|
|
317
|
+
self.assertEqual(data["status"], "unvalidated")
|
|
318
|
+
self.assertEqual(data["fixes"], {"fix_ncx_playorder": 1})
|
|
319
|
+
self.assertFalse(data["ncx_uid_synced"])
|
|
320
|
+
self.assertEqual(data["watermark_refusals"], 0)
|
|
321
|
+
|
|
322
|
+
def test_nochange_record_carries_empty_fixes(self):
|
|
323
|
+
with tempfile.TemporaryDirectory() as td:
|
|
324
|
+
src = Path(td) / "in.epub"
|
|
325
|
+
dst = Path(td) / "out.epub"
|
|
326
|
+
_phase1_book(src, broken=False)
|
|
327
|
+
jout = Path(td) / "repair.json"
|
|
328
|
+
with redirect_stdout(io.StringIO()):
|
|
329
|
+
rc = main(
|
|
330
|
+
["repair", str(src), str(dst), "--no-validate", "--json", str(jout)]
|
|
331
|
+
)
|
|
332
|
+
data = json.loads(jout.read_text())
|
|
333
|
+
self.assertEqual(rc, 0)
|
|
334
|
+
self.assertEqual(data["status"], "nochange")
|
|
335
|
+
self.assertEqual(data["fixes"], {})
|
|
336
|
+
self.assertFalse(data["ncx_uid_synced"])
|
|
337
|
+
self.assertEqual(data["watermark_refusals"], 0)
|
|
338
|
+
|
|
339
|
+
|
|
277
340
|
class TestDoctor(unittest.TestCase):
|
|
278
341
|
"""bindery doctor: the stranded stranger's first command. It must always
|
|
279
342
|
run, never traceback, and always exit 0; the findings ARE the output."""
|
|
@@ -744,6 +807,11 @@ class TestApplyFailureIsolation(unittest.TestCase):
|
|
|
744
807
|
self.assertEqual(len(paths), len(set(paths))) # one record per path
|
|
745
808
|
statuses = [b["status"] for b in data["books"]]
|
|
746
809
|
self.assertEqual(statuses, ["error"])
|
|
810
|
+
# the repair itself happened; its fixes are facts about the candidate
|
|
811
|
+
# and must survive the apply failure (the record contract promises
|
|
812
|
+
# the structured fields on every outcome)
|
|
813
|
+
(rec,) = data["books"]
|
|
814
|
+
self.assertEqual(rec["fixes"], {"fix_ncx_playorder": 1})
|
|
747
815
|
self.assertEqual(rc, 2)
|
|
748
816
|
|
|
749
817
|
def test_backup_dir_inside_library_root_is_refused(self):
|
|
@@ -1264,6 +1332,9 @@ class TestWatermarkRefusalDecision(unittest.TestCase):
|
|
|
1264
1332
|
"repair": {
|
|
1265
1333
|
"status": "equal",
|
|
1266
1334
|
"summary": "watermark_refusals:1 (no measurable gain)",
|
|
1335
|
+
"fixes": {},
|
|
1336
|
+
"ncx_uid_synced": False,
|
|
1337
|
+
"watermark_refusals": 1,
|
|
1267
1338
|
},
|
|
1268
1339
|
}
|
|
1269
1340
|
|
|
@@ -1278,6 +1349,9 @@ class TestWatermarkRefusalDecision(unittest.TestCase):
|
|
|
1278
1349
|
book["repair"] = {
|
|
1279
1350
|
"status": "accept",
|
|
1280
1351
|
"summary": "stripped_watermarks:1, watermark_refusals:1",
|
|
1352
|
+
"fixes": {"stripped_watermarks": 1},
|
|
1353
|
+
"ncx_uid_synced": False,
|
|
1354
|
+
"watermark_refusals": 1,
|
|
1281
1355
|
}
|
|
1282
1356
|
decisions = _phase1_decisions([book], apply=True)
|
|
1283
1357
|
(d,) = decisions # apply recorded: only the manual-repair question remains
|
|
@@ -8,7 +8,7 @@ resolution-markers = [
|
|
|
8
8
|
|
|
9
9
|
[[package]]
|
|
10
10
|
name = "bindery-cli"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "0.45.0"
|
|
12
12
|
source = { editable = "." }
|
|
13
13
|
dependencies = [
|
|
14
14
|
{ name = "cquarry", marker = "python_full_version >= '3.14'" },
|
|
@@ -41,11 +41,11 @@ wheels = [
|
|
|
41
41
|
|
|
42
42
|
[[package]]
|
|
43
43
|
name = "cquarry"
|
|
44
|
-
version = "1.23.
|
|
44
|
+
version = "1.23.2"
|
|
45
45
|
source = { registry = "https://pypi.org/simple" }
|
|
46
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
46
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ac/f3/68d8dea76897d2efb9db7c6ce5e3592b1b6e410656e6e6a727c3ddae0353/cquarry-1.23.2.tar.gz", hash = "sha256:cb7467b02dea4013189964528f389d825b23416d6c493912947e7bbf10238a84", size = 284386, upload-time = "2026-09-16T21:18:59.258Z" }
|
|
47
47
|
wheels = [
|
|
48
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
48
|
+
{ url = "https://files.pythonhosted.org/packages/a2/4b/40e5f2a45ccbcb228f4a4c6dfe4a3e0b191004249bd7de1dec1c5a68541f/cquarry-1.23.2-py3-none-any.whl", hash = "sha256:708884c454ef344202351e9633cfba856b83b3961a742eac18d4815e3f8b38ec", size = 96618, upload-time = "2026-09-16T21:18:57.906Z" },
|
|
49
49
|
]
|
|
50
50
|
|
|
51
51
|
[[package]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|