calkit-python 0.49.2__py3-none-any.whl → 0.49.4__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 (62) hide show
  1. calkit/agent_skills/conventions/SKILL.md +10 -10
  2. calkit/agent_skills/create-pipeline/SKILL.md +5 -5
  3. calkit/cli/__init__.py +10 -0
  4. calkit/cli/check.py +153 -75
  5. calkit/cli/core.py +21 -5
  6. calkit/cli/describe.py +38 -0
  7. calkit/cli/latex.py +41 -0
  8. calkit/cli/list.py +67 -15
  9. calkit/cli/main/core.py +381 -194
  10. calkit/cli/overleaf.py +541 -78
  11. calkit/cli/update.py +62 -5
  12. calkit/config.py +3 -0
  13. calkit/core.py +168 -3
  14. calkit/detect.py +50 -4
  15. calkit/dvc/core.py +2 -2
  16. calkit/environments.py +66 -24
  17. calkit/hub.py +3 -1
  18. calkit/latex.py +108 -2
  19. calkit/models/io.py +156 -1
  20. calkit/models/pipeline.py +91 -26
  21. calkit/overleaf.py +679 -37
  22. calkit/pipeline.py +183 -58
  23. calkit/resources/devcontainer/devcontainer.json +6 -2
  24. calkit/resources/vscode/extensions.json +2 -1
  25. calkit/resources/vscode/settings.json +4 -1
  26. calkit/tests/cli/main/test_core.py +90 -0
  27. calkit/tests/cli/main/test_subprojects.py +118 -0
  28. calkit/tests/cli/test_check.py +94 -0
  29. calkit/tests/cli/test_core.py +22 -0
  30. calkit/tests/cli/test_describe.py +73 -0
  31. calkit/tests/cli/test_latex.py +62 -0
  32. calkit/tests/cli/test_list.py +99 -0
  33. calkit/tests/cli/test_overleaf.py +888 -20
  34. calkit/tests/cli/test_update.py +67 -0
  35. calkit/tests/dvc/test_core.py +10 -0
  36. calkit/tests/models/test_io.py +115 -0
  37. calkit/tests/models/test_pipeline.py +59 -0
  38. calkit/tests/test_core.py +157 -0
  39. calkit/tests/test_detect.py +75 -0
  40. calkit/tests/test_environments.py +57 -1
  41. calkit/tests/test_latex.py +85 -0
  42. calkit/tests/test_overleaf.py +205 -0
  43. calkit/tests/test_pipeline.py +182 -3
  44. calkit/upgrade.py +3 -1
  45. {calkit_python-0.49.2.dist-info → calkit_python-0.49.4.dist-info}/METADATA +1 -5
  46. {calkit_python-0.49.2.dist-info → calkit_python-0.49.4.dist-info}/RECORD +62 -60
  47. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  48. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  49. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  50. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  51. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  52. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  53. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  54. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  55. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  56. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  57. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  58. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  59. {calkit_python-0.49.2.data → calkit_python-0.49.4.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  60. {calkit_python-0.49.2.dist-info → calkit_python-0.49.4.dist-info}/WHEEL +0 -0
  61. {calkit_python-0.49.2.dist-info → calkit_python-0.49.4.dist-info}/entry_points.txt +0 -0
  62. {calkit_python-0.49.2.dist-info → calkit_python-0.49.4.dist-info}/licenses/LICENSE +0 -0
@@ -37,9 +37,8 @@ A minimal example:
37
37
  ```yaml
38
38
  environments:
39
39
  main:
40
- kind: uv-venv
41
- path: requirements.txt
42
- python: "3.13"
40
+ kind: uv
41
+ path: pyproject.toml
43
42
 
44
43
  pipeline:
45
44
  stages:
@@ -61,7 +60,8 @@ kinds:
61
60
 
62
61
  | Kind | Spec file |
63
62
  | --------- | --------------------------------------- |
64
- | `uv-venv` | `requirements.txt` or `pyproject.toml` |
63
+ | `uv` | `pyproject.toml` (with `uv.lock`) |
64
+ | `uv-venv` | `requirements.txt` |
65
65
  | `venv` | `requirements.txt` |
66
66
  | `conda` | `environment.yml` |
67
67
  | `pixi` | `pixi.toml` |
@@ -72,6 +72,12 @@ kinds:
72
72
  | `ssh` | (remote host config) |
73
73
  | `slurm` | (HPC cluster config) |
74
74
 
75
+ For Python, default to a `uv` project environment, created with
76
+ `calkit new uv-env -n <name> <packages>`, which writes `pyproject.toml` and
77
+ `uv.lock`; set the Python version with `--python`, which becomes
78
+ `requires-python`. Use `uv-venv` only for a project that already has a
79
+ `requirements.txt` it should keep.
80
+
75
81
  Example environment definitions:
76
82
 
77
83
  ```yaml
@@ -79,12 +85,6 @@ environments:
79
85
  py:
80
86
  kind: uv
81
87
  path: pyproject.toml
82
- python: "3.13"
83
-
84
- py2:
85
- kind: uv-venv
86
- path: requirements.txt
87
- python: "3.13"
88
88
 
89
89
  texlive:
90
90
  kind: docker
@@ -45,8 +45,9 @@ no `pipeline` section, skip this—you will add one.
45
45
 
46
46
  Every stage must reference a named environment. Identify what's needed:
47
47
 
48
- - **Python**: `requirements.txt` or `pyproject.toml` → `uv-venv`;
49
- `environment.yml` → `conda`
48
+ - **Python**: `pyproject.toml`, or no spec yet → `uv`, created with
49
+ `calkit new uv-env`; an existing `requirements.txt` the project should
50
+ keep → `uv-venv`; `environment.yml` → `conda`
50
51
  - **R**: `renv.lock` → `renv`
51
52
  - **Julia**: `Project.toml` → `julia`
52
53
  - **LaTeX**: `docker` with `texlive/texlive:latest-full`
@@ -57,9 +58,8 @@ Add environments to `calkit.yaml`:
57
58
  ```yaml
58
59
  environments:
59
60
  main:
60
- kind: uv-venv
61
- path: requirements.txt
62
- python: "3.13"
61
+ kind: uv
62
+ path: pyproject.toml
63
63
  ```
64
64
 
65
65
  Name a single Python environment `main`. With multiple environments, use
calkit/cli/__init__.py CHANGED
@@ -19,6 +19,16 @@ def run() -> None:
19
19
  # otherwise print the *current* CLI's help instead of re-execing
20
20
  # under the requested version.
21
21
  _maybe_exec_with_version()
22
+ if sys.platform == "win32":
23
+ import io
24
+
25
+ from calkit.core import is_mintty
26
+
27
+ # mintty shows UTF-8, so emoji needn't be replaced, and output is
28
+ # line buffered as it would be to a terminal
29
+ for stream in (sys.stdout, sys.stderr):
30
+ if isinstance(stream, io.TextIOWrapper) and is_mintty(stream):
31
+ stream.reconfigure(encoding="utf-8", line_buffering=True)
22
32
  from .main import app
23
33
 
24
34
  # Completion lists commands, so needs them all
calkit/cli/check.py CHANGED
@@ -919,35 +919,49 @@ def check_environments(
919
919
  bool, typer.Option("--verbose", help="Print verbose output.")
920
920
  ] = False,
921
921
  ) -> None:
922
+ """Check that the project's environments are up to date.
923
+
924
+ Subprojects' environments are checked too, as 'calkit run' does.
925
+ """
922
926
  ck_info = calkit.load_calkit_info()
923
- envs = ck_info.get("environments", {})
924
- if not envs:
927
+ projects = list(calkit.iter_projects(ck_info=ck_info))
928
+ if not any(info.get("environments") for _, info in projects):
925
929
  typer.echo("No environments defined in calkit.yaml")
926
930
  return
927
931
  # Set any project-level environmental variables before checking
928
932
  # environments
929
933
  dotenv.load_dotenv(dotenv_path=".env", verbose=verbose)
930
- calkit.set_env_vars(ck_info=ck_info, cli=True)
931
934
  failures = []
932
- for env_name, env in envs.items():
933
- if env.get("kind") in calkit.environments.KINDS_NO_CHECK:
934
- if verbose:
935
- typer.echo(
936
- f"Skipping check for {env['kind']} env '{env_name}'"
937
- )
935
+ for wdir, info in projects:
936
+ # A parent's variables apply to its subprojects even if it has no
937
+ # environments of its own, as in 'calkit run'
938
+ calkit.set_env_vars(ck_info=info, cli=True)
939
+ if not info.get("environments"):
938
940
  continue
