calkit-python 0.44.1__py3-none-any.whl → 0.45.0__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/cli/check.py +13 -1
- calkit/cli/main/core.py +281 -22
- calkit/cli/main/xr.py +291 -3
- calkit/cli/new.py +76 -16
- calkit/core.py +59 -0
- calkit/dvc/core.py +74 -0
- calkit/environments.py +68 -6
- calkit/markdown.py +2720 -0
- calkit/models/pipeline.py +78 -7
- calkit/pipeline.py +320 -6
- calkit/tests/cli/main/test_core.py +99 -0
- calkit/tests/cli/main/test_xr.py +172 -0
- calkit/tests/cli/test_check.py +49 -0
- calkit/tests/cli/test_new.py +22 -0
- calkit/tests/dvc/test_core.py +53 -0
- calkit/tests/test_core.py +68 -0
- calkit/tests/test_markdown.py +1449 -0
- calkit/tests/test_pipeline.py +484 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/package.json +1 -1
- calkit_python-0.45.0.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.0.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.d7a43c7948f690d37d19.js +1 -1
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.0.dist-info}/METADATA +1 -1
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.0.dist-info}/RECORD +37 -35
- 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.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.44.1.data → calkit_python-0.45.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.0.dist-info}/WHEEL +0 -0
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.0.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.44.1.dist-info → calkit_python-0.45.0.dist-info}/licenses/LICENSE +0 -0
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:
|
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(
|
|
@@ -1427,6 +1514,136 @@ STAGE_OUTPUT_START = "--- calkit stage output ---"
|
|
|
1427
1514
|
STAGE_OUTPUT_END = "--- end calkit stage output ---"
|
|
1428
1515
|
|
|
1429
1516
|
|
|
1517
|
+
def _inject_markdown_stage_output(log_content: str) -> list[str]:
|
|
1518
|
+
"""Write each stage's output into its Markdown file's output blocks.
|
|
1519
|
+
|
|
1520
|
+
This is post-processing rather than a stage output: two stages in one
|
|
1521
|
+
file would both claim the Markdown, and DVC forbids two stages
|
|
1522
|
+
producing the same path. Output blocks are never read back when
|
|
1523
|
+
extracting scripts, so writing one can't make its stage stale.
|
|
1524
|
+
|
|
1525
|
+
Returns the names of the DVC stages whose cached output changed. Each
|
|
1526
|
+
of those depends on its cache, which was hashed before the stage ran,
|
|
1527
|
+
so the caller has to record the new hash or the stage reads as stale.
|
|
1528
|
+
"""
|
|
1529
|
+
import calkit.markdown
|
|
1530
|
+
|
|
1531
|
+
stdout_by_stage = _stage_stdout_from_log_content(log_content)
|
|
1532
|
+
if not stdout_by_stage:
|
|
1533
|
+
return []
|
|
1534
|
+
ck_info = calkit.load_calkit_info()
|
|
1535
|
+
md_stages = calkit.markdown.get_markdown_stages(ck_info)
|
|
1536
|
+
if not md_stages:
|
|
1537
|
+
return []
|
|
1538
|
+
sep = calkit.markdown.STAGE_NAME_SEPARATOR
|
|
1539
|
+
# Group by Markdown stage so each file is read and written once
|
|
1540
|
+
by_stage: dict[str, dict[str, str]] = {}
|
|
1541
|
+
for dvc_stage_name, output in stdout_by_stage.items():
|
|
1542
|
+
if sep not in dvc_stage_name:
|
|
1543
|
+
continue
|
|
1544
|
+
md_stage_name, block_name = dvc_stage_name.rsplit(sep, 1)
|
|
1545
|
+
if md_stage_name not in md_stages:
|
|
1546
|
+
continue
|
|
1547
|
+
by_stage.setdefault(md_stage_name, {})[block_name] = output
|
|
1548
|
+
changed_stages = []
|
|
1549
|
+
for md_stage_name, outputs in by_stage.items():
|
|
1550
|
+
md_path = md_stages[md_stage_name]
|
|
1551
|
+
if not os.path.isfile(md_path):
|
|
1552
|
+
continue
|
|
1553
|
+
with open(md_path, encoding="utf-8") as f:
|
|
1554
|
+
text = f.read()
|
|
1555
|
+
new_text, changed = calkit.markdown.set_output_blocks(text, outputs)
|
|
1556
|
+
if changed:
|
|
1557
|
+
with open(md_path, "w", encoding="utf-8", newline="\n") as f:
|
|
1558
|
+
f.write(new_text)
|
|
1559
|
+
# Keep each block's cache in step with the block itself. Read back
|
|
1560
|
+
# from the written text rather than from what was injected, so the
|
|
1561
|
+
# cache can only ever hold exactly what the file says.
|
|
1562
|
+
cached = calkit.markdown.extract_outputs(
|
|
1563
|
+
calkit.markdown.parse_markdown(new_text, path=md_path),
|
|
1564
|
+
md_path,
|
|
1565
|
+
)
|
|
1566
|
+
before = {}
|
|
1567
|
+
for block_name in cached:
|
|
1568
|
+
cache_path = calkit.markdown.get_output_cache_path(
|
|
1569
|
+
md_path, block_name
|
|
1570
|
+
)
|
|
1571
|
+
if os.path.isfile(cache_path):
|
|
1572
|
+
with open(cache_path, encoding="utf-8") as f:
|
|
1573
|
+
before[block_name] = f.read()
|
|
1574
|
+
cache_paths = calkit.markdown.write_output_caches(cached, md_path)
|
|
1575
|
+
for block_name, cache_path in cache_paths.items():
|
|
1576
|
+
with open(cache_path, encoding="utf-8") as f:
|
|
1577
|
+
if f.read() != before.get(block_name):
|
|
1578
|
+
changed_stages.append(md_stage_name + sep + block_name)
|
|
1579
|
+
return changed_stages
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _inject_markdown_values() -> list[str]:
|
|
1583
|
+
"""Bring each Markdown file's value markers into line with its results.
|
|
1584
|
+
|
|
1585
|
+
Done after every run rather than only for stages that ran: the
|
|
1586
|
+
results files are tracked outputs, so whatever they hold is what the
|
|
1587
|
+
prose should say. Returns the paths of the files that changed.
|
|
1588
|
+
"""
|
|
1589
|
+
import calkit.markdown
|
|
1590
|
+
|
|
1591
|
+
ck_info = calkit.load_calkit_info()
|
|
1592
|
+
changed_paths = []
|
|
1593
|
+
for md_path in set(calkit.markdown.get_markdown_stages(ck_info).values()):
|
|
1594
|
+
if not os.path.isfile(md_path):
|
|
1595
|
+
continue
|
|
1596
|
+
with open(md_path, encoding="utf-8") as f:
|
|
1597
|
+
text = f.read()
|
|
1598
|
+
new_text, changed = calkit.markdown.set_values(text, md_path)
|
|
1599
|
+
if changed:
|
|
1600
|
+
with open(md_path, "w", encoding="utf-8", newline="\n") as f:
|
|
1601
|
+
f.write(new_text)
|
|
1602
|
+
changed_paths.append(md_path)
|
|
1603
|
+
return changed_paths
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def _stage_stdout_from_log_content(log_content: str) -> dict[str, str]:
|
|
1607
|
+
"""Collect each stage's captured standard output from a run log.
|
|
1608
|
+
|
|
1609
|
+
The run log already brackets stage output with the markers above, so
|
|
1610
|
+
this reads what a stage printed without capturing it a second time.
|
|
1611
|
+
Stages that ran but printed nothing map to an empty string, which is
|
|
1612
|
+
what lets a stale output block be emptied rather than left wrong.
|
|
1613
|
+
"""
|
|
1614
|
+
res: dict[str, list[str]] = {}
|
|
1615
|
+
current_stage_name: str | None = None
|
|
1616
|
+
in_stage_output = False
|
|
1617
|
+
for line in log_content.splitlines():
|
|
1618
|
+
if line == STAGE_OUTPUT_START:
|
|
1619
|
+
in_stage_output = True
|
|
1620
|
+
continue
|
|
1621
|
+
if line.endswith(STAGE_OUTPUT_END):
|
|
1622
|
+
# The marker shares its line with output that lacked a final
|
|
1623
|
+
# newline, so keep whatever came before it
|
|
1624
|
+
remainder = line.removesuffix(STAGE_OUTPUT_END)
|
|
1625
|
+
if remainder and current_stage_name is not None:
|
|
1626
|
+
res.setdefault(current_stage_name, []).append(remainder)
|
|
1627
|
+
in_stage_output = False
|
|
1628
|
+
continue
|
|
1629
|
+
if in_stage_output:
|
|
1630
|
+
if current_stage_name is not None:
|
|
1631
|
+
res.setdefault(current_stage_name, []).append(line)
|
|
1632
|
+
continue
|
|
1633
|
+
ls = line.split(" -", maxsplit=2)
|
|
1634
|
+
if len(ls) < 3:
|
|
1635
|
+
continue
|
|
1636
|
+
message = ls[2].strip()
|
|
1637
|
+
if message.startswith("Running stage "):
|
|
1638
|
+
current_stage_name = (
|
|
1639
|
+
message.removeprefix("Running stage ").rstrip(":").strip("'")
|
|
1640
|
+
)
|
|
1641
|
+
# Recorded now so a stage that printed nothing is still known
|
|
1642
|
+
# to have run
|
|
1643
|
+
res.setdefault(current_stage_name, [])
|
|
1644
|
+
return {name: "\n".join(lines) for name, lines in res.items()}
|
|
1645
|
+
|
|
1646
|
+
|
|
1430
1647
|
def _stage_run_info_from_log_content(
|
|
1431
1648
|
log_content: str, run_finished: bool = False
|
|
1432
1649
|
) -> dict:
|
|
@@ -2025,6 +2242,17 @@ def run(
|
|
|
2025
2242
|
if mock_scheduler:
|
|
2026
2243
|
os.environ[calkit.cli.scheduler.MOCK_ENV_VAR] = "1"
|
|
2027
2244
|
dotenv.load_dotenv(dotenv_path=".env", verbose=verbose)
|
|
2245
|
+
# Refuse to run anywhere that isn't a project. Everything below
|
|
2246
|
+
# initializes Git and DVC as needed, so without this check a mistyped
|
|
2247
|
+
# path or a wrong working directory would silently scatter a .dvc
|
|
2248
|
+
# directory into an unrelated folder.
|
|
2249
|
+
if not os.path.isfile("calkit.yaml") and not os.path.isfile("dvc.yaml"):
|
|
2250
|
+
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
2251
|
+
raise_error(
|
|
2252
|
+
f"No calkit.yaml or dvc.yaml in {os.getcwd()}, so there is no "
|
|
2253
|
+
"pipeline to run here. Run 'calkit init' to make this a "
|
|
2254
|
+
"project, or change to the project directory."
|
|
2255
|
+
)
|
|
2028
2256
|
ck_info = calkit.load_calkit_info()
|
|
2029
2257
|
# Ensure Git is initialized so DVC can be used.
|
|
2030
2258
|
# Use search_parent_directories so running from a subproject folder
|
|
@@ -2069,6 +2297,14 @@ def run(
|
|
|
2069
2297
|
except Exception as e:
|
|
2070
2298
|
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
2071
2299
|
raise_error(str(e))
|
|
2300
|
+
# Extract anything the project's Markdown files declare before the
|
|
2301
|
+
# environments are checked, since an environment declared there must be
|
|
2302
|
+
# in calkit.yaml before it can be created or entered.
|
|
2303
|
+
try:
|
|
2304
|
+
calkit.pipeline.sync_markdown(ck_info=ck_info)
|
|
2305
|
+
except Exception as e:
|
|
2306
|
+
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
2307
|
+
raise_error(f"Failed to read markdown stages: {e}")
|
|
2072
2308
|
# Check all environments in the pipeline (with caching)
|
|
2073
2309
|
# If any failed, warn the user that we might have problems running
|
|
2074
2310
|
calkit.echo("📦 Checking environments")
|
|
@@ -2163,7 +2399,7 @@ def run(
|
|
|
2163
2399
|
except Exception:
|
|
2164
2400
|
if not quiet:
|
|
2165
2401
|
typer.echo("Initializing DVC repo")
|
|
2166
|
-
result = calkit.dvc.
|
|
2402
|
+
result = calkit.dvc.init()
|
|
2167
2403
|
if result != 0:
|
|
2168
2404
|
raise_error("Failed to initialize DVC repo")
|
|
2169
2405
|
# Convert deps into target stage names
|
|
@@ -2443,6 +2679,29 @@ def run(
|
|
|
2443
2679
|
)
|
|
2444
2680
|
else:
|
|
2445
2681
|
failed = failed or res != 0
|
|
2682
|
+
# Write what each stage printed back into any 'calkit output' blocks
|
|
2683
|
+
# in the Markdown that declared it
|
|
2684
|
+
try:
|
|
2685
|
+
md_changed_stages = _inject_markdown_stage_output(log_content)
|
|
2686
|
+
except Exception as e:
|
|
2687
|
+
warn(f"Failed to write stage output into markdown: {e}")
|
|
2688
|
+
md_changed_stages = []
|
|
2689
|
+
if md_changed_stages:
|
|
2690
|
+
# A stage depends on its output cache, which DVC hashed before the
|
|
2691
|
+
# stage ran. Now that the cache holds what the stage just printed,
|
|
2692
|
+
# record it in the lock, or the stage would read as stale and run
|
|
2693
|
+
# again just to arrive at the same place.
|
|
2694
|
+
res_commit = calkit.dvc.run_dvc_command(
|
|
2695
|
+
["commit", "--force", "--quiet"] + md_changed_stages
|
|
2696
|
+
)
|
|
2697
|
+
if res_commit != 0:
|
|
2698
|
+
warn("Failed to record markdown output in dvc.lock")
|
|
2699
|
+
# ...and bring any values the Markdown shows into line with the
|
|
2700
|
+
# results files the stages produced
|
|
2701
|
+
try:
|
|
2702
|
+
_inject_markdown_values()
|
|
2703
|
+
except Exception as e:
|
|
2704
|
+
warn(f"Failed to write values into markdown: {e}")
|
|
2446
2705
|
# Zip dvc-zip outputs for stages that actually ran
|
|
2447
2706
|
if stage_run_info:
|
|
2448
2707
|
from calkit.models.io import PathOutput
|