bindery-cli 0.29.0__tar.gz → 0.30.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.29.0 → bindery_cli-0.30.0}/CLAUDE.md +1 -1
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/PKG-INFO +2 -1
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/README.md +1 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/patchnotes.md +16 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/pyproject.toml +1 -1
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/roadmap.md +9 -2
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/spec.md +5 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/__init__.py +1 -1
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/cli.py +77 -4
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_cli.py +122 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/uv.lock +1 -1
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/.github/workflows/ci.yml +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/.github/workflows/publish.yml +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/.gitignore +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/AGENTS.md +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/LICENSE +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/docs/screenshots/library-sweep.png +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/logo.svg +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/run_tests.sh +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/FastSweep.java +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/fast_sweep.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_block_in_inline.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_css_illegal_tags.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_duplicate_playorder.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_empty_body.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_illegal_tags.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_invalid_ids.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_invalid_st_tag.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_invalid_value_attr.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_missing_images.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_missing_title.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/__main__.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/audit.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/epub.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/library.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/pagination.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/reserialize.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/transforms.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/validate.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/watermark.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/agda.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/agda_fixed.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/last_man_out.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/last_man_out_fixed.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_fixed.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_st_fixed.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_tags_fixed.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/muse_of_nightmares.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/muse_of_nightmares_fixed.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/poor_mans_fight.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/poor_mans_fight_fixed.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_block_in_inline.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_duplicate_playorder.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_empty_body.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_illegal_tags.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_invalid_ids.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_invalid_value.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_missing_title.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/unworthy_gods.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/unworthy_gods_fixed.epub +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/testing_facility/books_to_fix/.gitkeep +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/testing_facility/top500candidates/REPORT.md +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_audit.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_epub.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_library.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_pagination.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_reserialize.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_transforms.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_validate.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_version.py +0 -0
- {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_watermark.py +0 -0
|
@@ -66,7 +66,7 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
|
|
|
66
66
|
- `src/bindery/epub.py`: archive rewrite, NCX uid sync, RepairReport, mismatch detection, and the opt-in structural-repair plumbing (including the CSS precondition scan).
|
|
67
67
|
- `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
|
|
68
68
|
- `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` (remove+add in one `batch()`; `add_format` refuses duplicates); the external `calibredb` CLI is gone. Never reintroduce directory-name guessing as the primary source: renamed/mismatched directories would replace the wrong book.
|
|
69
|
-
- `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.
|
|
69
|
+
- `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).
|
|
70
70
|
- `tests/`: transforms, end-to-end repair, atomic replace, pagination, watermarks, the audit analyzers (content/pagenumbers/emptytext/ocr/monolithic, archive integrity, spine classification), the library sweep and `CalibreIdResolver`, validate, reserialize, CLI wiring, and the version pin.
|
|
71
71
|
|
|
72
72
|
## Conventions
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bindery-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.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
|
Author: vrnvctss
|
|
6
6
|
License: MIT
|
|
@@ -196,6 +196,7 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
|
|
|
196
196
|
- `--id <ids>` limits the sweep to a comma-separated list of Calibre book IDs, skipping the full library walk.
|
|
197
197
|
- `--audit CSV` (the `fatals,errors,warnings,path` format produced by an epubcheck sweep) skips clean books so a run is fast. Paths are resolved on both sides, and a CSV that matches nothing triggers a loud warning instead of silently selecting zero books.
|
|
198
198
|
- `--sweep` replaces the CSV step entirely: it runs a live epubcheck sweep for candidate selection and reuses each result as that book's before-measurement, so no book is checked twice. bindery-cli first tries a persistent Java daemon (`EpubcheckDaemon`) to eliminate the per-book JVM startup; in practice the daemon cannot boot on most installs (its classpath carries only epubcheck's launcher jar, not the `lib/` dependencies epubcheck itself puts on the classpath), and the run silently falls back to the standard per-book epubcheck subprocess at the usual seconds per book. Budget accordingly. Fixing the classpath is a recorded decision (it would also change the counts the gate measures), not a shipped behavior. Combine with `--only fatals` for a self-contained "find and fix the broken books" run.
|
|
199
|
+
- `--workers N` runs the sweep's candidate pass through N concurrent epubcheck workers (default 1: serial, unchanged). The subprocess releases the GIL, so threads parallelize the oracle honestly; books are checked in windows of N consumed in order, so the candidate set matches the serial sweep and `--limit` stays lazy within one window of overshoot. The repair phase stays serial: that is where the shared workdir and the atomic-replacement contract live.
|
|
199
200
|
- `--json FILE` writes a machine-readable report of the whole run (per-book status, before/after counts, applied flag, summary totals). `--manual-list FILE` writes the paths of every book that was not auto-repaired, one per line, ready for manual follow-up.
|
|
200
201
|
- `--apply` is required to write; the default is a dry run. `--backup DIR` mirrors originals before replacing; `--backup-inplace` writes `.epub.bak` beside each file.
|
|
201
202
|
- `--install-to-calibre` resolves the Calibre book id from `metadata.db` via cquarry (one read-only path→id map per run), so a hand-renamed `Author/Title (id)/` directory can never send the repaired file to the wrong book. The directory-name regex is only a no-catalog fallback; with neither, the file is saved atomically in place.
|
|
@@ -174,6 +174,7 @@ bindery library ~/docs/Calibre\ Library --only all --apply --all --install-to-ca
|
|
|
174
174
|
- `--id <ids>` limits the sweep to a comma-separated list of Calibre book IDs, skipping the full library walk.
|
|
175
175
|
- `--audit CSV` (the `fatals,errors,warnings,path` format produced by an epubcheck sweep) skips clean books so a run is fast. Paths are resolved on both sides, and a CSV that matches nothing triggers a loud warning instead of silently selecting zero books.
|
|
176
176
|
- `--sweep` replaces the CSV step entirely: it runs a live epubcheck sweep for candidate selection and reuses each result as that book's before-measurement, so no book is checked twice. bindery-cli first tries a persistent Java daemon (`EpubcheckDaemon`) to eliminate the per-book JVM startup; in practice the daemon cannot boot on most installs (its classpath carries only epubcheck's launcher jar, not the `lib/` dependencies epubcheck itself puts on the classpath), and the run silently falls back to the standard per-book epubcheck subprocess at the usual seconds per book. Budget accordingly. Fixing the classpath is a recorded decision (it would also change the counts the gate measures), not a shipped behavior. Combine with `--only fatals` for a self-contained "find and fix the broken books" run.
|
|
177
|
+
- `--workers N` runs the sweep's candidate pass through N concurrent epubcheck workers (default 1: serial, unchanged). The subprocess releases the GIL, so threads parallelize the oracle honestly; books are checked in windows of N consumed in order, so the candidate set matches the serial sweep and `--limit` stays lazy within one window of overshoot. The repair phase stays serial: that is where the shared workdir and the atomic-replacement contract live.
|
|
177
178
|
- `--json FILE` writes a machine-readable report of the whole run (per-book status, before/after counts, applied flag, summary totals). `--manual-list FILE` writes the paths of every book that was not auto-repaired, one per line, ready for manual follow-up.
|
|
178
179
|
- `--apply` is required to write; the default is a dry run. `--backup DIR` mirrors originals before replacing; `--backup-inplace` writes `.epub.bak` beside each file.
|
|
179
180
|
- `--install-to-calibre` resolves the Calibre book id from `metadata.db` via cquarry (one read-only path→id map per run), so a hand-renamed `Author/Title (id)/` directory can never send the repaired file to the wrong book. The directory-name regex is only a no-catalog fallback; with neither, the file is saved atomically in place.
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
# bindery-cli Patch Notes
|
|
2
|
+
## v0.30.0 (2026-09-06)
|
|
3
|
+
|
|
4
|
+
### Phase 8 stretch: concurrent sweep workers
|
|
5
|
+
|
|
6
|
+
- **`library --workers N`**: the `--sweep` candidate pass runs through N
|
|
7
|
+
concurrent epubcheck workers (default 1: serial, byte-for-byte unchanged).
|
|
8
|
+
epubcheck runs in a subprocess that releases the GIL, so threads parallelize
|
|
9
|
+
the oracle honestly; this is the pass that measured ~4.4 s/book on the
|
|
10
|
+
2026-08-27 full-library walk (5,070 EPUBs ≈ 6 hours serial). Books are
|
|
11
|
+
checked in windows of N consumed in input order, so the candidate set, the
|
|
12
|
+
before-measurements, and every emitted line match the serial sweep, and
|
|
13
|
+
`--limit` stays lazy within one window of overshoot. The repair phase stays
|
|
14
|
+
serial on purpose: that is where the shared workdir and the
|
|
15
|
+
atomic-replacement contract live. Without `--sweep`, `--workers` is a
|
|
16
|
+
no-op (with a note); counts below 1 are a usage error.
|
|
17
|
+
|
|
2
18
|
## v0.29.0 (2026-09-06)
|
|
3
19
|
|
|
4
20
|
### Phase 13: machine-readable audit and the acquisition run slices
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bindery-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.30.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 = [
|
|
@@ -510,12 +510,19 @@ by hand.*
|
|
|
510
510
|
release.
|
|
511
511
|
- [x] **`audit --id` comma lists**: v0.19.0 took a single book id; support the
|
|
512
512
|
same comma-separated form as the new `library --id` so the two modes match.
|
|
513
|
-
- [
|
|
513
|
+
- [x] **Concurrent validation workers (stretch; not shipped)**: the 2026-08-27 full-library
|
|
514
514
|
walk measured ~4.4 s/book on a single daemon (5,070 EPUBs ≈ 6 h). Gate k
|
|
515
515
|
validation workers behind `--workers N`, default 1 (behavior unchanged).
|
|
516
516
|
*(Un-ticked in the 2026-09-02 docs re-read (NEW-AUDIT.md Stage 0): the box was
|
|
517
517
|
ticked at the v0.23.0 ship, but no `--workers` code, test, or doc line ever
|
|
518
|
-
landed.
|
|
518
|
+
landed.)*
|
|
519
|
+
*(Shipped for real in v0.30.0: `library --workers N` runs the --sweep
|
|
520
|
+
candidate pass through N concurrent epubcheck workers, default 1 (serial,
|
|
521
|
+
unchanged). Threads parallelize the oracle honestly (the subprocess releases
|
|
522
|
+
the GIL); books are checked in windows of N consumed in input order, so the
|
|
523
|
+
candidate set matches the serial sweep and `--limit` stays lazy within one
|
|
524
|
+
window of overshoot. The repair phase stays serial, where the shared workdir
|
|
525
|
+
and the atomic-replacement contract live.)*
|
|
519
526
|
|
|
520
527
|
Non-goals: no auto-trim of bloated ToCs (semantics-preserving charter); no
|
|
521
528
|
content-doc synthesis; no PDF equivalent.
|
|
@@ -267,6 +267,11 @@ For a Calibre library (`Author/Title (id)/Title - Author.epub`):
|
|
|
267
267
|
reported and counted as `unreadable`; it never aborts the rest of the sweep.
|
|
268
268
|
- The `library` exit code is 0 for a clean sweep, 1 for a usage error, and 2 when any
|
|
269
269
|
book was rejected, unreadable, or failed epubcheck, so scripts can detect trouble.
|
|
270
|
+
- With `--sweep`, `--workers N` runs the candidate-selection epubcheck pass through N
|
|
271
|
+
concurrent workers (default 1: serial, unchanged). Books are checked in windows of N
|
|
272
|
+
consumed in input order, so the candidate set and the before-measurements are identical
|
|
273
|
+
to the serial sweep; the repair phase is never parallel (the shared workdir and the
|
|
274
|
+
atomic-replacement contract live there).
|
|
270
275
|
|
|
271
276
|
### Native format installation (`--install-to-calibre`)
|
|
272
277
|
Optionally, bindery-cli installs the repaired EPUB as the book's format through cquarry's write module (`WritableCalibreDB`): the file is placed atomically — an in-place replace over the catalogued file when one exists (same path, same `data.name`), or a fresh placement under the repaired file's stem otherwise — and the `data` row follows in one `batch()` transaction (`remove_format` + `add_format`, since `add_format` refuses duplicates), keeping the size truthful and queuing the book in `metadata_dirtied` so Calibre regenerates its sidecar .opf. The external `calibredb` CLI is no longer used (the v0.23.1 `--replace` crash class is gone with it). It automatically falls back to atomic filesystem replacement if a valid Calibre ID cannot be extracted, and a database failure degrades to the in-place save with a warning rather than losing the repair.
|
|
@@ -210,6 +210,51 @@ def _sweep_select(epubs, only: str, root: Path, checks: dict, *, quiet: bool):
|
|
|
210
210
|
yield epub
|
|
211
211
|
|
|
212
212
|
|
|
213
|
+
def _sweep_select_parallel(
|
|
214
|
+
epubs, only: str, root: Path, checks: dict, *, quiet: bool, workers: int
|
|
215
|
+
):
|
|
216
|
+
"""--workers N: k concurrent epubcheck workers over the sweep pass.
|
|
217
|
+
|
|
218
|
+
epubcheck runs in a subprocess that releases the GIL, so threads
|
|
219
|
+
parallelize the oracle honestly (this pass measured ~4.4 s/book on the
|
|
220
|
+
2026-08-27 full-library walk). Books are checked in windows of `workers`;
|
|
221
|
+
each window is consumed (and its keepers yielded) before the next one is
|
|
222
|
+
submitted, so a --limit still stops the sweep after at most one window of
|
|
223
|
+
overshoot instead of checking the whole tree. Within a window, results are
|
|
224
|
+
applied in input order, so the candidate set and every emitted line match
|
|
225
|
+
the serial sweep. The repair phase stays serial: that is where the shared
|
|
226
|
+
workdir and the atomic-replacement contract live."""
|
|
227
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
228
|
+
|
|
229
|
+
todo = list(epubs)
|
|
230
|
+
if not todo:
|
|
231
|
+
return
|
|
232
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
233
|
+
done_count = 0
|
|
234
|
+
progress = (
|
|
235
|
+
None if quiet else tqdm(total=len(todo), desc="Sweeping", unit="book")
|
|
236
|
+
)
|
|
237
|
+
for start in range(0, len(todo), workers):
|
|
238
|
+
window = todo[start : start + workers]
|
|
239
|
+
futures = [ex.submit(run_epubcheck, epub) for epub in window]
|
|
240
|
+
for epub, fut in zip(window, futures, strict=True):
|
|
241
|
+
c = fut.result()
|
|
242
|
+
done_count += 1
|
|
243
|
+
if not quiet:
|
|
244
|
+
tqdm.write(f"[sweep] {epub.relative_to(root)}", file=sys.stderr)
|
|
245
|
+
progress.update(1)
|
|
246
|
+
if c is not None:
|
|
247
|
+
checks[epub] = c
|
|
248
|
+
if only == "fatals":
|
|
249
|
+
keep = c is None or c.fatals > 0
|
|
250
|
+
else: # only == "all": skip clean books
|
|
251
|
+
keep = c != CheckResult(0, 0, 0)
|
|
252
|
+
if keep:
|
|
253
|
+
yield epub
|
|
254
|
+
if progress is not None:
|
|
255
|
+
progress.close()
|
|
256
|
+
|
|
257
|
+
|
|
213
258
|
# Everything a run did not (or could not) auto-repair; the --manual-list export.
|
|
214
259
|
_MANUAL_STATUSES = frozenset(
|
|
215
260
|
{"nochange", "equal", "partial", "reject", "error", "unreadable"}
|
|
@@ -353,11 +398,31 @@ def run_library(args) -> int:
|
|
|
353
398
|
all_epubs = list(iter_epubs(root))
|
|
354
399
|
audit_hits: list[Path] = []
|
|
355
400
|
checks: dict[Path, CheckResult] = {}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
401
|
+
workers = getattr(args, "workers", 1)
|
|
402
|
+
if workers is None:
|
|
403
|
+
workers = 1
|
|
404
|
+
if workers < 1:
|
|
405
|
+
print(f"error: --workers must be >= 1, got {workers}", file=sys.stderr)
|
|
406
|
+
return 1
|
|
407
|
+
if not args.sweep and workers > 1:
|
|
408
|
+
print(
|
|
409
|
+
"note: --workers applies to the --sweep candidate pass; ignored here.",
|
|
410
|
+
file=sys.stderr,
|
|
359
411
|
)
|
|
360
|
-
|
|
412
|
+
if args.sweep:
|
|
413
|
+
if workers > 1:
|
|
414
|
+
selected = _sweep_select_parallel(
|
|
415
|
+
all_epubs, args.only, root, checks, quiet=args.quiet, workers=workers
|
|
416
|
+
)
|
|
417
|
+
else:
|
|
418
|
+
iterator = (
|
|
419
|
+
all_epubs
|
|
420
|
+
if args.quiet
|
|
421
|
+
else tqdm(all_epubs, desc="Sweeping", unit="book")
|
|
422
|
+
)
|
|
423
|
+
selected = _sweep_select(
|
|
424
|
+
iterator, args.only, root, checks, quiet=args.quiet
|
|
425
|
+
)
|
|
361
426
|
else:
|
|
362
427
|
selected = _select(all_epubs, args.only, audit, audit_hits)
|
|
363
428
|
if args.limit is not None:
|
|
@@ -1309,6 +1374,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1309
1374
|
lib.add_argument(
|
|
1310
1375
|
"--limit", type=int, help="process at most N candidates (for sampling)"
|
|
1311
1376
|
)
|
|
1377
|
+
lib.add_argument(
|
|
1378
|
+
"--workers",
|
|
1379
|
+
type=int,
|
|
1380
|
+
default=1,
|
|
1381
|
+
metavar="N",
|
|
1382
|
+
help="concurrent epubcheck workers for the --sweep candidate pass "
|
|
1383
|
+
"(default 1: serial, unchanged). The repair phase stays serial",
|
|
1384
|
+
)
|
|
1312
1385
|
lib.add_argument(
|
|
1313
1386
|
"--quiet",
|
|
1314
1387
|
action="store_true",
|
|
@@ -1015,3 +1015,125 @@ class TestRunPhase3(unittest.TestCase):
|
|
|
1015
1015
|
self.assertEqual(data["summary"]["repair"]["applied"], 1)
|
|
1016
1016
|
self.assertEqual(data["decisions_needed"], [])
|
|
1017
1017
|
self.assertIn("PHASE 3 SUMMARY", out.getvalue())
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
class TestSweepWorkers(unittest.TestCase):
|
|
1021
|
+
"""--workers N: concurrent epubcheck workers over the --sweep candidate
|
|
1022
|
+
pass (Phase 8 stretch). Default 1 is the serial sweep; N > 1 must select
|
|
1023
|
+
the same candidates in the same order, reuse the sweep result as the
|
|
1024
|
+
book's before-measurement, and keep --limit lazy within one window of
|
|
1025
|
+
overshoot."""
|
|
1026
|
+
|
|
1027
|
+
def test_flag_defaults_to_one(self):
|
|
1028
|
+
parser = build_parser()
|
|
1029
|
+
self.assertEqual(parser.parse_args(["library", "/tmp"]).workers, 1)
|
|
1030
|
+
self.assertEqual(
|
|
1031
|
+
parser.parse_args(["library", "/tmp", "--workers", "4"]).workers, 4
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
def test_invalid_worker_count_is_a_usage_error(self):
|
|
1035
|
+
err = io.StringIO()
|
|
1036
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(err):
|
|
1037
|
+
rc = main(["library", "/tmp", "--workers", "0", "--no-validate"])
|
|
1038
|
+
self.assertEqual(rc, 1)
|
|
1039
|
+
self.assertIn("--workers", err.getvalue())
|
|
1040
|
+
|
|
1041
|
+
def test_parallel_sweep_selects_like_the_serial_one(self):
|
|
1042
|
+
# a path-keyed oracle: threads complete in any order, but every
|
|
1043
|
+
# book's result is pinned to the book, so determinism survives
|
|
1044
|
+
def oracle(path):
|
|
1045
|
+
return (
|
|
1046
|
+
CheckResult(1, 0, 0) if path.name == "a.epub" else CheckResult(0, 0, 0)
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1050
|
+
for name in ("a", "b", "c"):
|
|
1051
|
+
build(Path(td) / f"{name}.epub")
|
|
1052
|
+
jout = Path(td) / "report.json"
|
|
1053
|
+
out, err = io.StringIO(), io.StringIO()
|
|
1054
|
+
with (
|
|
1055
|
+
mock.patch("bindery.cli.epubcheck_available", return_value=True),
|
|
1056
|
+
mock.patch("bindery.cli.run_epubcheck", side_effect=oracle),
|
|
1057
|
+
redirect_stdout(out),
|
|
1058
|
+
redirect_stderr(err),
|
|
1059
|
+
):
|
|
1060
|
+
rc = main(
|
|
1061
|
+
[
|
|
1062
|
+
"library",
|
|
1063
|
+
td,
|
|
1064
|
+
"--sweep",
|
|
1065
|
+
"--only",
|
|
1066
|
+
"fatals",
|
|
1067
|
+
"--workers",
|
|
1068
|
+
"3",
|
|
1069
|
+
"--json",
|
|
1070
|
+
str(jout),
|
|
1071
|
+
]
|
|
1072
|
+
)
|
|
1073
|
+
data = json.loads(jout.read_text())
|
|
1074
|
+
self.assertEqual(rc, 0)
|
|
1075
|
+
statuses = {Path(b["path"]).name: b["status"] for b in data["books"]}
|
|
1076
|
+
self.assertEqual(list(statuses), ["a.epub"])
|
|
1077
|
+
# the sweep result was reused as the before-measurement and the gate
|
|
1078
|
+
# accepted the no-op-fix candidate exactly as the serial sweep would
|
|
1079
|
+
self.assertEqual(statuses["a.epub"], "accept")
|
|
1080
|
+
self.assertEqual(data["summary"]["accepted"], 1)
|
|
1081
|
+
|
|
1082
|
+
def test_limit_stays_lazy_within_one_window(self):
|
|
1083
|
+
checked: list[str] = []
|
|
1084
|
+
|
|
1085
|
+
def oracle(path):
|
|
1086
|
+
checked.append(path.name)
|
|
1087
|
+
return CheckResult(1, 0, 0)
|
|
1088
|
+
|
|
1089
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1090
|
+
for name in ("a", "b", "c", "d", "e"):
|
|
1091
|
+
build(Path(td) / f"{name}.epub")
|
|
1092
|
+
out, err = io.StringIO(), io.StringIO()
|
|
1093
|
+
with (
|
|
1094
|
+
mock.patch("bindery.cli.epubcheck_available", return_value=True),
|
|
1095
|
+
mock.patch("bindery.cli.run_epubcheck", side_effect=oracle),
|
|
1096
|
+
redirect_stdout(out),
|
|
1097
|
+
redirect_stderr(err),
|
|
1098
|
+
):
|
|
1099
|
+
rc = main(
|
|
1100
|
+
[
|
|
1101
|
+
"library",
|
|
1102
|
+
td,
|
|
1103
|
+
"--sweep",
|
|
1104
|
+
"--only",
|
|
1105
|
+
"all",
|
|
1106
|
+
"--limit",
|
|
1107
|
+
"1",
|
|
1108
|
+
"--workers",
|
|
1109
|
+
"2",
|
|
1110
|
+
]
|
|
1111
|
+
)
|
|
1112
|
+
self.assertEqual(rc, 0)
|
|
1113
|
+
# the first window (a, b) is checked; the keeper limit of 1 stops the
|
|
1114
|
+
# sweep there: at most one window of overshoot, never the whole tree.
|
|
1115
|
+
# (2 sweep calls + 1 after-validation on the candidate: the fixture's
|
|
1116
|
+
# always gives the core pass a fix, so the candidate validates.)
|
|
1117
|
+
self.assertEqual(len(checked), 3)
|
|
1118
|
+
|
|
1119
|
+
def test_serial_limit_checks_exactly_the_limit(self):
|
|
1120
|
+
checked: list[str] = []
|
|
1121
|
+
|
|
1122
|
+
def oracle(path):
|
|
1123
|
+
checked.append(path.name)
|
|
1124
|
+
return CheckResult(1, 0, 0)
|
|
1125
|
+
|
|
1126
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1127
|
+
for name in ("a", "b", "c", "d", "e"):
|
|
1128
|
+
build(Path(td) / f"{name}.epub")
|
|
1129
|
+
out, err = io.StringIO(), io.StringIO()
|
|
1130
|
+
with (
|
|
1131
|
+
mock.patch("bindery.cli.epubcheck_available", return_value=True),
|
|
1132
|
+
mock.patch("bindery.cli.run_epubcheck", side_effect=oracle),
|
|
1133
|
+
redirect_stdout(out),
|
|
1134
|
+
redirect_stderr(err),
|
|
1135
|
+
):
|
|
1136
|
+
rc = main(["library", td, "--sweep", "--only", "all", "--limit", "1"])
|
|
1137
|
+
self.assertEqual(rc, 0)
|
|
1138
|
+
# 1 sweep call + 1 after-validation on the single candidate
|
|
1139
|
+
self.assertEqual(len(checked), 2)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|