939
- typer.echo(f"Checking environment: '{env_name}'")
940
- try:
941
- check_environment(env_name=env_name, verbose=verbose)
942
- except typer.Exit:
943
- # The check reported its own reason through raise_error, which
944
- # has already printed it. Repeating it here would replace a
945
- # real message ("Unknown system property to lock: ...") with
946
- # the exit code, since that is all this exception carries.
947
- failures.append(env_name)
948
- except Exception as e:
949
- warn(f"Error checking environment '{env_name}': {e}")
950
- failures.append(env_name)
941
+ # check_environment reads the project it's in, so go there
942
+ with calkit.in_project(wdir):
943
+ for env_name, env in info["environments"].items():
944
+ if env.get("kind") in calkit.environments.KINDS_NO_CHECK:
945
+ if verbose:
946
+ typer.echo(
947
+ f"Skipping check for {env['kind']} env "
948
+ f"'{env_name}'"
949
+ )
950
+ continue
951
+ qualified = calkit.qualify_with_project(wdir, env_name)
952
+ typer.echo(f"Checking environment: '{qualified}'")
953
+ try:
954
+ check_environment(env_name=env_name, verbose=verbose)
955
+ except typer.Exit:
956
+ # The check reported its own reason through raise_error,
957
+ # which has already printed it. Repeating it here would
958
+ # replace a real message ("Unknown system property to
959
+ # lock: ...") with the exit code, since that is all this
960
+ # exception carries.
961
+ failures.append(qualified)
962
+ except Exception as e:
963
+ warn(f"Error checking environment '{qualified}': {e}")
964
+ failures.append(qualified)
951
965
  if failures:
952
966
  raise_error(
953
967
  f"Failed to check the following environments: {', '.join(failures)}"
@@ -2195,15 +2209,21 @@ def check_project_requirements(
2195
2209
  ),
2196
2210
  ] = False,
2197
2211
  ) -> None:
2198
- """Check that a project's system-level requirements are met."""
2212
+ """Check that a project's system-level requirements are met.
2213
+
2214
+ Subprojects' requirements are checked too, since the host has to meet
2215
+ them for the project's pipeline to run.
2216
+ """
2199
2217
  typer.echo("Checking project requirements")
2200
2218
  dotenv.load_dotenv(dotenv_path=".env", verbose=verbose)
2201
2219
  if no_cache:
2202
2220
  calkit.requirements.cache_clear()
2203
- try:
2204
- calkit.check_requirements(use_cache=not no_cache)
2205
- except Exception as e:
2206
- raise_error(str(e))
2221
+ for wdir, info in calkit.iter_projects():
2222
+ try:
2223
+ with calkit.in_project(wdir):
2224
+ calkit.check_requirements(ck_info=info, use_cache=not no_cache)
2225
+ except Exception as e:
2226
+ raise_error(f"{calkit.describe_project(wdir)}{e}")
2207
2227
  message = "✅ All set!"
2208
2228
  calkit.echo(message)
2209
2229
 
@@ -2219,34 +2239,71 @@ def check_stage_runs() -> None:
2219
2239
  mistakes and shortcuts, not forgery, since a matching entry can be
2220
2240
  written by anyone.
