bindery-cli 0.47.1__tar.gz → 0.47.2__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 (51) hide show
  1. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/CLAUDE.md +3 -1
  2. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/PKG-INFO +9 -1
  3. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/README.md +8 -0
  4. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/patchnotes.md +26 -0
  5. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/pyproject.toml +1 -1
  6. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/__init__.py +1 -1
  7. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/cli.py +79 -29
  8. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_cli.py +48 -0
  9. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/uv.lock +1 -1
  10. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/.github/FUNDING.yml +0 -0
  11. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/.github/workflows/ci.yml +0 -0
  12. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/.github/workflows/publish.yml +0 -0
  13. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/.gitignore +0 -0
  14. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/AGENTS.md +0 -0
  15. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/LICENSE +0 -0
  16. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/docs/screenshots/library-sweep.png +0 -0
  17. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/logo.svg +0 -0
  18. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/plugin/__init__.py +0 -0
  19. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/project.done +0 -0
  20. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/roadmap.md +0 -0
  21. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/run_tests.sh +0 -0
  22. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/scripts/FastSweep.java +0 -0
  23. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/scripts/build_plugin.py +0 -0
  24. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/scripts/fast_sweep.py +0 -0
  25. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/spec.md +0 -0
  26. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/__main__.py +0 -0
  27. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/audit.py +0 -0
  28. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/epub.py +0 -0
  29. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/library.py +0 -0
  30. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/pagination.py +0 -0
  31. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/reserialize.py +0 -0
  32. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/transforms.py +0 -0
  33. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/validate.py +0 -0
  34. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/src/bindery/watermark.py +0 -0
  35. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_audit.py +0 -0
  36. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_comment_hyphens.py +0 -0
  37. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_cover_tocdrift.py +0 -0
  38. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_epub.py +0 -0
  39. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_flags_real_core.py +0 -0
  40. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_flags_wiring.py +0 -0
  41. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_library.py +0 -0
  42. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_matcher_hardening.py +0 -0
  43. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_pagination.py +0 -0
  44. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_plugin.py +0 -0
  45. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_reserialize.py +0 -0
  46. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_space_renames.py +0 -0
  47. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_transforms.py +0 -0
  48. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_validate.py +0 -0
  49. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_version.py +0 -0
  50. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_watermark.py +0 -0
  51. {bindery_cli-0.47.1 → bindery_cli-0.47.2}/tests/test_xml_hardening.py +0 -0
@@ -118,7 +118,9 @@ Born from the 2026 library audit (see the user memory `calibre-library-epubcheck
118
118
  - `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.46.0 the opt-in selection is the `RepairFlags` dataclass (one field per flag, all off by default; the all-off instance IS the default pass): `repair_epub(src, dst, flags)` and `process_book(..., flags)` take one object instead of a ~25-kwarg signature, the EPUB2-targeted fixes' version gate swaps in a `dataclasses.replace` copy (never mutating the caller's instance), and the plugin's bare `repair_epub(src, dst)` call constructs exactly the all-off default pass. 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).
119
119
  - `src/bindery/validate.py`: epubcheck wrapper, the `gate` (improvement) and `no_worse` (no-regression, for the lossy strips) acceptance bars.
