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.
Files changed (72) hide show
  1. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/CLAUDE.md +1 -1
  2. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/PKG-INFO +2 -1
  3. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/README.md +1 -0
  4. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/patchnotes.md +16 -0
  5. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/pyproject.toml +1 -1
  6. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/roadmap.md +9 -2
  7. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/spec.md +5 -0
  8. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/__init__.py +1 -1
  9. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/cli.py +77 -4
  10. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_cli.py +122 -0
  11. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/uv.lock +1 -1
  12. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/.github/workflows/ci.yml +0 -0
  13. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/.github/workflows/publish.yml +0 -0
  14. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/.gitignore +0 -0
  15. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/AGENTS.md +0 -0
  16. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/LICENSE +0 -0
  17. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/docs/screenshots/library-sweep.png +0 -0
  18. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/logo.svg +0 -0
  19. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/run_tests.sh +0 -0
  20. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/FastSweep.java +0 -0
  21. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/fast_sweep.py +0 -0
  22. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_block_in_inline.py +0 -0
  23. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_css_illegal_tags.py +0 -0
  24. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_duplicate_playorder.py +0 -0
  25. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_empty_body.py +0 -0
  26. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_illegal_tags.py +0 -0
  27. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_invalid_ids.py +0 -0
  28. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_invalid_st_tag.py +0 -0
  29. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_invalid_value_attr.py +0 -0
  30. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_missing_images.py +0 -0
  31. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/scripts/find_missing_title.py +0 -0
  32. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/__main__.py +0 -0
  33. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/audit.py +0 -0
  34. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/epub.py +0 -0
  35. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/library.py +0 -0
  36. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/pagination.py +0 -0
  37. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/reserialize.py +0 -0
  38. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/transforms.py +0 -0
  39. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/validate.py +0 -0
  40. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/src/bindery/watermark.py +0 -0
  41. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/agda.epub +0 -0
  42. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/agda_fixed.epub +0 -0
  43. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/last_man_out.epub +0 -0
  44. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/last_man_out_fixed.epub +0 -0
  45. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass.epub +0 -0
  46. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_fixed.epub +0 -0
  47. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_st_fixed.epub +0 -0
  48. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/leaves_of_grass_tags_fixed.epub +0 -0
  49. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/muse_of_nightmares.epub +0 -0
  50. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/muse_of_nightmares_fixed.epub +0 -0
  51. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/poor_mans_fight.epub +0 -0
  52. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/poor_mans_fight_fixed.epub +0 -0
  53. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_block_in_inline.py +0 -0
  54. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_duplicate_playorder.py +0 -0
  55. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_empty_body.py +0 -0
  56. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_illegal_tags.py +0 -0
  57. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_invalid_ids.py +0 -0
  58. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_invalid_value.py +0 -0
  59. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/test_missing_title.py +0 -0
  60. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/unworthy_gods.epub +0 -0
  61. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/test_facility/unworthy_gods_fixed.epub +0 -0
  62. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/testing_facility/books_to_fix/.gitkeep +0 -0
  63. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/testing_facility/top500candidates/REPORT.md +0 -0
  64. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_audit.py +0 -0
  65. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_epub.py +0 -0
  66. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_library.py +0 -0
  67. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_pagination.py +0 -0
  68. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_reserialize.py +0 -0
  69. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_transforms.py +0 -0
  70. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_validate.py +0 -0
  71. {bindery_cli-0.29.0 → bindery_cli-0.30.0}/tests/test_version.py +0 -0
  72. {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.29.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.29.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
- - [ ] **Concurrent validation workers (stretch; not shipped)**: the 2026-08-27 full-library
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. Still open as a stretch goal.)*
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.
@@ -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.29.0"
7
+ VERSION = "0.30.0"
8
8
  __version__ = VERSION
9
9
 
10
10
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -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
- if args.sweep:
357
- iterator = (
358
- all_epubs if args.quiet else tqdm(all_epubs, desc="Sweeping", unit="book")
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
- selected = _sweep_select(iterator, args.only, root, checks, quiet=args.quiet)
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
+ # &nbsp; 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)
@@ -4,7 +4,7 @@ requires-python = ">=3.14"
4
4
 
5
5
  [[package]]
6
6
  name = "bindery-cli"
7
- version = "0.29.0"
7
+ version = "0.30.0"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "cquarry" },
File without changes
File without changes
File without changes
File without changes
File without changes