calkit-python 0.41.12__py3-none-any.whl → 0.41.14__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/delete.py +33 -0
- calkit/cli/list.py +56 -1
- calkit/cli/main/core.py +26 -5
- calkit/cli/new.py +97 -0
- calkit/cli/scheduler.py +251 -31
- calkit/cloud.py +29 -12
- calkit/detect.py +103 -2
- calkit/dvc/core.py +153 -12
- calkit/fs.py +78 -40
- calkit/models/core.py +58 -5
- calkit/pipeline.py +25 -0
- calkit/tests/cli/main/test_core.py +106 -47
- calkit/tests/cli/main/test_lock.py +274 -0
- calkit/tests/cli/test_delete.py +23 -0
- calkit/tests/cli/test_list.py +46 -0
- calkit/tests/cli/test_new.py +48 -0
- calkit/tests/cli/test_scheduler.py +207 -0
- calkit/tests/dvc/test_core.py +50 -0
- calkit/tests/models/test_core.py +25 -0
- calkit/tests/test_cloud.py +96 -0
- calkit/tests/test_detect.py +67 -0
- calkit/tests/test_fs.py +121 -0
- {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/METADATA +1 -1
- {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/RECORD +42 -38
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/WHEEL +0 -0
- {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/licenses/LICENSE +0 -0
calkit/cli/delete.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""CLI for deleting objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
import calkit
|
|
8
|
+
from calkit.cli import AliasGroup, raise_error
|
|
9
|
+
from calkit.core import ryaml
|
|
10
|
+
|
|
11
|
+
delete_app = typer.Typer(cls=AliasGroup, no_args_is_help=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@delete_app.command(name="question")
|
|
15
|
+
def delete_question(
|
|
16
|
+
index: int = typer.Argument(
|
|
17
|
+
...,
|
|
18
|
+
help="1-based index of the question to remove (see `calkit list questions`).",
|
|
19
|
+
),
|
|
20
|
+
):
|
|
21
|
+
"""Remove a question by its 1-based index."""
|
|
22
|
+
ck_info = calkit.load_calkit_info()
|
|
23
|
+
questions = ck_info.get("questions", []) or []
|
|
24
|
+
if index < 1 or index > len(questions):
|
|
25
|
+
raise_error(
|
|
26
|
+
f"Invalid question index {index}; "
|
|
27
|
+
f"there are {len(questions)} question(s)."
|
|
28
|
+
)
|
|
29
|
+
removed = questions.pop(index - 1)
|
|
30
|
+
ck_info["questions"] = questions
|
|
31
|
+
with open("calkit.yaml", "w") as f:
|
|
32
|
+
ryaml.dump(ck_info, f)
|
|
33
|
+
typer.echo(f"Removed question: {removed}")
|
calkit/cli/list.py
CHANGED
|
@@ -55,7 +55,7 @@ def _list_objects(
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def _list_artifacts(
|
|
58
|
-
kind: Literal["figures", "datasets"],
|
|
58
|
+
kind: Literal["figures", "datasets", "results", "presentations"],
|
|
59
59
|
json_output: bool,
|
|
60
60
|
declared_only: bool,
|
|
61
61
|
):
|
|
@@ -134,6 +134,61 @@ def list_datasets(
|
|
|
134
134
|
_list_artifacts("datasets", json_output, declared_only)
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
@list_app.command(name="results")
|
|
138
|
+
def list_results(
|
|
139
|
+
json_output: Annotated[
|
|
140
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
141
|
+
] = False,
|
|
142
|
+
declared_only: Annotated[
|
|
143
|
+
bool,
|
|
144
|
+
typer.Option(
|
|
145
|
+
"--declared-only",
|
|
146
|
+
help=(
|
|
147
|
+
"Only list results declared in calkit.yaml; "
|
|
148
|
+
"skip auto-detection."
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
] = False,
|
|
152
|
+
):
|
|
153
|
+
"""List results in the project."""
|
|
154
|
+
_list_artifacts("results", json_output, declared_only)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@list_app.command(name="presentations|pres")
|
|
158
|
+
def list_presentations(
|
|
159
|
+
json_output: Annotated[
|
|
160
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
161
|
+
] = False,
|
|
162
|
+
declared_only: Annotated[
|
|
163
|
+
bool,
|
|
164
|
+
typer.Option(
|
|
165
|
+
"--declared-only",
|
|
166
|
+
help=(
|
|
167
|
+
"Only list presentations declared in calkit.yaml; "
|
|
168
|
+
"skip auto-detection."
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
] = False,
|
|
172
|
+
):
|
|
173
|
+
"""List presentations in the project."""
|
|
174
|
+
_list_artifacts("presentations", json_output, declared_only)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@list_app.command(name="questions")
|
|
178
|
+
def list_questions(
|
|
179
|
+
json_output: Annotated[
|
|
180
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
181
|
+
] = False,
|
|
182
|
+
):
|
|
183
|
+
"""List the project's questions (1-indexed)."""
|
|
184
|
+
questions = calkit.load_calkit_info().get("questions", []) or []
|
|
185
|
+
if json_output:
|
|
186
|
+
typer.echo(json.dumps(questions))
|
|
187
|
+
return
|
|
188
|
+
for n, question in enumerate(questions, start=1):
|
|
189
|
+
typer.echo(f"{n}. {question}")
|
|
190
|
+
|
|
191
|
+
|
|
137
192
|
@list_app.command(name="publications|pubs")
|
|
138
193
|
def list_publications():
|
|
139
194
|
"""List publications in the project."""
|
calkit/cli/main/core.py
CHANGED
|
@@ -48,6 +48,7 @@ from calkit.cli.check import (
|
|
|
48
48
|
)
|
|
49
49
|
from calkit.cli.cloud import cloud_app
|
|
50
50
|
from calkit.cli.config import config_app
|
|
51
|
+
from calkit.cli.delete import delete_app
|
|
51
52
|
from calkit.cli.describe import describe_app
|
|
52
53
|
from calkit.cli.dev import dev_app
|
|
53
54
|
from calkit.cli.import_ import import_app
|
|
@@ -69,6 +70,7 @@ app = typer.Typer(
|
|
|
69
70
|
)
|
|
70
71
|
app.add_typer(config_app, name="config", help="Configure Calkit.")
|
|
71
72
|
app.add_typer(new_app, name="new|create", help="Create a new Calkit object.")
|
|
73
|
+
app.add_typer(delete_app, name="delete|rm", help="Delete a Calkit object.")
|
|
72
74
|
app.add_typer(
|
|
73
75
|
notebooks_app, name="notebooks|nb", help="Work with Jupyter notebooks."
|
|
74
76
|
)
|
|
@@ -1172,7 +1174,10 @@ def pull(
|
|
|
1172
1174
|
and "-R" not in dvc_args
|
|
1173
1175
|
):
|
|
1174
1176
|
dvc_args.append("--recursive")
|
|
1175
|
-
result = calkit.dvc.run_dvc_command(
|
|
1177
|
+
result = calkit.dvc.run_dvc_command(
|
|
1178
|
+
["pull"] + dvc_args,
|
|
1179
|
+
lock_timeout=calkit.dvc.DEFAULT_RUN_LOCK_TIMEOUT,
|
|
1180
|
+
)
|
|
1176
1181
|
if result != 0:
|
|
1177
1182
|
raise_error("DVC pull failed")
|
|
1178
1183
|
calkit.dvc.zip.sync_all(direction="to-workspace")
|
|
@@ -1187,7 +1192,9 @@ def pull(
|
|
|
1187
1192
|
continue
|
|
1188
1193
|
typer.echo(f"DVC pulling subproject: {sp_path}")
|
|
1189
1194
|
sp_result = calkit.dvc.run_dvc_command(
|
|
1190
|
-
["pull"] + dvc_args,
|
|
1195
|
+
["pull"] + dvc_args,
|
|
1196
|
+
cwd=sp_path,
|
|
1197
|
+
lock_timeout=calkit.dvc.DEFAULT_RUN_LOCK_TIMEOUT,
|
|
1191
1198
|
)
|
|
1192
1199
|
if sp_result != 0:
|
|
1193
1200
|
raise_error(f"DVC pull failed for subproject: {sp_path}")
|
|
@@ -1224,7 +1231,10 @@ def push(
|
|
|
1224
1231
|
calkit.dvc.set_remote_auth(remote_name=name)
|
|
1225
1232
|
if remotes:
|
|
1226
1233
|
typer.echo("Pushing to DVC remote")
|
|
1227
|
-
result = calkit.dvc.run_dvc_command(
|
|
1234
|
+
result = calkit.dvc.run_dvc_command(
|
|
1235
|
+
["push"] + dvc_args,
|
|
1236
|
+
lock_timeout=calkit.dvc.DEFAULT_RUN_LOCK_TIMEOUT,
|
|
1237
|
+
)
|
|
1228
1238
|
if result != 0:
|
|
1229
1239
|
raise_error("DVC push failed")
|
|
1230
1240
|
else:
|
|
@@ -2033,7 +2043,10 @@ def run(
|
|
|
2033
2043
|
original_dir = os.getcwd()
|
|
2034
2044
|
try:
|
|
2035
2045
|
os.chdir(sp_path)
|
|
2036
|
-
|
|
2046
|
+
with calkit.dvc.dvc_lock_timeout(
|
|
2047
|
+
calkit.dvc.DEFAULT_RUN_LOCK_TIMEOUT
|
|
2048
|
+
):
|
|
2049
|
+
sp_res = dvc_cli_main(["repro"] + sp_args)
|
|
2037
2050
|
finally:
|
|
2038
2051
|
os.chdir(original_dir)
|
|
2039
2052
|
if sp_res != 0:
|
|
@@ -2102,7 +2115,15 @@ def run(
|
|
|
2102
2115
|
if force:
|
|
2103
2116
|
os.environ["CALKIT_FORCE"] = "1"
|
|
2104
2117
|
try:
|
|
2105
|
-
|
|
2118
|
+
# Wait generously for the repo lock instead of failing after DVC's 3s
|
|
2119
|
+
# default. Brief contention is common and benign: a background
|
|
2120
|
+
# `calkit status` poller (e.g. the VS Code extension), a stage whose
|
|
2121
|
+
# command is itself `calkit run`, or DVC's own per-stage re-lock (DVC
|
|
2122
|
+
# releases the repo lock while a stage command runs and re-acquires it
|
|
2123
|
+
# the instant it finishes) can all hold it momentarily. Without this,
|
|
2124
|
+
# such a collision aborts the whole run with "Unable to acquire lock".
|
|
2125
|
+
with calkit.dvc.dvc_lock_timeout(calkit.dvc.DEFAULT_RUN_LOCK_TIMEOUT):
|
|
2126
|
+
res = dvc_cli_main(["repro"] + args)
|
|
2106
2127
|
finally:
|
|
2107
2128
|
os.environ.pop("CALKIT_FORCE", None)
|
|
2108
2129
|
failed = failed or res != 0
|
calkit/cli/new.py
CHANGED
|
@@ -9,6 +9,7 @@ import shutil
|
|
|
9
9
|
import subprocess
|
|
10
10
|
import time
|
|
11
11
|
from enum import Enum
|
|
12
|
+
from typing import Literal
|
|
12
13
|
|
|
13
14
|
import typer
|
|
14
15
|
from typing_extensions import Annotated
|
|
@@ -591,6 +592,102 @@ def new_figure(
|
|
|
591
592
|
repo.git.commit(["-m", f"Add figure {path}"])
|
|
592
593
|
|
|
593
594
|
|
|
595
|
+
def _new_simple_artifact(
|
|
596
|
+
kind: Literal["results", "presentations"],
|
|
597
|
+
path: str,
|
|
598
|
+
title: str,
|
|
599
|
+
description: str | None,
|
|
600
|
+
stage_name: str | None,
|
|
601
|
+
no_commit: bool,
|
|
602
|
+
overwrite: bool,
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Declare a simple artifact (path/title/description/stage) in calkit.yaml."""
|
|
605
|
+
singular = kind.rstrip("s")
|
|
606
|
+
ck_info = calkit.load_calkit_info()
|
|
607
|
+
objects = ck_info.get(kind, [])
|
|
608
|
+
paths = [o.get("path") for o in objects]
|
|
609
|
+
if not overwrite and path in paths:
|
|
610
|
+
raise_error(f"{singular.capitalize()} at path {path} already exists")
|
|
611
|
+
if overwrite and path in paths:
|
|
612
|
+
objects = [o for o in objects if o.get("path") != path]
|
|
613
|
+
obj = dict(path=path, title=title)
|
|
614
|
+
if description is not None:
|
|
615
|
+
obj["description"] = description
|
|
616
|
+
if stage_name is not None:
|
|
617
|
+
obj["stage"] = stage_name
|
|
618
|
+
objects.append(obj)
|
|
619
|
+
ck_info[kind] = objects
|
|
620
|
+
with open("calkit.yaml", "w") as f:
|
|
621
|
+
ryaml.dump(ck_info, f)
|
|
622
|
+
if not no_commit:
|
|
623
|
+
repo = calkit.git.get_repo()
|
|
624
|
+
repo.git.add("calkit.yaml")
|
|
625
|
+
if repo.git.diff("--staged"):
|
|
626
|
+
repo.git.commit(["-m", f"Add {singular} {path}"])
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@new_app.command(name="result")
|
|
630
|
+
def new_result(
|
|
631
|
+
path: str,
|
|
632
|
+
title: Annotated[str, typer.Option("--title")],
|
|
633
|
+
description: Annotated[str | None, typer.Option("--description")] = None,
|
|
634
|
+
stage_name: Annotated[
|
|
635
|
+
str | None,
|
|
636
|
+
typer.Option(
|
|
637
|
+
"--stage",
|
|
638
|
+
help="Name of the pipeline stage that generates this result.",
|
|
639
|
+
),
|
|
640
|
+
] = None,
|
|
641
|
+
no_commit: Annotated[bool, typer.Option("--no-commit")] = False,
|
|
642
|
+
overwrite: Annotated[
|
|
643
|
+
bool,
|
|
644
|
+
typer.Option(
|
|
645
|
+
"--overwrite",
|
|
646
|
+
"-f",
|
|
647
|
+
help="Overwrite existing result if one exists.",
|
|
648
|
+
),
|
|
649
|
+
] = False,
|
|
650
|
+
):
|
|
651
|
+
"""Declare a new result."""
|
|
652
|
+
_new_simple_artifact(
|
|
653
|
+
"results", path, title, description, stage_name, no_commit, overwrite
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@new_app.command(name="presentation|pres")
|
|
658
|
+
def new_presentation(
|
|
659
|
+
path: str,
|
|
660
|
+
title: Annotated[str, typer.Option("--title")],
|
|
661
|
+
description: Annotated[str | None, typer.Option("--description")] = None,
|
|
662
|
+
stage_name: Annotated[
|
|
663
|
+
str | None,
|
|
664
|
+
typer.Option(
|
|
665
|
+
"--stage",
|
|
666
|
+
help="Name of the pipeline stage that generates this presentation.",
|
|
667
|
+
),
|
|
668
|
+
] = None,
|
|
669
|
+
no_commit: Annotated[bool, typer.Option("--no-commit")] = False,
|
|
670
|
+
overwrite: Annotated[
|
|
671
|
+
bool,
|
|
672
|
+
typer.Option(
|
|
673
|
+
"--overwrite",
|
|
674
|
+
"-f",
|
|
675
|
+
help="Overwrite existing presentation if one exists.",
|
|
676
|
+
),
|
|
677
|
+
] = False,
|
|
678
|
+
):
|
|
679
|
+
"""Declare a new presentation."""
|
|
680
|
+
_new_simple_artifact(
|
|
681
|
+
"presentations",
|
|
682
|
+
path,
|
|
683
|
+
title,
|
|
684
|
+
description,
|
|
685
|
+
stage_name,
|
|
686
|
+
no_commit,
|
|
687
|
+
overwrite,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
594
691
|
@new_app.command("question")
|
|
595
692
|
def new_question(
|
|
596
693
|
question: str,
|
calkit/cli/scheduler.py
CHANGED
|
@@ -15,15 +15,17 @@ import shutil
|
|
|
15
15
|
import signal
|
|
16
16
|
import socket
|
|
17
17
|
import subprocess
|
|
18
|
+
import sys
|
|
18
19
|
import time
|
|
19
20
|
import uuid
|
|
21
|
+
from typing import Any
|
|
20
22
|
|
|
21
23
|
import typer
|
|
22
24
|
from sqlitedict import SqliteDict
|
|
23
25
|
from typing_extensions import Annotated
|
|
24
26
|
|
|
25
27
|
import calkit
|
|
26
|
-
from calkit.cli import AliasGroup, raise_error
|
|
28
|
+
from calkit.cli import AliasGroup, raise_error, warn
|
|
27
29
|
|
|
28
30
|
scheduler_app = typer.Typer(cls=AliasGroup, no_args_is_help=True)
|
|
29
31
|
|
|
@@ -75,6 +77,23 @@ def _record_job(name: str, info: dict) -> None:
|
|
|
75
77
|
jobs[name] = info
|
|
76
78
|
|
|
77
79
|
|
|
80
|
+
def _delete_job(name: str) -> None:
|
|
81
|
+
"""Remove a job record from the database.
|
|
82
|
+
|
|
83
|
+
Used when a job is canceled (e.g. the user hits Ctrl+C while waiting for
|
|
84
|
+
it). Without this the record would linger, and the next ``calkit run``
|
|
85
|
+
would find the job gone from the queue with no exit status and wrongly
|
|
86
|
+
treat it as successful instead of resubmitting it.
|
|
87
|
+
"""
|
|
88
|
+
if not os.path.isfile(JOBS_DB_PATH):
|
|
89
|
+
return
|
|
90
|
+
with SqliteDict(
|
|
91
|
+
JOBS_DB_PATH, autocommit=True, timeout=JOBS_DB_TIMEOUT
|
|
92
|
+
) as jobs:
|
|
93
|
+
if name in jobs:
|
|
94
|
+
del jobs[name]
|
|
95
|
+
|
|
96
|
+
|
|
78
97
|
def _mock_enabled() -> bool:
|
|
79
98
|
"""Whether scheduler commands should run jobs locally.
|
|
80
99
|
|
|
@@ -177,15 +196,20 @@ def _mock_submit(job_id: str, job_command: str, log_path: str) -> int:
|
|
|
177
196
|
env = dict(os.environ)
|
|
178
197
|
env.setdefault("PBS_O_WORKDIR", os.getcwd())
|
|
179
198
|
with open(log_path, "w") as log_file:
|
|
180
|
-
|
|
199
|
+
# Annotated so the mixed value types don't defeat Popen's overloads.
|
|
200
|
+
popen_kwargs: dict[str, Any] = dict(
|
|
181
201
|
stdout=log_file,
|
|
182
202
|
stderr=subprocess.STDOUT,
|
|
183
203
|
env=env,
|
|
184
204
|
)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
# Detach the job into its own session/process group so it outlives
|
|
206
|
+
# this command and can be signaled as a group when canceled. Guard on
|
|
207
|
+
# `sys.platform` (not `os.name`) so the unused branch reads as
|
|
208
|
+
# platform-specific dead code rather than an unreachable bug.
|
|
209
|
+
if sys.platform == "win32":
|
|
188
210
|
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
211
|
+
else:
|
|
212
|
+
popen_kwargs["start_new_session"] = True
|
|
189
213
|
proc = subprocess.Popen(
|
|
190
214
|
["bash", "-c", wrapped],
|
|
191
215
|
**popen_kwargs,
|
|
@@ -213,31 +237,145 @@ def _mock_cancel(job_id: str) -> tuple[bool, str]:
|
|
|
213
237
|
return True, ""
|
|
214
238
|
|
|
215
239
|
|
|
216
|
-
def
|
|
240
|
+
def _mock_exit_code(job_id: str) -> int | None:
|
|
241
|
+
# The job writes its exit code to the status sentinel before exiting; a
|
|
242
|
+
# canceled job writes "canceled" instead, which isn't an integer.
|
|
243
|
+
try:
|
|
244
|
+
with open(_mock_status_path(job_id)) as f:
|
|
245
|
+
content = f.read().strip()
|
|
246
|
+
except OSError:
|
|
247
|
+
return None
|
|
248
|
+
try:
|
|
249
|
+
return int(content)
|
|
250
|
+
except ValueError:
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _parse_slurm_exit_code(value: str) -> int | None:
|
|
255
|
+
# SLURM reports exit status as "<code>:<signal>"; a job killed by a signal
|
|
256
|
+
# (e.g. out-of-memory, walltime) has a non-zero signal even if code is 0.
|
|
257
|
+
code_part, _, signal_part = value.strip().partition(":")
|
|
258
|
+
try:
|
|
259
|
+
code = int(code_part)
|
|
260
|
+
# An empty signal part means "no signal"; a non-empty one that won't
|
|
261
|
+
# parse is malformed, so report the whole value as unknown rather than
|
|
262
|
+
# silently treating it as a clean exit.
|
|
263
|
+
sig = int(signal_part) if signal_part else 0
|
|
264
|
+
except ValueError:
|
|
265
|
+
return None
|
|
266
|
+
return code if sig == 0 else 128 + sig
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _slurm_exit_code(job_id: str) -> int | None:
|
|
270
|
+
# Prefer scontrol, which reports a finished job while it lingers in the
|
|
271
|
+
# controller's memory and is available even without accounting; fall back
|
|
272
|
+
# to sacct for jobs that have already aged out of scontrol.
|
|
273
|
+
p = subprocess.run(
|
|
274
|
+
["scontrol", "show", "job", job_id],
|
|
275
|
+
capture_output=True,
|
|
276
|
+
text=True,
|
|
277
|
+
check=False,
|
|
278
|
+
)
|
|
279
|
+
if p.returncode == 0 and p.stdout.strip():
|
|
280
|
+
state = None
|
|
281
|
+
exit_code = None
|
|
282
|
+
for token in p.stdout.split():
|
|
283
|
+
if token.startswith("JobState="):
|
|
284
|
+
state = token.split("=", 1)[1]
|
|
285
|
+
elif token.startswith("ExitCode="):
|
|
286
|
+
exit_code = _parse_slurm_exit_code(token.split("=", 1)[1])
|
|
287
|
+
if state is not None:
|
|
288
|
+
if state == "COMPLETED":
|
|
289
|
+
return exit_code or 0
|
|
290
|
+
return exit_code or 1
|
|
291
|
+
p = subprocess.run(
|
|
292
|
+
["sacct", "-j", job_id, "-n", "-P", "-o", "State,ExitCode"],
|
|
293
|
+
capture_output=True,
|
|
294
|
+
text=True,
|
|
295
|
+
check=False,
|
|
296
|
+
)
|
|
297
|
+
if p.returncode == 0 and p.stdout.strip():
|
|
298
|
+
# The first line is the job itself (later lines are its steps).
|
|
299
|
+
state, _, code = p.stdout.strip().splitlines()[0].partition("|")
|
|
300
|
+
exit_code = _parse_slurm_exit_code(code)
|
|
301
|
+
if state.strip() == "COMPLETED":
|
|
302
|
+
return exit_code or 0
|
|
303
|
+
return exit_code or 1
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _poll_job(kind: str, job_id: str) -> tuple[bool, int | None]:
|
|
308
|
+
"""Return ``(active, exit_code)`` for a scheduler job.
|
|
309
|
+
|
|
310
|
+
``active`` is whether the job is still queued or running. ``exit_code`` is
|
|
311
|
+
the job's exit status once it has finished---0 for success, non-zero for
|
|
312
|
+
failure---or ``None`` while the job is still active or when the scheduler
|
|
313
|
+
no longer has a record from which to read it. A ``None`` exit code on a
|
|
314
|
+
finished job is an unknown outcome the caller must decide how to treat.
|
|
315
|
+
"""
|
|
217
316
|
if _mock_enabled():
|
|
218
|
-
|
|
317
|
+
if _mock_active(job_id):
|
|
318
|
+
return True, None
|
|
319
|
+
return False, _mock_exit_code(job_id)
|
|
219
320
|
if kind == "slurm":
|
|
220
321
|
p = subprocess.run(
|
|
221
|
-
["squeue", "--job", job_id],
|
|
322
|
+
["squeue", "--job", job_id],
|
|
323
|
+
capture_output=True,
|
|
324
|
+
text=True,
|
|
325
|
+
check=False,
|
|
222
326
|
)
|
|
223
|
-
if p.returncode
|
|
224
|
-
|
|
225
|
-
|
|
327
|
+
if p.returncode == 0:
|
|
328
|
+
# squeue prints a header row plus one row per matching job, so more
|
|
329
|
+
# than the header means the job is still queued or running.
|
|
330
|
+
if len(p.stdout.strip().split("\n")) > 1:
|
|
331
|
+
return True, None
|
|
332
|
+
return False, _slurm_exit_code(job_id)
|
|
333
|
+
# A non-zero exit is ambiguous like PBS: an invalid/unknown job id
|
|
334
|
+
# means the job is gone, but a transient controller failure should not
|
|
335
|
+
# end the wait early (nor let `_slurm_exit_code` call a still-running
|
|
336
|
+
# job failed), so only conclude the job is done when squeue positively
|
|
337
|
+
# reports the id is invalid; otherwise keep waiting.
|
|
338
|
+
stderr = (p.stderr or "").lower()
|
|
339
|
+
if "invalid job id" in stderr:
|
|
340
|
+
return False, _slurm_exit_code(job_id)
|
|
341
|
+
return True, None
|
|
226
342
|
# Use `qstat -f` and parse job_state: on Torque/OpenPBS, plain `qstat
|
|
227
343
|
# <id>` returns exit 0 even for completed (C) jobs, so checking the
|
|
228
344
|
# return code alone would cause `calkit sched batch` to hang forever
|
|
229
|
-
# after a PBS job finishes. States C and F mean the job is done
|
|
345
|
+
# after a PBS job finishes. States C and F mean the job is done, and a
|
|
346
|
+
# finished job's record carries its `Exit_status`.
|
|
230
347
|
p = subprocess.run(
|
|
231
348
|
["qstat", "-f", job_id], capture_output=True, text=True, check=False
|
|
232
349
|
)
|
|
233
350
|
if p.returncode != 0:
|
|
234
|
-
|
|
351
|
+
# A non-zero exit is ambiguous: either the job is gone (completed and
|
|
352
|
+
# purged from history) or `qstat` itself failed transiently---a busy
|
|
353
|
+
# PBS server periodically refuses connections or times out. Treating a
|
|
354
|
+
# transient failure as completion would stop the wait while the job is
|
|
355
|
+
# still running and writing its log, so only conclude the job is done
|
|
356
|
+
# when qstat positively reports it is unknown; otherwise keep waiting.
|
|
357
|
+
# A purged job (unknown to qstat) is done but with an unknowable exit
|
|
358
|
+
# status; any other error is transient, so report the job as active.
|
|
359
|
+
stderr = (p.stderr or "").lower()
|
|
360
|
+
return ("unknown job" not in stderr), None
|
|
361
|
+
state = None
|
|
362
|
+
exit_code = None
|
|
235
363
|
for line in p.stdout.splitlines():
|
|
236
364
|
stripped = line.strip()
|
|
237
365
|
if stripped.startswith("job_state"):
|
|
238
366
|
state = stripped.split("=", 1)[-1].strip()
|
|
239
|
-
|
|
240
|
-
|
|
367
|
+
elif stripped.lower().startswith("exit_status"):
|
|
368
|
+
try:
|
|
369
|
+
exit_code = int(stripped.split("=", 1)[-1].strip())
|
|
370
|
+
except ValueError:
|
|
371
|
+
exit_code = None
|
|
372
|
+
if state in ("C", "F"):
|
|
373
|
+
return False, exit_code
|
|
374
|
+
return True, None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _is_active(kind: str, job_id: str) -> bool:
|
|
378
|
+
return _poll_job(kind, job_id)[0]
|
|
241
379
|
|
|
242
380
|
|
|
243
381
|
def _cancel(kind: str, job_id: str) -> tuple[bool, str]:
|
|
@@ -250,23 +388,93 @@ def _cancel(kind: str, job_id: str) -> tuple[bool, str]:
|
|
|
250
388
|
return p.returncode == 0, p.stderr
|
|
251
389
|
|
|
252
390
|
|
|
253
|
-
def _wait_until_done(kind: str, job_id: str, name: str) -> None:
|
|
254
|
-
"""Poll until the job finishes,
|
|
391
|
+
def _wait_until_done(kind: str, job_id: str, name: str) -> int | None:
|
|
392
|
+
"""Poll until the job finishes, returning its exit code.
|
|
255
393
|
|
|
394
|
+
The exit code is the scheduler-reported status of the finished job (0 for
|
|
395
|
+
success, non-zero for failure), or ``None`` when it can't be determined.
|
|
256
396
|
The job is submitted and tracked, so interrupting the local wait cancels
|
|
257
397
|
the scheduler job before exiting rather than leaving it orphaned.
|
|
258
398
|
"""
|
|
259
399
|
try:
|
|
260
|
-
while
|
|
400
|
+
while True:
|
|
401
|
+
active, exit_code = _poll_job(kind, job_id)
|
|
402
|
+
if not active:
|
|
403
|
+
return exit_code
|
|
261
404
|
time.sleep(1)
|
|
262
405
|
except KeyboardInterrupt:
|
|
263
406
|
typer.echo(f"Interrupted; canceling job '{name}' ({job_id})")
|
|
264
407
|
ok, stderr = _cancel(kind, job_id)
|
|
265
408
|
if not ok:
|
|
266
409
|
typer.echo(f"Failed to cancel job '{name}' ({job_id}): {stderr}")
|
|
410
|
+
# Drop the record so the next run resubmits this job instead of
|
|
411
|
+
# mistaking the canceled job (which has no exit status) for a success.
|
|
412
|
+
_delete_job(name)
|
|
267
413
|
raise typer.Exit(130)
|
|
268
414
|
|
|
269
415
|
|
|
416
|
+
def _wait_for_output_file(log_path: str, timeout: float = 120.0) -> None:
|
|
417
|
+
"""Wait for a finished job's output log to be fully written.
|
|
418
|
+
|
|
419
|
+
A scheduler reports a job as done as soon as it leaves the queue, but the
|
|
420
|
+
job's ``-o`` log---which is the batch stage's declared DVC output---may not
|
|
421
|
+
be in place yet: PBS stages it back from the exec host's spool after the
|
|
422
|
+
job exits, and a job can still be flushing its final lines. Returning
|
|
423
|
+
before the file lands (or while it is still growing) makes the subsequent
|
|
424
|
+
``dvc repro`` see a missing or mid-write output and wrongly report the job
|
|
425
|
+
as failed. Poll until the file exists and its size holds steady, or until
|
|
426
|
+
``timeout`` elapses (then return and let DVC surface the real state).
|
|
427
|
+
"""
|
|
428
|
+
deadline = time.monotonic() + timeout
|
|
429
|
+
last_size = -1
|
|
430
|
+
# Require the size to repeat across polls so we don't snapshot mid-write;
|
|
431
|
+
# a missing file reports -1, which never counts as stable.
|
|
432
|
+
stable_polls = 0
|
|
433
|
+
while time.monotonic() < deadline:
|
|
434
|
+
try:
|
|
435
|
+
size = os.path.getsize(log_path)
|
|
436
|
+
except OSError:
|
|
437
|
+
size = -1
|
|
438
|
+
if size >= 0 and size == last_size:
|
|
439
|
+
stable_polls += 1
|
|
440
|
+
if stable_polls >= 2:
|
|
441
|
+
return
|
|
442
|
+
else:
|
|
443
|
+
stable_polls = 0
|
|
444
|
+
last_size = size
|
|
445
|
+
time.sleep(1)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _finalize_job(
|
|
449
|
+
name: str, job_id: str, exit_code: int | None, log_path: str
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Fail the command if the finished job did not succeed.
|
|
452
|
+
|
|
453
|
+
Waiting for the job to leave the queue only tells us it stopped, not
|
|
454
|
+
whether it worked, so a non-zero scheduler exit code is surfaced as an
|
|
455
|
+
error---raising a non-zero exit that propagates up through ``dvc repro``
|
|
456
|
+
so the stage is marked failed rather than silently recorded as done. An
|
|
457
|
+
unknown exit code (the scheduler purged the record before we could read
|
|
458
|
+
it) can't be judged, so we warn and let the stage's declared outputs be
|
|
459
|
+
the arbiter.
|
|
460
|
+
"""
|
|
461
|
+
# Wait for the job's `-o` log---this stage's declared DVC output---to be
|
|
462
|
+
# staged back before we read from or point at it.
|
|
463
|
+
if not _mock_enabled():
|
|
464
|
+
_wait_for_output_file(log_path)
|
|
465
|
+
if exit_code is None:
|
|
466
|
+
warn(
|
|
467
|
+
f"Could not determine exit status for job '{name}' (ID {job_id}); "
|
|
468
|
+
f"assuming success. Check the log at {log_path}"
|
|
469
|
+
)
|
|
470
|
+
return
|
|
471
|
+
if exit_code != 0:
|
|
472
|
+
raise_error(
|
|
473
|
+
f"Job '{name}' (ID {job_id}) failed with exit code {exit_code}. "
|
|
474
|
+
f"See the log at {log_path}"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
270
478
|
@scheduler_app.command(name="batch")
|
|
271
479
|
def run_batch(
|
|
272
480
|
name: Annotated[
|
|
@@ -496,7 +704,11 @@ def run_batch(
|
|
|
496
704
|
# The recorded job may have been submitted under a different
|
|
497
705
|
# scheduler kind; use its own kind for activity/cancel checks.
|
|
498
706
|
prev_kind = job_info.get("kind", kind)
|
|
499
|
-
|
|
707
|
+
# Capture the exit code alongside liveness: if the job already left the
|
|
708
|
+
# queue (e.g. while we were disconnected) the scheduler may still have
|
|
709
|
+
# its status in history, and reading it now avoids losing it to a later
|
|
710
|
+
# purge.
|
|
711
|
+
running_or_queued, prev_exit_code = _poll_job(prev_kind, job_id)
|
|
500
712
|
should_wait = True
|
|
501
713
|
|
|
502
714
|
def _cancel_with_reason(reason: str) -> None:
|
|
@@ -543,14 +755,19 @@ def run_batch(
|
|
|
543
755
|
break
|
|
544
756
|
if should_wait:
|
|
545
757
|
typer.echo("Waiting for job to finish")
|
|
546
|
-
_wait_until_done(prev_kind, job_id, name)
|
|
758
|
+
exit_code = _wait_until_done(prev_kind, job_id, name)
|
|
759
|
+
_finalize_job(name, job_id, exit_code, log_path)
|
|
547
760
|
raise typer.Exit(0)
|
|
548
761
|
elif not os.environ.get("CALKIT_FORCE"):
|
|
549
|
-
# The job has left the queue.
|
|
550
|
-
#
|
|
551
|
-
#
|
|
552
|
-
#
|
|
553
|
-
#
|
|
762
|
+
# The job has left the queue (e.g. it finished while the master
|
|
763
|
+
# process was disconnected). If nothing it depends on changed and
|
|
764
|
+
# the prior submission succeeded---or its status was purged, in
|
|
765
|
+
# which case we assume success since there is no record left to
|
|
766
|
+
# judge by and the stage's declared outputs are the final
|
|
767
|
+
# arbiter---harvest it rather than resubmitting and discarding the
|
|
768
|
+
# work. A known failure instead falls through to resubmit, so
|
|
769
|
+
# `calkit run` retries it (e.g. after the user fixed the cause).
|
|
770
|
+
# Under --force (CALKIT_FORCE) we skip this and always resubmit.
|
|
554
771
|
job_dep_md5s = job_info.get("dep_md5s", {})
|
|
555
772
|
deps_unchanged = set(job_deps) == set(deps) and all(
|
|
556
773
|
current_dep_md5s.get(dep) == job_dep_md5s.get(dep)
|
|
@@ -561,13 +778,15 @@ def run_batch(
|
|
|
561
778
|
and job_args == args
|
|
562
779
|
and job_setup == setup_cmds
|
|
563
780
|
)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
781
|
+
if (
|
|
782
|
+
deps_unchanged
|
|
783
|
+
and command_unchanged
|
|
784
|
+
and prev_exit_code in (0, None)
|
|
785
|
+
):
|
|
568
786
|
typer.echo(
|
|
569
|
-
f"Job '{name}' already
|
|
787
|
+
f"Job '{name}' already left the queue; using its result"
|
|
570
788
|
)
|
|
789
|
+
_finalize_job(name, job_id, prev_exit_code, log_path)
|
|
571
790
|
raise typer.Exit(0)
|
|
572
791
|
# Job is not running or queued, so we can submit. First, delete any
|
|
573
792
|
# non-persistent outputs.
|
|
@@ -632,7 +851,8 @@ def run_batch(
|
|
|
632
851
|
)
|
|
633
852
|
raise typer.Exit(130)
|
|
634
853
|
typer.echo("Waiting for job to finish")
|
|
635
|
-
_wait_until_done(kind, job_id, name)
|
|
854
|
+
exit_code = _wait_until_done(kind, job_id, name)
|
|
855
|
+
_finalize_job(name, job_id, exit_code, log_path)
|
|
636
856
|
|
|
637
857
|
|
|
638
858
|
def _detect_interpreter(target: str) -> list[str]:
|