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/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}")