2221
2241
  """
2242
+ # Every pipeline 'calkit run' runs with this one is checked too, e.g., a
2243
+ # subproject's, against the record kept beside its dvc.lock: any other
2244
+ # dvc.yaml in this DVC repo. One inside a DVC repo of its own is run by
2245
+ # a wrapper stage, which doesn't record its stages.
2222
2246
  try:
2223
- with open(calkit.STAGE_RUNS_FPATH) as f:
2224
- stage_runs = json.load(f)
2225
- except OSError:
2226
- stage_runs = {}
2227
- except json.JSONDecodeError as e:
2228
- raise_error(f"Can't read {calkit.STAGE_RUNS_FPATH}: {e}")
2229
- # By directory, for a subproject's stages
2230
- lock_stages = {None: calkit.dvc.read_lock_stages()}
2231
- problems = []
2232
- for name in sorted(set(lock_stages[None]) | set(stage_runs)):
2233
- lock_dir, lock_name = calkit.dvc.split_lock_stage_name(name)
2234
- if lock_dir not in lock_stages:
2235
- lock_stages[lock_dir] = calkit.dvc.read_lock_stages(lock_dir)
2236
- locked = lock_stages[lock_dir].get(lock_name)
2237
- # One no longer in dvc.lock has nothing to vouch for
2238
- if not isinstance(locked, dict):
2239
- continue
2240
- recorded = stage_runs.get(name)
2241
- if not isinstance(recorded, dict):
2242
- problems.append(f"Stage '{name}' has no recorded run")
2243
- elif recorded.get("dvc_lock_hash") != calkit.dvc.stage_lock_hash(
2244
- locked
2247
+ listed = subprocess.check_output(
2248
+ [
2249
+ "git",
2250
+ "ls-files",
2251
+ "--cached",
2252
+ "--others",
2253
+ "--exclude-standard",
2254
+ "--",
2255
+ "*/dvc.yaml",
2256
+ ],
2257
+ text=True,
2258
+ ).splitlines()
2259
+ except (OSError, subprocess.CalledProcessError):
2260
+ listed = []
2261
+ lock_dirs: list[str | None] = [None]
2262
+ for path in sorted(listed):
2263
+ parts = path.split("/")[:-1]
2264
+ if not os.path.isfile(path) or any(
2265
+ os.path.isdir(os.path.join(*parts[:i], ".dvc"))
2266
+ for i in range(1, len(parts) + 1)
2245
2267
  ):
2246
- problems.append(
2247
- f"Stage '{name}' has changed in dvc.lock since its last "
2248
- "recorded run"
2268
+ continue
2269
+ lock_dirs.append("/".join(parts))
2270
+ stage_runs: dict[str | None, dict] = {}
2271
+ for lock_dir in lock_dirs:
2272
+ try:
2273
+ stage_runs[lock_dir] = calkit.read_stage_runs(lock_dir)
2274
+ except json.JSONDecodeError as e:
2275
+ fpath = os.path.join(lock_dir or "", calkit.STAGE_RUNS_FPATH)
2276
+ raise_error(f"Can't read {fpath}: {e}")
2277
+ # This project's records of other pipelines' stages, under the names
2278
+ # they have here, from before they were kept beside their own dvc.lock
2279
+ parent_runs = {
2280
+ k.replace("\\", "/"): v for k, v in stage_runs[None].items()
2281
+ }
2282
+ problems = []
2283
+ for lock_dir in lock_dirs:
2284
+ lock_stages = calkit.dvc.read_lock_stages(lock_dir)
2285
+ for lock_name, locked in sorted(lock_stages.items()):
2286
+ if not isinstance(locked, dict):
2287
+ continue
2288
+ name = (
2289
+ lock_name
2290
+ if lock_dir is None
2291
+ else f"{lock_dir}/dvc.yaml:{lock_name}"
2249
2292
  )
2293
+ # Either record matching will do, since a stage may have been
2294
+ # run from both directories before they were kept together
2295
+ candidates = [stage_runs[lock_dir].get(lock_name)]
2296
+ if lock_dir is not None:
2297
+ candidates.append(parent_runs.get(name))
2298
+ records = [r for r in candidates if isinstance(r, dict)]
2299
+ lock_hash = calkit.dvc.stage_lock_hash(locked)
2300
+ if not records:
2301
+ problems.append(f"Stage '{name}' has no recorded run")
2302
+ elif all(r.get("dvc_lock_hash") != lock_hash for r in records):
2303
+ problems.append(
2304
+ f"Stage '{name}' has changed in dvc.lock since its last "
2305
+ "recorded run"
2306
+ )
2250
2307
  for problem in problems:
2251
2308
  typer.echo(problem)
2252
2309
  if problems:
@@ -2264,23 +2321,31 @@ def check_env_vars(
2264
2321
  bool, typer.Option("--verbose", "-v", help="Print verbose output")
2265
2322
  ] = False,
2266
2323
  ) -> None:
2267
- """Check that the project's required environmental variables exist."""
2324
+ """Check that the project's required environmental variables exist.
2325
+
2326
+ Subprojects' are included, since they are read from the same
2327
+ environment.
2328
+ """
2268
2329
  from calkit.core import _normalize_requirement
