paperkit 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.
- paperkit/__init__.py +6 -0
- paperkit/archive.py +183 -0
- paperkit/bibliography.py +321 -0
- paperkit/builder.py +186 -0
- paperkit/checks.py +563 -0
- paperkit/cli.py +595 -0
- paperkit/config.py +437 -0
- paperkit/docker.py +265 -0
- paperkit/pages.py +254 -0
- paperkit/profiles/__init__.py +56 -0
- paperkit/profiles/acm.py +10 -0
- paperkit/py.typed +0 -0
- paperkit/report.py +246 -0
- paperkit/reproducible.py +158 -0
- paperkit/texlive.py +310 -0
- paperkit/text_export.py +165 -0
- paperkit-0.1.0.dist-info/METADATA +381 -0
- paperkit-0.1.0.dist-info/RECORD +21 -0
- paperkit-0.1.0.dist-info/WHEEL +4 -0
- paperkit-0.1.0.dist-info/entry_points.txt +2 -0
- paperkit-0.1.0.dist-info/licenses/LICENSE +21 -0
paperkit/config.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Load and validate .paperkit.toml configuration."""
|
|
5
|
+
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from paperkit import reproducible
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BibtexTidyConfig:
|
|
17
|
+
enabled: bool = True
|
|
18
|
+
version: str = "1.14.0"
|
|
19
|
+
# Sort entries by key and fields within entries. False keeps the file's
|
|
20
|
+
# own order, for both `paperkit bibtidy` and the tidiness check.
|
|
21
|
+
sort: bool = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class UnusedBibConfig:
|
|
26
|
+
enabled: bool = True
|
|
27
|
+
# Keys never reported as unused (e.g. an entry staged to be cited later).
|
|
28
|
+
ignore: list[str] = field(default_factory=list)
|
|
29
|
+
# When another target sharing a bib file has no up-to-date .aux, run one
|
|
30
|
+
# draft LaTeX pass (no PDF) to learn its citations. When false, entries
|
|
31
|
+
# its citations could cover are reported as "not checked", never unused.
|
|
32
|
+
draft_pass: bool = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ChecksConfig:
|
|
37
|
+
non_ascii: bool = True
|
|
38
|
+
trailing_whitespace: bool = True
|
|
39
|
+
final_newline: bool = True
|
|
40
|
+
unused_bib: UnusedBibConfig = field(default_factory=UnusedBibConfig)
|
|
41
|
+
font_embedding: bool = True
|
|
42
|
+
# Part of the font_embedding check: warn on Type 3 (bitmap) fonts.
|
|
43
|
+
no_type3: bool = True
|
|
44
|
+
overfull_boxes: bool = True
|
|
45
|
+
bibtex_tidy: BibtexTidyConfig = field(default_factory=BibtexTidyConfig)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PageLimitsConfig:
|
|
50
|
+
# Hard caps. None = no limit (informational only).
|
|
51
|
+
total: int | None = None
|
|
52
|
+
# Max pages the content before the section may occupy. A section starting
|
|
53
|
+
# mid-page counts that page against the budget.
|
|
54
|
+
before_refs: int | None = None
|
|
55
|
+
before_ack: int | None = None
|
|
56
|
+
# False (default): references and acknowledgments are both exempt, so the
|
|
57
|
+
# budget ends at whichever comes first. True: only the references are
|
|
58
|
+
# exempt, as at venues that count acknowledgments as text.
|
|
59
|
+
ack_counts_as_content: bool = False
|
|
60
|
+
# Regex patterns used to spot the section in the .aux TOC entries.
|
|
61
|
+
refs_pattern: str = r"(?i)\b(references|bibliography)\b"
|
|
62
|
+
ack_pattern: str = r"(?i)acknowledg"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class TexliveConfig:
|
|
67
|
+
# "distro": the base image's texlive-* packages. "upstream": install-tl
|
|
68
|
+
# from a tlnet repository (see paperkit.texlive for URL resolution).
|
|
69
|
+
source: str = "distro"
|
|
70
|
+
# Repository selection, highest precedence first: repository > snapshot
|
|
71
|
+
# (YYYY-MM-DD, dated tlnet archive) > year (frozen tlnet-final of a
|
|
72
|
+
# closed release) > live CTAN mirror (not reproducible).
|
|
73
|
+
repository: str | None = None
|
|
74
|
+
snapshot: str | None = None
|
|
75
|
+
year: int | None = None
|
|
76
|
+
# Expected sha512 of the repository's tlpkg/texlive.tlpdb, checked before
|
|
77
|
+
# installing so a changed repository fails the image build.
|
|
78
|
+
tlpdb_sha512: str | None = None
|
|
79
|
+
scheme: str = "small"
|
|
80
|
+
collections: list[str] = field(default_factory=list)
|
|
81
|
+
# Individual packages installed with tlmgr after install-tl.
|
|
82
|
+
packages: list[str] = field(default_factory=list)
|
|
83
|
+
docs: bool = False
|
|
84
|
+
sources: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ZipConfig:
|
|
89
|
+
name: str = ""
|
|
90
|
+
include: list[str] = field(default_factory=list)
|
|
91
|
+
# Add each included main's compiled bibliography (<main>.bbl, from the
|
|
92
|
+
# build directory) next to its .tex. Publishers that compile without
|
|
93
|
+
# running BibTeX need it; Springer's LNCS checklist asks for it.
|
|
94
|
+
include_bbl: bool = False
|
|
95
|
+
exclude: list[str] = field(
|
|
96
|
+
default_factory=lambda: ["*.aux", "*.log", "*.out", "*.blg", "*.bbl"],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class PaperkitConfig:
|
|
102
|
+
# [project]
|
|
103
|
+
# main is kept as a convenience for the single-target case and always
|
|
104
|
+
# equals mains[0]. mains is the canonical list the build/check loops over.
|
|
105
|
+
main: str = "main"
|
|
106
|
+
mains: list[str] = field(default_factory=list)
|
|
107
|
+
content_dirs: list[str] = field(default_factory=lambda: ["content"])
|
|
108
|
+
bib: str = ""
|
|
109
|
+
|
|
110
|
+
# [build]
|
|
111
|
+
engine: str = "pdflatex"
|
|
112
|
+
passes: int = 3
|
|
113
|
+
shell_escape: bool = True
|
|
114
|
+
bibtex: bool = True
|
|
115
|
+
biber: bool = False
|
|
116
|
+
output_dir: str = "out"
|
|
117
|
+
# None = use cpu_count. A positive int caps parallel workers; useful when
|
|
118
|
+
# one main includes the PDF output of another (`\includepdf`) and needs
|
|
119
|
+
# to build after it.
|
|
120
|
+
jobs: int | None = None
|
|
121
|
+
# Timestamp every tool builds with, for byte-identical rebuilds: an
|
|
122
|
+
# epoch, an ISO date, or "git" (HEAD's committer date).
|
|
123
|
+
source_date_epoch: int | str = "git"
|
|
124
|
+
|
|
125
|
+
# [docker]
|
|
126
|
+
base_image: str = "ubuntu:26.04"
|
|
127
|
+
image_name: str = "paperkit-builder"
|
|
128
|
+
extra_packages: list[str] = field(default_factory=list)
|
|
129
|
+
debconf_selections: list[str] = field(default_factory=list)
|
|
130
|
+
|
|
131
|
+
# [texlive]
|
|
132
|
+
texlive: TexliveConfig = field(default_factory=TexliveConfig)
|
|
133
|
+
|
|
134
|
+
# [checks]
|
|
135
|
+
checks: ChecksConfig = field(default_factory=ChecksConfig)
|
|
136
|
+
|
|
137
|
+
# [page_limits]
|
|
138
|
+
page_limits: PageLimitsConfig = field(default_factory=PageLimitsConfig)
|
|
139
|
+
|
|
140
|
+
# [zip]
|
|
141
|
+
zip: ZipConfig = field(default_factory=ZipConfig)
|
|
142
|
+
|
|
143
|
+
# [profile]
|
|
144
|
+
profile_name: str = "acm"
|
|
145
|
+
|
|
146
|
+
# resolved root directory (where .paperkit.toml lives)
|
|
147
|
+
root: Path = field(default_factory=lambda: Path.cwd())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
TEXLIVE_SCHEMES = (
|
|
151
|
+
"basic",
|
|
152
|
+
"context",
|
|
153
|
+
"full",
|
|
154
|
+
"gust",
|
|
155
|
+
"infraonly",
|
|
156
|
+
"medium",
|
|
157
|
+
"minimal",
|
|
158
|
+
"small",
|
|
159
|
+
"tetex",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Debian's texlive-* package names differ from TeX Live collection names.
|
|
163
|
+
_DEBIAN_COLLECTION_NAMES = {"science": "mathscience"}
|
|
164
|
+
|
|
165
|
+
# Names end up in a shell command and an install-tl profile, so keep them to
|
|
166
|
+
# the character set CTAN package names actually use.
|
|
167
|
+
_TL_NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
|
|
168
|
+
_SHA512_RE = re.compile(r"[0-9a-f]{128}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _strip_prefix(name: str, prefix: str) -> str:
|
|
172
|
+
return name[len(prefix) :] if name.startswith(prefix) else name
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _str_list(section: str, key: str, value: object) -> list[str]:
|
|
176
|
+
if not isinstance(value, list) or not all(isinstance(x, str) for x in value):
|
|
177
|
+
raise ValueError(f"[{section}] {key}: expected a list of strings")
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse_texlive(raw: dict[str, object]) -> TexliveConfig:
|
|
182
|
+
"""Build a TexliveConfig from the [texlive] table, rejecting bad values early."""
|
|
183
|
+
tl = TexliveConfig()
|
|
184
|
+
known = set(TexliveConfig.__dataclass_fields__)
|
|
185
|
+
unknown = sorted(set(raw) - known)
|
|
186
|
+
if unknown:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"[texlive] unknown key(s): {', '.join(unknown)} "
|
|
189
|
+
f"(expected: {', '.join(sorted(known))})",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
source = raw.get("source", tl.source)
|
|
193
|
+
if not isinstance(source, str) or source not in ("distro", "upstream"):
|
|
194
|
+
raise ValueError(f'[texlive] source: expected "distro" or "upstream", got {source!r}')
|
|
195
|
+
tl.source = source
|
|
196
|
+
|
|
197
|
+
if "snapshot" in raw and "year" in raw:
|
|
198
|
+
raise ValueError("[texlive] snapshot and year are mutually exclusive; set only one")
|
|
199
|
+
|
|
200
|
+
if "snapshot" in raw:
|
|
201
|
+
snapshot = raw["snapshot"]
|
|
202
|
+
# A bare TOML date (snapshot = 2026-09-18) parses as datetime.date.
|
|
203
|
+
if isinstance(snapshot, dt.date) and not isinstance(snapshot, dt.datetime):
|
|
204
|
+
snapshot = snapshot.isoformat()
|
|
205
|
+
if not isinstance(snapshot, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", snapshot):
|
|
206
|
+
raise ValueError(f'[texlive] snapshot: expected "YYYY-MM-DD", got {snapshot!r}')
|
|
207
|
+
try:
|
|
208
|
+
dt.date.fromisoformat(snapshot)
|
|
209
|
+
except ValueError:
|
|
210
|
+
raise ValueError(f"[texlive] snapshot: not a valid date: {snapshot!r}") from None
|
|
211
|
+
tl.snapshot = snapshot
|
|
212
|
+
|
|
213
|
+
if "year" in raw:
|
|
214
|
+
year = raw["year"]
|
|
215
|
+
if not isinstance(year, int) or isinstance(year, bool) or year < 2008:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"[texlive] year: expected a TeX Live release year >= 2008, got {year!r}",
|
|
218
|
+
)
|
|
219
|
+
tl.year = year
|
|
220
|
+
|
|
221
|
+
if "repository" in raw:
|
|
222
|
+
repo = raw["repository"]
|
|
223
|
+
if not isinstance(repo, str) or not re.fullmatch(r"(https?|ftp)://\S+", repo):
|
|
224
|
+
raise ValueError(
|
|
225
|
+
f"[texlive] repository: expected an http(s):// or ftp:// URL, got {repo!r}",
|
|
226
|
+
)
|
|
227
|
+
tl.repository = repo.rstrip("/")
|
|
228
|
+
|
|
229
|
+
if "tlpdb_sha512" in raw:
|
|
230
|
+
digest = raw["tlpdb_sha512"]
|
|
231
|
+
if not isinstance(digest, str) or not _SHA512_RE.fullmatch(digest.lower()):
|
|
232
|
+
raise ValueError("[texlive] tlpdb_sha512: expected 128 hex characters")
|
|
233
|
+
tl.tlpdb_sha512 = digest.lower()
|
|
234
|
+
|
|
235
|
+
if "scheme" in raw:
|
|
236
|
+
scheme = raw["scheme"]
|
|
237
|
+
name = _strip_prefix(scheme, "scheme-") if isinstance(scheme, str) else ""
|
|
238
|
+
if name not in TEXLIVE_SCHEMES:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"[texlive] scheme: unknown scheme {scheme!r} "
|
|
241
|
+
f"(expected one of: {', '.join(TEXLIVE_SCHEMES)})",
|
|
242
|
+
)
|
|
243
|
+
tl.scheme = name
|
|
244
|
+
|
|
245
|
+
for key in ("collections", "packages"):
|
|
246
|
+
if key not in raw:
|
|
247
|
+
continue
|
|
248
|
+
names = _str_list("texlive", key, raw[key])
|
|
249
|
+
if key == "collections":
|
|
250
|
+
names = [_strip_prefix(n, "collection-") for n in names]
|
|
251
|
+
for n in names:
|
|
252
|
+
hint = _DEBIAN_COLLECTION_NAMES.get(n) or (n.replace("-", "") if "-" in n else None)
|
|
253
|
+
if hint:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"[texlive] collections: {n!r} looks like a Debian package name; "
|
|
256
|
+
f"the TeX Live collection is {hint!r}",
|
|
257
|
+
)
|
|
258
|
+
bad = [n for n in names if not _TL_NAME_RE.fullmatch(n)]
|
|
259
|
+
if bad:
|
|
260
|
+
raise ValueError(f"[texlive] {key}: invalid name(s): {', '.join(map(repr, bad))}")
|
|
261
|
+
setattr(tl, key, names)
|
|
262
|
+
|
|
263
|
+
for key in ("docs", "sources"):
|
|
264
|
+
if key in raw:
|
|
265
|
+
if not isinstance(raw[key], bool):
|
|
266
|
+
raise ValueError(f"[texlive] {key}: expected true or false")
|
|
267
|
+
setattr(tl, key, raw[key])
|
|
268
|
+
|
|
269
|
+
return tl
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def parse_unused_bib(raw: object) -> UnusedBibConfig:
|
|
273
|
+
"""`unused_bib = true|false`, or a `[checks.unused_bib]` table."""
|
|
274
|
+
ub = UnusedBibConfig()
|
|
275
|
+
if isinstance(raw, bool):
|
|
276
|
+
ub.enabled = raw
|
|
277
|
+
return ub
|
|
278
|
+
if not isinstance(raw, dict):
|
|
279
|
+
raise ValueError("[checks] unused_bib: expected true, false or a table")
|
|
280
|
+
unknown = sorted(set(raw) - set(UnusedBibConfig.__dataclass_fields__))
|
|
281
|
+
if unknown:
|
|
282
|
+
raise ValueError(f"[checks.unused_bib] unknown key(s): {', '.join(unknown)}")
|
|
283
|
+
for key in ("enabled", "draft_pass"):
|
|
284
|
+
if key in raw:
|
|
285
|
+
if not isinstance(raw[key], bool):
|
|
286
|
+
raise ValueError(f"[checks.unused_bib] {key}: expected true or false")
|
|
287
|
+
setattr(ub, key, raw[key])
|
|
288
|
+
if "ignore" in raw:
|
|
289
|
+
ub.ignore = _str_list("checks.unused_bib", "ignore", raw["ignore"])
|
|
290
|
+
return ub
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def find_config(start: Path | None = None) -> Path:
|
|
294
|
+
"""Walk up from *start* to find .paperkit.toml."""
|
|
295
|
+
d = (start or Path.cwd()).resolve()
|
|
296
|
+
while True:
|
|
297
|
+
candidate = d / ".paperkit.toml"
|
|
298
|
+
if candidate.is_file():
|
|
299
|
+
return candidate
|
|
300
|
+
parent = d.parent
|
|
301
|
+
if parent == d:
|
|
302
|
+
raise FileNotFoundError("No .paperkit.toml found in any parent directory")
|
|
303
|
+
d = parent
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def load_config(path: Path | None = None) -> PaperkitConfig:
|
|
307
|
+
"""Load .paperkit.toml and return a PaperkitConfig."""
|
|
308
|
+
if path is None:
|
|
309
|
+
path = find_config()
|
|
310
|
+
raw = tomllib.loads(path.read_text())
|
|
311
|
+
|
|
312
|
+
cfg = PaperkitConfig(root=path.parent.resolve())
|
|
313
|
+
|
|
314
|
+
# [project]
|
|
315
|
+
proj = raw.get("project", {})
|
|
316
|
+
if "content_dirs" in proj:
|
|
317
|
+
cfg.content_dirs = proj["content_dirs"]
|
|
318
|
+
if "bib" in proj:
|
|
319
|
+
cfg.bib = proj["bib"]
|
|
320
|
+
|
|
321
|
+
# main / mains: accept either key, each may be a string or a list of strings,
|
|
322
|
+
# and entries may be globs that match against <root>/<pattern>.tex.
|
|
323
|
+
raw_patterns: list[str] = []
|
|
324
|
+
for key in ("main", "mains"):
|
|
325
|
+
if key not in proj:
|
|
326
|
+
continue
|
|
327
|
+
v = proj[key]
|
|
328
|
+
if isinstance(v, str):
|
|
329
|
+
raw_patterns.append(v)
|
|
330
|
+
elif isinstance(v, list) and all(isinstance(x, str) for x in v):
|
|
331
|
+
raw_patterns.extend(v)
|
|
332
|
+
else:
|
|
333
|
+
raise ValueError(f"[project] {key}: expected string or list of strings")
|
|
334
|
+
|
|
335
|
+
if not raw_patterns:
|
|
336
|
+
raw_patterns = ["main"]
|
|
337
|
+
|
|
338
|
+
expanded: list[str] = []
|
|
339
|
+
seen: set[str] = set()
|
|
340
|
+
for pat in raw_patterns:
|
|
341
|
+
if any(c in pat for c in "*?["):
|
|
342
|
+
matches = sorted(p.stem for p in cfg.root.glob(f"{pat}.tex"))
|
|
343
|
+
if not matches:
|
|
344
|
+
raise FileNotFoundError(
|
|
345
|
+
f"[project] '{pat}' matched no .tex files in {cfg.root}",
|
|
346
|
+
)
|
|
347
|
+
for m in matches:
|
|
348
|
+
if m not in seen:
|
|
349
|
+
expanded.append(m)
|
|
350
|
+
seen.add(m)
|
|
351
|
+
elif pat not in seen:
|
|
352
|
+
expanded.append(pat)
|
|
353
|
+
seen.add(pat)
|
|
354
|
+
|
|
355
|
+
cfg.mains = expanded
|
|
356
|
+
cfg.main = expanded[0]
|
|
357
|
+
|
|
358
|
+
# [build]
|
|
359
|
+
build = raw.get("build", {})
|
|
360
|
+
for key in ("engine", "passes", "shell_escape", "bibtex", "biber", "output_dir", "jobs"):
|
|
361
|
+
if key in build:
|
|
362
|
+
setattr(cfg, key, build[key])
|
|
363
|
+
if "source_date_epoch" in build:
|
|
364
|
+
cfg.source_date_epoch = reproducible.parse_setting(build["source_date_epoch"])
|
|
365
|
+
|
|
366
|
+
# [docker]
|
|
367
|
+
docker = raw.get("docker", {})
|
|
368
|
+
for key in ("base_image", "image_name", "extra_packages", "debconf_selections"):
|
|
369
|
+
if key in docker:
|
|
370
|
+
setattr(cfg, key, docker[key])
|
|
371
|
+
|
|
372
|
+
# [texlive]
|
|
373
|
+
cfg.texlive = parse_texlive(raw.get("texlive", {}))
|
|
374
|
+
|
|
375
|
+
# [checks]
|
|
376
|
+
checks_raw = raw.get("checks", {})
|
|
377
|
+
checks = ChecksConfig()
|
|
378
|
+
check_keys = (
|
|
379
|
+
"non_ascii",
|
|
380
|
+
"trailing_whitespace",
|
|
381
|
+
"final_newline",
|
|
382
|
+
"font_embedding",
|
|
383
|
+
"no_type3",
|
|
384
|
+
"overfull_boxes",
|
|
385
|
+
)
|
|
386
|
+
for key in check_keys:
|
|
387
|
+
if key in checks_raw:
|
|
388
|
+
setattr(checks, key, checks_raw[key])
|
|
389
|
+
checks.unused_bib = parse_unused_bib(checks_raw.get("unused_bib", True))
|
|
390
|
+
bt = checks_raw.get("bibtex_tidy", {})
|
|
391
|
+
if isinstance(bt, dict):
|
|
392
|
+
if "enabled" in bt:
|
|
393
|
+
checks.bibtex_tidy.enabled = bt["enabled"]
|
|
394
|
+
if "version" in bt:
|
|
395
|
+
checks.bibtex_tidy.version = bt["version"]
|
|
396
|
+
if "sort" in bt:
|
|
397
|
+
if not isinstance(bt["sort"], bool):
|
|
398
|
+
raise ValueError("[checks] bibtex_tidy.sort: expected true or false")
|
|
399
|
+
checks.bibtex_tidy.sort = bt["sort"]
|
|
400
|
+
cfg.checks = checks
|
|
401
|
+
|
|
402
|
+
# [page_limits]
|
|
403
|
+
pl_raw = raw.get("page_limits", {})
|
|
404
|
+
pl = PageLimitsConfig()
|
|
405
|
+
for key in ("total", "before_refs", "before_ack", "refs_pattern", "ack_pattern"):
|
|
406
|
+
if key in pl_raw:
|
|
407
|
+
setattr(pl, key, pl_raw[key])
|
|
408
|
+
if "ack_counts_as_content" in pl_raw:
|
|
409
|
+
value = pl_raw["ack_counts_as_content"]
|
|
410
|
+
if not isinstance(value, bool):
|
|
411
|
+
raise ValueError("[page_limits] ack_counts_as_content: expected true or false")
|
|
412
|
+
pl.ack_counts_as_content = value
|
|
413
|
+
cfg.page_limits = pl
|
|
414
|
+
|
|
415
|
+
# [zip]
|
|
416
|
+
zip_raw = raw.get("zip", {})
|
|
417
|
+
z = ZipConfig()
|
|
418
|
+
if "name" in zip_raw:
|
|
419
|
+
z.name = zip_raw["name"]
|
|
420
|
+
else:
|
|
421
|
+
z.name = cfg.main
|
|
422
|
+
if "include" in zip_raw:
|
|
423
|
+
z.include = zip_raw["include"]
|
|
424
|
+
if "exclude" in zip_raw:
|
|
425
|
+
z.exclude = zip_raw["exclude"]
|
|
426
|
+
if "include_bbl" in zip_raw:
|
|
427
|
+
if not isinstance(zip_raw["include_bbl"], bool):
|
|
428
|
+
raise ValueError("[zip] include_bbl: expected true or false")
|
|
429
|
+
z.include_bbl = zip_raw["include_bbl"]
|
|
430
|
+
cfg.zip = z
|
|
431
|
+
|
|
432
|
+
# [profile]
|
|
433
|
+
profile = raw.get("profile", {})
|
|
434
|
+
if "name" in profile:
|
|
435
|
+
cfg.profile_name = profile["name"]
|
|
436
|
+
|
|
437
|
+
return cfg
|
paperkit/docker.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Docker image building and container execution via pythainer."""
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pythainer.builders import UbuntuDockerBuilder
|
|
17
|
+
from pythainer.runners import ConcreteDockerRunner
|
|
18
|
+
|
|
19
|
+
from paperkit import __version__, texlive
|
|
20
|
+
from paperkit.config import PaperkitConfig
|
|
21
|
+
|
|
22
|
+
# Lines of captured build output shown when a quiet image build fails.
|
|
23
|
+
_FAILURE_TAIL_LINES = 30
|
|
24
|
+
_FAILURE_LINE_WIDTH = 200
|
|
25
|
+
|
|
26
|
+
CONTAINER_WORKDIR = Path("/paper")
|
|
27
|
+
CONTAINER_PAPERKIT = Path("/opt/paperkit")
|
|
28
|
+
|
|
29
|
+
# Distro TeX packages (for [texlive] source = "distro"), always installed
|
|
30
|
+
# together so that switching engine (pdflatex <-> lualatex) or bibliography
|
|
31
|
+
# backend (bibtex <-> biber) does not require a Docker image rebuild.
|
|
32
|
+
_DISTRO_TEX_PACKAGES = [
|
|
33
|
+
# TeX Live - all engines and common collections
|
|
34
|
+
"texlive-latex-recommended",
|
|
35
|
+
"texlive-latex-extra",
|
|
36
|
+
"texlive-luatex",
|
|
37
|
+
"texlive-xetex",
|
|
38
|
+
"texlive-fonts-recommended",
|
|
39
|
+
"texlive-fonts-extra",
|
|
40
|
+
"texlive-bibtex-extra",
|
|
41
|
+
"texlive-science",
|
|
42
|
+
"texlive-pictures",
|
|
43
|
+
"texlive-publishers",
|
|
44
|
+
"texlive-plain-generic",
|
|
45
|
+
"texlive-extra-utils",
|
|
46
|
+
# Bibliography
|
|
47
|
+
"biber",
|
|
48
|
+
# minted's highlighter
|
|
49
|
+
"python3-pygments",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# Tools paperkit itself needs, whatever the TeX Live source.
|
|
53
|
+
_TOOL_PACKAGES = [
|
|
54
|
+
"python3-pip",
|
|
55
|
+
"poppler-utils",
|
|
56
|
+
"npm",
|
|
57
|
+
"unzip",
|
|
58
|
+
"pandoc", # used by text_export to strip LaTeX from \title and \begin{abstract}
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# paperkit source root: two levels up from this file (src/paperkit/ -> src/ -> repo root)
|
|
62
|
+
_PAPERKIT_SRC_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
63
|
+
# What `pip install` needs from a source checkout (README and LICENSE are
|
|
64
|
+
# referenced by pyproject.toml). Copying only these keeps .git, .venv and
|
|
65
|
+
# working files out of the image and its layer cache.
|
|
66
|
+
_PAPERKIT_SRC_FILES = ("pyproject.toml", "README.md", "LICENSE", "src")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_source_checkout() -> bool:
|
|
70
|
+
"""True when paperkit runs from a source tree rather than an installed wheel."""
|
|
71
|
+
return all((_PAPERKIT_SRC_ROOT / name).exists() for name in _PAPERKIT_SRC_FILES)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@contextlib.contextmanager
|
|
75
|
+
def _quiet_stdout_stderr(verbose: bool) -> Generator[None]:
|
|
76
|
+
"""Suppress both stdout and stderr unless verbose.
|
|
77
|
+
|
|
78
|
+
pythainer prints [SHELL] lines to stdout; Docker BuildKit progress goes
|
|
79
|
+
to stderr via inherited file descriptors in subprocess.check_call.
|
|
80
|
+
"""
|
|
81
|
+
if verbose:
|
|
82
|
+
yield
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Capture Python-level stdout (pythainer's print() calls)
|
|
86
|
+
old_stdout = sys.stdout
|
|
87
|
+
sys.stdout = io.StringIO()
|
|
88
|
+
|
|
89
|
+
# Redirect OS-level stderr fd (Docker BuildKit output) to a temp file, so
|
|
90
|
+
# the tail can be shown if the build fails.
|
|
91
|
+
saved_stderr_fd = os.dup(2)
|
|
92
|
+
with tempfile.TemporaryFile() as captured:
|
|
93
|
+
os.dup2(captured.fileno(), 2)
|
|
94
|
+
try:
|
|
95
|
+
yield
|
|
96
|
+
except BaseException:
|
|
97
|
+
os.dup2(saved_stderr_fd, 2)
|
|
98
|
+
captured.seek(0)
|
|
99
|
+
tail = captured.read().decode(errors="replace").splitlines()[-_FAILURE_TAIL_LINES:]
|
|
100
|
+
# BuildKit echoes whole RUN commands; cut those so the error stays readable.
|
|
101
|
+
print("\n".join(line[:_FAILURE_LINE_WIDTH] for line in tail), file=sys.stderr)
|
|
102
|
+
raise
|
|
103
|
+
finally:
|
|
104
|
+
sys.stdout = old_stdout
|
|
105
|
+
os.dup2(saved_stderr_fd, 2)
|
|
106
|
+
os.close(saved_stderr_fd)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextlib.contextmanager
|
|
110
|
+
def _quiet_stdout(verbose: bool) -> Generator[None]:
|
|
111
|
+
"""Suppress only stdout (pythainer's [SHELL] lines) unless verbose."""
|
|
112
|
+
if verbose:
|
|
113
|
+
yield
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
old_stdout = sys.stdout
|
|
117
|
+
sys.stdout = io.StringIO()
|
|
118
|
+
try:
|
|
119
|
+
yield
|
|
120
|
+
finally:
|
|
121
|
+
sys.stdout = old_stdout
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_image(config: PaperkitConfig, verbose: bool = False) -> str:
|
|
125
|
+
"""Build the Docker image and return its tag."""
|
|
126
|
+
tl = config.texlive
|
|
127
|
+
if tl.source == "upstream" and not texlive.is_pinned(tl):
|
|
128
|
+
print(
|
|
129
|
+
'warning: [texlive] source = "upstream" without snapshot, year or repository '
|
|
130
|
+
f"installs from the live CTAN repository ({texlive.LIVE_REPOSITORY}); "
|
|
131
|
+
'rebuilds will pick up newer packages. Set snapshot = "YYYY-MM-DD" to pin it.',
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
builder = make_builder(config)
|
|
136
|
+
|
|
137
|
+
# Suppress both stdout ([SHELL] lines) and stderr (BuildKit progress)
|
|
138
|
+
try:
|
|
139
|
+
with _quiet_stdout_stderr(verbose):
|
|
140
|
+
builder.build()
|
|
141
|
+
except subprocess.CalledProcessError:
|
|
142
|
+
print(
|
|
143
|
+
f"Docker image build failed ({config.image_name}); see output above.",
|
|
144
|
+
file=sys.stderr,
|
|
145
|
+
)
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
return config.image_name
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def make_builder(config: PaperkitConfig) -> UbuntuDockerBuilder:
|
|
151
|
+
"""Assemble the image definition (Dockerfile + build context) for *config*."""
|
|
152
|
+
from paperkit.profiles import get_profile
|
|
153
|
+
|
|
154
|
+
profile = get_profile(config)
|
|
155
|
+
tl = config.texlive
|
|
156
|
+
|
|
157
|
+
builder = UbuntuDockerBuilder(
|
|
158
|
+
tag=config.image_name,
|
|
159
|
+
ubuntu_base_tag=config.base_image,
|
|
160
|
+
)
|
|
161
|
+
builder.space()
|
|
162
|
+
|
|
163
|
+
# Run apt-get / dpkg without interactive prompts. Without this, packages
|
|
164
|
+
# whose postinst scripts call debconf (e.g. ttf-mscorefonts-installer)
|
|
165
|
+
# try the Dialog/Readline/Teletype frontends and fail in BuildKit because
|
|
166
|
+
# there's no TTY.
|
|
167
|
+
builder.env("DEBIAN_FRONTEND", "noninteractive")
|
|
168
|
+
builder.space()
|
|
169
|
+
|
|
170
|
+
if tl.source == "upstream":
|
|
171
|
+
# Prerequisites and TeX Live get their own layers ahead of the tools,
|
|
172
|
+
# so the (large) TeX Live layer only rebuilds when [texlive] changes.
|
|
173
|
+
builder.desc("Install prerequisites for upstream TeX Live")
|
|
174
|
+
builder.add_packages(texlive.APT_PREREQS)
|
|
175
|
+
builder.space()
|
|
176
|
+
|
|
177
|
+
builder.desc(f"Install TeX Live ({texlive.describe(tl)})")
|
|
178
|
+
builder.run_multiple(texlive.install_commands(tl))
|
|
179
|
+
builder.env("PATH", f"{texlive.BIN_DIR}:${{PATH}}")
|
|
180
|
+
builder.space()
|
|
181
|
+
|
|
182
|
+
builder.desc("Install tools and profile packages")
|
|
183
|
+
builder.add_packages(_TOOL_PACKAGES + profile.docker_packages())
|
|
184
|
+
builder.space()
|
|
185
|
+
else:
|
|
186
|
+
# Base + profile packages in one layer so the cache is shared across
|
|
187
|
+
# all paperkit projects regardless of their per-project extra_packages.
|
|
188
|
+
builder.desc("Install TeX Live and dependencies")
|
|
189
|
+
builder.add_packages(_DISTRO_TEX_PACKAGES + _TOOL_PACKAGES + profile.docker_packages())
|
|
190
|
+
builder.space()
|
|
191
|
+
|
|
192
|
+
# Pre-seed debconf answers (e.g. EULA acceptance) before installing
|
|
193
|
+
# project extras. Must run before add_packages so the postinst scripts
|
|
194
|
+
# see the preseeded values.
|
|
195
|
+
if config.debconf_selections:
|
|
196
|
+
builder.desc("Pre-seed debconf selections")
|
|
197
|
+
joined = " && ".join(
|
|
198
|
+
f"echo {shlex.quote(s)} | debconf-set-selections" for s in config.debconf_selections
|
|
199
|
+
)
|
|
200
|
+
builder.run(joined)
|
|
201
|
+
builder.space()
|
|
202
|
+
|
|
203
|
+
# Project-specific extras in a separate layer: only this layer is rebuilt
|
|
204
|
+
# when extra_packages changes between projects.
|
|
205
|
+
if config.extra_packages:
|
|
206
|
+
builder.desc("Install project-specific extra packages")
|
|
207
|
+
builder.add_packages(config.extra_packages)
|
|
208
|
+
builder.space()
|
|
209
|
+
|
|
210
|
+
# bibtex-tidy
|
|
211
|
+
if config.checks.bibtex_tidy.enabled:
|
|
212
|
+
version = config.checks.bibtex_tidy.version
|
|
213
|
+
builder.desc("Install bibtex-tidy for bib file linting")
|
|
214
|
+
builder.run(f"npm install -g bibtex-tidy@{version}")
|
|
215
|
+
builder.space()
|
|
216
|
+
|
|
217
|
+
# Copy and install paperkit into the image
|
|
218
|
+
builder.desc("Install paperkit CLI")
|
|
219
|
+
if _is_source_checkout():
|
|
220
|
+
for name in _PAPERKIT_SRC_FILES:
|
|
221
|
+
builder.copy(source=_PAPERKIT_SRC_ROOT / name, destination=CONTAINER_PAPERKIT / name)
|
|
222
|
+
builder.run(f"pip install --break-system-packages {CONTAINER_PAPERKIT}")
|
|
223
|
+
else:
|
|
224
|
+
# Installed from a package index: install the same version in the image.
|
|
225
|
+
builder.run(f"pip install --break-system-packages paperkit=={__version__}")
|
|
226
|
+
builder.space()
|
|
227
|
+
|
|
228
|
+
builder.desc("Set working directory")
|
|
229
|
+
builder.workdir(CONTAINER_WORKDIR)
|
|
230
|
+
return builder
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_in_docker(
|
|
234
|
+
config: PaperkitConfig,
|
|
235
|
+
command: str,
|
|
236
|
+
interactive: bool = False,
|
|
237
|
+
verbose: bool = False,
|
|
238
|
+
env: dict[str, str] | None = None,
|
|
239
|
+
) -> int:
|
|
240
|
+
"""Run a command inside the Docker container. Returns exit code.
|
|
241
|
+
|
|
242
|
+
*env* is passed with `docker run --env`. The build timestamp travels this
|
|
243
|
+
way: the image has no git, so the host resolves it and the inner paperkit
|
|
244
|
+
picks it up from the environment.
|
|
245
|
+
"""
|
|
246
|
+
runner = ConcreteDockerRunner(
|
|
247
|
+
image=config.image_name,
|
|
248
|
+
name=f"{config.image_name}-run",
|
|
249
|
+
volumes={
|
|
250
|
+
str(config.root): str(CONTAINER_WORKDIR),
|
|
251
|
+
},
|
|
252
|
+
environment_variables=env or {},
|
|
253
|
+
workdir=CONTAINER_WORKDIR,
|
|
254
|
+
tty=True,
|
|
255
|
+
interactive=interactive,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Only suppress stdout ([SHELL] lines); keep stderr so the container's
|
|
259
|
+
# actual output (build progress, checks) is visible to the user.
|
|
260
|
+
try:
|
|
261
|
+
with _quiet_stdout(verbose):
|
|
262
|
+
runner.run(commands=[command])
|
|
263
|
+
except subprocess.CalledProcessError as e:
|
|
264
|
+
return e.returncode
|
|
265
|
+
return 0
|