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.
- calkit/cli/check.py +266 -111
- calkit/cli/main/core.py +11 -5
- calkit/cli/new.py +18 -4
- calkit/cli/notebooks.py +29 -3
- calkit/cli/slurm.py +41 -9
- calkit/conda.py +34 -12
- calkit/dvc/zip.py +9 -3
- calkit/environments.py +126 -13
- calkit/fs.py +111 -12
- calkit/git.py +1 -1
- calkit/julia.py +30 -2
- calkit/matlab.py +1 -1
- calkit/models/core.py +29 -0
- calkit/models/pipeline.py +18 -15
- calkit/pipeline.py +2 -2
- calkit/tests/cli/test_check.py +71 -0
- calkit/tests/cli/test_new.py +1 -1
- calkit/tests/models/test_pipeline.py +13 -1
- calkit/tests/test_conda.py +1 -1
- calkit/tests/test_environments.py +155 -0
- calkit/tests/test_fs.py +119 -1
- calkit/tests/test_git.py +5 -5
- calkit/tests/test_julia.py +67 -1
- calkit/tests/test_pipeline.py +38 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/package.json +1 -1
- 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
- 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
- {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.dist-info}/METADATA +56 -45
- {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.dist-info}/RECORD +44 -44
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {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
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.37.2.data → calkit_python-0.37.4.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.dist-info}/WHEEL +0 -0
- {calkit_python-0.37.2.dist-info → calkit_python-0.37.4.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
739
|
-
if not
|
|
740
|
-
|
|
741
|
-
cmd = ["docker", "build", "-t", tag, "-f",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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 =
|
|
1208
|
-
|
|
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 =
|
|
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'"{
|
|
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.
|
|
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
|
|
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"
|
|
371
|
+
typer.echo(f"Canceled job '{name}' with ID {job_id}")
|
|
340
372
|
|
|
341
373
|
|
|
342
374
|
@slurm_app.command(name="logs")
|