2269
2330
 
2270
2331
  typer.echo("Checking project environmental variables")
2271
2332
  dotenv.load_dotenv(dotenv_path=".env")
2272
- ck_info = calkit.load_calkit_info()
2273
- for raw in calkit.get_requirements(ck_info):
2274
- req = _normalize_requirement(raw)
2275
- if req["kind"] != "env-var":
2276
- continue
2277
- name = req["name"]
2278
- if verbose:
2279
- typer.echo(f"Checking for environmental variable '{name}'")
2280
- if name not in os.environ:
2281
- value = calkit.requirements.prompt_for_env_var_req(req)
2282
- if value is None:
2283
- raise_error(f"No value provided for '{name}'")
2333
+ for wdir, info in calkit.iter_projects():
2334
+ with calkit.in_project(wdir):
2335
+ for raw in calkit.get_requirements(info):
2336
+ req = _normalize_requirement(raw)
2337
+ if req["kind"] != "env-var":
2338
+ continue
2339
+ name = req["name"]
2340
+ if verbose:
2341
+ typer.echo(f"Checking for environmental variable '{name}'")
2342
+ if name not in os.environ:
2343
+ value = calkit.requirements.prompt_for_env_var_req(req)
2344
+ if value is None:
2345
+ raise_error(
2346
+ f"{calkit.describe_project(wdir)}no value "
2347
+ f"provided for '{name}'"
2348
+ )
2284
2349
  message = "✅ All set!"
2285
2350
  calkit.echo(message)
2286
2351
 
@@ -2296,21 +2361,34 @@ def check_pipeline(
2296
2361
  ),
2297
2362
  ] = False,
2298
2363
  ) -> None:
2299
- """Check that the project pipeline is defined correctly."""
2364
+ """Check that the project pipeline is defined correctly.
2365
+
2366
+ Subprojects' pipelines are checked too, since those are stages the
2367
+ project runs.
2368
+ """
2300
2369
  from calkit.models.pipeline import Pipeline
2301
2370
 
2302
2371
  ck_info = calkit.load_calkit_info()
2303
- if "pipeline" not in ck_info:
2372
+ projects = [
2373
+ (wdir, info)
2374
+ for wdir, info in calkit.iter_projects(ck_info=ck_info)
2375
+ if "pipeline" in info
2376
+ ]
2377
+ if not projects:
2304
2378
  raise_error("No pipeline is defined in calkit.yaml")
2305
- try:
2306
- pipeline = Pipeline.model_validate(ck_info["pipeline"], strict=True)
2307
- except Exception as e:
2308
- raise_error(f"Pipeline is not defined correctly: {e}")
2309
- # Check that we have no leading underscores in stage names, since those
2310
- # are reserved for auto-generated stages
2311
- for stage_name in pipeline.stages.keys():
2312
- if stage_name.startswith("_"):
2313
- raise_error("Stage names cannot start with an underscore")
2379
+ for wdir, info in projects:
2380
+ where = calkit.describe_project(wdir)
2381
+ try:
2382
+ pipeline = Pipeline.model_validate(info["pipeline"], strict=True)
2383
+ except Exception as e:
2384
+ raise_error(f"{where}pipeline is not defined correctly: {e}")
2385
+ # Check that we have no leading underscores in stage names, since
2386
+ # those are reserved for auto-generated stages
2387
+ for stage_name in pipeline.stages.keys():
2388
+ if stage_name.startswith("_"):
2389
+ raise_error(
2390
+ f"{where}stage names cannot start with an underscore"
2391
+ )
2314
2392
  message = "✅ This project's pipeline is defined correctly!"
