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/__init__.py
ADDED
paperkit/archive.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Submission zip creation."""
|
|
5
|
+
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import fnmatch
|
|
8
|
+
import zipfile
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from paperkit import reproducible
|
|
13
|
+
from paperkit.config import PaperkitConfig
|
|
14
|
+
|
|
15
|
+
# Zip stores DOS timestamps, which cannot predate 1980.
|
|
16
|
+
_DOS_EPOCH = dt.datetime(1980, 1, 1, tzinfo=dt.UTC)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _expand_pattern(root: Path, pattern: str) -> list[Path]:
|
|
20
|
+
"""Expand a glob-style include pattern relative to root.
|
|
21
|
+
|
|
22
|
+
Supports {main} placeholder for the main .tex filename.
|
|
23
|
+
"""
|
|
24
|
+
# Trailing / means include directory recursively
|
|
25
|
+
if pattern.endswith("/"):
|
|
26
|
+
d = root / pattern.rstrip("/")
|
|
27
|
+
if d.is_dir():
|
|
28
|
+
return sorted(d.rglob("*"))
|
|
29
|
+
return []
|
|
30
|
+
return sorted(root.glob(pattern))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_excluded(path: Path, excludes: list[str]) -> bool:
|
|
34
|
+
"""Check if a path matches any exclude pattern."""
|
|
35
|
+
name = path.name
|
|
36
|
+
return any(fnmatch.fnmatch(name, pat) for pat in excludes)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ZipResult:
|
|
41
|
+
"""The archive that was written, and anything the caller should be told."""
|
|
42
|
+
|
|
43
|
+
path: Path
|
|
44
|
+
warnings: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MissingBblError(Exception):
|
|
48
|
+
"""A requested compiled bibliography is not in the build directory."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bbl_entries(config: PaperkitConfig, files: set[Path]) -> tuple[dict[str, Path], list[str]]:
|
|
52
|
+
"""Compiled bibliographies to add, keyed by their path inside the archive.
|
|
53
|
+
|
|
54
|
+
One per main whose .tex the archive already carries, taken from the build
|
|
55
|
+
directory and stored next to that .tex, since a publisher compiling the
|
|
56
|
+
archive alone looks for <main>.bbl there.
|
|
57
|
+
"""
|
|
58
|
+
root = config.root
|
|
59
|
+
entries: dict[str, Path] = {}
|
|
60
|
+
warnings: list[str] = []
|
|
61
|
+
missing: list[str] = []
|
|
62
|
+
|
|
63
|
+
for main in config.mains or [config.main]:
|
|
64
|
+
tex = root / f"{main}.tex"
|
|
65
|
+
if tex not in files:
|
|
66
|
+
continue # this main is not part of the archive
|
|
67
|
+
bbl = root / config.output_dir / main / f"{main}.bbl"
|
|
68
|
+
if not bbl.is_file():
|
|
69
|
+
missing.append(f"{bbl.relative_to(root)}")
|
|
70
|
+
continue
|
|
71
|
+
entries[f"{main}.bbl"] = bbl
|
|
72
|
+
warnings += _staleness_warnings(config, main, bbl, tex)
|
|
73
|
+
|
|
74
|
+
if missing:
|
|
75
|
+
raise MissingBblError(
|
|
76
|
+
"compiled bibliography not found: "
|
|
77
|
+
+ ", ".join(missing)
|
|
78
|
+
+ "; run `paperkit build` first",
|
|
79
|
+
)
|
|
80
|
+
return entries, warnings
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _staleness_warnings(config: PaperkitConfig, main: str, bbl: Path, tex: Path) -> list[str]:
|
|
84
|
+
"""Warn when the .bbl predates the sources it was compiled from."""
|
|
85
|
+
warnings = []
|
|
86
|
+
bbl_mtime = bbl.stat().st_mtime
|
|
87
|
+
sources = [tex]
|
|
88
|
+
if config.bib:
|
|
89
|
+
sources.append(config.root / config.bib)
|
|
90
|
+
for source in sources:
|
|
91
|
+
if source.is_file() and source.stat().st_mtime > bbl_mtime:
|
|
92
|
+
name = source.relative_to(config.root)
|
|
93
|
+
warnings.append(
|
|
94
|
+
f"{main}.bbl is older than {name}: the archive may not match the "
|
|
95
|
+
f"sources, rebuild with `paperkit build`",
|
|
96
|
+
)
|
|
97
|
+
return warnings
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_zip(config: PaperkitConfig, include_bbl: bool | None = None) -> ZipResult:
|
|
101
|
+
"""Create the submission zip archive.
|
|
102
|
+
|
|
103
|
+
*include_bbl* overrides `[zip] include_bbl` for this run.
|
|
104
|
+
"""
|
|
105
|
+
root = config.root
|
|
106
|
+
zip_name = config.zip.name or config.main
|
|
107
|
+
zip_path = root / f"{zip_name}-sources.zip"
|
|
108
|
+
|
|
109
|
+
# Resolve {main} in patterns: expands once per main, so a single
|
|
110
|
+
# "{main}.tex" entry in the zip include list pulls in every project's .tex.
|
|
111
|
+
mains = config.mains or [config.main]
|
|
112
|
+
includes: list[str] = []
|
|
113
|
+
for include in config.zip.include:
|
|
114
|
+
if "{main}" in include:
|
|
115
|
+
includes.extend(include.replace("{main}", m) for m in mains)
|
|
116
|
+
else:
|
|
117
|
+
includes.append(include)
|
|
118
|
+
|
|
119
|
+
# Collect files
|
|
120
|
+
files: set[Path] = set()
|
|
121
|
+
for pattern in includes:
|
|
122
|
+
for p in _expand_pattern(root, pattern):
|
|
123
|
+
if p.is_file() and not _is_excluded(p, config.zip.exclude):
|
|
124
|
+
files.add(p)
|
|
125
|
+
|
|
126
|
+
# Resolved before the old archive is removed, so a missing .bbl leaves it
|
|
127
|
+
# in place. The exclude patterns do not apply: these are asked for by name.
|
|
128
|
+
wanted = config.zip.include_bbl if include_bbl is None else include_bbl
|
|
129
|
+
bbl_entries, warnings = _bbl_entries(config, files) if wanted else ({}, [])
|
|
130
|
+
|
|
131
|
+
# Entry metadata is normalized to the build timestamp, so the same sources
|
|
132
|
+
# archive to the same bytes on another machine or a fresh clone, where the
|
|
133
|
+
# files' own mtimes differ.
|
|
134
|
+
source_date = reproducible.resolve(config.source_date_epoch, root)
|
|
135
|
+
entries = {str(f.relative_to(root)): f for f in files}
|
|
136
|
+
entries.update(bbl_entries)
|
|
137
|
+
|
|
138
|
+
zip_path.unlink(missing_ok=True)
|
|
139
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
140
|
+
for arcname in sorted(entries):
|
|
141
|
+
zf.writestr(
|
|
142
|
+
_entry_info(arcname, entries[arcname], source_date), entries[arcname].read_bytes()
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return ZipResult(zip_path, warnings)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _entry_info(
|
|
149
|
+
arcname: str, source: Path, source_date: reproducible.SourceDate
|
|
150
|
+
) -> zipfile.ZipInfo:
|
|
151
|
+
"""Zip entry whose metadata depends on the sources, not on this machine.
|
|
152
|
+
|
|
153
|
+
The timestamp is the build timestamp rather than the file's mtime (git
|
|
154
|
+
does not restore mtimes, so a fresh clone would archive differently), and
|
|
155
|
+
the mode keeps only the executable bit. The zip format stores DOS times,
|
|
156
|
+
which start in 1980 and have two-second resolution.
|
|
157
|
+
"""
|
|
158
|
+
stamp = max(dt.datetime.fromtimestamp(source_date.epoch, dt.UTC), _DOS_EPOCH)
|
|
159
|
+
info = zipfile.ZipInfo(arcname, date_time=stamp.timetuple()[:6])
|
|
160
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
161
|
+
info.create_system = 3 # Unix, whatever the host
|
|
162
|
+
executable = source.stat().st_mode & 0o111
|
|
163
|
+
info.external_attr = (0o755 if executable else 0o644) << 16
|
|
164
|
+
return info
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def print_zip_summary(zip_path: Path) -> None:
|
|
168
|
+
"""Print a summary of the created zip."""
|
|
169
|
+
size_mb = zip_path.stat().st_size / (1024 * 1024)
|
|
170
|
+
print(f"==> Created: {zip_path}")
|
|
171
|
+
print(f" {size_mb:.1f} MiB")
|
|
172
|
+
|
|
173
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
174
|
+
names = zf.namelist()
|
|
175
|
+
|
|
176
|
+
print("")
|
|
177
|
+
print("Contents:")
|
|
178
|
+
for name in names[:40]:
|
|
179
|
+
print(f" {name}")
|
|
180
|
+
if len(names) > 40:
|
|
181
|
+
print(f" ... ({len(names)} files total)")
|
|
182
|
+
else:
|
|
183
|
+
print(f" ({len(names)} files total)")
|
paperkit/bibliography.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Project-wide bibliography checks: unused entries per bib file.
|
|
5
|
+
|
|
6
|
+
Several build targets may share a bib file, so an entry is unused only if no
|
|
7
|
+
target that uses the file cites it. Which bib files a target uses and which
|
|
8
|
+
keys it cites come from its LaTeX output, never from scanning sources:
|
|
9
|
+
|
|
10
|
+
* bibtex: `\\bibdata{a,b}` and `\\citation{k1,k2}` in the .aux (and the
|
|
11
|
+
.aux files it pulls in with `\\@input`); `\\citation{*}` is
|
|
12
|
+
`\\nocite{*}`, which uses every entry.
|
|
13
|
+
* biber: the .bcf `<bcf:datasource>` and `<bcf:citekey>` elements.
|
|
14
|
+
|
|
15
|
+
Targets not built or checked in this run are resolved from their existing
|
|
16
|
+
.aux if it is up to date, else from a draft LaTeX pass into a scratch
|
|
17
|
+
directory. If neither works, the target is "not checked" and no entry it
|
|
18
|
+
might cite is reported as unused.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import tempfile
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from paperkit.builder import BuildResult, run_latex
|
|
27
|
+
from paperkit.checks import CheckResult, CheckStatus, check_bibtex_tidy
|
|
28
|
+
from paperkit.config import PaperkitConfig
|
|
29
|
+
|
|
30
|
+
_DIM = "\033[2m"
|
|
31
|
+
_RESET = "\033[0m"
|
|
32
|
+
|
|
33
|
+
_CITATION_RE = re.compile(r"\\citation\{([^}]*)\}")
|
|
34
|
+
_ABX_CITE_RE = re.compile(r"\\abx@aux@cite\{[^}]*\}\{([^}]+)\}")
|
|
35
|
+
_BIBDATA_RE = re.compile(r"\\bibdata\{([^}]*)\}")
|
|
36
|
+
_INPUT_RE = re.compile(r"\\@input\{([^}]*)\}")
|
|
37
|
+
_BCF_DATASOURCE_RE = re.compile(r"<bcf:datasource\b[^>]*>([^<]+)</bcf:datasource>")
|
|
38
|
+
_BCF_CITEKEY_RE = re.compile(r"<bcf:citekey\b[^>]*>([^<]+)</bcf:citekey>")
|
|
39
|
+
_BIB_ENTRY_RE = re.compile(r"^\s*@(\w+)\s*\{\s*([^,\s{}]+)", re.MULTILINE)
|
|
40
|
+
# Bib "entries" that are not citable references.
|
|
41
|
+
_NON_ENTRY_TYPES = {"string", "preamble", "comment"}
|
|
42
|
+
|
|
43
|
+
# Only these files can change what a target cites, so they decide whether an
|
|
44
|
+
# existing .aux is still up to date.
|
|
45
|
+
_SOURCE_SUFFIXES = {".tex", ".sty", ".cls", ".bib", ".bst", ".bbx", ".cbx", ".def", ".cfg"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class AuxData:
|
|
50
|
+
"""What a target's LaTeX run recorded about its bibliography."""
|
|
51
|
+
|
|
52
|
+
bibdata: list[str] = field(default_factory=list) # bib names as written
|
|
53
|
+
citations: set[str] = field(default_factory=set)
|
|
54
|
+
cite_all: bool = False # \nocite{*}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class TargetUsage:
|
|
59
|
+
"""Bib files used and keys cited by one build target."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
bib_files: set[Path]
|
|
63
|
+
citations: set[str]
|
|
64
|
+
cite_all: bool
|
|
65
|
+
origin: str # "this run", "existing .aux", "draft pass"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_aux(aux_path: Path) -> AuxData:
|
|
69
|
+
"""Parse an .aux file and the .aux files it includes with \\@input."""
|
|
70
|
+
return _parse_aux(aux_path.resolve(), aux_path.resolve().parent, set())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_aux(aux_path: Path, base: Path, seen: set[Path]) -> AuxData:
|
|
74
|
+
data = AuxData()
|
|
75
|
+
if aux_path in seen or not aux_path.is_file():
|
|
76
|
+
return data
|
|
77
|
+
seen.add(aux_path)
|
|
78
|
+
|
|
79
|
+
text = aux_path.read_text(errors="replace")
|
|
80
|
+
for m in _BIBDATA_RE.finditer(text):
|
|
81
|
+
data.bibdata += [b.strip() for b in m.group(1).split(",") if b.strip()]
|
|
82
|
+
keys = [k for m in _CITATION_RE.finditer(text) for k in m.group(1).split(",")]
|
|
83
|
+
keys += [m.group(1) for m in _ABX_CITE_RE.finditer(text)]
|
|
84
|
+
_add_keys(data, keys)
|
|
85
|
+
|
|
86
|
+
for m in _INPUT_RE.finditer(text):
|
|
87
|
+
# Every \@input path, however deeply nested, is relative to the output
|
|
88
|
+
# directory holding the main .aux.
|
|
89
|
+
nested = _parse_aux((base / m.group(1).strip()).resolve(), base, seen)
|
|
90
|
+
data.bibdata += [b for b in nested.bibdata if b not in data.bibdata]
|
|
91
|
+
data.citations |= nested.citations
|
|
92
|
+
data.cite_all = data.cite_all or nested.cite_all
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_bcf(bcf_path: Path) -> AuxData:
|
|
97
|
+
"""Parse biblatex's control file (.bcf), which biber reads."""
|
|
98
|
+
data = AuxData()
|
|
99
|
+
if not bcf_path.is_file():
|
|
100
|
+
return data
|
|
101
|
+
text = bcf_path.read_text(errors="replace")
|
|
102
|
+
data.bibdata = [m.group(1).strip() for m in _BCF_DATASOURCE_RE.finditer(text)]
|
|
103
|
+
_add_keys(data, [m.group(1) for m in _BCF_CITEKEY_RE.finditer(text)])
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _add_keys(data: AuxData, keys: list[str]) -> None:
|
|
108
|
+
for key in (k.strip() for k in keys):
|
|
109
|
+
if key == "*":
|
|
110
|
+
data.cite_all = True
|
|
111
|
+
elif key:
|
|
112
|
+
data.citations.add(key)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_bib_file(root: Path, name: str) -> Path | None:
|
|
116
|
+
"""Project bib file for a \\bibdata / datasource name, or None.
|
|
117
|
+
|
|
118
|
+
Names resolve against the project root (where paperkit points bibtex and
|
|
119
|
+
biber). Files found only in the TeX tree (e.g. IEEEabrv) are not the
|
|
120
|
+
project's and are left out.
|
|
121
|
+
"""
|
|
122
|
+
path = root / name
|
|
123
|
+
if path.suffix != ".bib":
|
|
124
|
+
path = path.with_name(path.name + ".bib")
|
|
125
|
+
return path.resolve() if path.is_file() else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def usage_from_dir(
|
|
129
|
+
config: PaperkitConfig,
|
|
130
|
+
name: str,
|
|
131
|
+
directory: Path,
|
|
132
|
+
origin: str,
|
|
133
|
+
) -> TargetUsage | None:
|
|
134
|
+
"""Usage recorded in *directory*'s <name>.aux (and .bcf with biber)."""
|
|
135
|
+
aux = directory / f"{name}.aux"
|
|
136
|
+
if not aux.is_file():
|
|
137
|
+
return None
|
|
138
|
+
data = parse_aux(aux)
|
|
139
|
+
if config.biber:
|
|
140
|
+
bcf = parse_bcf(directory / f"{name}.bcf")
|
|
141
|
+
data.bibdata += bcf.bibdata
|
|
142
|
+
data.citations |= bcf.citations
|
|
143
|
+
data.cite_all = data.cite_all or bcf.cite_all
|
|
144
|
+
bib_files = {p for b in data.bibdata if (p := resolve_bib_file(config.root, b))}
|
|
145
|
+
return TargetUsage(name, bib_files, data.citations, data.cite_all, origin)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _newest_source_mtime(config: PaperkitConfig, name: str, usage: TargetUsage) -> float:
|
|
149
|
+
"""Newest modification time of anything that can change *name*'s citations."""
|
|
150
|
+
out_dir = (config.root / config.output_dir).resolve()
|
|
151
|
+
paths = [config.root / f"{name}.tex", *usage.bib_files]
|
|
152
|
+
if config.bib:
|
|
153
|
+
paths.append(config.root / config.bib)
|
|
154
|
+
for d in config.content_dirs:
|
|
155
|
+
for p in (config.root / d).rglob("*"):
|
|
156
|
+
if p.suffix in _SOURCE_SUFFIXES and out_dir not in p.resolve().parents:
|
|
157
|
+
paths.append(p)
|
|
158
|
+
return max((p.stat().st_mtime for p in paths if p.is_file()), default=0.0)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def is_up_to_date(config: PaperkitConfig, usage: TargetUsage, aux: Path) -> bool:
|
|
162
|
+
return aux.stat().st_mtime > _newest_source_mtime(config, usage.name, usage)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def draft_usage(config: PaperkitConfig, name: str) -> TargetUsage | None:
|
|
166
|
+
"""Citations of *name* from one draft LaTeX pass into a scratch directory.
|
|
167
|
+
|
|
168
|
+
No PDF is written (shell-escape tools such as minted still run), and the
|
|
169
|
+
real output directory is not touched. None if the pass fails.
|
|
170
|
+
"""
|
|
171
|
+
flag = "-no-pdf" if config.engine == "xelatex" else "-draftmode"
|
|
172
|
+
with tempfile.TemporaryDirectory(prefix=f"paperkit-draft-{name}-") as tmp:
|
|
173
|
+
rc = run_latex(
|
|
174
|
+
config.engine,
|
|
175
|
+
config.root / f"{name}.tex",
|
|
176
|
+
Path(tmp),
|
|
177
|
+
shell_escape=config.shell_escape,
|
|
178
|
+
extra_args=[flag],
|
|
179
|
+
)
|
|
180
|
+
if rc != 0:
|
|
181
|
+
return None
|
|
182
|
+
return usage_from_dir(config, name, Path(tmp), "draft pass")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resolve_other_targets(
|
|
186
|
+
config: PaperkitConfig,
|
|
187
|
+
known: dict[str, TargetUsage],
|
|
188
|
+
skip: set[str],
|
|
189
|
+
) -> tuple[dict[str, TargetUsage], list[str]]:
|
|
190
|
+
"""Usage of every main not in *known*: (resolved usages, names not checked).
|
|
191
|
+
|
|
192
|
+
Mains in *skip* (e.g. a failed build in this run) are not checked.
|
|
193
|
+
"""
|
|
194
|
+
usages = dict(known)
|
|
195
|
+
unchecked: list[str] = []
|
|
196
|
+
for name in config.mains:
|
|
197
|
+
if name in usages:
|
|
198
|
+
continue
|
|
199
|
+
if name in skip:
|
|
200
|
+
unchecked.append(name)
|
|
201
|
+
continue
|
|
202
|
+
out_dir = config.root / config.output_dir / name
|
|
203
|
+
usage = usage_from_dir(config, name, out_dir, "existing .aux")
|
|
204
|
+
if usage and is_up_to_date(config, usage, out_dir / f"{name}.aux"):
|
|
205
|
+
usages[name] = usage
|
|
206
|
+
continue
|
|
207
|
+
if config.checks.unused_bib.draft_pass:
|
|
208
|
+
print(f" {_DIM}citations of {name}: draft {config.engine} pass ...{_RESET}")
|
|
209
|
+
usage = draft_usage(config, name)
|
|
210
|
+
if usage:
|
|
211
|
+
usages[name] = usage
|
|
212
|
+
continue
|
|
213
|
+
unchecked.append(name)
|
|
214
|
+
return usages, unchecked
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def bib_entry_keys(bib_path: Path) -> set[str]:
|
|
218
|
+
text = bib_path.read_text(errors="replace")
|
|
219
|
+
return {
|
|
220
|
+
m.group(2).strip()
|
|
221
|
+
for m in _BIB_ENTRY_RE.finditer(text)
|
|
222
|
+
if m.group(1).lower() not in _NON_ENTRY_TYPES
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def unused_bib_checks(
|
|
227
|
+
config: PaperkitConfig,
|
|
228
|
+
usages: dict[str, TargetUsage],
|
|
229
|
+
unchecked: list[str],
|
|
230
|
+
focus: list[str],
|
|
231
|
+
) -> list[CheckResult]:
|
|
232
|
+
"""One "Unused bib entries" result per bib file used by the *focus* targets."""
|
|
233
|
+
focus_usages = [usages[n] for n in focus if n in usages]
|
|
234
|
+
configured = resolve_bib_file(config.root, config.bib) if config.bib else None
|
|
235
|
+
bib_files = {p for u in focus_usages for p in u.bib_files}
|
|
236
|
+
# A configured bib file no target declares (e.g. bibliography commented
|
|
237
|
+
# out) is still checked against the focus targets' citations.
|
|
238
|
+
legacy = configured is not None and not any(configured in u.bib_files for u in usages.values())
|
|
239
|
+
if legacy and configured is not None:
|
|
240
|
+
bib_files.add(configured)
|
|
241
|
+
|
|
242
|
+
if not focus_usages or not bib_files:
|
|
243
|
+
skipped = CheckResult(name="Unused bib entries")
|
|
244
|
+
skipped.messages.append("Skipped (aux or bib file not found)")
|
|
245
|
+
return [skipped]
|
|
246
|
+
|
|
247
|
+
ignore = set(config.checks.unused_bib.ignore)
|
|
248
|
+
root = config.root.resolve()
|
|
249
|
+
multi = len(config.mains) > 1
|
|
250
|
+
results = []
|
|
251
|
+
for bib in sorted(bib_files):
|
|
252
|
+
users = [u for u in usages.values() if bib in u.bib_files]
|
|
253
|
+
if legacy and bib == configured:
|
|
254
|
+
users = focus_usages
|
|
255
|
+
rel = bib.relative_to(root) if bib.is_relative_to(root) else bib
|
|
256
|
+
name = "Unused bib entries" if len(bib_files) == 1 else f"Unused bib entries ({rel})"
|
|
257
|
+
results.append(_unused_result(name, bib, users, unchecked, ignore, multi))
|
|
258
|
+
return results
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _unused_result(
|
|
262
|
+
name: str,
|
|
263
|
+
bib: Path,
|
|
264
|
+
users: list[TargetUsage],
|
|
265
|
+
unchecked: list[str],
|
|
266
|
+
ignore: set[str],
|
|
267
|
+
multi: bool,
|
|
268
|
+
) -> CheckResult:
|
|
269
|
+
result = CheckResult(name=name)
|
|
270
|
+
if any(u.cite_all for u in users):
|
|
271
|
+
result.messages.append("All bib entries are cited (\\nocite{*})")
|
|
272
|
+
return result
|
|
273
|
+
cited = set().union(*(u.citations for u in users))
|
|
274
|
+
not_cited = sorted(bib_entry_keys(bib) - cited - ignore)
|
|
275
|
+
user_names = ", ".join(u.name for u in users)
|
|
276
|
+
|
|
277
|
+
if not not_cited:
|
|
278
|
+
result.messages.append("All bib entries are cited")
|
|
279
|
+
elif unchecked:
|
|
280
|
+
# Another target may cite these: say so, but never call them unused.
|
|
281
|
+
result.show_details = True
|
|
282
|
+
result.messages.append(
|
|
283
|
+
f"{len(not_cited)} not cited by {user_names or 'the checked targets'}; "
|
|
284
|
+
f"other targets not checked: {', '.join(unchecked)}",
|
|
285
|
+
)
|
|
286
|
+
result.messages.extend(f" {key}" for key in not_cited)
|
|
287
|
+
else:
|
|
288
|
+
result.status = CheckStatus.WARNING
|
|
289
|
+
if multi:
|
|
290
|
+
result.messages.append(
|
|
291
|
+
f"{len(not_cited)} unused bib entry/entries (cited by no target: {user_names}):",
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
result.messages.append(f"{len(not_cited)} unused bib entry/entries:")
|
|
295
|
+
result.messages.extend(f" {key}" for key in not_cited)
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def project_bib_checks(
|
|
300
|
+
config: PaperkitConfig,
|
|
301
|
+
outputs: dict[str, BuildResult],
|
|
302
|
+
focus: list[str],
|
|
303
|
+
failed: set[str] | None = None,
|
|
304
|
+
) -> list[CheckResult]:
|
|
305
|
+
"""Bibliography checks for the bib files the *focus* targets use.
|
|
306
|
+
|
|
307
|
+
*outputs* holds this run's build (or checked) output per target; other
|
|
308
|
+
mains are resolved with resolve_other_targets.
|
|
309
|
+
"""
|
|
310
|
+
results: list[CheckResult] = []
|
|
311
|
+
if config.checks.unused_bib.enabled:
|
|
312
|
+
known = {
|
|
313
|
+
name: usage
|
|
314
|
+
for name, out in outputs.items()
|
|
315
|
+
if (usage := usage_from_dir(config, name, out.output_dir, "this run"))
|
|
316
|
+
}
|
|
317
|
+
usages, unchecked = resolve_other_targets(config, known, skip=failed or set())
|
|
318
|
+
results += unused_bib_checks(config, usages, unchecked, focus)
|
|
319
|
+
if config.checks.bibtex_tidy.enabled:
|
|
320
|
+
results.append(check_bibtex_tidy(config))
|
|
321
|
+
return results
|
paperkit/builder.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""LaTeX build pipeline (pdflatex, lualatex, xelatex + bibtex/biber)."""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from paperkit import reproducible
|
|
13
|
+
from paperkit.config import PaperkitConfig
|
|
14
|
+
|
|
15
|
+
# ANSI
|
|
16
|
+
_BOLD = "\033[1m"
|
|
17
|
+
_DIM = "\033[2m"
|
|
18
|
+
_GREEN = "\033[32m"
|
|
19
|
+
_RED = "\033[31m"
|
|
20
|
+
_RESET = "\033[0m"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class BuildResult:
|
|
25
|
+
"""Result of a paper build."""
|
|
26
|
+
|
|
27
|
+
pdf: Path | None = None
|
|
28
|
+
log: Path | None = None
|
|
29
|
+
blg: Path | None = None
|
|
30
|
+
aux: Path | None = None
|
|
31
|
+
output_dir: Path = field(default_factory=lambda: Path("out"))
|
|
32
|
+
success: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_latex(
|
|
36
|
+
engine: str,
|
|
37
|
+
tex_file: Path,
|
|
38
|
+
output_dir: Path,
|
|
39
|
+
shell_escape: bool = True,
|
|
40
|
+
extra_args: list[str] | None = None,
|
|
41
|
+
env: dict[str, str] | None = None,
|
|
42
|
+
prelude: str = "",
|
|
43
|
+
) -> int:
|
|
44
|
+
"""Run a single LaTeX pass (pdflatex, lualatex, or xelatex). Returns exit code.
|
|
45
|
+
|
|
46
|
+
*prelude* is TeX code executed before the main file is read; the job name
|
|
47
|
+
is then set explicitly, since the engine would otherwise name the job
|
|
48
|
+
after that code instead of the file.
|
|
49
|
+
"""
|
|
50
|
+
cmd = [
|
|
51
|
+
engine,
|
|
52
|
+
"-interaction=nonstopmode",
|
|
53
|
+
f"-output-directory={output_dir}",
|
|
54
|
+
]
|
|
55
|
+
if shell_escape:
|
|
56
|
+
cmd.append("-shell-escape")
|
|
57
|
+
cmd += extra_args or []
|
|
58
|
+
if prelude:
|
|
59
|
+
cmd.append(f"-jobname={tex_file.stem}")
|
|
60
|
+
cmd.append(f"{prelude}\\input{{{tex_file}}}")
|
|
61
|
+
else:
|
|
62
|
+
cmd.append(str(tex_file))
|
|
63
|
+
|
|
64
|
+
result = subprocess.run(cmd, capture_output=True, env=_environment(env))
|
|
65
|
+
return result.returncode
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _environment(extra: dict[str, str] | None) -> dict[str, str]:
|
|
69
|
+
"""The caller's environment plus *extra*."""
|
|
70
|
+
env = os.environ.copy()
|
|
71
|
+
env.update(extra or {})
|
|
72
|
+
return env
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_bibtex(
|
|
76
|
+
aux_file: Path, bib_search_dir: Path, extra_env: dict[str, str] | None = None
|
|
77
|
+
) -> int:
|
|
78
|
+
"""Run bibtex with BIBINPUTS pointing to *bib_search_dir*. Returns exit code."""
|
|
79
|
+
env = _environment(extra_env)
|
|
80
|
+
env["BIBINPUTS"] = f"{bib_search_dir}:"
|
|
81
|
+
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
["bibtex", aux_file.stem],
|
|
84
|
+
cwd=aux_file.parent,
|
|
85
|
+
env=env,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
)
|
|
88
|
+
return result.returncode
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_biber(
|
|
92
|
+
jobname: str,
|
|
93
|
+
output_dir: Path,
|
|
94
|
+
project_root: Path,
|
|
95
|
+
extra_env: dict[str, str] | None = None,
|
|
96
|
+
) -> int:
|
|
97
|
+
"""Run biber from the project root with output in *output_dir*. Returns exit code."""
|
|
98
|
+
result = subprocess.run(
|
|
99
|
+
["biber", f"--output-directory={output_dir}", jobname],
|
|
100
|
+
cwd=project_root,
|
|
101
|
+
capture_output=True,
|
|
102
|
+
env=_environment(extra_env),
|
|
103
|
+
)
|
|
104
|
+
return result.returncode
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_paper(config: PaperkitConfig, main: str | None = None) -> BuildResult:
|
|
108
|
+
"""Orchestrate the full build for one main: N LaTeX passes with bibtex/biber after pass 1."""
|
|
109
|
+
if main is None:
|
|
110
|
+
main = config.main
|
|
111
|
+
root = config.root
|
|
112
|
+
output_dir = root / config.output_dir / main
|
|
113
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
tex_file = root / f"{main}.tex"
|
|
116
|
+
result = BuildResult(output_dir=output_dir)
|
|
117
|
+
|
|
118
|
+
# One timestamp for the whole pipeline, so rebuilds are byte-identical.
|
|
119
|
+
source_date = reproducible.resolve(config.source_date_epoch, root)
|
|
120
|
+
if source_date.warning:
|
|
121
|
+
print(f" {_DIM}warning: {source_date.warning}{_RESET}")
|
|
122
|
+
env = reproducible.build_env(source_date)
|
|
123
|
+
prelude = (
|
|
124
|
+
reproducible.trailer_id_code(config.engine, source_date, main)
|
|
125
|
+
if reproducible.needs_trailer_id(config.engine)
|
|
126
|
+
else ""
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
use_bib = config.bibtex or config.biber
|
|
130
|
+
bib_tool = "biber" if config.biber else "bibtex"
|
|
131
|
+
total_steps = config.passes + (1 if use_bib else 0)
|
|
132
|
+
step = 0
|
|
133
|
+
|
|
134
|
+
for i in range(1, config.passes + 1):
|
|
135
|
+
# Run bibliography tool after pass 1
|
|
136
|
+
if use_bib and i == 2:
|
|
137
|
+
step += 1
|
|
138
|
+
print(f" {_DIM}[{step}/{total_steps}]{_RESET} {bib_tool} ...")
|
|
139
|
+
if config.biber:
|
|
140
|
+
run_biber(main, output_dir, root, env)
|
|
141
|
+
else:
|
|
142
|
+
aux = output_dir / f"{main}.aux"
|
|
143
|
+
run_bibtex(aux, root, env)
|
|
144
|
+
|
|
145
|
+
step += 1
|
|
146
|
+
label = f"pass {i}"
|
|
147
|
+
if i == config.passes and config.passes >= 4:
|
|
148
|
+
label += " \u2013 pbalance"
|
|
149
|
+
print(f" {_DIM}[{step}/{total_steps}]{_RESET} {config.engine} ({label}) ...")
|
|
150
|
+
run_latex(
|
|
151
|
+
config.engine,
|
|
152
|
+
tex_file,
|
|
153
|
+
output_dir,
|
|
154
|
+
shell_escape=config.shell_escape,
|
|
155
|
+
env=env,
|
|
156
|
+
prelude=prelude,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Check outputs
|
|
160
|
+
pdf_path = output_dir / f"{main}.pdf"
|
|
161
|
+
log_path = output_dir / f"{main}.log"
|
|
162
|
+
blg_path = output_dir / f"{main}.blg"
|
|
163
|
+
aux_path = output_dir / f"{main}.aux"
|
|
164
|
+
|
|
165
|
+
result.log = log_path if log_path.is_file() else None
|
|
166
|
+
result.blg = blg_path if blg_path.is_file() else None
|
|
167
|
+
result.aux = aux_path if aux_path.is_file() else None
|
|
168
|
+
|
|
169
|
+
if pdf_path.is_file():
|
|
170
|
+
result.pdf = pdf_path
|
|
171
|
+
result.success = True
|
|
172
|
+
dest = root / f"{main}.pdf"
|
|
173
|
+
shutil.copy2(pdf_path, dest)
|
|
174
|
+
print(f" {_GREEN}\u2714{_RESET} {_BOLD}{main}.pdf{_RESET}")
|
|
175
|
+
|
|
176
|
+
# Best-effort: dump LaTeX-free title and abstract next to the PDF for
|
|
177
|
+
# easy paste into submission systems (HotCRP, EasyChair, ...).
|
|
178
|
+
from paperkit.text_export import print_text_exports, write_text_exports
|
|
179
|
+
|
|
180
|
+
print_text_exports(write_text_exports(config, main, output_dir))
|
|
181
|
+
else:
|
|
182
|
+
print(f" {_RED}\u2718 ERROR: {main}.pdf was not produced{_RESET}")
|
|
183
|
+
if log_path.is_file():
|
|
184
|
+
print(f" See {log_path} for details")
|
|
185
|
+
|
|
186
|
+
return result
|