calkit-python 0.37.2__py3-none-any.whl → 0.37.3__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 (41) hide show
  1. calkit/cli/check.py +247 -105
  2. calkit/cli/main/core.py +3 -0
  3. calkit/cli/new.py +16 -0
  4. calkit/cli/notebooks.py +7 -0
  5. calkit/cli/slurm.py +41 -9
  6. calkit/dvc/zip.py +9 -3
  7. calkit/environments.py +126 -13
  8. calkit/fs.py +53 -2
  9. calkit/git.py +1 -1
  10. calkit/julia.py +30 -2
  11. calkit/matlab.py +1 -1
  12. calkit/models/core.py +29 -0
  13. calkit/models/pipeline.py +18 -15
  14. calkit/pipeline.py +2 -2
  15. calkit/tests/cli/test_check.py +35 -0
  16. calkit/tests/models/test_pipeline.py +13 -1
  17. calkit/tests/test_environments.py +155 -0
  18. calkit/tests/test_fs.py +119 -1
  19. calkit/tests/test_git.py +5 -5
  20. calkit/tests/test_julia.py +67 -1
  21. calkit/tests/test_pipeline.py +38 -0
  22. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/package.json +1 -1
  23. calkit_python-0.37.2.data/data/share/jupyter/labextensions/calkit/static/909.651be47ca47390b78a92.js → calkit_python-0.37.3.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +1 -1
  24. calkit_python-0.37.2.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.c091821b3d7f2d287a67.js → calkit_python-0.37.3.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +1 -1
  25. {calkit_python-0.37.2.dist-info → calkit_python-0.37.3.dist-info}/METADATA +56 -45
  26. {calkit_python-0.37.2.dist-info → calkit_python-0.37.3.dist-info}/RECORD +41 -41
  27. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  28. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
  29. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  30. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  31. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  32. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  33. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  34. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  35. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  36. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  37. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  38. {calkit_python-0.37.2.data → calkit_python-0.37.3.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  39. {calkit_python-0.37.2.dist-info → calkit_python-0.37.3.dist-info}/WHEEL +0 -0
  40. {calkit_python-0.37.2.dist-info → calkit_python-0.37.3.dist-info}/entry_points.txt +0 -0
  41. {calkit_python-0.37.2.dist-info → calkit_python-0.37.3.dist-info}/licenses/LICENSE +0 -0
calkit/cli/check.py CHANGED
@@ -31,6 +31,217 @@ from calkit.environments import (
31
31
  check_app = typer.Typer(no_args_is_help=True)
32
32
 
33
33
 
34
+ def _juliaup_version_installed(julia_version: str) -> bool:
35
+ """Return True if juliaup already has the given channel installed locally."""
36
+ try:
37
+ result = subprocess.run(
38
+ ["juliaup", "status"],
39
+ capture_output=True,
40
+ text=True,
41
+ check=False,
42
+ timeout=10,
43
+ )
44
+ except FileNotFoundError:
45
+ return False
46
+ except subprocess.TimeoutExpired:
47
+ warn("Timed out while running `juliaup status`.")
48
+ return False
49
+ if result.returncode != 0:
50
+ err_output = (result.stderr or result.stdout or "").strip()
51
+ if err_output:
52
+ warn(f"`juliaup status` failed: {err_output}")
53
+ else:
54
+ warn(
55
+ f"`juliaup status` failed with exit code {result.returncode}."
56
+ )
57
+ return False
58
+ for line in result.stdout.splitlines():
59
+ parts = line.split()
60
+ if not parts:
61
+ continue
62
+ # Columns: [Default?] Channel Version [Update?]
63
+ # Default column is either empty or "*", so channel is parts[0] or parts[1]
64
+ for col in parts[:2]:
65
+ if col == julia_version:
66
+ return True
67
+ return False
68
+
69
+
70
+ def _check_julia_env(
71
+ env_path: str,
72
+ julia_version: str | None = None,
73
+ verbose: bool = False,
74
+ cache_key: str | None = None,
75
+ ) -> str:
76
+ """Check a Julia environment and instantiate only when needed."""
77
+ abs_env_path = os.path.abspath(env_path)
78
+ env_fname = os.path.basename(abs_env_path)
79
+ if env_fname != "Project.toml":
80
+ raise_error(
81
+ "Julia environments require a path pointing to Project.toml"
82
+ )
83
+ env_dir = os.path.dirname(abs_env_path) or "."
84
+ env_path_for_cache = os.path.basename(abs_env_path)
85
+ env = {
86
+ "kind": "julia",
87
+ "path": env_path_for_cache,
88
+ "julia": julia_version or "",
89
+ }
90
+ cache_env_name = cache_key or (
91
+ f"julia::{abs_env_path}::{julia_version or ''}"
92
+ )
93
+ if calkit.environments.check_cache(
94
+ env_name=cache_env_name,
95
+ env=env,
96
+ wdir=env_dir,
97
+ respect_ttl=False,
98
+ ):
99
+ if verbose:
100
+ typer.echo(
101
+ "Julia environment cache is valid; skipping Pkg.instantiate()"
102
+ )
103
+ lock_fpath = calkit.environments.get_env_lock_fpath(
104
+ env=env,
105
+ env_name=cache_env_name,
106
+ wdir=env_dir,
107
+ as_posix=False,
108
+ )
109
+ return lock_fpath or os.path.join(env_dir, "Manifest.toml")
110
+ if julia_version:
111
+ if shutil.which("juliaup") is not None:
112
+ if _juliaup_version_installed(julia_version):
113
+ if verbose:
114
+ typer.echo(
115
+ f"Julia {julia_version} is already installed; "
116
+ "skipping juliaup add"
117
+ )
118
+ else:
119
+ cmd = ["juliaup", "add", julia_version]
120
+ if verbose:
121
+ typer.echo(f"Running command: {cmd}")
122
+ try:
123
+ subprocess.run(cmd, check=True)
124
+ except subprocess.CalledProcessError:
125
+ raise_error(
126
+ f"Failed to install Julia version {julia_version}"
127
+ )
128
+ else:
129
+ try:
130
+ compatible = calkit.julia.current_version_is_compatible(
131
+ julia_version
132
+ )
133
+ except ValueError as e:
134
+ raise_error(str(e))
135
+ if not compatible:
136
+ raise_error(
137
+ "Current Julia version is not compatible with required "
138
+ f"version ({julia_version}), and juliaup is not "
139
+ "available to install it"
140
+ )
141
+ deps_to_add: list[str] = []
142
+ try:
143
+ with open(abs_env_path, "r") as f:
144
+ content = f.read()
145
+ lines = [line.rstrip() for line in content.splitlines()]
146
+ deps_section = False
147
+ deps_found = False
148
+ for line in lines:
149
+ stripped = line.strip()
150
+ if stripped == "[deps]":
151
+ deps_section = True
152
+ continue
153
+ if deps_section:
154
+ if stripped.startswith("[") and stripped.endswith("]"):
155
+ break
156
+ if stripped and not stripped.startswith("#"):
157
+ if "=" in stripped:
158
+ deps_found = True
159
+ break
160
+ if not deps_found:
161
+ for idx, line in enumerate(lines):
162
+ marker = "# Dependencies (add with Julia's Pkg.add):"
163
+ if line.strip() == marker and idx + 1 < len(lines):
164
+ dep_line = lines[idx + 1].strip()
165
+ if dep_line.startswith("#"):
166
+ dep_line = dep_line.lstrip("#").strip()
167
+ deps_to_add = [
168
+ dep.strip()
169
+ for dep in dep_line.split(",")
170
+ if dep.strip()
171
+ ]
172
+ break
173
+ except OSError:
174
+ deps_to_add = []
175
+ if deps_to_add:
176
+ pkg_list = ", ".join(f'"{dep}"' for dep in deps_to_add)
177
+ cmd = ["julia"]
178
+ if julia_version:
179
+ cmd.append(f"+{julia_version}")
180
+ cmd += [
181
+ f"--project={env_dir}",
182
+ "-e",
183
+ f"using Pkg; Pkg.add([{pkg_list}]);",
184
+ ]
185
+ if julia_version:
186
+ try:
187
+ cmd = calkit.julia.check_version_in_command(cmd)
188
+ except Exception as e:
189
+ raise_error(f"Failed to check Julia version: {e}")
190
+ cmd = calkit.julia.ensure_startup_file_disabled_in_command(cmd)
191
+ try:
192
+ subprocess.check_call(
193
+ cmd,
194
+ env=os.environ.copy() | {"JULIA_LOAD_PATH": "@:@stdlib"},
195
+ )
196
+ except subprocess.CalledProcessError:
197
+ calkit.environments.save_cache(
198
+ env_name=cache_env_name,
199
+ env=env,
200
+ wdir=env_dir,
201
+ success=False,
202
+ )
203
+ raise_error("Failed to add Julia dependencies")
204
+ cmd = ["julia"]
205
+ if julia_version:
206
+ cmd.append(f"+{julia_version}")
207
+ cmd += [
208
+ f"--project={env_dir}",
209
+ "-e",
210
+ "using Pkg; Pkg.instantiate();",
211
+ ]
212
+ if julia_version:
213
+ try:
214
+ cmd = calkit.julia.check_version_in_command(cmd)
215
+ except Exception as e:
216
+ raise_error(f"Failed to check Julia version: {e}")
217
+ cmd = calkit.julia.ensure_startup_file_disabled_in_command(cmd)
218
+ try:
219
+ subprocess.check_call(
220
+ cmd, env=os.environ.copy() | {"JULIA_LOAD_PATH": "@:@stdlib"}
221
+ )
222
+ except subprocess.CalledProcessError:
223
+ calkit.environments.save_cache(
224
+ env_name=cache_env_name,
225
+ env=env,
226
+ wdir=env_dir,
227
+ success=False,
228
+ )
229
+ raise_error("Failed to check julia environment")
230
+ calkit.environments.save_cache(
231
+ env_name=cache_env_name,
232
+ env=env,
233
+ wdir=env_dir,
234
+ success=True,
235
+ )
236
+ lock_fpath = calkit.environments.get_env_lock_fpath(
237
+ env=env,
238
+ env_name=cache_env_name,
239
+ wdir=env_dir,
240
+ as_posix=False,
241
+ )
242
+ return lock_fpath or os.path.join(env_dir, "Manifest.toml")
243
+
244
+
34
245
  @check_app.command(name="repro")
35
246
  def check_repro(
36
247
  wdir: Annotated[
@@ -190,116 +401,47 @@ def check_environment(
190
401
  julia_version = env.get("julia")
191
402
  if julia_version is None:
192
403
  raise_error("Julia environments require a Julia version")
193
- env_fname = os.path.basename(env_path)
194
- if not env_fname == "Project.toml":
195
- raise_error(
196
- "Julia environments require a path pointing to Project.toml"
197
- )
198
- # First ensure the Julia version exists
199
- if shutil.which("juliaup") is not None:
200
- cmd = ["juliaup", "add", julia_version]
201
- if verbose:
202
- typer.echo(f"Running command: {cmd}")
203
- try:
204
- subprocess.run(cmd, check=True)
205
- except subprocess.CalledProcessError:
206
- raise_error(f"Failed to install Julia version {julia_version}")
207
- else:
208
- try:
209
- compatible = calkit.julia.current_version_is_compatible(
210
- julia_version
211
- )
212
- except ValueError as e:
213
- raise_error(str(e))
214
- if not compatible:
215
- raise_error(
216
- f"Current Julia version is not compatible with required "
217
- f"version ({julia_version}), and juliaup is not available to "
218
- "install it"
219
- )
220
- env_dir = os.path.dirname(env_path)
221
- if not env_dir:
222
- env_dir = "."
223
- # If auto-detection couldn't resolve UUIDs, Project.toml includes a
224
- # commented dependency list
225
- # In that case, add those packages with
226
- # Pkg.add before instantiating so the env is usable at run time
227
- deps_to_add: list[str] = []
228
- try:
229
- with open(env_path, "r") as f:
230
- content = f.read()
231
- lines = [line.rstrip() for line in content.splitlines()]
232
- deps_section = False
233
- deps_found = False
234
- for line in lines:
235
- stripped = line.strip()
236
- if stripped == "[deps]":
237
- deps_section = True
238
- continue
239
- if deps_section:
240
- if stripped.startswith("[") and stripped.endswith("]"):
241
- break
242
- if stripped and not stripped.startswith("#"):
243
- if "=" in stripped:
244
- deps_found = True
245
- break
246
- if not deps_found:
247
- for idx, line in enumerate(lines):
248
- marker = "# Dependencies (add with Julia's Pkg.add):"
249
- if line.strip() == marker and idx + 1 < len(lines):
250
- dep_line = lines[idx + 1].strip()
251
- if dep_line.startswith("#"):
252
- dep_line = dep_line.lstrip("#").strip()
253
- deps_to_add = [
254
- dep.strip()
255
- for dep in dep_line.split(",")
256
- if dep.strip()
257
- ]
258
- break
259
- except OSError:
260
- deps_to_add = []
261
- if deps_to_add:
262
- pkg_list = ", ".join(f'"{dep}"' for dep in deps_to_add)
263
- cmd = [
264
- "julia",
265
- f"+{julia_version}",
266
- f"--project={env_dir}",
267
- "-e",
268
- f"using Pkg; Pkg.add([{pkg_list}]);",
269
- ]
270
- try:
271
- cmd = calkit.julia.check_version_in_command(cmd)
272
- except Exception as e:
273
- raise_error(f"Failed to check Julia version: {e}")
274
- try:
275
- subprocess.check_call(
276
- cmd,
277
- env=os.environ.copy() | {"JULIA_LOAD_PATH": "@:@stdlib"},
278
- )
279
- except subprocess.CalledProcessError:
280
- raise_error("Failed to add Julia dependencies")
281
- cmd = [
282
- "julia",
283
- f"+{julia_version}",
284
- f"--project={env_dir}",
285
- "-e",
286
- "using Pkg; Pkg.instantiate();",
287
- ]
288
- try:
289
- cmd = calkit.julia.check_version_in_command(cmd)
290
- except Exception as e:
291
- raise_error(f"Failed to check Julia version: {e}")
292
- try:
293
- subprocess.check_call(
294
- cmd, env=os.environ.copy() | {"JULIA_LOAD_PATH": "@:@stdlib"}
295
- )
296
- except subprocess.CalledProcessError:
297
- raise_error("Failed to check julia environment")
404
+ _check_julia_env(
405
+ env_path=env_path,
406
+ julia_version=julia_version,
407
+ verbose=verbose,
408
+ cache_key=env_name,
409
+ )
298
410
  else:
299
411
  raise_error(f"Environment kind '{env['kind']}' not supported")
300
412
  return get_env_lock_fpath(env=env, env_name=env_name, as_posix=False)
301
413
 
302
414
 
415
+ @check_app.command(
416
+ name="julia-env",
417
+ help=(
418
+ "Check a Julia environment and instantiate only when project, "
419
+ "manifest, and package cache state have changed."
420
+ ),
421
+ )
422
+ def check_julia_env(
423
+ env_path: Annotated[
424
+ str,
425
+ typer.Argument(help="Path to Julia Project.toml file."),
426
+ ] = "Project.toml",
427
+ julia_version: Annotated[
428
+ str | None,
429
+ typer.Option(
430
+ "--julia",
431
+ help="Julia version to enforce (e.g., 1.11).",
432
+ ),
433
+ ] = None,
434
+ verbose: Annotated[
435
+ bool, typer.Option("--verbose", help="Print verbose output.")
436
+ ] = False,
437
+ ) -> str:
438
+ return _check_julia_env(
439
+ env_path=env_path,
440
+ julia_version=julia_version,
441
+ verbose=verbose,
442
+ )
443
+
444
+
303
445
  @check_app.command(
304
446
  name="envs",
305
447
  help="Check that all environments are up-to-date.",
calkit/cli/main/core.py CHANGED
@@ -1910,6 +1910,9 @@ def run_in_env(
1910
1910
  julia_cmd = calkit.julia.check_version_in_command(julia_cmd)
1911
1911
  except Exception as e:
1912
1912
  raise_error(f"Failed to check Julia version: {e}")
1913
+ julia_cmd = calkit.julia.ensure_startup_file_disabled_in_command(
1914
+ julia_cmd
1915
+ )
1913
1916
  if verbose:
1914
1917
  typer.echo(f"Running command: {julia_cmd}")
1915
1918
  try:
calkit/cli/new.py CHANGED
@@ -1351,6 +1351,17 @@ def new_slurm_env(
1351
1351
  ),
1352
1352
  ),
1353
1353
  ] = [],
1354
+ default_setup: Annotated[
1355
+ list[str],
1356
+ typer.Option(
1357
+ "--default-setup",
1358
+ help=(
1359
+ "Default shell setup command to run before SLURM jobs "
1360
+ "(for example 'module load julia/1.11'). Repeat for "
1361
+ "multiple commands."
1362
+ ),
1363
+ ),
1364
+ ] = [],
1354
1365
  description: Annotated[
1355
1366
  str | None, typer.Option("--description", help="Description.")
1356
1367
  ] = None,
@@ -1383,9 +1394,14 @@ def new_slurm_env(
1383
1394
  normalized_default_options = [
1384
1395
  opt.strip() for opt in default_options if opt.strip()
1385
1396
  ]
1397
+ normalized_default_setup = [
1398
+ cmd.strip() for cmd in default_setup if cmd.strip()
1399
+ ]
1386
1400
  env = {"kind": "slurm", "host": host}
1387
1401
  if normalized_default_options:
1388
1402
  env["default_options"] = normalized_default_options # type: ignore
1403
+ if normalized_default_setup:
1404
+ env["default_setup"] = normalized_default_setup # type: ignore
1389
1405
  if description is not None:
1390
1406
  env["description"] = description
1391
1407
  envs[name] = env
calkit/cli/notebooks.py CHANGED
@@ -109,6 +109,11 @@ def _check_ijulia_available(
109
109
  except Exception as e:
110
110
  raise_error(f"Failed to check Julia version: {e}")
111
111
  return False
112
+ ijulia_check_cmd_checked = (
113
+ calkit.julia.ensure_startup_file_disabled_in_command(
114
+ ijulia_check_cmd_checked
115
+ )
116
+ )
112
117
  res = subprocess.run(
113
118
  ijulia_check_cmd_checked,
114
119
  capture_output=True,
@@ -292,6 +297,7 @@ def check_env_kernel(
292
297
  "kp=IJulia.installkernel("
293
298
  f'"{display_name}",'
294
299
  f'"--project={env_dir_abs}",'
300
+ '"--startup-file=no",'
295
301
  'env=Dict("JULIA_LOAD_PATH" => "@:@stdlib")'
296
302
  ");"
297
303
  "println(kp);"
@@ -307,6 +313,7 @@ def check_env_kernel(
307
313
  cmd = calkit.julia.check_version_in_command(cmd)
308
314
  except Exception as e:
309
315
  raise_error(f"Failed to check Julia version: {e}")
316
+ cmd = calkit.julia.ensure_startup_file_disabled_in_command(cmd)
310
317
  res = subprocess.run(cmd, capture_output=True, text=True)
311
318
  if res.returncode != 0:
312
319
  raise_error(f"Failed to create kernel:\n{res.stdout}")
calkit/cli/slurm.py CHANGED
@@ -81,6 +81,16 @@ def run_sbatch(
81
81
  help="Additional options to pass to sbatch (no spaces allowed).",
82
82
  ),
83
83
  ] = [],
84
+ setup_cmds: Annotated[
85
+ list[str],
86
+ typer.Option(
87
+ "--setup",
88
+ help=(
89
+ "Shell setup command to run before launching the target "
90
+ "(repeat for multiple commands)."
91
+ ),
92
+ ),
93
+ ] = [],
84
94
  log_path: Annotated[
85
95
  str | None, typer.Option("--log-path", help="Output log path.")
86
96
  ] = None,
@@ -97,7 +107,7 @@ def run_sbatch(
97
107
  Duplicates are not allowed, so if one is already running or queued with
98
108
  the same name, we'll wait for it to finish. The only exception is if the
99
109
  dependencies have changed, in which case any queued or running jobs will
100
- be cancelled and a new one submitted.
110
+ be canceled and a new one submitted.
101
111
  """
102
112
 
103
113
  def check_job_running_or_queued(job_id: str) -> bool:
@@ -132,13 +142,6 @@ def run_sbatch(
132
142
  ] + sbatch_opts
133
143
  if is_command is None:
134
144
  is_command = not os.path.isfile(target)
135
- if is_command:
136
- # Use shlex.join to properly escape target and args for shell execution
137
- cmd += ["--wrap", shlex.join([target] + args)]
138
- else:
139
- cmd += [target] + args
140
- if target not in deps:
141
- deps = [target] + deps
142
145
  if environment != "_system":
143
146
  ck_info = calkit.load_calkit_info()
144
147
  env = ck_info.get("environments", {}).get(environment, {})
@@ -163,6 +166,26 @@ def run_sbatch(
163
166
  f"Environment '{environment}' is for host '{env_host}', but "
164
167
  f"this is '{current_host}'"
165
168
  )
169
+ env_setup_cmds = env.get("default_setup", [])
170
+ if env_setup_cmds:
171
+ setup_cmds = [
172
+ s for s in [*env_setup_cmds, *setup_cmds] if s.strip()
173
+ ]
174
+ if setup_cmds:
175
+ # Run setup and target in the same shell so setup side effects (like
176
+ # module loads) persist for the target command.
177
+ wrapped_target = shlex.join([target] + args)
178
+ setup_chain = " && ".join([*setup_cmds, wrapped_target])
179
+ cmd += ["--wrap", setup_chain]
180
+ if not is_command and target not in deps:
181
+ deps = [target] + deps
182
+ elif is_command:
183
+ # Use shlex.join to properly escape target and args for shell execution
184
+ cmd += ["--wrap", shlex.join([target] + args)]
185
+ else:
186
+ cmd += [target] + args
187
+ if target not in deps:
188
+ deps = [target] + deps
166
189
  slurm_dir = os.path.join(".calkit", "slurm")
167
190
  os.makedirs(slurm_dir, exist_ok=True)
168
191
  logs_dir = os.path.dirname(log_path)
@@ -187,6 +210,7 @@ def run_sbatch(
187
210
  job_deps = job_info["deps"]
188
211
  job_target = job_info.get("target")
189
212
  job_args = job_info.get("args", [])
213
+ job_setup = job_info.get("setup", [])
190
214
  running_or_queued = check_job_running_or_queued(job_id)
191
215
  should_wait = True
192
216
  if running_or_queued:
@@ -207,6 +231,13 @@ def run_sbatch(
207
231
  job_id=job_id,
208
232
  reason=f"Arguments for job '{name}' have changed",
209
233
  )
234
+ # Check if setup commands have changed
235
+ if job_setup != setup_cmds:
236
+ should_wait = False
237
+ cancel_job(
238
+ job_id=job_id,
239
+ reason=f"Setup commands for job '{name}' have changed",
240
+ )
210
241
  # Check if dependency paths have changed
211
242
  if set(job_deps) != set(deps):
212
243
  should_wait = False
@@ -258,6 +289,7 @@ def run_sbatch(
258
289
  "deps": deps,
259
290
  "target": target,
260
291
  "args": args,
292
+ "setup": setup_cmds,
261
293
  "dep_md5s": current_dep_md5s,
262
294
  }
263
295
  jobs[name] = new_job
@@ -336,7 +368,7 @@ def cancel_jobs(
336
368
  )
337
369
  if p.returncode != 0:
338
370
  raise_error(f"Failed to cancel job ID {job_id}: {p.stderr}")
339
- typer.echo(f"Cancelled job '{name}' with ID {job_id}")
371
+ typer.echo(f"Canceled job '{name}' with ID {job_id}")
340
372
 
341
373
 
342
374
  @slurm_app.command(name="logs")
calkit/dvc/zip.py CHANGED
@@ -185,17 +185,23 @@ def hash_path(path: str, alg="md5") -> str:
185
185
  def calc_dir_sig(path: str) -> str:
186
186
  """Calculate a fast signature for a directory to know if we should
187
187
  rehash.
188
+
189
+ The signature includes file count, total size, and latest mtime in
190
+ nanoseconds over all files in the directory tree.
188
191
  """
189
192
  if not os.path.isdir(path):
190
193
  return ""
191
194
  file_count = 0
192
195
  total_size = 0
193
196
  latest_mtime = 0
194
- for foldername, subfolders, filenames in os.walk(path):
197
+ for foldername, _, filenames in os.walk(path):
195
198
  for filename in filenames:
196
- file_count += 1
197
199
  fpath = os.path.join(foldername, filename)
198
- st = os.stat(fpath)
200
+ try:
201
+ st = os.stat(fpath)
202
+ except OSError:
203
+ continue
204
+ file_count += 1
199
205
  total_size += st.st_size
200
206
  if st.st_mtime_ns > latest_mtime:
201
207
  latest_mtime = st.st_mtime_ns