2315
2393
  calkit.echo(message)
2316
2394
  if compile_to_dvc:
calkit/cli/core.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import os
6
6
  import re
7
7
  import subprocess
8
+ import sys
8
9
  from enum import Enum
9
10
  from typing import Any, NoReturn
10
11
 
@@ -152,8 +153,24 @@ def echo_json(obj: Any) -> None:
152
153
  typer.echo(json.dumps(obj, default=str))
153
154
 
154
155
 
156
+ def _echo_above_spinner(text: str, err: bool) -> bool:
157
+ """Print through a spinner's stand-in for the stream, if there is one.
158
+
159
+ Click writes to a Windows console's handle directly, which would put the
160
+ text on the spinner's line rather than above it.
161
+ """
162
+ file_proxy = sys.modules.get("rich.file_proxy")
163
+ stream = sys.stderr if err else sys.stdout
164
+ if file_proxy is None or not isinstance(stream, file_proxy.FileProxy):
165
+ return False
166
+ print(text, file=stream, flush=True)
167
+ return True
168
+
169
+
155
170
  def raise_error(txt: str) -> NoReturn:
156
- typer.echo(typer.style("Error: " + str(txt), fg="red"), err=True)
171
+ text = typer.style("Error: " + str(txt), fg="red")
172
+ if not _echo_above_spinner(text, err=True):
173
+ typer.echo(text, err=True)
157
174
  raise typer.Exit(1)
158
175
 
159
176
 
@@ -164,7 +181,6 @@ def warn(txt: str, prefix: str = "Warning: ", err: bool = False):
164
181
  # defined.
165
182
  from calkit.core import encode_safe
166
183
 
