calkit-python 0.44.1__py3-none-any.whl → 0.45.1__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/__init__.py +6 -2
- calkit/cli/check.py +14 -2
- calkit/cli/import_.py +18 -5
- calkit/cli/list.py +13 -0
- calkit/cli/main/core.py +306 -37
- calkit/cli/main/xr.py +291 -3
- calkit/cli/new.py +174 -18
- calkit/cli/update.py +117 -11
- calkit/core.py +75 -2
- calkit/dvc/core.py +103 -0
- calkit/environments.py +68 -6
- calkit/markdown.py +2720 -0
- calkit/models/core.py +435 -10
- calkit/models/pipeline.py +132 -13
- calkit/notebooks.py +23 -12
- calkit/pipeline.py +320 -6
- calkit/procedures.py +53 -0
- calkit/provenance.py +28 -0
- calkit/reproducibility.py +476 -0
- calkit/templates/core.py +27 -3
- calkit/templates/latex/ieee-conference/paper.tex +61 -0
- calkit/templates/latex/report/paper.tex +64 -0
- calkit/tests/cli/main/test_core.py +145 -0
- calkit/tests/cli/main/test_xr.py +172 -0
- calkit/tests/cli/test_check.py +49 -0
- calkit/tests/cli/test_list.py +5 -0
- calkit/tests/cli/test_new.py +156 -0
- calkit/tests/cli/test_update.py +125 -0
- calkit/tests/dvc/test_core.py +92 -0
- calkit/tests/test_core.py +68 -0
- calkit/tests/test_markdown.py +1449 -0
- calkit/tests/test_models.py +458 -0
- calkit/tests/test_pipeline.py +484 -0
- calkit/tests/test_procedures.py +64 -0
- calkit/tests/test_reproducibility.py +201 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/package.json +1 -1
- calkit_python-0.45.1.data/data/share/jupyter/labextensions/calkit/static/909.6d8285ce7c45878ac508.js +1 -0
- calkit_python-0.44.1.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.ac9035764d5b2adbb542.js → calkit_python-0.45.1.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.d7a43c7948f690d37d19.js +1 -1
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/METADATA +1 -1
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/RECORD +54 -46
- calkit/check.py +0 -248
- calkit/tests/test_check.py +0 -54
- calkit_python-0.44.1.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -1
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/WHEEL +0 -0
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/licenses/LICENSE +0 -0
calkit/__init__.py
CHANGED
|
@@ -10,7 +10,6 @@ from .core import * # noqa: F403, I001
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
11
|
from calkit import ( # noqa: F401
|
|
12
12
|
calc,
|
|
13
|
-
check,
|
|
14
13
|
conda,
|
|
15
14
|
config,
|
|
16
15
|
datasets,
|
|
@@ -37,7 +36,10 @@ if TYPE_CHECKING:
|
|
|
37
36
|
ops,
|
|
38
37
|
overleaf,
|
|
39
38
|
pipeline,
|
|
39
|
+
procedures,
|
|
40
|
+
provenance,
|
|
40
41
|
releases,
|
|
42
|
+
reproducibility,
|
|
41
43
|
resources,
|
|
42
44
|
schema,
|
|
43
45
|
server,
|
|
@@ -59,7 +61,9 @@ _SUBMODULES = {
|
|
|
59
61
|
"templates",
|
|
60
62
|
"conda",
|
|
61
63
|
"calc",
|
|
62
|
-
"
|
|
64
|
+
"procedures",
|
|
65
|
+
"provenance",
|
|
66
|
+
"reproducibility",
|
|
63
67
|
"github",
|
|
64
68
|
"invenio",
|
|
65
69
|
"releases",
|
calkit/cli/check.py
CHANGED
|
@@ -197,7 +197,19 @@ def _check_julia_env(
|
|
|
197
197
|
cmd += [
|
|
198
198
|
f"--project={env_dir}",
|
|
199
199
|
"-e",
|
|
200
|
-
|
|
200
|
+
# Resolve only when the manifest no longer matches the project
|
|
201
|
+
# (e.g. a dependency was added, or the manifest was written by a
|
|
202
|
+
# different Julia version): plain instantiate would install it
|
|
203
|
+
# as-is and fail later at precompile. A current manifest is
|
|
204
|
+
# installed exactly as locked, with no registry involved---
|
|
205
|
+
# resolving one unconditionally can fail spuriously (and would
|
|
206
|
+
# rewrite the lock). Older Julias without the check keep the
|
|
207
|
+
# plain instantiate they always had.
|
|
208
|
+
"using Pkg; "
|
|
209
|
+
"if hasmethod(Pkg.is_manifest_current, Tuple{String}) && "
|
|
210
|
+
"Pkg.is_manifest_current(dirname(Base.active_project())) == false; "
|
|
211
|
+
"Pkg.resolve(); end; "
|
|
212
|
+
"Pkg.instantiate();",
|
|
201
213
|
]
|
|
202
214
|
if julia_version:
|
|
203
215
|
try:
|
|
@@ -296,7 +308,7 @@ def check_repro(
|
|
|
296
308
|
] = ".",
|
|
297
309
|
) -> None:
|
|
298
310
|
"""Check the reproducibility of a project."""
|
|
299
|
-
from calkit.
|
|
311
|
+
from calkit.reproducibility import check_reproducibility
|
|
300
312
|
|
|
301
313
|
res = check_reproducibility(wdir=wdir, log_func=typer.echo)
|
|
302
314
|
calkit.echo(res.to_pretty())
|
calkit/cli/import_.py
CHANGED
|
@@ -208,7 +208,9 @@ def import_dataset(
|
|
|
208
208
|
filter_paths=filter_paths,
|
|
209
209
|
),
|
|
210
210
|
)
|
|
211
|
-
|
|
211
|
+
# Nulls left out, so the entry reads as what was recorded rather than as
|
|
212
|
+
# a form with most of its fields blank
|
|
213
|
+
datasets.append(new_ds.model_dump(exclude_none=True))
|
|
212
214
|
ck_info["datasets"] = datasets
|
|
213
215
|
with open("calkit.yaml", "w") as f:
|
|
214
216
|
calkit.ryaml.dump(ck_info, f)
|
|
@@ -465,13 +467,24 @@ def import_from_zenodo(
|
|
|
465
467
|
if kind is not None:
|
|
466
468
|
ck_info = calkit.load_calkit_info()
|
|
467
469
|
items = ck_info.get(kind + "s", [])
|
|
470
|
+
# A record without a DOI (not every one has been minted one) is still
|
|
471
|
+
# somewhere in particular, so fall back to its URL rather than
|
|
472
|
+
# writing ``doi: null``, which nothing would then accept
|
|
473
|
+
doi = calkit.invenio.extract_doi(record)
|
|
474
|
+
if doi is not None:
|
|
475
|
+
imported_from: dict = {"doi": doi}
|
|
476
|
+
else:
|
|
477
|
+
imported_from = {
|
|
478
|
+
"url": record.get("links", {}).get("self_html")
|
|
479
|
+
or f"https://zenodo.org/records/{record_id}"
|
|
480
|
+
}
|
|
468
481
|
item_record = {
|
|
469
482
|
"path": dest_dir,
|
|
470
|
-
"imported_from":
|
|
471
|
-
"doi": record.get("doi"),
|
|
472
|
-
},
|
|
473
|
-
"title": record.get("metadata", {}).get("title"),
|
|
483
|
+
"imported_from": imported_from,
|
|
474
484
|
}
|
|
485
|
+
title = record.get("metadata", {}).get("title")
|
|
486
|
+
if title:
|
|
487
|
+
item_record["title"] = title
|
|
475
488
|
items.append(item_record)
|
|
476
489
|
ck_info[kind + "s"] = items
|
|
477
490
|
with open("calkit.yaml", "w") as f:
|
calkit/cli/list.py
CHANGED
|
@@ -45,6 +45,7 @@ def _list_objects(
|
|
|
45
45
|
"figures",
|
|
46
46
|
"references",
|
|
47
47
|
"publications",
|
|
48
|
+
"misc",
|
|
48
49
|
],
|
|
49
50
|
json_output: bool = False,
|
|
50
51
|
):
|
|
@@ -241,6 +242,18 @@ def list_publications(
|
|
|
241
242
|
_list_objects("publications", json_output)
|
|
242
243
|
|
|
243
244
|
|
|
245
|
+
@list_app.command(name="misc")
|
|
246
|
+
def list_misc(
|
|
247
|
+
json_output: Annotated[
|
|
248
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
249
|
+
] = False,
|
|
250
|
+
):
|
|
251
|
+
"""List misc artifacts in the project, i.e., attributed paths that
|
|
252
|
+
aren't one of the typed kinds.
|
|
253
|
+
"""
|
|
254
|
+
_list_objects("misc", json_output)
|
|
255
|
+
|
|
256
|
+
|
|
244
257
|
@list_app.command(name="references|refs")
|
|
245
258
|
def list_references(
|
|
246
259
|
json_output: Annotated[
|
calkit/cli/main/core.py
CHANGED
|
@@ -125,6 +125,17 @@ def main(
|
|
|
125
125
|
bool,
|
|
126
126
|
typer.Option("--version", help="Show version and exit."),
|
|
127
127
|
] = False,
|
|
128
|
+
directory: Annotated[
|
|
129
|
+
Optional[str],
|
|
130
|
+
typer.Option(
|
|
131
|
+
"-C",
|
|
132
|
+
"--directory",
|
|
133
|
+
help=(
|
|
134
|
+
"Change to this directory before doing anything else, so a "
|
|
135
|
+
"project can be worked with from outside it."
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
] = None,
|
|
128
139
|
use_version: Annotated[
|
|
129
140
|
Optional[str],
|
|
130
141
|
typer.Option(
|
|
@@ -139,6 +150,13 @@ def main(
|
|
|
139
150
|
if version:
|
|
140
151
|
typer.echo(f"Calkit {calkit.__version__}")
|
|
141
152
|
raise typer.Exit()
|
|
153
|
+
# Change directory first, so everything below---the project's .env, the
|
|
154
|
+
# config it reads, and whatever command runs next---sees the project
|
|
155
|
+
# rather than wherever the user happened to be standing.
|
|
156
|
+
if directory is not None:
|
|
157
|
+
if not os.path.isdir(directory):
|
|
158
|
+
raise_error(f"Directory does not exist: {directory}")
|
|
159
|
+
os.chdir(directory)
|
|
142
160
|
if use_version:
|
|
143
161
|
_exec_with_version(use_version)
|
|
144
162
|
# Load the project's .env here rather than in the handful of commands
|
|
@@ -214,6 +232,21 @@ def _exec_with_version(version_spec: str) -> None:
|
|
|
214
232
|
os.execvp(cmd[0], cmd)
|
|
215
233
|
|
|
216
234
|
|
|
235
|
+
def project_is_initialized() -> bool:
|
|
236
|
+
"""Determine whether the working directory is a Calkit project."""
|
|
237
|
+
import git
|
|
238
|
+
from git.exc import InvalidGitRepositoryError
|
|
239
|
+
|
|
240
|
+
# Parent directories count, since a project can be a self-contained
|
|
241
|
+
# directory inside a larger repo and so has no .git of its own. What
|
|
242
|
+
# makes it initialized is its own DVC repo and calkit.yaml.
|
|
243
|
+
try:
|
|
244
|
+
git.Repo(".", search_parent_directories=True)
|
|
245
|
+
except InvalidGitRepositoryError:
|
|
246
|
+
return False
|
|
247
|
+
return os.path.isfile(".dvc/config") and os.path.isfile("calkit.yaml")
|
|
248
|
+
|
|
249
|
+
|
|
217
250
|
@app.command(name="init")
|
|
218
251
|
def init(
|
|
219
252
|
force: Annotated[
|
|
@@ -224,43 +257,54 @@ def init(
|
|
|
224
257
|
help="Re-initialize even if the project is already initialized.",
|
|
225
258
|
),
|
|
226
259
|
] = False,
|
|
260
|
+
no_commit: Annotated[
|
|
261
|
+
bool,
|
|
262
|
+
typer.Option(
|
|
263
|
+
"--no-commit",
|
|
264
|
+
help="Stage the initial files rather than committing them.",
|
|
265
|
+
),
|
|
266
|
+
] = False,
|
|
227
267
|
):
|
|
228
268
|
"""Initialize the current working directory."""
|
|
229
|
-
import git
|
|
230
269
|
from git.exc import InvalidGitRepositoryError
|
|
231
270
|
|
|
232
|
-
|
|
233
|
-
try:
|
|
234
|
-
git.Repo(".", search_parent_directories=False)
|
|
235
|
-
except InvalidGitRepositoryError:
|
|
236
|
-
return False
|
|
237
|
-
return os.path.isfile(".dvc/config") and os.path.isfile("calkit.yaml")
|
|
238
|
-
|
|
239
|
-
if _project_is_initialized() and not force:
|
|
271
|
+
if project_is_initialized() and not force:
|
|
240
272
|
raise_error(
|
|
241
273
|
"This project is already initialized. "
|
|
242
274
|
"Use --force to re-initialize."
|
|
243
275
|
)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
276
|
+
# Only create a Git repo if we're not already inside one; a project
|
|
277
|
+
# can be a self-contained directory within a larger repo, and nesting
|
|
278
|
+
# a second repo there hides its contents from the outer one.
|
|
279
|
+
try:
|
|
280
|
+
calkit.git.get_repo()
|
|
281
|
+
# A directory the enclosing repo ignores is not part of it, so it
|
|
282
|
+
# gets its own repo rather than being treated as a subdirectory of
|
|
283
|
+
# one that will never track it.
|
|
284
|
+
needs_own_repo = calkit.dvc.enclosing_repo_ignores()
|
|
285
|
+
except InvalidGitRepositoryError:
|
|
286
|
+
needs_own_repo = True
|
|
287
|
+
if needs_own_repo:
|
|
288
|
+
subprocess.run(["git", "init"])
|
|
289
|
+
result = calkit.dvc.init(force=force)
|
|
248
290
|
if result != 0:
|
|
249
291
|
raise_error("Failed to initialize DVC")
|
|
250
292
|
# Ensure autostage is enabled for DVC
|
|
251
293
|
result = calkit.dvc.run_dvc_command(["config", "core.autostage", "true"])
|
|
252
294
|
if result != 0:
|
|
253
295
|
raise_error("Failed to configure DVC autostage")
|
|
254
|
-
# Commit the newly created .dvc directory
|
|
296
|
+
# Commit the newly created .dvc directory. Paths are made absolute
|
|
297
|
+
# because Git resolves them against the repo root, which is not this
|
|
298
|
+
# directory when the project is a subdirectory of a larger repo.
|
|
255
299
|
repo = calkit.git.get_repo()
|
|
256
|
-
repo.git.add(".dvc")
|
|
257
|
-
if calkit.git.get_staged_files(repo=repo):
|
|
300
|
+
repo.git.add(os.path.abspath(".dvc"))
|
|
301
|
+
if not no_commit and calkit.git.get_staged_files(repo=repo):
|
|
258
302
|
repo.git.commit("-m", "Initialize DVC")
|
|
259
303
|
# Create an empty calkit.yaml if one doesn't already exist
|
|
260
304
|
if not os.path.isfile("calkit.yaml"):
|
|
261
305
|
calkit.schema.ensure_modeline("calkit.yaml")
|
|
262
|
-
repo.git.add("calkit.yaml")
|
|
263
|
-
if calkit.git.get_staged_files(repo=repo):
|
|
306
|
+
repo.git.add(os.path.abspath("calkit.yaml"))
|
|
307
|
+
if not no_commit and calkit.git.get_staged_files(repo=repo):
|
|
264
308
|
repo.git.commit("-m", "Initialize Calkit")
|
|
265
309
|
# TODO: Initialize `dvc.yaml`
|
|
266
310
|
# TODO: Add a sane .gitignore file
|
|
@@ -485,7 +529,7 @@ def get_status(
|
|
|
485
529
|
if not os.path.isfile(os.path.join(".dvc", "config")):
|
|
486
530
|
typer.echo("Initializing DVC repository")
|
|
487
531
|
try:
|
|
488
|
-
result = calkit.dvc.
|
|
532
|
+
result = calkit.dvc.init(quiet=True)
|
|
489
533
|
if result != 0:
|
|
490
534
|
raise subprocess.CalledProcessError(result, "dvc init")
|
|
491
535
|
except subprocess.CalledProcessError as e:
|
|
@@ -691,7 +735,17 @@ def get_status(
|
|
|
691
735
|
if stale_stage.modified_inputs:
|
|
692
736
|
typer.echo(" modified inputs:")
|
|
693
737
|
for input_path in stale_stage.modified_inputs:
|
|
694
|
-
|
|
738
|
+
ignored = stale_stage.ignored_files_in_inputs.get(
|
|
739
|
+
input_path
|
|
740
|
+
)
|
|
741
|
+
if ignored:
|
|
742
|
+
warn(
|
|
743
|
+
f" {input_path} (contains "
|
|
744
|
+
f"Git-ignored files: {', '.join(ignored)})",
|
|
745
|
+
prefix="",
|
|
746
|
+
)
|
|
747
|
+
else:
|
|
748
|
+
typer.echo(f" {input_path}")
|
|
695
749
|
if pipeline_status.always_run_stage_names:
|
|
696
750
|
typer.echo("Always-run stages:")
|
|
697
751
|
for stage_name in pipeline_status.always_run_stage_names:
|
|
@@ -702,6 +756,22 @@ def get_status(
|
|
|
702
756
|
typer.echo("Always-run stages:")
|
|
703
757
|
for stage_name in pipeline_status.always_run_stage_names:
|
|
704
758
|
typer.echo(f" {typer.style(stage_name, fg='cyan')}")
|
|
759
|
+
# Ignored files aren't carried to other machines by Git, so the stage
|
|
760
|
+
# hashes its input differently there and looks stale, e.g., in CI
|
|
761
|
+
if pipeline_status and pipeline_status.ignored_files_in_inputs:
|
|
762
|
+
warn(
|
|
763
|
+
"Stage inputs contain Git-ignored files, which can make "
|
|
764
|
+
"stages stale on other machines, e.g., in CI:"
|
|
765
|
+
)
|
|
766
|
+
for (
|
|
767
|
+
stage_name,
|
|
768
|
+
inputs,
|
|
769
|
+
) in pipeline_status.ignored_files_in_inputs.items():
|
|
770
|
+
warn(f" {stage_name}:", prefix="")
|
|
771
|
+
for input_path, files in inputs.items():
|
|
772
|
+
warn(f" {input_path}:", prefix="")
|
|
773
|
+
for path in files:
|
|
774
|
+
warn(f" {path}", prefix="")
|
|
705
775
|
|
|
706
776
|
|
|
707
777
|
@app.command(name="diff")
|
|
@@ -814,9 +884,26 @@ def add(
|
|
|
814
884
|
typer.echo(
|
|
815
885
|
"This is not a DVC repository; would initialize DVC here"
|
|
816
886
|
)
|
|
887
|
+
elif (
|
|
888
|
+
calkit.dvc.dvc_init_needs_subdir()
|
|
889
|
+
and not os.path.isfile("calkit.yaml")
|
|
890
|
+
and not os.path.isfile("dvc.yaml")
|
|
891
|
+
):
|
|
892
|
+
# Bootstrapping a project from nothing is fine, and is what
|
|
893
|
+
# this does at the root of a new repo. What isn't fine is doing
|
|
894
|
+
# it in some subdirectory of an existing repo, which would put
|
|
895
|
+
# a .dvc directory wherever the user happened to be standing.
|
|
896
|
+
raise_error(
|
|
897
|
+
f"No calkit.yaml or dvc.yaml in {os.getcwd()}, which is a "
|
|
898
|
+
"subdirectory of an existing Git repository, so this is "
|
|
899
|
+
"not a project. Run 'calkit init' first, or change to the "
|
|
900
|
+
"project directory."
|
|
901
|
+
)
|
|
817
902
|
else:
|
|
818
903
|
warn("DVC not initialized yet; initializing")
|
|
819
|
-
dvc_repo = dvc.repo.Repo.init(
|
|
904
|
+
dvc_repo = dvc.repo.Repo.init(
|
|
905
|
+
subdir=calkit.dvc.dvc_init_needs_subdir()
|
|
906
|
+
)
|
|
820
907
|
if not dry_run:
|
|
821
908
|
# Ensure autostage is enabled for DVC
|
|
822
909
|
calkit.dvc.run_dvc_command(
|
|
@@ -857,9 +944,11 @@ def add(
|
|
|
857
944
|
if dvc_repo is not None:
|
|
858
945
|
dvc_status = dvc_repo.data_status()
|
|
859
946
|
uncommitted = dvc_status["uncommitted"]
|
|
947
|
+
dvc_not_in_cache = set(dvc_status.get("not_in_cache", []))
|
|
860
948
|
dvc_uncommitted_all = uncommitted.get(
|
|
861
949
|
"modified", []
|
|
862
950
|
) + uncommitted.get("deleted", [])
|
|
951
|
+
missing_from_cache = []
|
|
863
952
|
for dvc_uncommitted in dvc_uncommitted_all:
|
|
864
953
|
if os.path.exists(dvc_uncommitted):
|
|
865
954
|
if dry_run:
|
|
@@ -868,11 +957,11 @@ def add(
|
|
|
868
957
|
)
|
|
869
958
|
else:
|
|
870
959
|
typer.echo(f"Adding {dvc_uncommitted} to DVC")
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
960
|
+
calkit.dvc.commit_path(dvc_repo, dvc_uncommitted)
|
|
961
|
+
elif dvc_uncommitted in dvc_not_in_cache:
|
|
962
|
+
# The data isn't on this machine at all, so this isn't
|
|
963
|
+
# a real deletion and there's nothing to commit
|
|
964
|
+
missing_from_cache.append(dvc_uncommitted)
|
|
876
965
|
else:
|
|
877
966
|
if dry_run:
|
|
878
967
|
typer.echo(
|
|
@@ -882,11 +971,20 @@ def add(
|
|
|
882
971
|
typer.echo(
|
|
883
972
|
f"Committing deleted {dvc_uncommitted} to DVC"
|
|
884
973
|
)
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
974
|
+
calkit.dvc.commit_path(dvc_repo, dvc_uncommitted)
|
|
975
|
+
if missing_from_cache:
|
|
976
|
+
n_shown = 5
|
|
977
|
+
paths_txt = ", ".join(missing_from_cache[:n_shown])
|
|
978
|
+
if len(missing_from_cache) > n_shown:
|
|
979
|
+
paths_txt += (
|
|
980
|
+
f", and {len(missing_from_cache) - n_shown} more"
|
|
981
|
+
)
|
|
982
|
+
warn(
|
|
983
|
+
f"Not committing {len(missing_from_cache)} missing "
|
|
984
|
+
f"DVC-tracked path(s) since their data is not in the "
|
|
985
|
+
f"cache ({paths_txt}); "
|
|
986
|
+
"run 'calkit pull' to fetch their data"
|
|
987
|
+
)
|
|
890
988
|
if not disable_auto_ignore:
|
|
891
989
|
for untracked_file in untracked_git_files:
|
|
892
990
|
if (
|
|
@@ -1427,6 +1525,136 @@ STAGE_OUTPUT_START = "--- calkit stage output ---"
|
|
|
1427
1525
|
STAGE_OUTPUT_END = "--- end calkit stage output ---"
|
|
1428
1526
|
|
|
1429
1527
|
|
|
1528
|
+
def _inject_markdown_stage_output(log_content: str) -> list[str]:
|
|
1529
|
+
"""Write each stage's output into its Markdown file's output blocks.
|
|
1530
|
+
|
|
1531
|
+
This is post-processing rather than a stage output: two stages in one
|
|
1532
|
+
file would both claim the Markdown, and DVC forbids two stages
|
|
1533
|
+
producing the same path. Output blocks are never read back when
|
|
1534
|
+
extracting scripts, so writing one can't make its stage stale.
|
|
1535
|
+
|
|
1536
|
+
Returns the names of the DVC stages whose cached output changed. Each
|
|
1537
|
+
of those depends on its cache, which was hashed before the stage ran,
|
|
1538
|
+
so the caller has to record the new hash or the stage reads as stale.
|
|
1539
|
+
"""
|
|
1540
|
+
import calkit.markdown
|
|
1541
|
+
|
|
1542
|
+
stdout_by_stage = _stage_stdout_from_log_content(log_content)
|
|
1543
|
+
if not stdout_by_stage:
|
|
1544
|
+
return []
|
|
1545
|
+
ck_info = calkit.load_calkit_info()
|
|
1546
|
+
md_stages = calkit.markdown.get_markdown_stages(ck_info)
|
|
1547
|
+
if not md_stages:
|
|
1548
|
+
return []
|
|
1549
|
+
sep = calkit.markdown.STAGE_NAME_SEPARATOR
|
|
1550
|
+
# Group by Markdown stage so each file is read and written once
|
|
1551
|
+
by_stage: dict[str, dict[str, str]] = {}
|
|
1552
|
+
for dvc_stage_name, output in stdout_by_stage.items():
|
|
1553
|
+
if sep not in dvc_stage_name:
|
|
1554
|
+
continue
|
|
1555
|
+
md_stage_name, block_name = dvc_stage_name.rsplit(sep, 1)
|
|
1556
|
+
if md_stage_name not in md_stages:
|
|
1557
|
+
continue
|
|
1558
|
+
by_stage.setdefault(md_stage_name, {})[block_name] = output
|
|
1559
|
+
changed_stages = []
|
|
1560
|
+
for md_stage_name, outputs in by_stage.items():
|
|
1561
|
+
md_path = md_stages[md_stage_name]
|
|
1562
|
+
if not os.path.isfile(md_path):
|
|
1563
|
+
continue
|
|
1564
|
+
with open(md_path, encoding="utf-8") as f:
|
|
1565
|
+
text = f.read()
|
|
1566
|
+
new_text, changed = calkit.markdown.set_output_blocks(text, outputs)
|
|
1567
|
+
if changed:
|
|
1568
|
+
with open(md_path, "w", encoding="utf-8", newline="\n") as f:
|
|
1569
|
+
f.write(new_text)
|
|
1570
|
+
# Keep each block's cache in step with the block itself. Read back
|
|
1571
|
+
# from the written text rather than from what was injected, so the
|
|
1572
|
+
# cache can only ever hold exactly what the file says.
|
|
1573
|
+
cached = calkit.markdown.extract_outputs(
|
|
1574
|
+
calkit.markdown.parse_markdown(new_text, path=md_path),
|
|
1575
|
+
md_path,
|
|
1576
|
+
)
|
|
1577
|
+
before = {}
|
|
1578
|
+
for block_name in cached:
|
|
1579
|
+
cache_path = calkit.markdown.get_output_cache_path(
|
|
1580
|
+
md_path, block_name
|
|
1581
|
+
)
|
|
1582
|
+
if os.path.isfile(cache_path):
|
|
1583
|
+
with open(cache_path, encoding="utf-8") as f:
|
|
1584
|
+
before[block_name] = f.read()
|
|
1585
|
+
cache_paths = calkit.markdown.write_output_caches(cached, md_path)
|
|
1586
|
+
for block_name, cache_path in cache_paths.items():
|
|
1587
|
+
with open(cache_path, encoding="utf-8") as f:
|
|
1588
|
+
if f.read() != before.get(block_name):
|
|
1589
|
+
changed_stages.append(md_stage_name + sep + block_name)
|
|
1590
|
+
return changed_stages
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _inject_markdown_values() -> list[str]:
|
|
1594
|
+
"""Bring each Markdown file's value markers into line with its results.
|
|
1595
|
+
|
|
1596
|
+
Done after every run rather than only for stages that ran: the
|
|
1597
|
+
results files are tracked outputs, so whatever they hold is what the
|
|
1598
|
+
prose should say. Returns the paths of the files that changed.
|
|
1599
|
+
"""
|
|
1600
|
+
import calkit.markdown
|
|
1601
|
+
|
|
1602
|
+
ck_info = calkit.load_calkit_info()
|
|
1603
|
+
changed_paths = []
|
|
1604
|
+
for md_path in set(calkit.markdown.get_markdown_stages(ck_info).values()):
|
|
1605
|
+
if not os.path.isfile(md_path):
|
|
1606
|
+
continue
|
|
1607
|
+
with open(md_path, encoding="utf-8") as f:
|
|
1608
|
+
text = f.read()
|
|
1609
|
+
new_text, changed = calkit.markdown.set_values(text, md_path)
|
|
1610
|
+
if changed:
|
|
1611
|
+
with open(md_path, "w", encoding="utf-8", newline="\n") as f:
|
|
1612
|
+
f.write(new_text)
|
|
1613
|
+
changed_paths.append(md_path)
|
|
1614
|
+
return changed_paths
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _stage_stdout_from_log_content(log_content: str) -> dict[str, str]:
|
|
1618
|
+
"""Collect each stage's captured standard output from a run log.
|
|
1619
|
+
|
|
1620
|
+
The run log already brackets stage output with the markers above, so
|
|
1621
|
+
this reads what a stage printed without capturing it a second time.
|
|
1622
|
+
Stages that ran but printed nothing map to an empty string, which is
|
|
1623
|
+
what lets a stale output block be emptied rather than left wrong.
|
|
1624
|
+
"""
|
|
1625
|
+
res: dict[str, list[str]] = {}
|
|
1626
|
+
current_stage_name: str | None = None
|
|
1627
|
+
in_stage_output = False
|
|
1628
|
+
for line in log_content.splitlines():
|
|
1629
|
+
if line == STAGE_OUTPUT_START:
|
|
1630
|
+
in_stage_output = True
|
|
1631
|
+
continue
|
|
1632
|
+
if line.endswith(STAGE_OUTPUT_END):
|
|
1633
|
+
# The marker shares its line with output that lacked a final
|
|
1634
|
+
# newline, so keep whatever came before it
|
|
1635
|
+
remainder = line.removesuffix(STAGE_OUTPUT_END)
|
|
1636
|
+
if remainder and current_stage_name is not None:
|
|
1637
|
+
res.setdefault(current_stage_name, []).append(remainder)
|
|
1638
|
+
in_stage_output = False
|
|
1639
|
+
continue
|
|
1640
|
+
if in_stage_output:
|
|
1641
|
+
if current_stage_name is not None:
|
|
1642
|
+
res.setdefault(current_stage_name, []).append(line)
|
|
1643
|
+
continue
|
|
1644
|
+
ls = line.split(" -", maxsplit=2)
|
|
1645
|
+
if len(ls) < 3:
|
|
1646
|
+
continue
|
|
1647
|
+
message = ls[2].strip()
|
|
1648
|
+
if message.startswith("Running stage "):
|
|
1649
|
+
current_stage_name = (
|
|
1650
|
+
message.removeprefix("Running stage ").rstrip(":").strip("'")
|
|
1651
|
+
)
|
|
1652
|
+
# Recorded now so a stage that printed nothing is still known
|
|
1653
|
+
# to have run
|
|
1654
|
+
res.setdefault(current_stage_name, [])
|
|
1655
|
+
return {name: "\n".join(lines) for name, lines in res.items()}
|
|
1656
|
+
|
|
1657
|
+
|
|
1430
1658
|
def _stage_run_info_from_log_content(
|
|
1431
1659
|
log_content: str, run_finished: bool = False
|
|
1432
1660
|
) -> dict:
|
|
@@ -2025,6 +2253,17 @@ def run(
|
|
|
2025
2253
|
if mock_scheduler:
|
|
2026
2254
|
os.environ[calkit.cli.scheduler.MOCK_ENV_VAR] = "1"
|
|
2027
2255
|
dotenv.load_dotenv(dotenv_path=".env", verbose=verbose)
|
|
2256
|
+
# Refuse to run anywhere that isn't a project. Everything below
|
|
2257
|
+
# initializes Git and DVC as needed, so without this check a mistyped
|
|
2258
|
+
# path or a wrong working directory would silently scatter a .dvc
|
|
2259
|
+
# directory into an unrelated folder.
|
|
2260
|
+
if not os.path.isfile("calkit.yaml") and not os.path.isfile("dvc.yaml"):
|
|
2261
|
+
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
2262
|
+
raise_error(
|
|
2263
|
+
f"No calkit.yaml or dvc.yaml in {os.getcwd()}, so there is no "
|
|
2264
|
+
"pipeline to run here. Run 'calkit init' to make this a "
|
|
2265
|
+
"project, or change to the project directory."
|
|
2266
|
+
)
|
|
2028
2267
|
ck_info = calkit.load_calkit_info()
|
|
2029
2268
|
# Ensure Git is initialized so DVC can be used.
|
|
2030
2269
|
# Use search_parent_directories so running from a subproject folder
|
|
@@ -2069,6 +2308,14 @@ def run(
|
|
|
2069
2308
|
except Exception as e:
|
|
2070
2309
|
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
2071
2310
|
raise_error(str(e))
|
|
2311
|
+
# Extract anything the project's Markdown files declare before the
|
|
2312
|
+
# environments are checked, since an environment declared there must be
|
|
2313
|
+
# in calkit.yaml before it can be created or entered.
|
|
2314
|
+
try:
|
|
2315
|
+
calkit.pipeline.sync_markdown(ck_info=ck_info)
|
|
2316
|
+
except Exception as e:
|
|
2317
|
+
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
2318
|
+
raise_error(f"Failed to read markdown stages: {e}")
|
|
2072
2319
|
# Check all environments in the pipeline (with caching)
|
|
2073
2320
|
# If any failed, warn the user that we might have problems running
|
|
2074
2321
|
calkit.echo("📦 Checking environments")
|
|
@@ -2163,7 +2410,7 @@ def run(
|
|
|
2163
2410
|
except Exception:
|
|
2164
2411
|
if not quiet:
|
|
2165
2412
|
typer.echo("Initializing DVC repo")
|
|
2166
|
-
result = calkit.dvc.
|
|
2413
|
+
result = calkit.dvc.init()
|
|
2167
2414
|
if result != 0:
|
|
2168
2415
|
raise_error("Failed to initialize DVC repo")
|
|
2169
2416
|
# Convert deps into target stage names
|
|
@@ -2443,6 +2690,29 @@ def run(
|
|
|
2443
2690
|
)
|
|
2444
2691
|
else:
|
|
2445
2692
|
failed = failed or res != 0
|
|
2693
|
+
# Write what each stage printed back into any 'calkit output' blocks
|
|
2694
|
+
# in the Markdown that declared it
|
|
2695
|
+
try:
|
|
2696
|
+
md_changed_stages = _inject_markdown_stage_output(log_content)
|
|
2697
|
+
except Exception as e:
|
|
2698
|
+
warn(f"Failed to write stage output into markdown: {e}")
|
|
2699
|
+
md_changed_stages = []
|
|
2700
|
+
if md_changed_stages:
|
|
2701
|
+
# A stage depends on its output cache, which DVC hashed before the
|
|
2702
|
+
# stage ran. Now that the cache holds what the stage just printed,
|
|
2703
|
+
# record it in the lock, or the stage would read as stale and run
|
|
2704
|
+
# again just to arrive at the same place.
|
|
2705
|
+
res_commit = calkit.dvc.run_dvc_command(
|
|
2706
|
+
["commit", "--force", "--quiet"] + md_changed_stages
|
|
2707
|
+
)
|
|
2708
|
+
if res_commit != 0:
|
|
2709
|
+
warn("Failed to record markdown output in dvc.lock")
|
|
2710
|
+
# ...and bring any values the Markdown shows into line with the
|
|
2711
|
+
# results files the stages produced
|
|
2712
|
+
try:
|
|
2713
|
+
_inject_markdown_values()
|
|
2714
|
+
except Exception as e:
|
|
2715
|
+
warn(f"Failed to write values into markdown: {e}")
|
|
2446
2716
|
# Zip dvc-zip outputs for stages that actually ran
|
|
2447
2717
|
if stage_run_info:
|
|
2448
2718
|
from calkit.models.io import PathOutput
|
|
@@ -3247,15 +3517,14 @@ def run_procedure(
|
|
|
3247
3517
|
return bool(value)
|
|
3248
3518
|
return value
|
|
3249
3519
|
|
|
3250
|
-
from calkit.models import Procedure
|
|
3251
|
-
|
|
3252
3520
|
ck_info = calkit.load_calkit_info()
|
|
3253
3521
|
calkit.set_env_vars(ck_info=ck_info)
|
|
3254
|
-
|
|
3255
|
-
if name not in procs:
|
|
3522
|
+
if name not in ck_info.get("procedures", {}):
|
|
3256
3523
|
raise_error(f"'{name}' is not defined as a procedure")
|
|
3524
|
+
# Read from its own file if that's where it's kept, so the rest of this
|
|
3525
|
+
# sees the same thing either way
|
|
3257
3526
|
try:
|
|
3258
|
-
proc =
|
|
3527
|
+
proc = calkit.procedures.load(name, ck_info=ck_info)
|
|
3259
3528
|
except Exception as e:
|
|
3260
3529
|
raise_error(f"Procedure '{name}' is invalid: {e}")
|
|
3261
3530
|
git_repo = calkit.git.get_repo()
|