quirework 0.1.0__py3-none-any.whl
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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`quirework publish build`: build a target, after the targets it needs.
|
|
2
|
+
|
|
3
|
+
quirework publish build every target, each after its needs
|
|
4
|
+
quirework publish build site one target, after what it needs
|
|
5
|
+
quirework publish build site --no-needs one target, from its needs' last builds
|
|
6
|
+
quirework publish build book --to html arguments after the target go to it
|
|
7
|
+
|
|
8
|
+
With one target the name may be left out, and every argument goes to its
|
|
9
|
+
backend. With several, arguments are for one target, so it must be named.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from quirework.project import ConfigurationError, current_project, project_scope
|
|
17
|
+
from quirework.publish import targets as _targets
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build(target, known, argv) -> int:
|
|
21
|
+
with project_scope(target.project):
|
|
22
|
+
status = target.backend.build(argv, target.name)
|
|
23
|
+
if status:
|
|
24
|
+
return status
|
|
25
|
+
for line in _targets.copy_into(target, known):
|
|
26
|
+
print(f"publish build: {target.name}: {line}")
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: list[str] | None = None) -> int:
|
|
31
|
+
argv = list(argv or [])
|
|
32
|
+
known = _targets.targets()
|
|
33
|
+
skip_needs = "--no-needs" in argv
|
|
34
|
+
argv = [a for a in argv if a != "--no-needs"]
|
|
35
|
+
if argv and argv[0] in known:
|
|
36
|
+
wanted, argv = [argv[0]], argv[1:]
|
|
37
|
+
elif len(known) == 1:
|
|
38
|
+
wanted = list(known)
|
|
39
|
+
elif argv:
|
|
40
|
+
raise ConfigurationError(
|
|
41
|
+
f"name the target these arguments are for: {', '.join(known)}"
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
wanted = list(known)
|
|
45
|
+
plan = wanted if skip_needs else _targets.order(wanted, known)
|
|
46
|
+
for name in plan:
|
|
47
|
+
# A needed target is built as it would be with no arguments; the ones
|
|
48
|
+
# given are for the target named.
|
|
49
|
+
status = _build(known[name], known, argv if name in wanted else [])
|
|
50
|
+
if status:
|
|
51
|
+
if name not in wanted:
|
|
52
|
+
print(
|
|
53
|
+
f"publish build: {name}, which {', '.join(wanted)} needs, "
|
|
54
|
+
f"failed; nothing after it was built",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
return status
|
|
58
|
+
if len(known) > 1:
|
|
59
|
+
print(f"publish build: built {', '.join(plan)} for {current_project().root}")
|
|
60
|
+
return 0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Where a derived edition lives, and how it renders as if it were the book.
|
|
2
|
+
|
|
3
|
+
An edition is one directory per kind under `publish.variants`. The blind
|
|
4
|
+
drafts that `publish variant build` fetches land in its `_raw/`, which is
|
|
5
|
+
evidence of what a model wrote and never a source. The author composes the
|
|
6
|
+
edition beside them: its documents, its own `_quarto.yml`, and their marks
|
|
7
|
+
in `_marks/`. An edition with a `_quarto.yml` is composed, and
|
|
8
|
+
`publish build` renders it.
|
|
9
|
+
|
|
10
|
+
Rendering reuses the book's pipeline whole. The edition's view of the
|
|
11
|
+
project moves the manuscript's source, chapters and Quarto file to the
|
|
12
|
+
edition's directory, and its build tree to `build/variants/KIND/` under the
|
|
13
|
+
book's. Everything else is the book's own: the figure gallery, the fonts
|
|
14
|
+
and the render policies. So an edition renders from staged copies as the
|
|
15
|
+
book does, never in its own directory, where Quarto would leave a cache
|
|
16
|
+
behind, and its figures come from the same sources as the book's.
|
|
17
|
+
|
|
18
|
+
An edition keeps its documents at its top level, beside its `_quarto.yml`,
|
|
19
|
+
because that is where figures are looked for: an embed is
|
|
20
|
+
`illustrations/NAME.svg` relative to the document, as in the book.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from quirework.core.artifact_paths import EDITION_DRAFTS
|
|
26
|
+
from quirework.manuscript.editions import composed, root # noqa: F401
|
|
27
|
+
from quirework.manuscript.quarto import BOOK_CONFIG
|
|
28
|
+
from quirework.project import current_project
|
|
29
|
+
|
|
30
|
+
# An edition's blind drafts: what each model wrote, kept as evidence. Never
|
|
31
|
+
# staged, reflowed or read as a source.
|
|
32
|
+
RAW = EDITION_DRAFTS
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def project(kind: str):
|
|
36
|
+
"""The project as the edition ``kind`` renders: see the module docstring."""
|
|
37
|
+
book = current_project()
|
|
38
|
+
folder = root() / kind
|
|
39
|
+
return book.replaced(
|
|
40
|
+
{
|
|
41
|
+
"manuscript.source": str(folder),
|
|
42
|
+
"manuscript.chapters": str(folder),
|
|
43
|
+
"manuscript.quarto": str(folder / BOOK_CONFIG),
|
|
44
|
+
"publish.build": str(
|
|
45
|
+
book.path("publish.build", "build") / "variants" / kind
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Validate the built EPUB against the spec, using the reference checker.
|
|
2
|
+
|
|
3
|
+
The EPUB is the one edition whose defects are INVISIBLE from the inside. A
|
|
4
|
+
figure number that disagrees across editions is caught by `fignumbers`, and a
|
|
5
|
+
missing typeface is caught by `fonts`, because both are properties this repo
|
|
6
|
+
can compute. Spec conformance is not: an `.epub` is a zip of XHTML validated
|
|
7
|
+
against schemas nothing here carries, and the failure mode is that every
|
|
8
|
+
desktop reader opens the book perfectly while a retailer's ingestion pipeline
|
|
9
|
+
rejects it. Foliate, Okular, and every browser-based reader are forgiving in
|
|
10
|
+
exactly the places EPUB is strict, so "it looks fine here" is not evidence.
|
|
11
|
+
|
|
12
|
+
So this shells out to **EPUBCheck**, the W3C reference implementation and the
|
|
13
|
+
same validator the retail pipelines run. It is pinned by version AND by
|
|
14
|
+
SHA-256, and fetched into a cache rather than vendored: it is 32 MB of jars,
|
|
15
|
+
which has no business in a prose repository, and an unpinned download is a
|
|
16
|
+
supply-chain hole in a tool that runs on every render.
|
|
17
|
+
|
|
18
|
+
Java is the one dependency, and its absence is reported rather than guessed
|
|
19
|
+
around. Unlike a missing typeface -- which fails SILENTLY, and is why `render`
|
|
20
|
+
refuses outright -- a missing validator is loud by construction: nothing claims
|
|
21
|
+
the book was checked. So `render` prints how to install it and carries on,
|
|
22
|
+
while `quirework publish epubcheck` run directly exits non-zero, since asking for a
|
|
23
|
+
check and getting a shrug is a different thing from rendering without one.
|
|
24
|
+
|
|
25
|
+
Severity follows EPUBCheck's own exit semantics: FATAL and ERROR fail, WARNING
|
|
26
|
+
and INFO are counted and shown. Messages are deduplicated by (severity, code,
|
|
27
|
+
message) because one systematic defect reports once per document -- the font
|
|
28
|
+
`src` bug below produced 152 identical errors across 38 files -- and a raw dump
|
|
29
|
+
buries the count that actually tells you what kind of problem it is.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from quirework.core import pinned
|
|
42
|
+
|
|
43
|
+
# Pinned. Bump the three together: a version without its checksum is an
|
|
44
|
+
# unverified download, and a checksum without its version is a lie.
|
|
45
|
+
VERSION = "5.3.0"
|
|
46
|
+
URL = (
|
|
47
|
+
f"https://github.com/w3c/epubcheck/releases/download/v{VERSION}"
|
|
48
|
+
f"/epubcheck-{VERSION}.zip"
|
|
49
|
+
)
|
|
50
|
+
SHA256 = "6c07e68584b2e2ce2f89fe06e1246dfead3eb36b46b340e7d93524f29dcff6c5"
|
|
51
|
+
|
|
52
|
+
# `ERROR(RSC-007): /path/to.epub/EPUB/nav.xhtml(331,5): message`
|
|
53
|
+
_LINE = re.compile(r"^(FATAL|ERROR|WARNING|INFO|USAGE)\(([A-Z0-9-]+)\):\s*(.*)$")
|
|
54
|
+
|
|
55
|
+
# The location prefix on a message, stripped so identical defects in different
|
|
56
|
+
# files collapse to one row. Keeps the trailing message only.
|
|
57
|
+
_WHERE = re.compile(r"^\S+?\.epub/(\S+?)(?:\(\d+,\d+\))?:\s*")
|
|
58
|
+
|
|
59
|
+
FAIL = ("FATAL", "ERROR")
|
|
60
|
+
|
|
61
|
+
# EPUBCheck quotes the entire permitted-attribute list into a single RSC-005
|
|
62
|
+
# message -- roughly 2 KB of it -- so an untruncated report is unreadable.
|
|
63
|
+
_ELIDE = 140
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
MEMBER = "epubcheck.jar"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cache_dir() -> Path:
|
|
70
|
+
"""Where the pinned checker lives. XDG, so it is shared across clones."""
|
|
71
|
+
return pinned.cache_root()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def jar_path() -> Path:
|
|
75
|
+
return pinned.tool_root("epubcheck", VERSION) / MEMBER
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_jar() -> Path | None:
|
|
79
|
+
"""The jar, by explicit override, then by cache. Never by PATH guess.
|
|
80
|
+
|
|
81
|
+
`EPUBCHECK_JAR` is honored first so a distro package or a CI image can
|
|
82
|
+
supply its own copy; everything else uses the pinned cache.
|
|
83
|
+
"""
|
|
84
|
+
return pinned.find("epubcheck", VERSION, MEMBER, env_override="EPUBCHECK_JAR")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def have_java() -> bool:
|
|
88
|
+
return shutil.which("java") is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def install(force: bool = False) -> int:
|
|
92
|
+
"""Fetch, verify and unpack the pinned EPUBCheck into the cache."""
|
|
93
|
+
return pinned.install(
|
|
94
|
+
"epubcheck", VERSION, URL, SHA256, MEMBER, label="EPUBCheck", force=force
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse(output: str) -> list[tuple[str, str, str, str]]:
|
|
99
|
+
"""(severity, code, where, message) per reported line.
|
|
100
|
+
|
|
101
|
+
`where` is the path inside the container; it is kept for the sample line
|
|
102
|
+
and dropped from the dedupe key, since one systematic defect reports once
|
|
103
|
+
per document and the count is the interesting part.
|
|
104
|
+
"""
|
|
105
|
+
rows: list[tuple[str, str, str, str]] = []
|
|
106
|
+
for line in output.splitlines():
|
|
107
|
+
m = _LINE.match(line.strip())
|
|
108
|
+
if not m:
|
|
109
|
+
continue
|
|
110
|
+
severity, code, rest = m.groups()
|
|
111
|
+
where = ""
|
|
112
|
+
w = _WHERE.match(rest)
|
|
113
|
+
if w:
|
|
114
|
+
where = w.group(1)
|
|
115
|
+
rest = rest[w.end() :]
|
|
116
|
+
rows.append((severity, code, where, rest.strip()))
|
|
117
|
+
return rows
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def report(rows: list[tuple[str, str, str, str]]) -> None:
|
|
121
|
+
"""Group by (severity, code, message), worst first, with one example."""
|
|
122
|
+
groups: dict[tuple[str, str, str], list[str]] = {}
|
|
123
|
+
for severity, code, where, message in rows:
|
|
124
|
+
groups.setdefault((severity, code, message), []).append(where)
|
|
125
|
+
|
|
126
|
+
order = {"FATAL": 0, "ERROR": 1, "WARNING": 2, "INFO": 3, "USAGE": 4}
|
|
127
|
+
for (severity, code, message), wheres in sorted(
|
|
128
|
+
groups.items(), key=lambda kv: (order.get(kv[0][0], 9), -len(kv[1]))
|
|
129
|
+
):
|
|
130
|
+
text = message if len(message) <= _ELIDE else message[:_ELIDE] + " ..."
|
|
131
|
+
print(f" {severity}({code}) x{len(wheres)}: {text}")
|
|
132
|
+
if wheres[0]:
|
|
133
|
+
print(f" e.g. {wheres[0]}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def check(epub: Path) -> tuple[int, list[tuple[str, str, str, str]]] | None:
|
|
137
|
+
"""Run the checker. None when it cannot run (no java, no jar)."""
|
|
138
|
+
jar = find_jar()
|
|
139
|
+
if jar is None or not have_java():
|
|
140
|
+
return None
|
|
141
|
+
proc = subprocess.run(
|
|
142
|
+
["java", "-jar", str(jar), str(epub)], capture_output=True, text=True
|
|
143
|
+
)
|
|
144
|
+
# EPUBCheck writes its findings to stderr and its summary to stdout.
|
|
145
|
+
return proc.returncode, parse(proc.stdout + "\n" + proc.stderr)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def missing_hint() -> str:
|
|
149
|
+
if not have_java():
|
|
150
|
+
return (
|
|
151
|
+
"epubcheck: java not found (EPUBCheck needs a JRE).\n"
|
|
152
|
+
" Fedora: sudo dnf install java-latest-openjdk-headless"
|
|
153
|
+
)
|
|
154
|
+
return (
|
|
155
|
+
"epubcheck: the validator is not installed.\n"
|
|
156
|
+
" Install the pinned copy with: quirework publish epubcheck --install"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def verify(site: Path) -> int:
|
|
161
|
+
"""The render-time gate over every EPUB in `site`.
|
|
162
|
+
|
|
163
|
+
Absence is a loud skip rather than a render failure: EPUBCheck is a 32 MB
|
|
164
|
+
download and a JRE, which is a heavy thing to demand of a clone that only
|
|
165
|
+
wants to read the book. It never skips SILENTLY -- the point of the fonts
|
|
166
|
+
rule is that a silent substitution leaves no trace, and this one prints.
|
|
167
|
+
"""
|
|
168
|
+
epubs = sorted(site.glob("*.epub"))
|
|
169
|
+
if not epubs:
|
|
170
|
+
return 0
|
|
171
|
+
if find_jar() is None or not have_java():
|
|
172
|
+
print(missing_hint(), file=sys.stderr)
|
|
173
|
+
print(" The EPUB was NOT validated.", file=sys.stderr)
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
rc = 0
|
|
177
|
+
for epub in epubs:
|
|
178
|
+
result = check(epub)
|
|
179
|
+
if result is None: # raced with a removal; treated as unchecked
|
|
180
|
+
print(missing_hint(), file=sys.stderr)
|
|
181
|
+
continue
|
|
182
|
+
_, rows = result
|
|
183
|
+
bad = [r for r in rows if r[0] in FAIL]
|
|
184
|
+
if not bad:
|
|
185
|
+
others = len(rows)
|
|
186
|
+
tail = f" ({others} warnings/notices)" if others else ""
|
|
187
|
+
print(f"epubcheck: {epub.name} is valid EPUB{tail}.")
|
|
188
|
+
continue
|
|
189
|
+
print(
|
|
190
|
+
f"epubcheck: {epub.name} FAILED with {len(bad)} error(s):", file=sys.stderr
|
|
191
|
+
)
|
|
192
|
+
report(rows)
|
|
193
|
+
rc = 1
|
|
194
|
+
return rc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
HELP = """\
|
|
198
|
+
usage: quirework publish epubcheck [--install] [--force] [EPUB]
|
|
199
|
+
|
|
200
|
+
Validate the built EPUB with the W3C reference checker (EPUBCheck),
|
|
201
|
+
pinned by version and SHA-256 and cached outside the repo. Without an
|
|
202
|
+
argument, checks every .epub in build/book/_site/.
|
|
203
|
+
|
|
204
|
+
Desktop readers are forgiving where EPUB is strict, so "it opens fine"
|
|
205
|
+
is not evidence; a retailer's ingestion pipeline runs this validator.
|
|
206
|
+
|
|
207
|
+
options:
|
|
208
|
+
-h, --help show this help message and exit
|
|
209
|
+
--install fetch and verify the pinned EPUBCheck into the cache
|
|
210
|
+
--force re-download even if the cache already has it
|
|
211
|
+
|
|
212
|
+
Exit status is non-zero when the book has FATAL or ERROR findings, or
|
|
213
|
+
when the checker was asked for and is not installed.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main(argv: list[str] | None = None) -> int:
|
|
218
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
219
|
+
parser.add_argument("epub", nargs="?")
|
|
220
|
+
parser.add_argument("--install", action="store_true")
|
|
221
|
+
parser.add_argument("--force", action="store_true")
|
|
222
|
+
parser.add_argument("-h", "--help", action="store_true", dest="help")
|
|
223
|
+
args = parser.parse_args(list(argv if argv is not None else sys.argv[1:]))
|
|
224
|
+
|
|
225
|
+
if args.help:
|
|
226
|
+
print(HELP, end="")
|
|
227
|
+
return 0
|
|
228
|
+
if args.install or args.force:
|
|
229
|
+
return install(force=args.force)
|
|
230
|
+
|
|
231
|
+
import quirework.publish.render as _options_quirework_book_render
|
|
232
|
+
from quirework.publish.render import SITE
|
|
233
|
+
|
|
234
|
+
if args.epub:
|
|
235
|
+
targets = [Path(args.epub)]
|
|
236
|
+
if not targets[0].exists():
|
|
237
|
+
print(f"epubcheck: no such file: {args.epub}", file=sys.stderr)
|
|
238
|
+
return 2
|
|
239
|
+
else:
|
|
240
|
+
targets = sorted(
|
|
241
|
+
(_options_quirework_book_render._options().BUILD_BOOK / SITE).glob("*.epub")
|
|
242
|
+
)
|
|
243
|
+
if not targets:
|
|
244
|
+
print(
|
|
245
|
+
f"epubcheck: no .epub in {_options_quirework_book_render._options().BUILD_BOOK / SITE} "
|
|
246
|
+
f"(run `quirework publish build --to epub` first).",
|
|
247
|
+
file=sys.stderr,
|
|
248
|
+
)
|
|
249
|
+
return 2
|
|
250
|
+
|
|
251
|
+
# Asked for directly, an uninstalled checker is a failure, not a skip.
|
|
252
|
+
if find_jar() is None or not have_java():
|
|
253
|
+
print(missing_hint(), file=sys.stderr)
|
|
254
|
+
return 127
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
return _check_all(targets)
|
|
258
|
+
except KeyboardInterrupt:
|
|
259
|
+
# `subprocess.run` kills the validator for us and re-raises; all that
|
|
260
|
+
# is left is not to hand the caller a traceback for a deliberate stop.
|
|
261
|
+
print("\nepubcheck: interrupted.", file=sys.stderr)
|
|
262
|
+
return 130
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_all(targets: list[Path]) -> int:
|
|
266
|
+
rc = 0
|
|
267
|
+
for epub in targets:
|
|
268
|
+
result = check(epub)
|
|
269
|
+
assert result is not None # guarded by the caller
|
|
270
|
+
_, rows = result
|
|
271
|
+
bad = [r for r in rows if r[0] in FAIL]
|
|
272
|
+
counts = {}
|
|
273
|
+
for severity, _code, _where, _msg in rows:
|
|
274
|
+
counts[severity] = counts.get(severity, 0) + 1
|
|
275
|
+
summary = ", ".join(
|
|
276
|
+
f"{n} {s.lower()}{'s' if n != 1 else ''}" for s, n in sorted(counts.items())
|
|
277
|
+
)
|
|
278
|
+
print(f"{epub.name}: {summary or 'clean'}")
|
|
279
|
+
if rows:
|
|
280
|
+
report(rows)
|
|
281
|
+
if bad:
|
|
282
|
+
rc = 1
|
|
283
|
+
return rc
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
raise SystemExit(main())
|