calkit-python 0.37.2__py3-none-any.whl → 0.37.4__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 (44) hide show
  1. calkit/cli/check.py +266 -111
  2. calkit/cli/main/core.py +11 -5
  3. calkit/cli/new.py +18 -4
  4. calkit/cli/notebooks.py +29 -3
  5. calkit/cli/slurm.py +41 -9
  6. calkit/conda.py +34 -12
  7. calkit/dvc/zip.py +9 -3
  8. calkit/environments.py +126 -13
  9. calkit/fs.py +111 -12
  10. calkit/git.py +1 -1
  11. calkit/julia.py +30 -2
  12. calkit/matlab.py +1 -1
  13. calkit/models/core.py +29 -0
  14. calkit/models/pipeline.py +18 -15
  15. calkit/pipeline.py +2 -2
  16. calkit/tests/cli/test_check.py +71 -0
  17. calkit/tests/cli/test_new.py +1 -1
  18. calkit/tests/models/test_pipeline.py +13 -1
  19. calkit/tests/test_conda.py +1 -1
  20. calkit/tests/test_environments.py +155 -0
  21. calkit/tests/test_fs.py +119 -1
  22. calkit/tests/test_git.py +5 -5
  23. calkit/tests/test_julia.py +67 -1
  24. calkit/tests/test_pipeline.py +38 -0
  25. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/package.json +1 -1
  26. calkit_python-0.37.2.data/data/share/jupyter/labextensions/calkit/static/909.651be47ca47390b78a92.js → calkit_python-0.37.4.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +1 -1
  27. calkit_python-0.37.2.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.c091821b3d7f2d287a67.js → calkit_python-0.37.4.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +1 -1
  28. {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.dist-info}/METADATA +56 -45
  29. {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.dist-info}/RECORD +44 -44
  30. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  31. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
  32. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  33. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  34. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  35. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  36. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  37. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  38. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  39. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  40. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  41. {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  42. {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.dist-info}/WHEEL +0 -0
  43. {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.dist-info}/entry_points.txt +0 -0
  44. {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.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.",
@@ -734,15 +876,26 @@ def check_docker_env(
734
876
  typer.echo(f"Found modified dependency: {dep}")
735
877
  rebuild_or_pull = True
736
878
  break
879
+
880
+ def delete_lock_on_failure() -> None:
881
+ if lock_fpath and os.path.exists(lock_fpath):
882
+ os.remove(lock_fpath)
883
+
737
884
  if fpath is not None and rebuild_or_pull:
738
- wdir, fname = os.path.split(fpath)
739
- if not wdir:
740
- wdir = None
741
- cmd = ["docker", "build", "-t", tag, "-f", fname]
885
+ dockerfile_dir, dockerfile_name = os.path.split(fpath)
886
+ if not dockerfile_dir:
887
+ dockerfile_dir = None
888
+ cmd = ["docker", "build", "-t", tag, "-f", dockerfile_name]
742
889
  if platform is not None:
743
890
  cmd += ["--platform", platform]
744
891
  cmd.append(".")
745
- subprocess.check_output(cmd, cwd=wdir)
892
+ try:
893
+ subprocess.check_output(cmd, cwd=dockerfile_dir)
894
+ except subprocess.CalledProcessError:
895
+ delete_lock_on_failure()
896
+ raise_error(
897
+ f"Failed to build Docker image with tag {tag} from {fpath}"
898
+ )
746
899
  elif fpath is None and rebuild_or_pull:
747
900
  # First try to pull by repo digest
748
901
  pulled = False
@@ -759,6 +912,7 @@ def check_docker_env(
759
912
  subprocess.check_output(tag_cmd)
760
913
  pulled = True
761
914
  except subprocess.CalledProcessError:
915
+ delete_lock_on_failure()
762
916
  warn(
763
917
  f"Failed to pull image by digest: {image_with_digest}; "
764
918
  "falling back to pulling by tag"
@@ -770,6 +924,7 @@ def check_docker_env(
770
924
  try:
771
925
  subprocess.check_output(cmd)
772
926
  except subprocess.CalledProcessError:
927
+ delete_lock_on_failure()
773
928
  raise_error(f"Failed to pull image: {tag}")
774
929
  # Write the lock file
775
930
  inspect = get_docker_inspect()
@@ -851,7 +1006,7 @@ def check_conda_env(
851
1006
  try:
852
1007
  calkit.conda.check_env(
853
1008
  env_fpath=env_fpath,
854
- output_fpath=output_fpath,
1009
+ lock_fpath=output_fpath,
855
1010
  alt_lock_fpaths=alt_lock_fpaths,
856
1011
  alt_lock_fpaths_delete=alt_lock_fpaths_delete,
857
1012
  log_func=log_func,
calkit/cli/main/core.py CHANGED
@@ -958,10 +958,9 @@ def push(
958
958
  ):
959
959
  """Push with both Git and DVC."""
960
960
  if not no_dvc:
961
- typer.echo("Pushing to DVC remote")
961
+ remotes = calkit.dvc.get_remotes()
962
962
  if not no_check_auth:
963
963
  # Check that our dvc remotes all have our DVC token set for them
964
- remotes = calkit.dvc.get_remotes()
965
964
  ck_remote_name = calkit.config.get_app_name()
966
965
  for name, url in remotes.items():
967
966
  if (
@@ -972,9 +971,13 @@ def push(
972
971
  f"Checking authentication for DVC remote: {name}"
973
972
  )
974
973
  calkit.dvc.set_remote_auth(remote_name=name)
975
- result = run_dvc_command(["push"] + dvc_args)
976
- if result != 0:
977
- raise_error("DVC push failed")
974
+ if remotes:
975
+ typer.echo("Pushing to DVC remote")
976
+ result = run_dvc_command(["push"] + dvc_args)
977
+ if result != 0:
978
+ raise_error("DVC push failed")
979
+ else:
980
+ warn("No DVC remotes configured; skipping DVC push")
978
981
  if not no_git:
979
982
  typer.echo("Pushing to Git remote")
980
983
  try:
@@ -1910,6 +1913,9 @@ def run_in_env(
1910
1913
  julia_cmd = calkit.julia.check_version_in_command(julia_cmd)
1911
1914
  except Exception as e:
1912
1915
  raise_error(f"Failed to check Julia version: {e}")
1916
+ julia_cmd = calkit.julia.ensure_startup_file_disabled_in_command(
1917
+ julia_cmd
1918
+ )
1913
1919
  if verbose:
1914
1920
  typer.echo(f"Running command: {julia_cmd}")
1915
1921
  try:
calkit/cli/new.py CHANGED
@@ -1204,10 +1204,8 @@ def new_conda_env(
1204
1204
  "(use -f to overwrite)"
1205
1205
  )
1206
1206
  if conda_name is None:
1207
- project_name = ck_info.get("name")
1208
- if project_name is None:
1209
- project_name = os.path.basename(os.getcwd())
1210
- conda_name = calkit.to_kebab_case(project_name) + "-" + name
1207
+ project_name = calkit.detect_project_name(prepend_owner=False)
1208
+ conda_name = calkit.to_kebab_case(project_name) + "." + name
1211
1209
  if packages is not None:
1212
1210
  assert isinstance(packages, list)
1213
1211
  # Write environment to path
@@ -1351,6 +1349,17 @@ def new_slurm_env(
1351
1349
  ),
1352
1350
  ),
1353
1351
  ] = [],
1352
+ default_setup: Annotated[
1353
+ list[str],
1354
+ typer.Option(
1355
+ "--default-setup",
1356
+ help=(
1357
+ "Default shell setup command to run before SLURM jobs "
1358
+ "(for example 'module load julia/1.11'). Repeat for "
1359
+ "multiple commands."
1360
+ ),
1361
+ ),
1362
+ ] = [],
1354
1363
  description: Annotated[
1355
1364
  str | None, typer.Option("--description", help="Description.")
1356
1365
  ] = None,
@@ -1383,9 +1392,14 @@ def new_slurm_env(
1383
1392
  normalized_default_options = [
1384
1393
  opt.strip() for opt in default_options if opt.strip()
1385
1394
  ]
1395
+ normalized_default_setup = [
1396
+ cmd.strip() for cmd in default_setup if cmd.strip()
1397
+ ]
1386
1398
  env = {"kind": "slurm", "host": host}
1387
1399
  if normalized_default_options:
1388
1400
  env["default_options"] = normalized_default_options # type: ignore
1401
+ if normalized_default_setup:
1402
+ env["default_setup"] = normalized_default_setup # type: ignore
1389
1403
  if description is not None:
1390
1404
  env["description"] = description
1391
1405
  envs[name] = env
calkit/cli/notebooks.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import base64
6
6
  import json
7
7
  import os
8
+ import re
8
9
  import subprocess
9
10
  import sys
10
11
  from pathlib import Path
@@ -109,6 +110,11 @@ def _check_ijulia_available(
109
110
  except Exception as e:
110
111
  raise_error(f"Failed to check Julia version: {e}")
111
112
  return False
113
+ ijulia_check_cmd_checked = (
114
+ calkit.julia.ensure_startup_file_disabled_in_command(
115
+ ijulia_check_cmd_checked
116
+ )
117
+ )
112
118
  res = subprocess.run(
113
119
  ijulia_check_cmd_checked,
114
120
  capture_output=True,
@@ -117,6 +123,17 @@ def _check_ijulia_available(
117
123
  return res.returncode == 0
118
124
 
119
125
 
126
+ def _sanitize_kernel_name_component(name: str, label: str) -> str:
127
+ """Keep readable names while removing kernelspec-unfriendly characters."""
128
+ sanitized = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("._-")
129
+ if not sanitized:
130
+ raise_error(
131
+ f"{label} cannot be empty after sanitizing for kernel name"
132
+ )
133
+ return "" # For typing analysis since raise_error exits
134
+ return sanitized
135
+
136
+
120
137
  @notebooks_app.command("check-kernel")
121
138
  def check_env_kernel(
122
139
  env_name: Annotated[
@@ -186,8 +203,14 @@ def check_env_kernel(
186
203
  project_name = calkit.detect_project_name(prepend_owner=False)
187
204
  if not project_name:
188
205
  raise_error("Project name cannot be empty")
189
- kernel_name = calkit.to_kebab_case(f"{project_name}-{env_name}")
206
+ kernel_name = (
207
+ f"{_sanitize_kernel_name_component(project_name, 'Project name')}"
208
+ f".{_sanitize_kernel_name_component(env_name, 'Environment name')}"
209
+ )
190
210
  display_name = f"{project_name}: {env_name}"
211
+ if verbose and not json_output:
212
+ typer.echo(f"Using kernel name: {kernel_name}")
213
+ typer.echo(f"Using display name: {display_name}")
191
214
  if language == "python":
192
215
  cmd = [
193
216
  "python",
@@ -290,8 +313,10 @@ def check_env_kernel(
290
313
  julia_cmd = (
291
314
  "import IJulia;"
292
315
  "kp=IJulia.installkernel("
293
- f'"{display_name}",'
316
+ f'"{kernel_name}",'
294
317
  f'"--project={env_dir_abs}",'
318
+ '"--startup-file=no",'
319
+ f'displayname="{display_name}",'
295
320
  'env=Dict("JULIA_LOAD_PATH" => "@:@stdlib")'
296
321
  ");"
297
322
  "println(kp);"
@@ -307,9 +332,10 @@ def check_env_kernel(
307
332
  cmd = calkit.julia.check_version_in_command(cmd)
308
333
  except Exception as e:
309
334
  raise_error(f"Failed to check Julia version: {e}")
335
+ cmd = calkit.julia.ensure_startup_file_disabled_in_command(cmd)
310
336
  res = subprocess.run(cmd, capture_output=True, text=True)
311
337
  if res.returncode != 0:
312
- raise_error(f"Failed to create kernel:\n{res.stdout}")
338
+ raise_error(f"Failed to create kernel:\n{res.stderr}")
313
339
  kernel_path = res.stdout.strip()
314
340
  kernel_name = os.path.basename(kernel_path)
315
341
  # Update display_name to include version for matching in VS Code
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")