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/cli.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Click CLI entry point for paperkit."""
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
11
|
+
from contextlib import redirect_stdout
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from paperkit import __version__, reproducible, texlive
|
|
17
|
+
from paperkit.config import PaperkitConfig, load_config
|
|
18
|
+
|
|
19
|
+
# ANSI
|
|
20
|
+
_BOLD = "\033[1m"
|
|
21
|
+
_DIM = "\033[2m"
|
|
22
|
+
_CYAN = "\033[36m"
|
|
23
|
+
_RESET = "\033[0m"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _header(msg: str) -> str:
|
|
27
|
+
return f"{_BOLD}{_CYAN}{msg}{_RESET}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.group()
|
|
31
|
+
@click.version_option(__version__, "-V", "--version", prog_name="paperkit")
|
|
32
|
+
@click.option(
|
|
33
|
+
"--config",
|
|
34
|
+
"config_path",
|
|
35
|
+
type=click.Path(exists=True, path_type=Path), # type: ignore[type-var]
|
|
36
|
+
default=None,
|
|
37
|
+
help="Path to .paperkit.toml (default: auto-detect)",
|
|
38
|
+
)
|
|
39
|
+
@click.option("-v", "--verbose", is_flag=True, help="Show verbose output (Docker commands, etc.)")
|
|
40
|
+
@click.pass_context
|
|
41
|
+
def main(ctx: click.Context, config_path: Path | None, verbose: bool) -> None:
|
|
42
|
+
"""paperkit: reproducible builds and pre-submission checks for research papers."""
|
|
43
|
+
ctx.ensure_object(dict)
|
|
44
|
+
ctx.obj["config_path"] = config_path
|
|
45
|
+
ctx.obj["verbose"] = verbose
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_config(ctx: click.Context) -> PaperkitConfig:
|
|
49
|
+
return load_config(ctx.obj.get("config_path"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _config_path(ctx: click.Context) -> Path:
|
|
53
|
+
"""The .paperkit.toml in use, for commands that edit it."""
|
|
54
|
+
from paperkit.config import find_config
|
|
55
|
+
|
|
56
|
+
return ctx.obj.get("config_path") or find_config()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _default_jobs() -> int:
|
|
60
|
+
return os.cpu_count() or 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_targets(config: PaperkitConfig, names: tuple[str, ...]) -> list[str]:
|
|
64
|
+
"""Map NAMES (with or without .tex) to entries of config.mains.
|
|
65
|
+
|
|
66
|
+
With no names, returns all of config.mains. Exits with an error listing
|
|
67
|
+
the known mains when a name doesn't match any of them.
|
|
68
|
+
"""
|
|
69
|
+
if not names:
|
|
70
|
+
return list(config.mains)
|
|
71
|
+
targets = [n[:-4] if n.endswith(".tex") else n for n in names]
|
|
72
|
+
unknown = [t for t in targets if t not in config.mains]
|
|
73
|
+
if unknown:
|
|
74
|
+
click.echo(f"Unknown main(s): {', '.join(unknown)}")
|
|
75
|
+
click.echo(f"Known mains: {', '.join(config.mains)}")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
return targets
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@main.command()
|
|
81
|
+
@click.argument("names", nargs=-1)
|
|
82
|
+
@click.option("--no-docker", is_flag=True, help="Build on host instead of in Docker")
|
|
83
|
+
@click.option(
|
|
84
|
+
"--jobs",
|
|
85
|
+
"-j",
|
|
86
|
+
type=int,
|
|
87
|
+
default=None,
|
|
88
|
+
help=f"Parallel build workers (default: cpu_count = {_default_jobs()})",
|
|
89
|
+
)
|
|
90
|
+
@click.pass_context
|
|
91
|
+
def build(ctx: click.Context, names: tuple[str, ...], no_docker: bool, jobs: int | None) -> None:
|
|
92
|
+
"""Build the paper PDF (loops over config.mains).
|
|
93
|
+
|
|
94
|
+
With no NAMES, builds every main in config.mains. NAMES may be given with
|
|
95
|
+
or without the .tex extension; each must be one of config.mains. Building
|
|
96
|
+
a single main prints the full detailed report instead of the summary table.
|
|
97
|
+
"""
|
|
98
|
+
config = _get_config(ctx)
|
|
99
|
+
# config.mains keeps every target: bibliography checks look at the targets
|
|
100
|
+
# not being built too, when they share a bib file.
|
|
101
|
+
targets = _resolve_targets(config, names)
|
|
102
|
+
# Precedence: CLI --jobs > [build] jobs in .paperkit.toml > cpu_count.
|
|
103
|
+
if jobs and jobs > 0:
|
|
104
|
+
n_jobs = jobs
|
|
105
|
+
elif config.jobs and config.jobs > 0:
|
|
106
|
+
n_jobs = config.jobs
|
|
107
|
+
else:
|
|
108
|
+
n_jobs = _default_jobs()
|
|
109
|
+
|
|
110
|
+
if no_docker:
|
|
111
|
+
_build_host(config, targets, jobs=n_jobs)
|
|
112
|
+
else:
|
|
113
|
+
_build_docker(config, targets, names, jobs=n_jobs, verbose=ctx.obj["verbose"])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@main.command()
|
|
117
|
+
@click.pass_context
|
|
118
|
+
def shell(ctx: click.Context) -> None:
|
|
119
|
+
"""Open an interactive bash shell in the Docker container."""
|
|
120
|
+
from paperkit.docker import build_image, run_in_docker
|
|
121
|
+
|
|
122
|
+
config = _get_config(ctx)
|
|
123
|
+
verbose = ctx.obj["verbose"]
|
|
124
|
+
|
|
125
|
+
click.echo(_header("\U0001f433 Building Docker image ..."))
|
|
126
|
+
build_image(config, verbose=verbose)
|
|
127
|
+
click.echo(_header("\U0001f4bb Opening shell (project mounted at /paper) ..."))
|
|
128
|
+
run_in_docker(config, "bash", interactive=True, verbose=verbose)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _run_one(config: PaperkitConfig, main: str) -> dict:
|
|
132
|
+
"""Worker entry: build one main + run checks, capturing per-build output.
|
|
133
|
+
|
|
134
|
+
Top-level function so it can be pickled by ProcessPoolExecutor. Returned
|
|
135
|
+
dict bundles BuildResult, check results, env info, and the captured
|
|
136
|
+
stdout that build_paper would have printed live.
|
|
137
|
+
"""
|
|
138
|
+
from paperkit.builder import build_paper
|
|
139
|
+
from paperkit.checks import run_all_checks
|
|
140
|
+
from paperkit.report import get_environment_info
|
|
141
|
+
|
|
142
|
+
buf = io.StringIO()
|
|
143
|
+
with redirect_stdout(buf):
|
|
144
|
+
result = build_paper(config, main)
|
|
145
|
+
check_results = run_all_checks(config, result) if result.log or result.pdf else []
|
|
146
|
+
env_info = get_environment_info(config, result) if result.log else {}
|
|
147
|
+
return {
|
|
148
|
+
"main": main,
|
|
149
|
+
"result": result,
|
|
150
|
+
"checks": check_results,
|
|
151
|
+
"env_info": env_info,
|
|
152
|
+
"captured": buf.getvalue(),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _build_host(config: PaperkitConfig, targets: list[str], jobs: int = 1) -> None:
|
|
157
|
+
"""Build *targets* directly on the host (parallel by default for several)."""
|
|
158
|
+
from paperkit.bibliography import project_bib_checks
|
|
159
|
+
from paperkit.builder import BuildResult, build_paper
|
|
160
|
+
from paperkit.checks import CheckResult, run_all_checks
|
|
161
|
+
from paperkit.report import get_environment_info, print_multi_summary, print_report
|
|
162
|
+
|
|
163
|
+
n = len(targets)
|
|
164
|
+
runs: list[tuple[str, BuildResult, list[CheckResult]]] = []
|
|
165
|
+
|
|
166
|
+
# Single target: keep the existing chatty flow (live "[k/4]" pass markers + full report).
|
|
167
|
+
if n == 1:
|
|
168
|
+
main = targets[0]
|
|
169
|
+
click.echo(_header(f"\U0001f4c4 Building {main}.tex ..."))
|
|
170
|
+
result = build_paper(config, main)
|
|
171
|
+
if result.success:
|
|
172
|
+
env_info = get_environment_info(config, result)
|
|
173
|
+
bib_results = project_bib_checks(config, {main: result}, focus=[main])
|
|
174
|
+
check_results = run_all_checks(config, result, bib_results)
|
|
175
|
+
print_report(result, check_results, env_info=env_info)
|
|
176
|
+
runs.append((main, result, check_results))
|
|
177
|
+
else:
|
|
178
|
+
runs.append((main, result, []))
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Multi-main: pool workers, lightweight progress, summary at the end.
|
|
183
|
+
effective_jobs = max(1, min(jobs, n))
|
|
184
|
+
click.echo(_header(f"\U0001f4c4 Building {n} project(s) (jobs={effective_jobs}) ..."))
|
|
185
|
+
|
|
186
|
+
if effective_jobs == 1:
|
|
187
|
+
# Sequential fallback: same as parallel path but without the pool.
|
|
188
|
+
for completed, main in enumerate(targets, start=1):
|
|
189
|
+
buf = io.StringIO()
|
|
190
|
+
with redirect_stdout(buf):
|
|
191
|
+
result = build_paper(config, main)
|
|
192
|
+
checks = run_all_checks(config, result) if result.log or result.pdf else []
|
|
193
|
+
_emit_progress(completed, n, main, result.success, buf.getvalue())
|
|
194
|
+
runs.append((main, result, checks))
|
|
195
|
+
else:
|
|
196
|
+
results_by_main: dict[str, tuple[str, BuildResult, list[CheckResult]]] = {}
|
|
197
|
+
with ProcessPoolExecutor(max_workers=effective_jobs) as ex:
|
|
198
|
+
futs = {ex.submit(_run_one, config, m): m for m in targets}
|
|
199
|
+
completed = 0
|
|
200
|
+
for fut in as_completed(futs):
|
|
201
|
+
payload = fut.result()
|
|
202
|
+
main = payload["main"]
|
|
203
|
+
result = payload["result"]
|
|
204
|
+
checks = payload["checks"]
|
|
205
|
+
completed += 1
|
|
206
|
+
_emit_progress(completed, n, main, result.success, payload["captured"])
|
|
207
|
+
results_by_main[main] = (main, result, checks)
|
|
208
|
+
# Reorder by target order for the summary table.
|
|
209
|
+
runs = [results_by_main[m] for m in targets if m in results_by_main]
|
|
210
|
+
|
|
211
|
+
print()
|
|
212
|
+
built = {m: r for m, r, _ in runs if r.success}
|
|
213
|
+
failed = {m for m, r, _ in runs if not r.success}
|
|
214
|
+
shared = project_bib_checks(config, built, focus=targets, failed=failed)
|
|
215
|
+
print_multi_summary(runs, shared)
|
|
216
|
+
|
|
217
|
+
if not all(r.success for _, r, _ in runs):
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _emit_progress(k: int, n: int, main: str, success: bool, captured: str) -> None:
|
|
222
|
+
"""One-line completion marker; on failure also flush the captured per-build output."""
|
|
223
|
+
width = len(str(n))
|
|
224
|
+
marker = "\033[32m\u2714\033[0m" if success else "\033[31m\u2718\033[0m"
|
|
225
|
+
click.echo(f" [{k:>{width}}/{n}] {marker} {main}")
|
|
226
|
+
if not success and captured:
|
|
227
|
+
click.echo(captured)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _build_docker(
|
|
231
|
+
config: PaperkitConfig,
|
|
232
|
+
targets: list[str],
|
|
233
|
+
names: tuple[str, ...] = (),
|
|
234
|
+
jobs: int = 1,
|
|
235
|
+
verbose: bool = False,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Build inside a Docker container; the inner CLI invocation does the looping."""
|
|
238
|
+
from paperkit.docker import build_image, run_in_docker
|
|
239
|
+
|
|
240
|
+
click.echo(_header("\U0001f433 Building Docker image ..."))
|
|
241
|
+
build_image(config, verbose=verbose)
|
|
242
|
+
|
|
243
|
+
n = len(targets)
|
|
244
|
+
label = f"{n} project(s)" if n != 1 else f"{targets[0]}.tex"
|
|
245
|
+
click.echo(_header(f"\U0001f4c4 Building {label} in Docker ..."))
|
|
246
|
+
# The image has no git, so resolve the build timestamp here and pass it in;
|
|
247
|
+
# the inner paperkit then takes it from the environment.
|
|
248
|
+
source_date = reproducible.resolve(config.source_date_epoch, config.root)
|
|
249
|
+
if source_date.warning:
|
|
250
|
+
click.echo(f" {_DIM}warning: {source_date.warning}{_RESET}")
|
|
251
|
+
# The inner invocation re-reads .paperkit.toml, so pass the selection along.
|
|
252
|
+
rc = run_in_docker(
|
|
253
|
+
config,
|
|
254
|
+
f"paperkit build --no-docker --jobs {jobs}{_shell_args(names)}",
|
|
255
|
+
verbose=verbose,
|
|
256
|
+
env=reproducible.build_env(source_date),
|
|
257
|
+
)
|
|
258
|
+
if rc:
|
|
259
|
+
sys.exit(rc)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _shell_args(args: tuple[str, ...]) -> str:
|
|
263
|
+
"""Arguments quoted for the shell command run inside the container."""
|
|
264
|
+
return "".join(f" {shlex.quote(a)}" for a in args)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@main.command()
|
|
268
|
+
@click.argument("names", nargs=-1)
|
|
269
|
+
@click.option("--no-docker", is_flag=True, help="Run checks on host instead of in Docker")
|
|
270
|
+
@click.pass_context
|
|
271
|
+
def check(ctx: click.Context, names: tuple[str, ...], no_docker: bool) -> None:
|
|
272
|
+
"""Run checks on existing build output and print full per-main reports.
|
|
273
|
+
|
|
274
|
+
With no NAMES, checks every main in config.mains. NAMES may be given with
|
|
275
|
+
or without the .tex extension; each must be one of config.mains. Checks
|
|
276
|
+
run in the Docker image by default, so their tools (pdffonts,
|
|
277
|
+
bibtex-tidy, ...) are the ones the build used, not the host's.
|
|
278
|
+
"""
|
|
279
|
+
config = _get_config(ctx)
|
|
280
|
+
targets = _resolve_targets(config, names)
|
|
281
|
+
|
|
282
|
+
if no_docker:
|
|
283
|
+
_check_host(config, targets)
|
|
284
|
+
else:
|
|
285
|
+
_check_docker(config, names, verbose=ctx.obj["verbose"])
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _check_docker(config: PaperkitConfig, names: tuple[str, ...], verbose: bool = False) -> None:
|
|
289
|
+
"""Run `paperkit check` inside the container."""
|
|
290
|
+
from paperkit.docker import build_image, run_in_docker
|
|
291
|
+
|
|
292
|
+
click.echo(_header("\U0001f433 Building Docker image ..."))
|
|
293
|
+
build_image(config, verbose=verbose)
|
|
294
|
+
rc = run_in_docker(config, f"paperkit check --no-docker{_shell_args(names)}", verbose=verbose)
|
|
295
|
+
if rc:
|
|
296
|
+
sys.exit(rc)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _check_host(config: PaperkitConfig, targets: list[str]) -> None:
|
|
300
|
+
"""Run checks on the host, with the host's tools."""
|
|
301
|
+
from paperkit.bibliography import project_bib_checks
|
|
302
|
+
from paperkit.builder import BuildResult
|
|
303
|
+
from paperkit.checks import CheckResult, run_all_checks
|
|
304
|
+
from paperkit.report import get_environment_info, print_multi_summary, print_report
|
|
305
|
+
|
|
306
|
+
outputs: dict[str, BuildResult] = {}
|
|
307
|
+
for main in targets:
|
|
308
|
+
per_main_dir = config.root / config.output_dir / main
|
|
309
|
+
log_path = per_main_dir / f"{main}.log"
|
|
310
|
+
if not log_path.is_file():
|
|
311
|
+
click.echo(
|
|
312
|
+
f"No build output for '{main}' in {per_main_dir}/. Run 'paperkit build' first.",
|
|
313
|
+
)
|
|
314
|
+
continue
|
|
315
|
+
outputs[main] = BuildResult(
|
|
316
|
+
pdf=per_main_dir / f"{main}.pdf",
|
|
317
|
+
log=log_path,
|
|
318
|
+
blg=per_main_dir / f"{main}.blg",
|
|
319
|
+
aux=per_main_dir / f"{main}.aux",
|
|
320
|
+
output_dir=per_main_dir,
|
|
321
|
+
success=True,
|
|
322
|
+
)
|
|
323
|
+
if not outputs:
|
|
324
|
+
sys.exit(1)
|
|
325
|
+
|
|
326
|
+
# With one target, its bibliography results go into its report; with
|
|
327
|
+
# several, they are shown once for the project after the per-target reports.
|
|
328
|
+
single = len(outputs) == 1
|
|
329
|
+
bib_results = project_bib_checks(config, outputs, focus=list(outputs))
|
|
330
|
+
|
|
331
|
+
runs: list[tuple[str, BuildResult, list[CheckResult]]] = []
|
|
332
|
+
for main, result in outputs.items():
|
|
333
|
+
click.echo(_header(f"\U0001f50d Checking {main} ..."))
|
|
334
|
+
env_info = get_environment_info(config, result)
|
|
335
|
+
check_results = run_all_checks(config, result, bib_results if single else None)
|
|
336
|
+
print_report(result, check_results, env_info=env_info)
|
|
337
|
+
runs.append((main, result, check_results))
|
|
338
|
+
|
|
339
|
+
if not single:
|
|
340
|
+
print_multi_summary(runs, bib_results)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@main.command(name="zip")
|
|
344
|
+
@click.option(
|
|
345
|
+
"--bbl/--no-bbl",
|
|
346
|
+
default=None,
|
|
347
|
+
help="Include each main's compiled bibliography (default: [zip] include_bbl)",
|
|
348
|
+
)
|
|
349
|
+
@click.pass_context
|
|
350
|
+
def zip_cmd(ctx: click.Context, bbl: bool | None) -> None:
|
|
351
|
+
"""Create submission archive.
|
|
352
|
+
|
|
353
|
+
--bbl adds <main>.bbl next to <main>.tex, for publishers that compile the
|
|
354
|
+
sources without running BibTeX. The build output must be up to date.
|
|
355
|
+
"""
|
|
356
|
+
config = _get_config(ctx)
|
|
357
|
+
|
|
358
|
+
from paperkit.archive import MissingBblError, create_zip, print_zip_summary
|
|
359
|
+
|
|
360
|
+
click.echo(_header("\U0001f4e6 Creating submission archive ..."))
|
|
361
|
+
try:
|
|
362
|
+
result = create_zip(config, include_bbl=bbl)
|
|
363
|
+
except MissingBblError as e:
|
|
364
|
+
click.echo(f" {e}", err=True)
|
|
365
|
+
sys.exit(1)
|
|
366
|
+
for warning in result.warnings:
|
|
367
|
+
click.echo(f" {_DIM}warning: {warning}{_RESET}")
|
|
368
|
+
print_zip_summary(result.path)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@main.command()
|
|
372
|
+
@click.option("--no-docker", is_flag=True, help="Run on host instead of in Docker")
|
|
373
|
+
@click.option(
|
|
374
|
+
"--sort/--no-sort",
|
|
375
|
+
default=None,
|
|
376
|
+
help="Sort entries by key and fields within entries "
|
|
377
|
+
"(default: [checks] bibtex_tidy.sort, itself true)",
|
|
378
|
+
)
|
|
379
|
+
@click.pass_context
|
|
380
|
+
def bibtidy(ctx: click.Context, no_docker: bool, sort: bool | None) -> None:
|
|
381
|
+
"""Run bibtex-tidy on the bib file (in-place).
|
|
382
|
+
|
|
383
|
+
--no-sort keeps the file's own entry and field order and applies the
|
|
384
|
+
other defaults. Without the flag, [checks] bibtex_tidy.sort decides, so
|
|
385
|
+
the tidiness check in `paperkit build` agrees with this command.
|
|
386
|
+
"""
|
|
387
|
+
config = _get_config(ctx)
|
|
388
|
+
if sort is not None:
|
|
389
|
+
config.checks.bibtex_tidy.sort = sort
|
|
390
|
+
|
|
391
|
+
if no_docker:
|
|
392
|
+
_tidy_host(config)
|
|
393
|
+
else:
|
|
394
|
+
_tidy_docker(config, verbose=ctx.obj["verbose"])
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@main.command(name="tidy", hidden=True)
|
|
398
|
+
@click.option("--no-docker", is_flag=True)
|
|
399
|
+
@click.option("--sort/--no-sort", default=None)
|
|
400
|
+
@click.pass_context
|
|
401
|
+
def tidy(ctx: click.Context, no_docker: bool, sort: bool | None) -> None:
|
|
402
|
+
"""Deprecated alias for `bibtidy`."""
|
|
403
|
+
click.echo("warning: 'paperkit tidy' is now 'paperkit bibtidy'.", err=True)
|
|
404
|
+
ctx.invoke(bibtidy, no_docker=no_docker, sort=sort)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _tidy_host(config: PaperkitConfig) -> None:
|
|
408
|
+
"""Run bibtex-tidy directly on the host."""
|
|
409
|
+
import shutil
|
|
410
|
+
import subprocess
|
|
411
|
+
|
|
412
|
+
from paperkit.profiles import get_profile
|
|
413
|
+
|
|
414
|
+
bib_path = config.root / config.bib if config.bib else None
|
|
415
|
+
if not bib_path or not bib_path.is_file():
|
|
416
|
+
click.echo(f"Bib file not found: {config.bib}")
|
|
417
|
+
sys.exit(1)
|
|
418
|
+
|
|
419
|
+
if not shutil.which("bibtex-tidy"):
|
|
420
|
+
click.echo("bibtex-tidy not found. Install it with: npm install -g bibtex-tidy")
|
|
421
|
+
sys.exit(1)
|
|
422
|
+
|
|
423
|
+
profile = get_profile(config)
|
|
424
|
+
sort = config.checks.bibtex_tidy.sort
|
|
425
|
+
tidy_args = profile.bibtex_tidy_args(sort=sort)
|
|
426
|
+
|
|
427
|
+
how = "sorted" if sort else "unsorted"
|
|
428
|
+
click.echo(_header(f"\U0001f9f9 Tidying {config.bib} ({how}) ..."))
|
|
429
|
+
proc = subprocess.run(
|
|
430
|
+
["bibtex-tidy", *tidy_args, str(bib_path)],
|
|
431
|
+
capture_output=True,
|
|
432
|
+
text=True,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if proc.returncode != 0:
|
|
436
|
+
click.echo(f"bibtex-tidy failed (exit {proc.returncode})")
|
|
437
|
+
if proc.stderr:
|
|
438
|
+
click.echo(proc.stderr)
|
|
439
|
+
sys.exit(1)
|
|
440
|
+
|
|
441
|
+
# Report results
|
|
442
|
+
duplicates = [line for line in proc.stdout.splitlines() if "DUPLICATE_ENTRY" in line]
|
|
443
|
+
if duplicates:
|
|
444
|
+
click.echo(f" {_DIM}Duplicates found:{_RESET}")
|
|
445
|
+
for line in duplicates:
|
|
446
|
+
click.echo(f" {line.replace('DUPLICATE_ENTRY: ', '')}")
|
|
447
|
+
|
|
448
|
+
click.echo(f" {_BOLD}\u2705 {config.bib} tidied{_RESET}")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _tidy_docker(config: PaperkitConfig, verbose: bool = False) -> None:
|
|
452
|
+
"""Run bibtex-tidy inside the Docker container."""
|
|
453
|
+
from paperkit.docker import build_image, run_in_docker
|
|
454
|
+
|
|
455
|
+
click.echo(_header("\U0001f433 Building Docker image ..."))
|
|
456
|
+
build_image(config, verbose=verbose)
|
|
457
|
+
|
|
458
|
+
click.echo(_header(f"\U0001f9f9 Tidying {config.bib} in Docker ..."))
|
|
459
|
+
# The inner invocation re-reads .paperkit.toml, so pass the sort choice along.
|
|
460
|
+
sort_flag = "--sort" if config.checks.bibtex_tidy.sort else "--no-sort"
|
|
461
|
+
rc = run_in_docker(config, f"paperkit bibtidy --no-docker {sort_flag}", verbose=verbose)
|
|
462
|
+
if rc:
|
|
463
|
+
sys.exit(rc)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@main.command(name="texlive-update")
|
|
467
|
+
@click.option("--check", is_flag=True, help="Report the newest snapshot without editing the config")
|
|
468
|
+
@click.pass_context
|
|
469
|
+
def texlive_update(ctx: click.Context, check: bool) -> None:
|
|
470
|
+
"""Move [texlive] to the newest dated snapshot.
|
|
471
|
+
|
|
472
|
+
Queries the snapshot archive, then rewrites `snapshot` and `tlpdb_sha512`
|
|
473
|
+
in .paperkit.toml, leaving the rest of the file untouched. This is the
|
|
474
|
+
only command that looks things up online; builds never do.
|
|
475
|
+
"""
|
|
476
|
+
config_path = _config_path(ctx)
|
|
477
|
+
click.echo(_header("\U0001f4c5 Looking up the newest TeX Live snapshot ..."))
|
|
478
|
+
newest = texlive.latest_snapshot()
|
|
479
|
+
if newest is None:
|
|
480
|
+
click.echo(" no snapshot found (is the archive reachable?)", err=True)
|
|
481
|
+
sys.exit(1)
|
|
482
|
+
snapshot, digest = newest
|
|
483
|
+
|
|
484
|
+
text = config_path.read_text()
|
|
485
|
+
try:
|
|
486
|
+
updated, previous = texlive.update_config_text(text, snapshot, digest)
|
|
487
|
+
except ValueError as e:
|
|
488
|
+
click.echo(f" {e}", err=True)
|
|
489
|
+
sys.exit(1)
|
|
490
|
+
|
|
491
|
+
current = previous.get("snapshot", "")
|
|
492
|
+
if current == snapshot:
|
|
493
|
+
click.echo(f" already on the newest snapshot ({snapshot})")
|
|
494
|
+
return
|
|
495
|
+
click.echo(f" {current or 'unset'} -> {snapshot}")
|
|
496
|
+
click.echo(f" {_DIM}tlpdb_sha512 {digest}{_RESET}")
|
|
497
|
+
if check:
|
|
498
|
+
click.echo(f" {_DIM}--check: {config_path.name} not modified{_RESET}")
|
|
499
|
+
return
|
|
500
|
+
config_path.write_text(updated)
|
|
501
|
+
click.echo(f" {_BOLD}\u2705 {config_path.name} updated{_RESET}; rebuild to pick it up")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _texlive_block(mode: str) -> str:
|
|
505
|
+
"""The [texlive] section `paperkit init` writes."""
|
|
506
|
+
if mode == "distro":
|
|
507
|
+
return """# TeX Live comes from the base image's texlive-* packages. Note that the
|
|
508
|
+
# distro minted and latexminted do not work together on ubuntu:26.04; switch
|
|
509
|
+
# to the snapshot below if you use minted.
|
|
510
|
+
[texlive]
|
|
511
|
+
source = "distro"
|
|
512
|
+
"""
|
|
513
|
+
collections = ", ".join(f'"{c}"' for c in texlive.DEFAULT_COLLECTIONS)
|
|
514
|
+
return f"""# TeX Live from a dated upstream snapshot: the same package set on every
|
|
515
|
+
# image rebuild, and a working minted. `paperkit texlive-update` moves this
|
|
516
|
+
# to a newer snapshot; `source = "distro"` uses the base image's packages.
|
|
517
|
+
[texlive]
|
|
518
|
+
source = "upstream"
|
|
519
|
+
snapshot = "{texlive.KNOWN_GOOD_SNAPSHOT}"
|
|
520
|
+
tlpdb_sha512 = "{texlive.KNOWN_GOOD_TLPDB_SHA512}"
|
|
521
|
+
scheme = "{texlive.DEFAULT_SCHEME}"
|
|
522
|
+
collections = [{collections}]
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@main.command()
|
|
527
|
+
@click.option("--profile", default="acm", help="Profile to use (default: acm)")
|
|
528
|
+
@click.option(
|
|
529
|
+
"--texlive",
|
|
530
|
+
"texlive_mode",
|
|
531
|
+
type=click.Choice(["snapshot", "distro"]),
|
|
532
|
+
default="snapshot",
|
|
533
|
+
help="TeX Live source for the new project (default: snapshot)",
|
|
534
|
+
)
|
|
535
|
+
def init(profile: str, texlive_mode: str) -> None:
|
|
536
|
+
"""Generate a .paperkit.toml template."""
|
|
537
|
+
template = f"""[project]
|
|
538
|
+
main = "main" # main .tex file (without extension)
|
|
539
|
+
content_dirs = ["content"]
|
|
540
|
+
bib = "references.bib"
|
|
541
|
+
|
|
542
|
+
[build]
|
|
543
|
+
engine = "pdflatex"
|
|
544
|
+
passes = 3
|
|
545
|
+
shell_escape = true
|
|
546
|
+
bibtex = true
|
|
547
|
+
output_dir = "out"
|
|
548
|
+
|
|
549
|
+
[docker]
|
|
550
|
+
base_image = "ubuntu:26.04"
|
|
551
|
+
image_name = "paper-builder"
|
|
552
|
+
extra_packages = []
|
|
553
|
+
|
|
554
|
+
{_texlive_block(texlive_mode)}
|
|
555
|
+
[checks]
|
|
556
|
+
non_ascii = true
|
|
557
|
+
trailing_whitespace = true
|
|
558
|
+
final_newline = true
|
|
559
|
+
unused_bib = true
|
|
560
|
+
font_embedding = true
|
|
561
|
+
no_type3 = true
|
|
562
|
+
overfull_boxes = true
|
|
563
|
+
bibtex_tidy = {{ enabled = true, version = "1.14.0" }}
|
|
564
|
+
|
|
565
|
+
# Page-count limits: leave fields unset to get an informational report only.
|
|
566
|
+
# Auto-detects the references and acknowledgments sections from the .aux file
|
|
567
|
+
# (English titles; override refs_pattern / ack_pattern if needed).
|
|
568
|
+
# [page_limits]
|
|
569
|
+
# total = 14 # max total pages (incl. references)
|
|
570
|
+
# before_refs = 12 # content before references must fit in 12 pages
|
|
571
|
+
# before_ack = 13 # content before acknowledgments must fit in 13 pages
|
|
572
|
+
# ack_counts_as_content = false # true: acknowledgments count toward
|
|
573
|
+
# # before_refs, as at venues that exempt
|
|
574
|
+
# # only the references
|
|
575
|
+
|
|
576
|
+
[zip]
|
|
577
|
+
name = "main"
|
|
578
|
+
include = [
|
|
579
|
+
"{{main}}.tex",
|
|
580
|
+
"content/*.tex",
|
|
581
|
+
]
|
|
582
|
+
exclude = ["*.aux", "*.log", "*.out", "*.blg", "*.bbl"]
|
|
583
|
+
# Ship each main's compiled bibliography (<main>.bbl) next to its .tex, for
|
|
584
|
+
# publishers that compile without running BibTeX (e.g. Springer LNCS).
|
|
585
|
+
include_bbl = false
|
|
586
|
+
|
|
587
|
+
[profile]
|
|
588
|
+
name = "{profile}"
|
|
589
|
+
"""
|
|
590
|
+
dest = Path.cwd() / ".paperkit.toml"
|
|
591
|
+
if dest.exists():
|
|
592
|
+
click.echo(f".paperkit.toml already exists at {dest}")
|
|
593
|
+
sys.exit(1)
|
|
594
|
+
dest.write_text(template)
|
|
595
|
+
click.echo(f"\u2705 Created {dest}")
|