167
- typer.echo(
168
- typer.style(prefix + encode_safe(str(txt), err=err), fg="yellow"),
169
- err=err,
170
- )
184
+ text = typer.style(prefix + encode_safe(str(txt), err=err), fg="yellow")
185
+ if not _echo_above_spinner(text, err=err):
186
+ typer.echo(text, err=err)
calkit/cli/describe.py CHANGED
@@ -109,6 +109,44 @@ def describe_envs(
109
109
  _echo_description(desc, indent=" ")
110
110
 
111
111
 
112
+ @describe_app.command(name="subprojects")
113
+ def describe_subprojects(
114
+ json_output: Annotated[
115
+ bool, typer.Option("--json", help="Output result as JSON.")
116
+ ] = False,
117
+ ) -> None:
118
+ """Describe subprojects, including nested ones.
119
+
120
+ Stages and environments are named as in the subproject itself; from
121
+ here, they're addressed as '<path>:<name>'.
122
+ """
123
+ subprojects = calkit.get_subprojects()
124
+ result = {}
125
+ for path, sp in subprojects.items():
126
+ info = sp["ck_info"]
127
+ result[path] = {
128
+ "parent": sp["parent"],
129
+ "description": sp["entry"].get("description"),
130
+ "name": info.get("name"),
131
+ "title": info.get("title"),
132
+ # With its own DVC repo, a subproject runs as one parent stage
133
+ "isolated": os.path.isdir(os.path.join(path, ".dvc")),
134
+ "environments": list(info.get("environments") or {}),
135
+ "stages": list((info.get("pipeline") or {}).get("stages") or {}),
136
+ "subprojects": [
137
+ p
138
+ for p, child in subprojects.items()
139
+ if child["parent"] == path
140
+ ],
141
+ }
142
+ if json_output:
143
+ echo_json(result)
144
+ return
145
+ for path, desc in result.items():
146
+ typer.echo(path + ":")
147
+ _echo_description(desc, indent=" ")
148
+
149
+
112
150
  @describe_app.command(name="schema")
113
151
  def describe_schema(
114
152
  output: Annotated[
calkit/cli/latex.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import glob
5
6
  import hashlib
6
7
  import json
7
8
  import os
@@ -646,6 +647,8 @@ def build(
646
647
  system environment if available. If not available, a TeX Live Docker
647
648
  container will be used.
648
649
  """
650
+ import time
651
+
649
652
  # latexmk records a failed run in its file database and then refuses
650
653
  # to try again, reporting "Nothing to do" and exiting non-zero with no
651
654
  # PDF. Running it again is the first thing anyone does after a
@@ -681,6 +684,7 @@ def build(
681
684
  rel = Path(os.path.relpath(aux_dir, tex_dir)).as_posix()
682
685
  latexmk_cmd.append(f"-auxdir={rel}")
683
686
  latexmk_cmd.append("-interaction=nonstopmode")
687
+ latexmk_cmd.append(f"-usepretex={calkit.latex.PDF_PRETEX}")
684
688
  # User pass-through args come last so they can override Calkit's defaults.
685
689
  latexmk_cmd += latexmk_args
686
690
  latexmk_cmd.append(tex_file)
@@ -694,6 +698,8 @@ def build(
694
698
  env_vars=tex_env_vars,
695
699
  )
696
700
  log_dir = aux_dir or output_dir or tex_dir
701
+ # A little early, for file systems that keep times coarsely
702
+ started = time.time() - 2
697
703
  if _run_latexmk(
698
704
  cmd,
699
705
  env=(os.environ | tex_env_vars) if tex_env_vars else None,
@@ -703,6 +709,39 @@ def build(
703
709
  verbose=verbose,
704
710
  ):
705
711
  raise_error("latexmk failed")
712
+ # A latexmkrc or a pass-through -outdir can put the PDF elsewhere, so
713
+ # whichever this build wrote gets its ID
714
+ pdf_fpaths = [os.path.join(pdf_dir, stem + ".pdf")] + glob.glob(
715
+ os.path.join(tex_dir, "**", stem + ".pdf"), recursive=True
716
+ )
717
+ for pdf_fpath in dict.fromkeys(map(os.path.normpath, pdf_fpaths)):
718
+ if (
719
+ os.path.isfile(pdf_fpath)
720
+ and os.path.getmtime(pdf_fpath) >= started
721
+ ):
722
+ calkit.latex.set_content_id(pdf_fpath)
723
+ if not no_synctex:
724
+ # A container build records its own paths, which reverse search in
725
+ # a viewer can't open
726
+ synctex_paths = [
727
+ os.path.join(pdf_dir, stem + ext)
728
+ for ext in (".synctex.gz", ".synctex")
729
+ ]
730
+ if latexmk_rc_path is not None and output_dir is None:
731
+ # The rc can set $out_dir, which Calkit doesn't read
732
+ synctex_paths += sorted(
733
+ glob.glob(
734
+ os.path.join(tex_dir, "**", stem + ".synctex*"),
735
+ recursive=True,
736
+ ),
737
+ key=os.path.getmtime,
738
+ reverse=True,
739
+ )
740
+ synctex_path = next(
741
+ (p for p in synctex_paths if os.path.isfile(p)), None
742
+ )
743
+ if synctex_path is not None:
744
+ calkit.latex.localize_synctex(synctex_path, tex_dir)
706
745
 
707
746
 
708
747
  DIFF_TMP_DIR = calkit.latex.DIFF_TMP_DIR
@@ -1760,6 +1799,7 @@ def _build_diff(
1760
1799
  "-interaction=nonstopmode",
1761
1800
  f"-auxdir={rel_aux}",
1762
1801
  f"-outdir={rel_aux}",
1802
+ f"-usepretex={calkit.latex.PDF_PRETEX}",
1763
1803
  ]
1764
1804
  if not verbose:
1765
1805
  latexmk_cmd.append("-silent")
@@ -1828,6 +1868,7 @@ def _build_diff(
1828
1868
  )
1829
1869
  if not os.path.isfile(built):
1830
1870
  raise_error("latexmk did not produce a PDF")
1871
+ calkit.latex.set_content_id(built)
1831
1872
  os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
1832
1873
  shutil.move(built, output)
1833
1874
  # A checkout is removed afterwards, but the working tree's document