Repository navigation
Expand file tree
/
Copy pathcheck_render_log.py
More file actions
238 lines (220 loc) · 8.58 KB
/
Copy pathcheck_render_log.py
File metadata and controls
238 lines (220 loc) · 8.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
#!/usr/bin/env python3
"""Reject a manuscript render whose LaTeX log records errors or dropped glyphs.
The shared rendering template's own success test only looks for four fatal
markers, so ``! `` errors and ``Missing character:`` notes pass as a successful
render (see ``fep_lean.output.render_log``). This wrapper is the project-side
fail-closed acceptance for a combined PDF build and must be run after
``stage_03_render.py``.
Continuous integration renders this manuscript end-to-end: the ``render`` job
in ``.github/workflows/ci.yml`` checks out the shared template at its pinned
ref, installs XeLaTeX, pandoc, ``rsvg-convert``, the mermaid CLI and the two
faces ``manuscript/preamble.md`` selects, renders, and then re-runs this
acceptance's verification against the fresh result. ``--receipt`` writes what
it found into a committed file and ``--verify-receipt`` re-reads it, so a
chapter edited without a fresh render leaves the receipt naming a digest the
checkout no longer has -- locally and in that job.
Usage:
uv run python scripts/check_render_log.py
uv run python scripts/check_render_log.py --pdf-dir output/pdf
uv run python scripts/check_render_log.py --receipt docs/render-acceptance.json
uv run python scripts/check_render_log.py --verify-receipt docs/render-acceptance.json
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# A direct acceptance invocation must not update checkout-local bytecode.
if __name__ == "__main__":
sys.dont_write_bytecode = True
import yaml
from fep_lean.output.publication_metadata import publication_cover_defects
from fep_lean.output.render_log import (
build_acceptance_receipt,
contents_number_overflow_defects,
equation_numbering_defects,
mermaid_fallback_defects,
receipt_defects,
reference_render_defects,
render_log_defects,
stale_render_defects,
uncaptioned_table_defects,
)
DEFAULT_RECEIPT = Path("docs") / "render-acceptance.json"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="fail-closed acceptance for a combined manuscript render"
)
parser.add_argument(
"--pdf-dir",
type=Path,
default=None,
help="directory holding the compiler logs (default: output/pdf)",
)
parser.add_argument(
"--manuscript-dir",
type=Path,
default=None,
help="authored manuscript sources to compare the render against "
"(default: manuscript)",
)
parser.add_argument(
"--receipt",
type=Path,
default=None,
help=f"write the acceptance receipt here (default: {DEFAULT_RECEIPT})",
nargs="?",
const=DEFAULT_RECEIPT,
)
parser.add_argument(
"--verify-receipt",
type=Path,
default=None,
help="verify a committed receipt against these sources and do nothing "
"else; this is the half a runner without a LaTeX toolchain can run",
nargs="?",
const=DEFAULT_RECEIPT,
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
project_root = Path(__file__).resolve().parents[1]
if args.verify_receipt is not None:
return verify_receipt(project_root, args)
pdf_dir = (
args.pdf_dir if args.pdf_dir is not None else project_root / "output" / "pdf"
)
manuscript_dir = (
args.manuscript_dir
if args.manuscript_dir is not None
else project_root / "manuscript"
)
results = render_log_defects(pdf_dir)
failed = False
for defects in results:
for line in defects.report():
print(line)
failed = failed or not defects.clean
fallbacks = mermaid_fallback_defects(pdf_dir)
for line in fallbacks:
print(f"FAIL: {line}")
if not fallbacks:
print("OK: no mermaid diagram fell back to verbatim source")
# The renderer resolves ``{{placeholder}}`` tokens from this file, so the
# staleness check needs it to compare a source line against the rendered
# line it became. Reading the committed projection costs nothing and keeps
# this acceptance script free of the catalog build.
variables = None
vars_path = manuscript_dir / "manuscript_vars.yaml"
vars_present = vars_path.is_file()
if vars_present:
variables = yaml.safe_load(vars_path.read_text(encoding="utf-8"))
stale = stale_render_defects(manuscript_dir, pdf_dir, variables=variables)
for line in stale:
print(f"FAIL: {line}")
if not stale:
if vars_present:
print("OK: every manuscript source is typeset in the combined render")
else:
print(
"WARN: manuscript_vars.yaml absent; placeholder-carrying lines "
"skipped from the staleness comparison"
)
uncaptioned = uncaptioned_table_defects(pdf_dir)
for line in uncaptioned:
print(f"FAIL: {line}")
if not uncaptioned:
print("OK: every rendered table carries a caption")
overflowed = contents_number_overflow_defects(pdf_dir)
for line in overflowed:
print(f"FAIL: {line}")
if not overflowed:
print("OK: no contents number overflows its number box")
references = reference_render_defects(pdf_dir)
for line in references:
print(f"FAIL: {line}")
if not references:
print("OK: final references resolve and no crossref markup leaked")
cover = publication_cover_defects(manuscript_dir, pdf_dir)
for line in cover:
print(f"FAIL: {line}")
unnumbered = equation_numbering_defects(pdf_dir)
for line in unnumbered:
print(f"FAIL: {line}")
if not unnumbered:
print("OK: every displayed equation uses automatic numbering")
status = (
1
if failed
or fallbacks
or stale
or uncaptioned
or overflowed
or references
or cover
or unnumbered
else 0
)
if args.receipt is not None:
counts = {
"tex_errors": sum(len(defects.tex_errors) for defects in results),
"missing_characters": sum(
len(defects.missing_characters) for defects in results
),
"mermaid_fallbacks": len(fallbacks),
"stale_sources": len(stale),
"uncaptioned_tables": len(uncaptioned),
"contents_number_overflows": len(overflowed),
"unresolved_references": len(references),
"publication_cover": len(cover),
"unnumbered_equations": len(unnumbered),
}
receipt = build_acceptance_receipt(manuscript_dir, pdf_dir, counts=counts)
path = (
args.receipt if args.receipt.is_absolute() else project_root / args.receipt
)
# A rejected render must not leave a receipt claiming otherwise, and
# the previous render's receipt is such a claim: it says these sources
# were accepted, and this run is direct evidence that they no longer
# are. Sources can drift out of a render without changing -- a moved
# count leaves every chapter byte-identical -- so the digest alone
# would not catch it. Removing the receipt does; recover it with
# ``git checkout`` once the render is clean again.
if not receipt["accepted"]:
print(
f"FAIL: this render was rejected, so no receipt may claim "
f"these sources were accepted; not writing {path}"
)
if path.is_file():
path.unlink()
print(f"FAIL: removed the previous receipt at {path}")
return 1
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
print(f"OK: wrote the acceptance receipt to {path}")
return status
def verify_receipt(project_root: Path, args: argparse.Namespace) -> int:
"""Report whether the committed receipt covers this checkout's sources."""
receipt = (
args.verify_receipt
if args.verify_receipt.is_absolute()
else project_root / args.verify_receipt
)
manuscript_dir = (
args.manuscript_dir
if args.manuscript_dir is not None
else project_root / "manuscript"
)
defects = receipt_defects(receipt, manuscript_dir)
for line in defects:
print(f"FAIL: {line}")
if defects:
return 1
print(
f"OK: {receipt} records a clean acceptance of exactly these manuscript sources"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())