calkit-python 0.47.7__py3-none-any.whl → 0.47.9__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.
Files changed (48) hide show
  1. calkit/agent_skills/conventions/SKILL.md +26 -0
  2. calkit/cli/config.py +9 -1
  3. calkit/cli/latex.py +346 -52
  4. calkit/cli/main/core.py +192 -8
  5. calkit/cli/new.py +7 -8
  6. calkit/cli/overleaf.py +8 -7
  7. calkit/cli/update.py +251 -0
  8. calkit/docker.py +129 -5
  9. calkit/dvc/core.py +39 -0
  10. calkit/environments.py +4 -6
  11. calkit/install.py +7 -7
  12. calkit/latex.py +95 -1
  13. calkit/models/pipeline.py +27 -5
  14. calkit/pipeline.py +173 -8
  15. calkit/questions.py +31 -4
  16. calkit/resources/devcontainer/devcontainer.json +1 -1
  17. calkit/resources/vscode/settings.json +1 -1
  18. calkit/tests/cli/main/test_core.py +186 -0
  19. calkit/tests/cli/main/test_xr.py +1 -0
  20. calkit/tests/cli/test_check.py +20 -9
  21. calkit/tests/cli/test_latex.py +169 -4
  22. calkit/tests/cli/test_new.py +3 -2
  23. calkit/tests/cli/test_overleaf.py +2 -1
  24. calkit/tests/cli/test_update.py +73 -0
  25. calkit/tests/models/test_pipeline.py +17 -1
  26. calkit/tests/test_conda.py +3 -0
  27. calkit/tests/test_docker.py +61 -0
  28. calkit/tests/test_environments.py +2 -1
  29. calkit/tests/test_pipeline.py +121 -0
  30. calkit/tests/test_questions.py +37 -0
  31. {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/METADATA +190 -58
  32. {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/RECORD +48 -48
  33. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  34. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  35. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  36. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  37. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  38. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  39. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  40. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  41. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  42. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  43. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  44. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  45. {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  46. {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/WHEEL +0 -0
  47. {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/entry_points.txt +0 -0
  48. {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/licenses/LICENSE +0 -0
@@ -25,6 +25,8 @@ is `calkit.yaml`, the project's metadata database.
25
25
  - `environments`—computational environments (Python venvs, Conda, Docker,
26
26
  R, Julia, MATLAB, etc.)
27
27
  - `pipeline.stages`—the reproducible pipeline
28
+ - `questions`—research questions, hypotheses, and answers backed by
29
+ evidence the pipeline produces
28
30
  - `notebooks`—registered Jupyter notebooks
29
31
  - `datasets`, `figures`, `publications`—versioned project outputs
30
32
  - `procedures`, `calculations`, `references`—supporting metadata
@@ -209,6 +211,9 @@ DVC handles:
209
211
  | `calkit add <file>` | Add a file to version control |
210
212
  | `calkit check env --name <env>` | Verify an environment matches its spec |
211
213
  | `calkit new` | Create new project objects (notebook, dataset, etc.) |
214
+ | `calkit new question "<text>"` | Record a research question |
215
+ | `calkit list questions` | Show questions with answers rendered from evidence |
216
+ | `calkit check questions` | Check answers against current evidence |
212
217
 
213
218
  ## `calkit xr`: The fastest path to a reproducible stage
214
219
 
@@ -233,6 +238,27 @@ calkit xr scripts/run.py --environment main
233
238
  calkit xr scripts/run.py --dry-run # see what would happen without running
234
239
  ```
235
240
 
241
+ ## Questions and answers
242
+
243
+ Research in a Calkit project is organized around the `questions` list in
244
+ `calkit.yaml`. Follow this workflow:
245
+
246
+ 1. Before starting an analysis, make sure the question it answers is
247
+ recorded, e.g., with `calkit new question`. If the user hasn't stated
248
+ one, ask them for it rather than inventing it. Add a `hypothesis` if
249
+ they have one.
250
+ 2. Produce evidence with pipeline stages, never by hand, so every figure,
251
+ table, and number traces back to data and code.
252
+ 3. Write the `answer` citing that evidence. Read numbers out of results
253
+ files and put them in the prose with `{name}` placeholders instead of
254
+ typing them in, either one per `kind: value` entry, or several from one
255
+ file with a `kind: result` entry's `values` mapping of names to keys.
256
+ 4. After a pipeline run changes results, run `calkit check questions`,
257
+ then use the `check-questions` skill to judge whether each answer's
258
+ wording still follows from its evidence.
259
+
260
+ See the Calkit docs on questions for the full evidence schema.
261
+
236
262
  ## Version control conventions
237
263
 
238
264
  - Source code and small outputs: tracked with Git
calkit/cli/config.py CHANGED
@@ -12,7 +12,7 @@ import typer
12
12
  from typing_extensions import Annotated
13
13
 
14
14
  import calkit
15
- from calkit.cli.core import raise_error
15
+ from calkit.cli.core import raise_error, warn
16
16
 
17
17
  config_app = typer.Typer(no_args_is_help=True)
18
18
 
@@ -189,9 +189,17 @@ def setup_remote(
189
189
  ):
190
190
  """Set up the Calkit hub as the default DVC remote and store a token
191
191
  in the local config.
192
+
193
+ Deprecated: this configures the project, not Calkit itself, which is
194
+ what the rest of this app is for.
192
195
  """
193
196
  from git.exc import InvalidGitRepositoryError
194
197
 
198
+ warn(
199
+ "'calkit config remote' is deprecated; use 'calkit update hub', "
200
+ "which also creates the project on the hub if it isn't there"
201
+ )
202
+
195
203
  from calkit.dvc import configure_remote, set_remote_auth
196
204
 
197
205
  try:
calkit/cli/latex.py CHANGED
@@ -137,6 +137,20 @@ def from_json(
137
137
  json2latex.dump(cmd_name, formatted, f)
138
138
 
139
139
 
140
+ def _tex_env_vars(source_date_epoch: str | None) -> dict[str, str]:
141
+ r"""The environmental variables a TeX command needs, beyond the ambient.
142
+
143
+ ``FORCE_SOURCE_DATE`` is what makes pdfTeX apply the date to
144
+ ``\pdfcreationdate`` and friends, not only to the trailer ID.
145
+ """
146
+ if source_date_epoch is None:
147
+ return {}
148
+ return {
149
+ "SOURCE_DATE_EPOCH": source_date_epoch,
150
+ "FORCE_SOURCE_DATE": "1",
151
+ }
152
+
153
+
140
154
  @latex_app.command(name="from-questions")
141
155
  def from_questions(
142
156
  output_fpaths: Annotated[
@@ -180,6 +194,7 @@ def _tex_cmd(
180
194
  no_check: bool,
181
195
  verbose: bool,
182
196
  dep: str,
197
+ env_vars: dict[str, str] | None = None,
183
198
  ) -> list[str]:
184
199
  """Wrap a TeX command so it runs wherever the project's TeX lives.
185
200
 
@@ -189,31 +204,162 @@ def _tex_cmd(
189
204
  inside the project -- which is why the diff builds its copy of the
190
205
  base revision there rather than in a temp directory.
191
206
  """
207
+ env_vars = env_vars or {}
192
208
  if environment is not None:
193
209
  cmd = (
194
210
  ["calkit", "xenv", "--name", environment]
195
211
  + (["--no-check"] if no_check else [])
212
+ # Named rather than inherited: the environment may run in a
213
+ # container, which inherits nothing from here
214
+ + [f"--env-var={k}={v}" for k, v in env_vars.items()]
196
215
  + ["--"]
197
216
  + tex_cmd
198
217
  )
199
218
  elif calkit.check_dep_exists(dep):
200
219
  cmd = tex_cmd
201
220
  else:
221
+ # Pulled deliberately, since docker run's implicit pull of an
222
+ # image that isn't there can stall rather than report it
223
+ try:
224
+ calkit.docker.ensure_image_available(
225
+ calkit.latex.DEFAULT_LATEX_IMAGE
226
+ )
227
+ except ValueError as e:
228
+ raise_error(str(e))
229
+ # Packages fetched at run time go to the project's cache, which
230
+ # the working directory mount already covers, rather than over
231
+ # the image's own tree, which would hide the distribution entirely
232
+ os.makedirs(calkit.latex.get_texmf_cache_dir(), exist_ok=True)
202
233
  cmd = [
203
234
  "docker",
204
235
  "run",
205
236
  "--rm",
206
237
  "-v",
207
238
  f"{os.getcwd()}:/work",
239
+ "-e",
240
+ f"TEXMFHOME={calkit.latex.CONTAINER_TEXMF_DIR}",
208
241
  "-w",
209
242
  "/work",
210
- "texlive/texlive:latest-full",
211
- ] + tex_cmd
243
+ ]
244
+ # As the user, so a PDF doesn't come back owned by root
245
+ try:
246
+ cmd += ["--user", f"{os.getuid()}:{os.getgid()}"]
247
+ except AttributeError:
248
+ # Windows has no UID to map
249
+ pass
250
+ # The container gets its own environment, so anything the command
251
+ # needs has to be handed to it rather than inherited
252
+ for key, value in env_vars.items():
253
+ cmd += ["-e", f"{key}={value}"]
254
+ cmd += [calkit.latex.DEFAULT_LATEX_IMAGE] + tex_cmd
212
255
  if verbose:
213
256
  typer.echo(f"Running command: {cmd}")
214
257
  return cmd
215
258
 
216
259
 
260
+ def _run_latexmk(
261
+ cmd: list[str],
262
+ env: dict[str, str] | None,
263
+ log_path: str,
264
+ fdb_path: str,
265
+ environment: str | None,
266
+ verbose: bool,
267
+ ) -> int:
268
+ """Run latexmk, fetching the TeX packages it's missing and retrying.
269
+
270
+ Only where what's fetched is kept with the project, i.e., Calkit's
271
+ LaTeX image, run directly or as a Docker environment built on it. A
272
+ system TeX is the user's to manage, and anything installed in another
273
+ container is gone when it exits. Returns latexmk's exit status.
274
+ """
275
+ import shlex
276
+
277
+ def in_tex(tex_cmd: list[str]) -> list[str]:
278
+ # Where the build runs, so what's installed is what it sees
279
+ return _tex_cmd(
280
+ tex_cmd,
281
+ environment=environment,
282
+ no_check=True,
283
+ verbose=verbose,
284
+ dep="latexmk",
285
+ )
286
+
287
+ def can_fetch() -> bool:
288
+ if environment is None:
289
+ return not calkit.check_dep_exists("latexmk")
290
+ envs = calkit.load_calkit_info().get("environments", {})
291
+ if envs.get(environment, {}).get("kind") != "docker":
292
+ return False
293
+ # The image switches to the project's cache when it's there
294
+ os.makedirs(calkit.latex.get_texmf_cache_dir(), exist_ok=True)
295
+ out = subprocess.run(
296
+ in_tex(["printenv", "TEXMFHOME"]), capture_output=True, text=True
297
+ ).stdout.strip()
298
+ return out.endswith("/.calkit/local/texmf")
299
+
300
+ fetched: set[str] = set()
301
+ fetchable = None
302
+ while True:
303
+ try:
304
+ subprocess.check_call(cmd, env=env)
305
+ return 0
306
+ except subprocess.CalledProcessError as e:
307
+ status = e.returncode
308
+ try:
309
+ with open(log_path, encoding="utf-8", errors="replace") as f:
310
+ log = f.read()
311
+ except OSError:
312
+ return status
313
+ # A file still missing after fetching it is one fetching can't fix
314
+ missing = [
315
+ f
316
+ for f in calkit.latex.find_missing_tex_files(log)
317
+ if f not in fetched
318
+ ]
319
+ if not missing:
320
+ return status
321
+ if fetchable is None:
322
+ fetchable = can_fetch()
323
+ if not fetchable:
324
+ return status
325
+ packages = []
326
+ for name in missing:
327
+ out = subprocess.run(
328
+ in_tex(["tlmgr", "search", "--global", "--file", f"/{name}"]),
329
+ capture_output=True,
330
+ text=True,
331
+ ).stdout
332
+ # Package names end with a colon; the paths under them are
333
+ # indented, and the first one ending in the file is its owner
334
+ owner = None
335
+ for line in out.splitlines():
336
+ if line.endswith(":") and not line.startswith((" ", "\t")):
337
+ owner = line[:-1]
338
+ elif owner and line.strip().endswith(f"/{name}"):
339
+ packages.append(owner)
340
+ break
341
+ packages = list(dict.fromkeys(packages))
342
+ if not packages:
343
+ return status
344
+ typer.echo(
345
+ f"Fetching TeX packages for {', '.join(missing)}: "
346
+ f"{', '.join(packages)}"
347
+ )
348
+ install = in_tex(["tlmgr", "--usermode", "install", *packages])
349
+ if verbose:
350
+ typer.echo(f"Running command: {shlex.join(install)}")
351
+ # A font's install fails at its last step, updating the font map,
352
+ # which user mode can't do in this image, with the files already in
353
+ # place and usable. Whether it worked is the retry's to say.
354
+ if subprocess.run(install, capture_output=not verbose).returncode:
355
+ if verbose:
356
+ warn(f"tlmgr reported an error installing {packages}")
357
+ fetched.update(missing)
358
+ # Otherwise latexmk remembers the failure and won't try again
359
+ if os.path.isfile(fdb_path):
360
+ os.remove(fdb_path)
361
+
362
+
217
363
  @latex_app.command(name="build")
218
364
  def build(
219
365
  tex_file: Annotated[str, typer.Argument(help="The .tex file to compile.")],
@@ -300,6 +446,21 @@ def build(
300
446
  system environment if available. If not available, a TeX Live Docker
301
447
  container will be used.
302
448
  """
449
+ # latexmk records a failed run in its file database and then refuses
450
+ # to try again, reporting "Nothing to do" and exiting non-zero with no
451
+ # PDF. Running it again is the first thing anyone does after a
452
+ # failure, so the record is cleared when there is no PDF to show for
453
+ # it, which makes a retry a real retry.
454
+ tex_dir = os.path.dirname(tex_file) or "."
455
+ stem = Path(tex_file).stem
456
+ pdf_dir = output_dir if output_dir is not None else tex_dir
457
+ if not os.path.isfile(os.path.join(pdf_dir, stem + ".pdf")):
458
+ fdb_dir = aux_dir if aux_dir is not None else tex_dir
459
+ fdb_fpath = os.path.join(fdb_dir, stem + ".fdb_latexmk")
460
+ if os.path.isfile(fdb_fpath):
461
+ if verbose:
462
+ typer.echo(f"Removing {fdb_fpath} so latexmk will retry")
463
+ os.remove(fdb_fpath)
303
464
  # Now formulate the command
304
465
  latexmk_cmd = ["latexmk", "-pdf", "-cd"]
305
466
  if latexmk_rc_path is not None:
@@ -313,7 +474,6 @@ def build(
313
474
  # latexmk runs with -cd, so its -outdir/-auxdir are relative to the .tex
314
475
  # file's directory; convert the (current-directory-relative) Calkit paths
315
476
  # into that frame.
316
- tex_dir = os.path.dirname(tex_file) or "."
317
477
  if output_dir is not None:
318
478
  rel = Path(os.path.relpath(output_dir, tex_dir)).as_posix()
319
479
  latexmk_cmd.append(f"-outdir={rel}")
@@ -324,16 +484,24 @@ def build(
324
484
  # User pass-through args come last so they can override Calkit's defaults.
325
485
  latexmk_cmd += latexmk_args
326
486
  latexmk_cmd.append(tex_file)
487
+ tex_env_vars = _tex_env_vars(calkit.latex.get_source_date_epoch(tex_file))
327
488
  cmd = _tex_cmd(
328
489
  latexmk_cmd,
329
490
  environment=environment,
330
491
  no_check=no_check,
331
492
  verbose=verbose,
332
493
  dep="latexmk",
494
+ env_vars=tex_env_vars,
333
495
  )
334
- try:
335
- subprocess.check_call(cmd)
336
- except subprocess.CalledProcessError:
496
+ log_dir = aux_dir or output_dir or tex_dir
497
+ if _run_latexmk(
498
+ cmd,
499
+ env=(os.environ | tex_env_vars) if tex_env_vars else None,
500
+ log_path=os.path.join(log_dir, stem + ".log"),
501
+ fdb_path=os.path.join(log_dir, stem + ".fdb_latexmk"),
502
+ environment=environment,
503
+ verbose=verbose,
504
+ ):
337
505
  raise_error("latexmk failed")
338
506
 
339
507
 
@@ -345,6 +513,9 @@ _VERBATIM_PARAM_RE = re.compile(
345
513
  r"(\\(?:verbatiminput\*?|lstinputlisting))"
346
514
  r"(?=\s*(?:\[[^\]\n]*\])?\s*\{[^}\n]*#[0-9])"
347
515
  )
516
+ _INPUT_WRAPPER_RE = re.compile(
517
+ r"\\([A-Za-z@]+)\*?\s*(?:\[[^\]]*\]\s*)*\{\s*\\(?:input|include)\s*\{"
518
+ )
348
519
  get_diff_path = calkit.latex.get_diff_path
349
520
  _default_base_ref = calkit.latex.default_base_ref
350
521
 
@@ -409,7 +580,9 @@ def diff(
409
580
  typer.Option(
410
581
  "--latexmk-rc",
411
582
  "-r",
412
- help="Path to a latexmkrc file to build the marked-up document with.",
583
+ help=(
584
+ "Path to a latexmkrc file to build the marked-up document with."
585
+ ),
413
586
  ),
414
587
  ] = None,
415
588
  latexmk_args: Annotated[
@@ -473,7 +646,11 @@ def diff(
473
646
  bool,
474
647
  typer.Option(
475
648
  "--keep-tex",
476
- help="Keep the generated diff .tex file for inspection.",
649
+ help=(
650
+ "Keep the old, new, and diff .tex files beside the diff "
651
+ "PDF for inspection, e.g., "
652
+ ".calkit/latex-diffs/v1/paper/main-old.tex."
653
+ ),
477
654
  ),
478
655
  ] = False,
479
656
  no_check: Annotated[
@@ -508,6 +685,53 @@ def diff(
508
685
  the comparison shows its own.
509
686
  """
510
687
 
688
+ def stage_path(stage: dict, path: str) -> str:
689
+ """A stage's path in the project's frame rather than its wdir's."""
690
+ return Path(
691
+ os.path.normpath(os.path.join(stage.get("wdir") or "", path))
692
+ ).as_posix()
693
+
694
+ def find_latex_stage() -> tuple[str | None, dict | None]:
695
+ """The pipeline stage that builds the document, if any."""
696
+ ck_info = calkit.load_calkit_info()
697
+ stages = (ck_info.get("pipeline") or {}).get("stages") or {}
698
+ target = Path(os.path.normpath(tex_file)).as_posix()
699
+ for name, stage in stages.items():
700
+ if (
701
+ isinstance(stage, dict)
702
+ and stage.get("kind") == "latex"
703
+ and stage_path(stage, stage.get("target_path") or "") == target
704
+ ):
705
+ return name, stage
706
+ return None, None
707
+
708
+ def stage_inputs(name: str, stage: dict) -> list[str]:
709
+ """What a stage's diffs fetch at each revision.
710
+
711
+ Its dependencies in the compiled pipeline, which include other
712
+ stages' outputs it reads, found there since calkit.yaml only names
713
+ the stages they come from.
714
+ """
715
+ from calkit.models.pipeline import LatexStage
716
+
717
+ try:
718
+ dvc_stage = calkit.ryaml.load(Path("dvc.yaml").read_text())[
719
+ "stages"
720
+ ][name]
721
+ deps = [
722
+ dep if isinstance(dep, str) else next(iter(dep))
723
+ for dep in dvc_stage.get("deps") or []
724
+ ]
725
+ except Exception:
726
+ deps = LatexStage.model_validate(stage | {"name": name}).dvc_deps
727
+ skip = {tex_file, latexmk_rc_path}
728
+ return [
729
+ path
730
+ for dep in deps
731
+ if (path := stage_path(stage, dep)) not in skip
732
+ and not path.startswith(".calkit/")
733
+ ]
734
+
511
735
  def fetch_dvc_inputs(root: str, rev: str, paths: list[str]) -> list[str]:
512
736
  """Fetch the DVC-tracked content of ``paths`` at ``rev`` into ``root``.
513
737
 
@@ -629,7 +853,9 @@ def diff(
629
853
  Detection finds a file by its own name or pointer, but a file in a
630
854
  directory DVC tracks as a whole has only the directory's pointer,
631
855
  which can't say what's inside, so those directories beside the
632
- document are included whole.
856
+ document are included whole. Neither finds another stage's output
857
+ DVC doesn't cache, so the pipeline's inputs for the document are
858
+ added too.
633
859
  """
634
860
  doc_dir = Path(root, os.path.dirname(tex_file))
635
861
  pointed = [
@@ -637,7 +863,8 @@ def diff(
637
863
  for p in sorted(doc_dir.rglob("*.dvc"))
638
864
  if p.is_file()
639
865
  ]
640
- return calkit.latex.detect_inputs(tex_file, wdir=root) + pointed
866
+ detected = calkit.latex.detect_inputs(tex_file, wdir=root)
867
+ return detected + pointed + pipeline_inputs
641
868
 
642
869
  def point_changed_figures_at_base(base_root: str, head_root: str) -> None:
643
870
  """Make the older side's changed figures refer to its own copies.
@@ -698,6 +925,20 @@ def diff(
698
925
  from_ref = _default_base_ref(repo)
699
926
  if to_ref is None and not os.path.isfile(tex_file):
700
927
  raise_error(f"{tex_file} does not exist")
928
+ # A document the pipeline builds is diffed the way it's built, with its
929
+ # stage's environment, settings, and inputs, unless told otherwise
930
+ stage_name, stage = find_latex_stage()
931
+ pipeline_inputs: list[str] = []
932
+ if stage is not None:
933
+ if environment is None:
934
+ environment = stage.get("environment")
935
+ if latexmk_rc_path is None and stage.get("latexmkrc_path"):
936
+ latexmk_rc_path = stage_path(stage, stage["latexmkrc_path"])
937
+ latexmk_args = latexmk_args or list(stage.get("latexmk_args") or [])
938
+ latexdiff_args = latexdiff_args or list(
939
+ stage.get("latexdiff_args") or []
940
+ )
941
+ pipeline_inputs = stage_inputs(str(stage_name), stage)
701
942
  if output is None:
702
943
  output = get_diff_path(
703
944
  tex_file,
@@ -766,9 +1007,9 @@ def diff(
766
1007
  break_verbatim_params(root)
767
1008
  point_changed_figures_at_base(checkouts["base"], head_root)
768
1009
  _build_diff(
769
- base_tex=sides["base"],
770
- head_tex=sides["head"],
771
- tex_file=tex_file,
1010
+ base_tex_fpath=sides["base"],
1011
+ head_tex_fpath=sides["head"],
1012
+ tex_file_fpath=tex_file,
772
1013
  head_root=head_root,
773
1014
  output=output,
774
1015
  environment=environment,
@@ -825,9 +1066,9 @@ def _remove_worktree(path: str) -> None:
825
1066
 
826
1067
 
827
1068
  def _build_diff(
828
- base_tex: str,
829
- head_tex: str,
830
- tex_file: str,
1069
+ base_tex_fpath: str,
1070
+ head_tex_fpath: str,
1071
+ tex_file_fpath: str,
831
1072
  head_root: str,
832
1073
  output: str,
833
1074
  environment: str | None,
@@ -840,39 +1081,63 @@ def _build_diff(
840
1081
  force: bool,
841
1082
  verbose: bool,
842
1083
  ) -> None:
843
- """Mark up one document against another and build the result."""
1084
+ """Mark up one document against another and build the result.
1085
+
1086
+ base_tex_fpath is the older revision's document, head_tex_fpath the
1087
+ newer revision's, and tex_file_fpath the document as named on the
1088
+ command line. head_root is where the newer side lives: a checkout,
1089
+ or the working tree, beside which the marked-up document is built
1090
+ so its relative inputs resolve.
1091
+ """
844
1092
  # Built beside the newer side, so \graphicspath, \bibliography, and
845
1093
  # relative \includegraphics resolve the way they do for the real thing,
846
1094
  # against that revision's own files
847
- tex_dir = os.path.dirname(tex_file) or "."
1095
+ tex_dir = os.path.dirname(tex_file_fpath) or "."
848
1096
  build_dir = os.path.normpath(os.path.join(head_root, tex_dir))
849
- stem = Path(tex_file).stem
850
- diff_tex = os.path.join(build_dir, f"{stem}-diff.tex")
851
- # Where --keep-tex leaves it: beside the document, since a checkout is
852
- # removed afterwards
853
- kept_tex = os.path.normpath(os.path.join(tex_dir, f"{stem}-diff.tex"))
1097
+ stem = Path(tex_file_fpath).stem
1098
+ diff_tex_fpath = os.path.join(build_dir, f"{stem}-diff.tex")
1099
+ # Where --keep-tex leaves its copies: beside the diff PDF, since a
1100
+ # checkout is removed afterwards. The old and new files are what
1101
+ # latexdiff saw, after verbatim fixes and figure repointing, so a
1102
+ # --flatten or macro expansion failure can be inspected.
1103
+ kept_stem = os.path.splitext(output)[0]
1104
+ kept_diff_fpath = f"{kept_stem}-diff.tex"
1105
+ kept_old_fpath = f"{kept_stem}-old.tex"
1106
+ kept_new_fpath = f"{kept_stem}-new.tex"
1107
+ marked_up: bytes | None = None
854
1108
  aux_dir = os.path.join(build_dir, calkit.latex.DIFF_AUX_DIRNAME)
855
1109
  try:
856
1110
  # --flatten pulls \input and \include files into one document on
857
1111
  # each side, so a multi-file paper compares as a whole
858
1112
  latexdiff_cmd = ["latexdiff", "--flatten", "--encoding=utf8"]
859
- # A checkout's verbatim inputs named by macro parameters are already
860
- # broken, so this only finds the working tree's, which can't be
861
- # edited. A filter breaks those instead, though latexdiff's
862
- # --filter-script mangles anything outside Latin-1.
863
- sources = [base_tex, head_tex] + [
1113
+ sources = [base_tex_fpath, head_tex_fpath] + [
864
1114
  path
865
- for side in (base_tex, head_tex)
1115
+ for side in (base_tex_fpath, head_tex_fpath)
866
1116
  for path in calkit.latex.detect_inputs(side)
867
1117
  if Path(path).suffix in calkit.latex._SOURCE_EXTS
868
1118
  ]
869
- if any(
870
- os.path.isfile(path)
871
- and _VERBATIM_PARAM_RE.search(
872
- Path(path).read_text(encoding="utf-8", errors="replace")
873
- )
1119
+ texts = [
1120
+ Path(path).read_text(encoding="utf-8", errors="replace")
874
1121
  for path in sources
875
- ):
1122
+ if os.path.isfile(path)
1123
+ ]
1124
+ # A macro wrapping an \input or \include, e.g., one making an
1125
+ # appendix single column, gets the whole flattened file as its
1126
+ # argument, which latexdiff would otherwise mark up as one token
1127
+ wrappers = sorted(
1128
+ {
1129
+ name
1130
+ for text in texts
1131
+ for name in _INPUT_WRAPPER_RE.findall(text)
1132
+ }
1133
+ )
1134
+ if wrappers:
1135
+ latexdiff_cmd.append("--append-textcmd=" + ";".join(wrappers))
1136
+ # A checkout's verbatim inputs named by macro parameters are already
1137
+ # broken, so this only finds the working tree's, which can't be
1138
+ # edited. A filter breaks those instead, though latexdiff's
1139
+ # --filter-script mangles anything outside Latin-1.
1140
+ if any(_VERBATIM_PARAM_RE.search(text) for text in texts):
876
1141
  filter_path = Path(DIFF_TMP_DIR, "verbatim-param-filter.pl")
877
1142
  os.makedirs(filter_path.parent, exist_ok=True)
878
1143
  # latexdiff appends a newline to what it sends, so drop it
@@ -896,7 +1161,7 @@ def _build_diff(
896
1161
  # defaults
897
1162
  latexdiff_cmd += latexdiff_args
898
1163
  cmd = _tex_cmd(
899
- latexdiff_cmd + [base_tex, head_tex],
1164
+ latexdiff_cmd + [base_tex_fpath, head_tex_fpath],
900
1165
  environment=environment,
901
1166
  no_check=no_check,
902
1167
  verbose=verbose,
@@ -904,7 +1169,9 @@ def _build_diff(
904
1169
  )
905
1170
  typer.echo("Marking up the document with latexdiff")
906
1171
  try:
907
- marked_up = subprocess.check_output(cmd)
1172
+ # No stdin, so an environment's container isn't given a TTY,
1173
+ # which would merge latexdiff's warnings into the document
1174
+ marked_up = subprocess.check_output(cmd, stdin=subprocess.DEVNULL)
908
1175
  except FileNotFoundError:
909
1176
  raise_error(
910
1177
  "latexdiff was not found; it ships with TeX Live, so a "
@@ -944,8 +1211,7 @@ def _build_diff(
944
1211
  ):
945
1212
  typer.echo(f"{output} is up to date")
946
1213
  return
947
- with open(diff_tex, "wb") as f:
948
- f.write(marked_up)
1214
+ Path(diff_tex_fpath).write_bytes(marked_up)
949
1215
  os.makedirs(aux_dir, exist_ok=True)
950
1216
  rel_aux = calkit.latex.DIFF_AUX_DIRNAME
951
1217
  latexmk_cmd = ["latexmk"]
@@ -965,17 +1231,30 @@ def _build_diff(
965
1231
  # User pass-through args come last so they can override Calkit's
966
1232
  # defaults
967
1233
  latexmk_cmd += latexmk_args
968
- latexmk_cmd.append(diff_tex)
1234
+ latexmk_cmd.append(diff_tex_fpath)
1235
+ tex_env_vars = _tex_env_vars(
1236
+ calkit.latex.get_source_date_epoch(tex_file_fpath)
1237
+ )
969
1238
  cmd = _tex_cmd(
970
1239
  latexmk_cmd,
971
1240
  environment=environment,
972
1241
  no_check=no_check,
973
1242
  verbose=verbose,
974
1243
  dep="latexmk",
1244
+ env_vars=tex_env_vars,
975
1245
  )
976
1246
  typer.echo("Building the marked-up document")
977
1247
  try:
978
- subprocess.check_call(cmd)
1248
+ status = _run_latexmk(
1249
+ cmd,
1250
+ env=(os.environ | tex_env_vars) if tex_env_vars else None,
1251
+ log_path=os.path.join(aux_dir, f"{stem}-diff.log"),
1252
+ fdb_path=os.path.join(aux_dir, f"{stem}-diff.fdb_latexmk"),
1253
+ environment=environment,
1254
+ verbose=verbose,
1255
+ )
1256
+ if status:
1257
+ raise subprocess.CalledProcessError(status, cmd)
979
1258
  except subprocess.CalledProcessError as e:
980
1259
  # -silent hides why, so show the errors LaTeX logged
981
1260
  log_path = Path(aux_dir, f"{stem}-diff.log")
@@ -993,10 +1272,13 @@ def _build_diff(
993
1272
  if excerpt:
994
1273
  typer.echo(f"From {log_path.as_posix()}:", err=True)
995
1274
  typer.echo("\n".join(excerpt), err=True)
996
- raise_error(
997
- "latexmk failed on the marked-up document with exit status "
998
- f"{e.returncode}; rerun with --keep-tex to inspect it"
1275
+ msg = (
1276
+ "latexmk failed on the diff document with exit code "
1277
+ f"{e.returncode}"
999
1278
  )
1279
+ if not keep_tex:
1280
+ msg += "; rerun with --keep-tex to inspect"
1281
+ raise_error(msg)
1000
1282
  built = os.path.join(aux_dir, f"{stem}-diff.pdf")
1001
1283
  if not os.path.isfile(built):
1002
1284
  raise_error("latexmk did not produce a PDF")
@@ -1007,16 +1289,28 @@ def _build_diff(
1007
1289
  if os.path.abspath(head_root) == os.path.abspath("."):
1008
1290
  shutil.rmtree(aux_dir, ignore_errors=True)
1009
1291
  os.makedirs(os.path.dirname(state_path), exist_ok=True)
1010
- with open(state_path, "w") as f:
1011
- f.write(digest)
1292
+ Path(state_path).write_text(digest)
1012
1293
  typer.echo(f"Wrote {output}")
1013
1294
  finally:
1014
- if os.path.isfile(diff_tex):
1015
- in_place = os.path.abspath(diff_tex) == os.path.abspath(kept_tex)
1016
- if keep_tex and not in_place:
1017
- shutil.copy(diff_tex, kept_tex)
1018
- if not keep_tex or not in_place:
1019
- os.remove(diff_tex)
1295
+ if keep_tex:
1296
+ os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
1297
+ if marked_up is not None:
1298
+ Path(kept_diff_fpath).write_bytes(marked_up)
1299
+ for src, kept_fpath in (
1300
+ (base_tex_fpath, kept_old_fpath),
1301
+ (head_tex_fpath, kept_new_fpath),
1302
+ ):
1303
+ if os.path.isfile(src):
1304
+ shutil.copy(src, kept_fpath)
1305
+ for kept_fpath in (
1306
+ kept_old_fpath,
1307
+ kept_new_fpath,
1308
+ kept_diff_fpath,
1309
+ ):
1310
+ if os.path.isfile(kept_fpath):
1311
+ typer.echo(f"Kept {kept_fpath} for inspection")
1312
+ if os.path.isfile(diff_tex_fpath):
1313
+ os.remove(diff_tex_fpath)
1020
1314
 
1021
1315
 
1022
1316
  @latex_app.command(name="to-docx")