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/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