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.
Files changed (57) hide show
  1. calkit/__init__.py +6 -2
  2. calkit/cli/check.py +14 -2
  3. calkit/cli/import_.py +18 -5
  4. calkit/cli/list.py +13 -0
  5. calkit/cli/main/core.py +306 -37
  6. calkit/cli/main/xr.py +291 -3
  7. calkit/cli/new.py +174 -18
  8. calkit/cli/update.py +117 -11
  9. calkit/core.py +75 -2
  10. calkit/dvc/core.py +103 -0
  11. calkit/environments.py +68 -6
  12. calkit/markdown.py +2720 -0
  13. calkit/models/core.py +435 -10
  14. calkit/models/pipeline.py +132 -13
  15. calkit/notebooks.py +23 -12
  16. calkit/pipeline.py +320 -6
  17. calkit/procedures.py +53 -0
  18. calkit/provenance.py +28 -0
  19. calkit/reproducibility.py +476 -0
  20. calkit/templates/core.py +27 -3
  21. calkit/templates/latex/ieee-conference/paper.tex +61 -0
  22. calkit/templates/latex/report/paper.tex +64 -0
  23. calkit/tests/cli/main/test_core.py +145 -0
  24. calkit/tests/cli/main/test_xr.py +172 -0
  25. calkit/tests/cli/test_check.py +49 -0
  26. calkit/tests/cli/test_list.py +5 -0
  27. calkit/tests/cli/test_new.py +156 -0
  28. calkit/tests/cli/test_update.py +125 -0
  29. calkit/tests/dvc/test_core.py +92 -0
  30. calkit/tests/test_core.py +68 -0
  31. calkit/tests/test_markdown.py +1449 -0
  32. calkit/tests/test_models.py +458 -0
  33. calkit/tests/test_pipeline.py +484 -0
  34. calkit/tests/test_procedures.py +64 -0
  35. calkit/tests/test_reproducibility.py +201 -0
  36. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/package.json +1 -1
  37. calkit_python-0.45.1.data/data/share/jupyter/labextensions/calkit/static/909.6d8285ce7c45878ac508.js +1 -0
  38. 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
  39. {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/METADATA +1 -1
  40. {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/RECORD +54 -46
  41. calkit/check.py +0 -248
  42. calkit/tests/test_check.py +0 -54
  43. calkit_python-0.44.1.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -1
  44. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  45. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  46. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  47. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  48. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  49. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  50. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  51. {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
  52. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  53. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  54. {calkit_python-0.44.1.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  55. {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/WHEEL +0 -0
  56. {calkit_python-0.44.1.dist-info → calkit_python-0.45.1.dist-info}/entry_points.txt +0 -0
  57. {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
- "check",
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
- "using Pkg; Pkg.instantiate();",
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.check import check_reproducibility
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
- datasets.append(new_ds.model_dump())
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
- def _project_is_initialized() -> bool:
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
- subprocess.run(["git", "init"])
245
- result = calkit.dvc.run_dvc_command(
246
- ["init"] + (["--force"] if force else [])
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.run_dvc_command(["init", "-q"])
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
- typer.echo(f" {input_path}")
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
- dvc_repo.commit(
872
- dvc_uncommitted,
873
- force=True,
874
- allow_missing=True,
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
- dvc_repo.commit(
886
- dvc_uncommitted,
887
- force=True,
888
- allow_missing=True,
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.run_dvc_command(["init"])
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
- procs = ck_info.get("procedures", {})
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 = Procedure.model_validate(procs[name])
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()