120
120
  - `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.
121
- - `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. Since v0.46.0 `cli._flags_from_args(args)` is the one place the argparse names map onto `RepairFlags` fields (each `or --all`), shared by `run_library` and `run_repair`; the old per-verb ~25-kwarg blocks are gone. Since v0.47.1 the help is two-level and the repair-flag inventory is one table: `_REPAIR_FLAG_TABLE` (dest + one-line short help + long reference text) registers the compact grouped `--help` (argparse argument groups, hand-short usage line) and `--help-repairs` renders the long descriptions (a custom argparse Action, deliberately NOT store_true so the wiring test's selection count ignores it); adding a flag means adding a table row. `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.
121
+ - `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. Since v0.46.0 `cli._flags_from_args(args)` is the one place the argparse names map onto `RepairFlags` fields (each `or --all`), shared by `run_library` and `run_repair`; the old per-verb ~25-kwarg blocks are gone. Since v0.47.1 the help is two-level (the --help-repairs reference is
122
+ custom-rendered and colors itself with the CPython 3.14 argparse theme,
123
+ gated by argparse's own _colorize detection so the levels always agree) and the repair-flag inventory is one table: `_REPAIR_FLAG_TABLE` (dest + one-line short help + long reference text) registers the compact grouped `--help` (argparse argument groups, hand-short usage line) and `--help-repairs` renders the long descriptions (a custom argparse Action, deliberately NOT store_true so the wiring test's selection count ignores it); adding a flag means adding a table row. `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.
122
124
  - `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, `max_log_mb` rotation cap default 2, `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.
123
125
  - `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). Since the v0.46.0 follow-ups: `tests/test_flags_wiring.py` reflectively pins the RepairFlags inventory to the argparse dests and `_flags_from_args` mapping (a flag added without a field, a field without a flag, or a remapped line fails there), the all-off defaults, and the version gate's caller-immutability contract (test_epub's `TestVersionGateKeepsFlagsIntact`); `tests/test_comment_hyphens.py` carries the RSC-016 fix; `tests/test_flags_real_core.py` drives every repair flag from argv through the unmocked core (--no-validate; the gate itself stays pinned by the mocked-oracle tests and runs for real wherever epubcheck exists). All three ride CI's core-compat 3.12/3.13 leg; the hand-listed module list there must gain an entry for every new stack-free test module.
124
126
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bindery-cli
3
- Version: 0.47.1
3
+ Version: 0.47.2
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
@@ -203,6 +203,14 @@ vetting, phase-3 scoped post-import repair) for a calling agent, and `bindery do
203
203
  checks the installation (see Install).
204
204
  The sections below take each in turn.
205
205
 
206
+ Help is two-level: `bindery repair --help` (or `library --help`) lists the
207
+ shared repair flags grouped with one-line descriptions, and
208
+ `bindery repair --help-repairs` prints the full reference carrying every
209
+ flag's long rationale. On a terminal both are ANSI-colored with the same
210
+ theme Python 3.14's argparse uses; piped output stays plain, and `NO_COLOR`,
211
+ `FORCE_COLOR`, and `TERM=dumb` are honored exactly as `python --help` honors
212
+ them.
213
+
206
214
  ## Auditing
207
215
 
208
216
  bindery-cli's auditing tool inspects EPUB body text for non-schema flaws that epubcheck cannot catch. It extracts and analyzes the visible text to detect content issues, producing console reports that can be used to filter your library or feed into `bindery repair`.
@@ -176,6 +176,14 @@ vetting, phase-3 scoped post-import repair) for a calling agent, and `bindery do
176
176
  checks the installation (see Install).
177
177
  The sections below take each in turn.
178
178
 
179
+ Help is two-level: `bindery repair --help` (or `library --help`) lists the
180
+ shared repair flags grouped with one-line descriptions, and
181
+ `bindery repair --help-repairs` prints the full reference carrying every
182
+ flag's long rationale. On a terminal both are ANSI-colored with the same
183
+ theme Python 3.14's argparse uses; piped output stays plain, and `NO_COLOR`,
184
+ `FORCE_COLOR`, and `TERM=dumb` are honored exactly as `python --help` honors
185
+ them.
186
+
179
187
  ## Auditing
180
188
 
181
189
  bindery-cli's auditing tool inspects EPUB body text for non-schema flaws that epubcheck cannot catch. It extracts and analyzes the visible text to detect content issues, producing console reports that can be used to filter your library or feed into `bindery repair`.
@@ -1,5 +1,31 @@
1
1
  # bindery-cli Patch Notes
2
2
 
3
+ ## v0.47.2 (2026-10-06)
4
+
5
+ ### The --help-repairs reference keeps argparse's color
6
+
7
+ The lattice-music lesson, landed here the same day: Python 3.14's argparse
8
+ colors `-h` on terminals by itself, so the argparse-rendered surfaces (the
9
+ top-level help and `-h` for every verb) kept their theme through the
10
+ v0.47.1 restructure untouched. The one gap was the page v0.47.1 added: the
11
+ custom-rendered `--help-repairs` reference printed plain, silently losing
12
+ the colorization every other surface has (the exact regression
13
+ lattice-music repaired in its 6b88a52).
14
+
15
+ * The reference now paints with the exact CPython 3.14 default theme
16
+ (bold blue headings, magenta prog, bold cyan long options; read off real
17
+ 3.14 output, so the page reads like the argparse-rendered `-h` beside
18
+ it) through a single layout path whose disabled style emits empty codes.
19
+ * The color gate IS argparse's own detection (`_colorize.can_colorize` on
20
+ 3.14+; plain False below 3.14, where argparse has no colorization to
21
+ match), so the two help levels can never disagree: verified under a pty
22
+ that `--help-repairs` carries the same code vocabulary as `-h` and that
23
+ `TERM=dumb` and `NO_COLOR` silence both, and that piped output stays
24
+ plain for README embedding and tests.
25
+ * Four new tests: the theme codes, the strip invariant (colored, codes
26
+ stripped == plain, byte for byte), pipe safety, and gate tracking.
27
+ TestHelpLayout is now nine; suite: 624.
28
+
3
29
  ## v0.47.1 (2026-10-06)
4
30
 
5
31
  ### The two-level help: compact grouped --help, full --help-repairs reference
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bindery-cli"
7
- version = "0.47.1"
7
+ version = "0.47.2"
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 = [
@@ -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.47.1"
14
+ VERSION = "0.47.2"
15
15
  __version__ = VERSION
16
16
 
17
17
  from .epub import RepairReport, repair_epub # noqa: E402
@@ -1642,6 +1642,84 @@ _REPAIR_FLAG_TABLE: dict[str, list[tuple[str, str, str]]] = {
1642
1642
  }
1643
1643
 
1644
1644
 
1645
+ # The --help-repairs page is custom-rendered (not argparse output), so it
1646
+ # paints itself with the exact CPython 3.14 argparse default theme
1647
+ # (ColorfulTheme: bold blue headings, magenta prog, bold cyan long options
1648
+ # -- read off real 3.14 output, so the page reads like the argparse-rendered
1649
+ # -h beside it). This is the lattice-music lesson (6b88a52 there): a custom
1650
+ # help renderer that prints plain silently loses the colorization argparse
1651
+ # gives every other surface.
1652
+ _THEME = {
1653
+ "heading": "\x1b[1;34m",
1654
+ "prog": "\x1b[35m",
1655
+ "long_option": "\x1b[1;36m",
1656
+ "reset": "\x1b[0m",
1657
+ }
1658
+
1659
+
1660
+ def _can_color_help() -> bool:
1661
+ """True exactly when argparse itself would colorize -h here: CPython
1662
+ 3.14's own detection (_colorize.can_colorize: PYTHON_COLORS, NO_COLOR,
1663
+ FORCE_COLOR, TERM=dumb, isatty), so the two help levels can never
1664
+ disagree about color. Plain False below 3.14, where argparse has no
1665
+ colorization to match."""
1666
+ if sys.version_info < (3, 14):
1667
+ return False
1668
+ from _colorize import can_colorize
1669
+
1670
+ return can_colorize()
1671
+
1672
+
1673
+ def _repair_flag_reference(color: bool | None = None) -> str:
1674
+ """The full repair-flag reference: the _REPAIR_FLAG_TABLE rows with
1675
+ their long descriptions. `color=None` decides by the same gate argparse
1676
+ uses; the disabled style emits empty codes, so one layout path serves
1677
+ both faces and the strip invariant (colored, codes stripped == plain)
1678
+ holds by construction."""
1679
+ if color is None:
1680
+ color = _can_color_help()
1681
+ if color:
1682
+ heading, prog, option, reset = (
1683
+ _THEME["heading"],
1684
+ _THEME["prog"],
1685
+ _THEME["long_option"],
1686
+ _THEME["reset"],
1687
+ )
1688
+ else:
1689
+ heading = prog = option = reset = ""
1690
+ lines = [
1691
+ prog
1692
+ + "bindery"
1693
+ + reset
1694
+ + " repair-flag reference (shared by `bindery repair` and "
1695
+ "`bindery library`)",
1696
+ "",
1697
+ "The always-on core pass is exactly five well-formedness fixes, the NCX",
1698
+ "pipeline, and the mimetype fix. Everything below is opt-in, and every",
1699
+ "repair is epubcheck-gated: applied only when the measured result improved",
1700
+ "(lossy strips: only when it did not get worse). Library mode is dry-run",
1701
+ "by default; `--all` enables every flag below at once. The README and",
1702
+ "spec.md carry the full rationale for each repair.",
1703
+ ]
1704
+ for title, rows in _REPAIR_FLAG_TABLE.items():
1705
+ lines.append("")
1706
+ lines.append(heading + title + reset)
1707
+ lines.append("")
1708
+ for dest, _short, long_help in rows:
1709
+ lines.append(" " + option + "--" + dest.replace("_", "-") + reset)
1710
+ for para in long_help.split("\n"):
1711
+ lines.extend(
1712
+ textwrap.wrap(
1713
+ para,
1714
+ width=88,
1715
+ initial_indent=" ",
1716
+ subsequent_indent=" ",
1717
+ )
1718
+ or [" "]
1719
+ )
1720
+ return "\n".join(lines)
1721
+
1722
+
1645
1723
  class _HelpRepairsAction(argparse.Action):
1646
1724
  """The second help level: print the full repair-flag reference and exit.
1647
1725
 
@@ -1665,35 +1743,7 @@ class _HelpRepairsAction(argparse.Action):
1665
1743
  )
1666
1744
 
1667
1745
  def __call__(self, parser, namespace, values, option_string=None):
1668
- lines = [
1669
- "bindery repair-flag reference (shared by `bindery repair` and "
1670
- "`bindery library`)",
1671
- "",
1672
- "The always-on core pass is exactly five well-formedness fixes, the NCX",
1673
- "pipeline, and the mimetype fix. Everything below is opt-in, and every",
1674
- "repair is epubcheck-gated: applied only when the measured result improved",
1675
- "(lossy strips: only when it did not get worse). Library mode is dry-run",
1676
- "by default; `--all` enables every flag below at once. The README and",
1677
- "spec.md carry the full rationale for each repair.",
1678
- ]
1679
- for title, rows in _REPAIR_FLAG_TABLE.items():
1680
- lines.append("")
1681
- lines.append(title)
1682
- lines.append("")
1683
- for dest, _short, long_help in rows:
1684
- option = "--" + dest.replace("_", "-")
1685
- lines.append(f" {option}")
1686
- for para in long_help.split("\n"):
1687
- lines.extend(
1688
- textwrap.wrap(
1689
- para,
1690
- width=88,
1691
- initial_indent=" ",
1692
- subsequent_indent=" ",
1693
- )
1694
- or [" "]
1695
- )
1696
- print("\n".join(lines))
1746
+ print(_repair_flag_reference())
1697
1747
  parser.exit(0)
1698
1748
 
1699
1749
 
@@ -1866,6 +1866,54 @@ class TestHelpLayout(unittest.TestCase):
1866
1866
  self.assertIn("XML NameChar set", text, verb)
1867
1867
  self.assertIn("no-worse", text, verb)
1868
1868
 
1869
+ def test_help_repairs_theme_codes_when_colored(self):
1870
+ from bindery import cli
1871
+
1872
+ text = cli._repair_flag_reference(color=True)
1873
+ # the exact CPython 3.14 argparse theme: heading blue, prog magenta,
1874
+ # long-option cyan
1875
+ self.assertIn("\x1b[1;34m", text)
1876
+ self.assertIn("\x1b[35mbindery\x1b[0m", text)
1877
+ self.assertIn("\x1b[1;36m--fix-ids\x1b[0m", text)
1878
+ self.assertIn("\x1b[1;34mlossy strips", text)
1879
+
1880
+ def test_help_repairs_strip_invariant(self):
1881
+ # colored, codes stripped == plain, byte for byte: one layout path
1882
+ # serves both faces (the lattice-music invariant)
1883
+ import re
1884
+
1885
+ from bindery import cli
1886
+
1887
+ colored = cli._repair_flag_reference(color=True)
1888
+ plain = cli._repair_flag_reference(color=False)
1889
+ self.assertEqual(re.sub(r"\x1b\[[0-9;]*m", "", colored), plain)
1890
+
1891
+ def test_help_repairs_piped_output_is_plain(self):
1892
+ # a pipe (tests, README embedding) never carries codes
1893
+ for verb in ("repair", "library"):
1894
+ buf = io.StringIO()
1895
+ with self.assertRaises(SystemExit), contextlib.redirect_stdout(buf):
1896
+ main([verb, "--help-repairs"])
1897
+ self.assertNotIn("\x1b[", buf.getvalue(), verb)
1898
+
1899
+ def test_help_gate_tracks_argparse(self):
1900
+ # the gate IS argparse's own detection on 3.14+ (so -h and
1901
+ # --help-repairs can never disagree); plain False below, where
1902
+ # argparse has no colorization
1903
+ import os
1904
+
1905
+ from bindery import cli
1906
+
1907
+ if sys.version_info < (3, 14):
1908
+ self.assertFalse(cli._can_color_help())
1909
+ return
1910
+ from _colorize import can_colorize
1911
+
1912
+ self.assertEqual(cli._can_color_help(), can_colorize())
1913
+ # NO_COLOR suppresses on every interpreter
1914
+ with mock.patch.dict(os.environ, {"NO_COLOR": "1"}):
1915
+ self.assertFalse(cli._can_color_help())
1916
+
1869
1917
  def test_help_repairs_is_not_a_selection_flag(self):
1870
1918
  # _HelpRepairsAction is deliberately not a store_true: the wiring
1871
1919
  # test counts store_true dests as repair selections
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
 
9
9
  [[package]]
10
10
  name = "bindery-cli"
11
- version = "0.47.1"
11
+ version = "0.47.2"
12
12
  source = { editable = "." }
13
13
  dependencies = [
14
14
  { name = "cquarry", marker = "python_full_version >= '3.14'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes