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.
Files changed (42) hide show
  1. calkit/cli/delete.py +33 -0
  2. calkit/cli/list.py +56 -1
  3. calkit/cli/main/core.py +26 -5
  4. calkit/cli/new.py +97 -0
  5. calkit/cli/scheduler.py +251 -31
  6. calkit/cloud.py +29 -12
  7. calkit/detect.py +103 -2
  8. calkit/dvc/core.py +153 -12
  9. calkit/fs.py +78 -40
  10. calkit/models/core.py +58 -5
  11. calkit/pipeline.py +25 -0
  12. calkit/tests/cli/main/test_core.py +106 -47
  13. calkit/tests/cli/main/test_lock.py +274 -0
  14. calkit/tests/cli/test_delete.py +23 -0
  15. calkit/tests/cli/test_list.py +46 -0
  16. calkit/tests/cli/test_new.py +48 -0
  17. calkit/tests/cli/test_scheduler.py +207 -0
  18. calkit/tests/dvc/test_core.py +50 -0
  19. calkit/tests/models/test_core.py +25 -0
  20. calkit/tests/test_cloud.py +96 -0
  21. calkit/tests/test_detect.py +67 -0
  22. calkit/tests/test_fs.py +121 -0
  23. {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/METADATA +1 -1
  24. {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/RECORD +42 -38
  25. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  26. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
  27. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  28. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  29. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  30. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  31. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  32. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  33. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
  34. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  35. {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
  36. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  37. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
  38. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  39. {calkit_python-0.41.12.data → calkit_python-0.41.14.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  40. {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/WHEEL +0 -0
  41. {calkit_python-0.41.12.dist-info → calkit_python-0.41.14.dist-info}/entry_points.txt +0 -0
  42. {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(["pull"] + dvc_args)
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, cwd=sp_path
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(["push"] + dvc_args)
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
- sp_res = dvc_cli_main(["repro"] + sp_args)
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
- res = dvc_cli_main(["repro"] + args)
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
- popen_kwargs = dict(
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
- if os.name == "posix":
186
- popen_kwargs["start_new_session"] = True
187
- elif hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
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 _is_active(kind: str, job_id: str) -> bool:
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
- return _mock_active(job_id)
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], capture_output=True, text=True
322
+ ["squeue", "--job", job_id],
323
+ capture_output=True,
324
+ text=True,
325
+ check=False,
222
326
  )
223
- if p.returncode != 0:
224
- return False
225
- return len(p.stdout.strip().split("\n")) > 1
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
- return False
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
- return state not in ("C", "F")
240
- return False
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, canceling it on Ctrl+C.
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 _is_active(kind, job_id):
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
- running_or_queued = _is_active(prev_kind, job_id)
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. If nothing it depends on changed and
550
- # its outputs are present, it already finished successfully (e.g.
551
- # while the master process was disconnected), so treat it as done
552
- # rather than resubmitting and discarding that work. Under --force
553
- # (CALKIT_FORCE) we skip this check and resubmit.
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
- outputs_present = bool(outs) and all(
565
- os.path.exists(out) for out in outs
566
- )
567
- if deps_unchanged and command_unchanged and outputs_present:
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 completed; skipping resubmission"
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]: