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.
- calkit/agent_skills/conventions/SKILL.md +26 -0
- calkit/cli/config.py +9 -1
- calkit/cli/latex.py +346 -52
- calkit/cli/main/core.py +192 -8
- calkit/cli/new.py +7 -8
- calkit/cli/overleaf.py +8 -7
- calkit/cli/update.py +251 -0
- calkit/docker.py +129 -5
- calkit/dvc/core.py +39 -0
- calkit/environments.py +4 -6
- calkit/install.py +7 -7
- calkit/latex.py +95 -1
- calkit/models/pipeline.py +27 -5
- calkit/pipeline.py +173 -8
- calkit/questions.py +31 -4
- calkit/resources/devcontainer/devcontainer.json +1 -1
- calkit/resources/vscode/settings.json +1 -1
- calkit/tests/cli/main/test_core.py +186 -0
- calkit/tests/cli/main/test_xr.py +1 -0
- calkit/tests/cli/test_check.py +20 -9
- calkit/tests/cli/test_latex.py +169 -4
- calkit/tests/cli/test_new.py +3 -2
- calkit/tests/cli/test_overleaf.py +2 -1
- calkit/tests/cli/test_update.py +73 -0
- calkit/tests/models/test_pipeline.py +17 -1
- calkit/tests/test_conda.py +3 -0
- calkit/tests/test_docker.py +61 -0
- calkit/tests/test_environments.py +2 -1
- calkit/tests/test_pipeline.py +121 -0
- calkit/tests/test_questions.py +37 -0
- {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/METADATA +190 -58
- {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/RECORD +48 -48
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.47.7.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/WHEEL +0 -0
- {calkit_python-0.47.7.dist-info → calkit_python-0.47.9.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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(
|
|
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(
|
|
850
|
-
|
|
851
|
-
# Where --keep-tex leaves
|
|
852
|
-
# removed afterwards
|
|
853
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
870
|
-
|
|
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 + [
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
997
|
-
"latexmk failed on the
|
|
998
|
-
f"{e.returncode}
|
|
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
|
-
|
|
1011
|
-
f.write(digest)
|
|
1292
|
+
Path(state_path).write_text(digest)
|
|
1012
1293
|
typer.echo(f"Wrote {output}")
|
|
1013
1294
|
finally:
|
|
1014
|
-
if
|
|
1015
|
-
|
|
1016
|
-
if
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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")
|