calkit-python 0.49.2__py3-none-any.whl → 0.49.3__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 +10 -10
- calkit/agent_skills/create-pipeline/SKILL.md +5 -5
- calkit/cli/check.py +153 -75
- calkit/cli/describe.py +38 -0
- calkit/cli/latex.py +41 -0
- calkit/cli/list.py +67 -15
- calkit/cli/main/core.py +349 -189
- calkit/cli/overleaf.py +423 -77
- calkit/cli/update.py +61 -4
- calkit/config.py +3 -0
- calkit/core.py +119 -3
- calkit/detect.py +50 -4
- calkit/dvc/core.py +2 -2
- calkit/environments.py +16 -4
- calkit/latex.py +108 -2
- calkit/models/io.py +156 -1
- calkit/models/pipeline.py +86 -26
- calkit/overleaf.py +678 -37
- calkit/pipeline.py +153 -50
- calkit/resources/devcontainer/devcontainer.json +6 -2
- calkit/resources/vscode/extensions.json +2 -1
- calkit/resources/vscode/settings.json +4 -1
- calkit/tests/cli/main/test_core.py +90 -0
- calkit/tests/cli/main/test_subprojects.py +118 -0
- calkit/tests/cli/test_check.py +94 -0
- calkit/tests/cli/test_describe.py +73 -0
- calkit/tests/cli/test_latex.py +62 -0
- calkit/tests/cli/test_list.py +99 -0
- calkit/tests/cli/test_overleaf.py +759 -20
- calkit/tests/cli/test_update.py +67 -0
- calkit/tests/dvc/test_core.py +10 -0
- calkit/tests/models/test_io.py +115 -0
- calkit/tests/models/test_pipeline.py +45 -0
- calkit/tests/test_core.py +82 -0
- calkit/tests/test_detect.py +75 -0
- calkit/tests/test_environments.py +29 -0
- calkit/tests/test_latex.py +85 -0
- calkit/tests/test_overleaf.py +205 -0
- calkit/tests/test_pipeline.py +141 -3
- {calkit_python-0.49.2.dist-info → calkit_python-0.49.3.dist-info}/METADATA +1 -5
- {calkit_python-0.49.2.dist-info → calkit_python-0.49.3.dist-info}/RECORD +57 -55
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.49.2.data → calkit_python-0.49.3.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.49.2.dist-info → calkit_python-0.49.3.dist-info}/WHEEL +0 -0
- {calkit_python-0.49.2.dist-info → calkit_python-0.49.3.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.49.2.dist-info → calkit_python-0.49.3.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
|
|
41
|
-
path:
|
|
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
|
|
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**: `
|
|
49
|
-
`
|
|
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
|
|
61
|
-
path:
|
|
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/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
|
-
|
|
924
|
-
if not
|
|
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
|
|
933
|
-
if
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
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
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
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
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
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
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
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/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
|
calkit/cli/list.py
CHANGED
|
@@ -183,20 +183,23 @@ def list_presentations(
|
|
|
183
183
|
_list_artifacts("presentations", json_output, declared_only)
|
|
184
184
|
|
|
185
185
|
|
|
186
|
-
def _echo_question(n: int, question: str | dict) -> None:
|
|
186
|
+
def _echo_question(n: int, question: str | dict, project: str = "") -> None:
|
|
187
187
|
"""Print a single question in the human-readable YAML-ish listing format.
|
|
188
188
|
|
|
189
189
|
String questions are shown as a numbered line. Rich (dict) questions are
|
|
190
190
|
shown with their text on the first line followed by hypothesis, answer,
|
|
191
|
-
evidence, and any other fields in YAML-like indented form.
|
|
191
|
+
evidence, and any other fields in YAML-like indented form. A
|
|
192
|
+
subproject's are numbered within it and prefixed with its path, as
|
|
193
|
+
``calkit check questions`` names them.
|
|
192
194
|
"""
|
|
195
|
+
where = f"{project}: " if project else ""
|
|
193
196
|
if isinstance(question, str):
|
|
194
|
-
typer.echo(f"{n}. {question}")
|
|
197
|
+
typer.echo(f"{where}{n}. {question}")
|
|
195
198
|
return
|
|
196
199
|
# Copy so popping 'question' doesn't mutate the caller's dict.
|
|
197
200
|
question = dict(question)
|
|
198
201
|
text = question.pop("question", "")
|
|
199
|
-
typer.echo(f"{n}. question: {text}")
|
|
202
|
+
typer.echo(f"{where}{n}. question: {text}")
|
|
200
203
|
# Rendering fills every field, so leave out the ones that aren't set
|
|
201
204
|
for k, v in question.items():
|
|
202
205
|
if v is None:
|
|
@@ -244,8 +247,12 @@ def list_questions(
|
|
|
244
247
|
|
|
245
248
|
Placeholders in the text, such as ``{improvement:.1f}``, are filled from
|
|
246
249
|
the question's value evidence, so numbers shown are read from the
|
|
247
|
-
results files rather than retyped into ``calkit.yaml``.
|
|
250
|
+
results files rather than retyped into ``calkit.yaml``. Subprojects'
|
|
251
|
+
questions are listed too, numbered within each, and in the JSON output
|
|
252
|
+
each has a ``project`` key with the subproject's path.
|
|
248
253
|
"""
|
|
254
|
+
from pathlib import Path
|
|
255
|
+
|
|
249
256
|
from calkit.questions import (
|
|
250
257
|
TEMPLATED_FIELDS,
|
|
251
258
|
placeholders,
|
|
@@ -260,10 +267,17 @@ def list_questions(
|
|
|
260
267
|
if isinstance(ev, dict)
|
|
261
268
|
]
|
|
262
269
|
|
|
263
|
-
|
|
264
|
-
|
|
270
|
+
# Each with its 1-based index in its own project and that project's
|
|
271
|
+
# path, empty for this one
|
|
272
|
+
listed: list[tuple[int, str | dict, str]] = []
|
|
273
|
+
for wdir, info in calkit.iter_projects():
|
|
274
|
+
project = "" if wdir == "." else Path(wdir).as_posix()
|
|
275
|
+
for n, q in enumerate(info.get("questions", []) or [], start=1):
|
|
276
|
+
if not raw:
|
|
277
|
+
q = render_question(q, info, wdir=wdir)
|
|
278
|
+
listed.append((n, q, project))
|
|
279
|
+
questions = [q for _, q, _ in listed]
|
|
265
280
|
if not raw:
|
|
266
|
-
rendered = [render_question(q, ck_info) for q in questions]
|
|
267
281
|
# A placeholder left as written looks exactly like text somebody
|
|
268
282
|
# meant literally, so say when one could not be filled rather than
|
|
269
283
|
# let a fresh clone read as a project that types its braces. Any
|
|
@@ -273,7 +287,7 @@ def list_questions(
|
|
|
273
287
|
# answer still in clauses is one whose conditions could not be read.
|
|
274
288
|
unfilled = any(
|
|
275
289
|
isinstance(text, dict) or placeholders(text)
|
|
276
|
-
for q in
|
|
290
|
+
for q in questions
|
|
277
291
|
if isinstance(q, dict)
|
|
278
292
|
for text in _texts(q)
|
|
279
293
|
)
|
|
@@ -285,12 +299,20 @@ def list_questions(
|
|
|
285
299
|
"the results files are not here yet.",
|
|
286
300
|
err=json_output,
|
|
287
301
|
)
|
|
288
|
-
questions = rendered
|
|
289
302
|
if json_output:
|
|
290
|
-
echo_json(
|
|
303
|
+
echo_json(
|
|
304
|
+
[
|
|
305
|
+
q
|
|
306
|
+
if not project
|
|
307
|
+
else {"project": project, "question": q}
|
|
308
|
+
if isinstance(q, str)
|
|
309
|
+
else {"project": project, **q}
|
|
310
|
+
for _, q, project in listed
|
|
311
|
+
]
|
|
312
|
+
)
|
|
291
313
|
return
|
|
292
|
-
for n, question in
|
|
293
|
-
_echo_question(n, question)
|
|
314
|
+
for n, question, project in listed:
|
|
315
|
+
_echo_question(n, question, project)
|
|
294
316
|
|
|
295
317
|
|
|
296
318
|
@list_app.command(name="comments")
|
|
@@ -639,9 +661,16 @@ def list_stages(
|
|
|
639
661
|
bool, typer.Option("--json", help="Output result as JSON.")
|
|
640
662
|
] = False,
|
|
641
663
|
):
|
|
642
|
-
"""List pipeline stages."""
|
|
664
|
+
"""List pipeline stages, including subprojects'."""
|
|
643
665
|
ck_info = calkit.load_calkit_info()
|
|
644
|
-
|
|
666
|
+
# Named as 'calkit status' names them and 'calkit run' accepts them
|
|
667
|
+
stages = {
|
|
668
|
+
calkit.qualify_with_project(wdir, name): stage
|
|
669
|
+
for wdir, info in calkit.iter_projects(ck_info=ck_info)
|
|
670
|
+
for name, stage in (
|
|
671
|
+
(info.get("pipeline") or {}).get("stages") or {}
|
|
672
|
+
).items()
|
|
673
|
+
}
|
|
645
674
|
# If we only want stale stages, we need to get the status first.
|
|
646
675
|
# This compiles the pipeline, cleans notebooks, and checks environments,
|
|
647
676
|
# all of which can affect whether a stage is stale, so we don't skip them.
|
|
@@ -666,6 +695,29 @@ def list_stages(
|
|
|
666
695
|
typer.echo(name)
|
|
667
696
|
|
|
668
697
|
|
|
698
|
+
@list_app.command(name="subprojects")
|
|
699
|
+
def list_subprojects(
|
|
700
|
+
json_output: Annotated[
|
|
701
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
702
|
+
] = False,
|
|
703
|
+
) -> None:
|
|
704
|
+
"""List subprojects, including nested ones, by path."""
|
|
705
|
+
subprojects = calkit.get_subprojects()
|
|
706
|
+
if json_output:
|
|
707
|
+
echo_json(
|
|
708
|
+
{
|
|
709
|
+
path: {
|
|
710
|
+
"parent": sp["parent"],
|
|
711
|
+
"description": sp["entry"].get("description"),
|
|
712
|
+
}
|
|
713
|
+
for path, sp in subprojects.items()
|
|
714
|
+
}
|
|
715
|
+
)
|
|
716
|
+
return
|
|
717
|
+
for path in subprojects:
|
|
718
|
+
typer.echo(path)
|
|
719
|
+
|
|
720
|
+
|
|
669
721
|
@list_app.command(name="latex-diffs")
|
|
670
722
|
def list_latex_diffs(
|
|
671
723
|
json_output: Annotated[
|