calkit-python 0.41.9__py3-none-any.whl → 0.41.11__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 (43) hide show
  1. calkit/cli/check.py +90 -131
  2. calkit/cli/describe.py +45 -0
  3. calkit/cli/list.py +18 -2
  4. calkit/cli/main/core.py +28 -5
  5. calkit/cli/new.py +36 -16
  6. calkit/cli/notebooks.py +2 -2
  7. calkit/cli/scheduler.py +7 -8
  8. calkit/cli/update.py +558 -4
  9. calkit/core.py +12 -0
  10. calkit/dvc/core.py +19 -0
  11. calkit/environments.py +62 -6
  12. calkit/julia.py +36 -3
  13. calkit/models/core.py +12 -5
  14. calkit/pipeline.py +28 -10
  15. calkit/tests/cli/main/test_core.py +49 -1
  16. calkit/tests/cli/test_list.py +19 -2
  17. calkit/tests/cli/test_new.py +2 -1
  18. calkit/tests/cli/test_scheduler.py +31 -0
  19. calkit/tests/cli/test_update.py +136 -0
  20. calkit/tests/dvc/test_core.py +13 -0
  21. calkit/tests/test_environments.py +63 -3
  22. calkit/tests/test_julia.py +34 -0
  23. calkit/tests/test_pipeline.py +35 -0
  24. {calkit_python-0.41.9.dist-info → calkit_python-0.41.11.dist-info}/METADATA +1 -1
  25. {calkit_python-0.41.9.dist-info → calkit_python-0.41.11.dist-info}/RECORD +43 -43
  26. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  27. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
  28. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  29. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  30. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  31. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  32. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  33. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  34. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
  35. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  36. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  37. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  38. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
  39. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  40. {calkit_python-0.41.9.data → calkit_python-0.41.11.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  41. {calkit_python-0.41.9.dist-info → calkit_python-0.41.11.dist-info}/WHEEL +0 -0
  42. {calkit_python-0.41.9.dist-info → calkit_python-0.41.11.dist-info}/entry_points.txt +0 -0
  43. {calkit_python-0.41.9.dist-info → calkit_python-0.41.11.dist-info}/licenses/LICENSE +0 -0
calkit/cli/check.py CHANGED
@@ -163,7 +163,7 @@ def _check_julia_env(
163
163
  deps_to_add = []
164
164
  if deps_to_add:
165
165
  pkg_list = ", ".join(f'"{dep}"' for dep in deps_to_add)
166
- cmd = ["julia"]
166
+ cmd = [calkit.julia.get_julia_exe()]
167
167
  if julia_version:
168
168
  cmd.append(f"+{julia_version}")
169
169
  cmd += [
@@ -190,7 +190,7 @@ def _check_julia_env(
190
190
  success=False,
191
191
  )
192
192
  raise_error("Failed to add Julia dependencies")
193
- cmd = ["julia"]
193
+ cmd = [calkit.julia.get_julia_exe()]
194
194
  if julia_version:
195
195
  cmd.append(f"+{julia_version}")
196
196
  cmd += [
@@ -318,6 +318,7 @@ def check_environment(
318
318
  get_all_conda_lock_fpaths,
319
319
  get_all_docker_lock_fpaths,
320
320
  get_all_venv_lock_fpaths,
321
+ get_default_venv_prefix,
321
322
  get_env_lock_fpath,
322
323
  write_scheduler_env_lock,
323
324
  )
@@ -403,12 +404,14 @@ def check_environment(
403
404
  except subprocess.CalledProcessError:
404
405
  raise_error("Failed to check uv environment")
405
406
  elif (kind := env["kind"]) in ["uv-venv", "venv"]:
406
- if "prefix" not in env:
407
- raise_error("venv environments require a prefix")
408
407
  if "path" not in env:
409
408
  raise_error("venv environments require a path")
410
- prefix = os.path.expandvars(env["prefix"])
411
409
  path = os.path.expandvars(env["path"])
410
+ # Resolve the prefix on the fly if it isn't pinned in calkit.yaml
411
+ prefix = env.get("prefix")
412
+ if prefix is None:
413
+ prefix = get_default_venv_prefix(envs, path, env_name)
414
+ prefix = os.path.expandvars(prefix)
412
415
  lock_fpath = get_env_lock_fpath(
413
416
  env=env, env_name=env_name, as_posix=False
414
417
  )
@@ -543,6 +546,41 @@ def check_environments(
543
546
  )
544
547
 
545
548
 
549
+ def _renv_snapshot_from_description(env_dir: str, verbose: bool) -> None:
550
+ """Install packages from DESCRIPTION and (re)write renv.lock.
551
+
552
+ Used to create the initial lock, to add newly declared packages, and as a
553
+ fallback when an existing lock can't be restored (e.g. its versions don't
554
+ build against the installed R version).
555
+ """
556
+ hydrate_cmd = [
557
+ "Rscript",
558
+ "--vanilla",
559
+ "-e",
560
+ "renv::load(); renv::hydrate()",
561
+ ]
562
+ if verbose:
563
+ typer.echo(f"Running: {' '.join(hydrate_cmd)}")
564
+ try:
565
+ subprocess.check_call(hydrate_cmd, cwd=env_dir)
566
+ except subprocess.CalledProcessError:
567
+ # Hydrate may fail if some packages aren't available; snapshot anyway
568
+ if verbose:
569
+ typer.echo("Warning: hydrate had issues, continuing to snapshot")
570
+ snapshot_cmd = [
571
+ "Rscript",
572
+ "--vanilla",
573
+ "-e",
574
+ "renv::load(); renv::snapshot(type='explicit', prompt=FALSE)",
575
+ ]
576
+ if verbose:
577
+ typer.echo(f"Running: {' '.join(snapshot_cmd)}")
578
+ try:
579
+ subprocess.check_call(snapshot_cmd, cwd=env_dir)
580
+ except subprocess.CalledProcessError:
581
+ raise_error(f"Failed to snapshot renv in {env_dir}")
582
+
583
+
546
584
  @check_app.command(name="renv")
547
585
  def check_renv(
548
586
  env_path: Annotated[
@@ -606,153 +644,74 @@ def check_renv(
606
644
  subprocess.check_call(init_cmd, cwd=env_dir)
607
645
  except subprocess.CalledProcessError:
608
646
  raise_error(f"Failed to initialize renv in {env_dir}")
609
- # Use hydrate to install packages from DESCRIPTION and snapshot
647
+ # Install packages from DESCRIPTION and write the lock file
610
648
  if verbose:
611
649
  typer.echo("Setting up environment from DESCRIPTION")
612
- hydrate_cmd = [
613
- "Rscript",
614
- "--vanilla",
615
- "-e",
616
- "renv::load(); renv::hydrate()",
617
- ]
618
- if verbose:
619
- typer.echo(f"Running: {' '.join(hydrate_cmd)}")
620
- try:
621
- subprocess.check_call(hydrate_cmd, cwd=env_dir)
622
- except subprocess.CalledProcessError:
623
- # Hydrate might fail if packages aren't available, continue anyway
624
- if verbose:
625
- typer.echo(
626
- "Warning: hydrate had issues, continuing to snapshot"
627
- )
628
- # Always snapshot after hydrate to create lock file from DESCRIPTION
650
+ _renv_snapshot_from_description(env_dir, verbose=verbose)
651
+ else:
652
+ # A lock file exists, so treat it as the source of truth: restore the
653
+ # library to the exact recorded versions rather than re-resolving from
654
+ # DESCRIPTION (which would bump packages and overwrite the lock).
629
655
  if verbose:
630
- typer.echo("Creating lock file from DESCRIPTION")
631
- snapshot_cmd = [
656
+ typer.echo("Restoring library from lockfile")
657
+ restore_cmd = [
632
658
  "Rscript",
633
659
  "--vanilla",
634
660
  "-e",
635
- "renv::load(); renv::snapshot(type='explicit', prompt=FALSE)",
661
+ "renv::load(); renv::restore(prompt=FALSE)",
636
662
  ]
637
663
  if verbose:
638
- typer.echo(f"Running: {' '.join(snapshot_cmd)}")
664
+ typer.echo(f"Running: {' '.join(restore_cmd)}")
639
665
  try:
640
- subprocess.check_call(snapshot_cmd, cwd=env_dir)
666
+ subprocess.check_call(restore_cmd, cwd=env_dir)
667
+ restored = True
641
668
  except subprocess.CalledProcessError:
642
- raise_error(f"Failed to snapshot renv in {env_dir}")
643
- else:
644
- # Lock file exists, check if it's in sync with DESCRIPTION
645
- if verbose:
646
- typer.echo("Checking if lockfile is in sync with DESCRIPTION")
647
- # Check status to see if lockfile needs updating
648
- status_cmd = [
649
- "Rscript",
650
- "--vanilla",
651
- "-e",
652
- "renv::load(); status <- renv::status(); cat(status$synchronized)",
653
- ]
654
- try:
655
- result = subprocess.run(
656
- status_cmd,
657
- cwd=env_dir,
658
- capture_output=True,
659
- text=True,
660
- check=True,
669
+ restored = False
670
+ if not restored:
671
+ # The locked versions couldn't be installed (e.g. they don't build
672
+ # against the installed R version). Fall back to re-resolving from
673
+ # DESCRIPTION, which updates renv.lock to a working set.
674
+ warn(
675
+ f"Could not restore renv environment in {env_dir} from "
676
+ "renv.lock; the locked versions may be incompatible with the "
677
+ "installed R version. Re-resolving from DESCRIPTION and "
678
+ "updating renv.lock."
661
679
  )
662
- lockfile_synced = "TRUE" in result.stdout
663
- except subprocess.CalledProcessError:
664
- # If status fails, assume we need to update
665
- lockfile_synced = False
666
- if verbose:
667
- typer.echo("Warning: status check failed, will update lock")
668
- if not lockfile_synced:
680
+ _renv_snapshot_from_description(env_dir, verbose=verbose)
681
+ else:
682
+ # Only update the lock if DESCRIPTION declares dependencies the
683
+ # lock doesn't cover (e.g. the user added a package). After the
684
+ # restore the library matches the lock, so status is unsynchronized
685
+ # only when DESCRIPTION and the lock genuinely disagree---not merely
686
+ # because packages were missing on a fresh checkout.
669
687
  if verbose:
670
- typer.echo("Lockfile out of sync, updating from DESCRIPTION")
671
- # Use hydrate to update from DESCRIPTION
672
- hydrate_cmd = [
688
+ typer.echo("Checking if DESCRIPTION matches lockfile")
689
+ status_cmd = [
673
690
  "Rscript",
674
691
  "--vanilla",
675
692
  "-e",
676
- "renv::load(); renv::hydrate()",
693
+ "renv::load(); cat(renv::status()$synchronized)",
677
694
  ]
678
- if verbose:
679
- typer.echo(f"Running: {' '.join(hydrate_cmd)}")
680
695
  try:
681
- subprocess.check_call(hydrate_cmd, cwd=env_dir)
696
+ result = subprocess.run(
697
+ status_cmd,
698
+ cwd=env_dir,
699
+ capture_output=True,
700
+ text=True,
701
+ check=True,
702
+ )
703
+ in_sync = "TRUE" in result.stdout
682
704
  except subprocess.CalledProcessError:
705
+ # If status fails, keep the lock rather than risk clobbering it
706
+ in_sync = True
683
707
  if verbose:
684
- typer.echo(
685
- "Warning: hydrate had issues, continuing to snapshot"
686
- )
687
- # Snapshot to update lock
688
- snapshot_cmd = [
689
- "Rscript",
690
- "--vanilla",
691
- "-e",
692
- "renv::load(); renv::snapshot(type='explicit', prompt=FALSE)",
693
- ]
694
- if verbose:
695
- typer.echo(f"Running: {' '.join(snapshot_cmd)}")
696
- try:
697
- subprocess.check_call(snapshot_cmd, cwd=env_dir)
698
- except subprocess.CalledProcessError:
708
+ typer.echo("Warning: status check failed, keeping lock")
709
+ if not in_sync:
699
710
  if verbose:
700
- typer.echo("Warning: snapshot failed, using existing lock")
701
- else:
702
- if verbose:
711
+ typer.echo("DESCRIPTION changed; updating lockfile")
712
+ _renv_snapshot_from_description(env_dir, verbose=verbose)
713
+ elif verbose:
703
714
  typer.echo("Lockfile is already in sync with DESCRIPTION")
704
- # Check if library needs restoring
705
- if verbose:
706
- typer.echo("Checking if library is in sync with lockfile")
707
- lib_status_cmd = [
708
- "Rscript",
709
- "--vanilla",
710
- "-e",
711
- (
712
- "renv::load(); "
713
- "status <- tryCatch({"
714
- " renv::status();"
715
- " cat('synchronized');"
716
- "}, error = function(e) {"
717
- " cat('needs_restore');"
718
- "})"
719
- ),
720
- ]
721
- try:
722
- result = subprocess.run(
723
- lib_status_cmd,
724
- cwd=env_dir,
725
- capture_output=True,
726
- text=True,
727
- check=True,
728
- )
729
- needs_restore = "needs_restore" in result.stdout or (
730
- "synchronized" not in result.stdout
731
- )
732
- except subprocess.CalledProcessError:
733
- # If check fails, restore to be safe
734
- needs_restore = True
735
- if verbose:
736
- typer.echo("Warning: library status check failed, will restore")
737
-
738
- if needs_restore:
739
- if verbose:
740
- typer.echo("Restoring library from lockfile")
741
- restore_cmd = [
742
- "Rscript",
743
- "--vanilla",
744
- "-e",
745
- "renv::load(); renv::restore(prompt=FALSE)",
746
- ]
747
- if verbose:
748
- typer.echo(f"Running: {' '.join(restore_cmd)}")
749
- try:
750
- subprocess.check_call(restore_cmd, cwd=env_dir)
751
- except subprocess.CalledProcessError:
752
- raise_error(f"Failed to restore renv in {env_dir}")
753
- else:
754
- if verbose:
755
- typer.echo("Library is already in sync with lockfile")
756
715
 
757
716
 
758
717
  @check_app.command(name="docker-env")
calkit/cli/describe.py CHANGED
@@ -3,10 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from typing import Annotated
6
7
 
7
8
  import typer
8
9
 
9
10
  import calkit
11
+ from calkit.cli import raise_error
12
+ from calkit.environments import get_env_lock_fpath
10
13
 
11
14
  describe_app = typer.Typer(no_args_is_help=True)
12
15
 
@@ -16,3 +19,45 @@ def describe_system():
16
19
  """Describe the system."""
17
20
  system_info = calkit.get_system_info()
18
21
  typer.echo(json.dumps(system_info, indent=2))
22
+
23
+
24
+ @describe_app.command(name="env")
25
+ def describe_env(
26
+ name: Annotated[
27
+ str,
28
+ typer.Option("--name", "-n", help="Environment name."),
29
+ ],
30
+ ):
31
+ """Describe a single environment, including spec and lock file paths."""
32
+ ck_info = calkit.load_calkit_info()
33
+ envs: dict = ck_info.get("environments", {})
34
+ if name not in envs:
35
+ raise_error(f"Environment '{name}' not found.")
36
+ env = envs[name]
37
+ lock_path = get_env_lock_fpath(env=env, env_name=name)
38
+ result = {
39
+ "kind": env.get("kind"),
40
+ "spec_path": env.get("path"),
41
+ "lock_path": lock_path,
42
+ "prefix": env.get("prefix"),
43
+ "python": env.get("python"),
44
+ }
45
+ typer.echo(json.dumps(result, indent=2))
46
+
47
+
48
+ @describe_app.command(name="envs")
49
+ def describe_envs():
50
+ """Describe all environments, including spec and lock file paths."""
51
+ ck_info = calkit.load_calkit_info()
52
+ envs: dict = ck_info.get("environments", {})
53
+ result = {}
54
+ for env_name, env in envs.items():
55
+ lock_path = get_env_lock_fpath(env=env, env_name=env_name)
56
+ result[env_name] = {
57
+ "kind": env.get("kind"),
58
+ "spec_path": env.get("path"),
59
+ "lock_path": lock_path,
60
+ "prefix": env.get("prefix"),
61
+ "python": env.get("python"),
62
+ }
63
+ typer.echo(json.dumps(result, indent=2))
calkit/cli/list.py CHANGED
@@ -7,7 +7,7 @@ from typing import Annotated, Literal
7
7
  import typer
8
8
 
9
9
  import calkit
10
- from calkit.cli import AliasGroup, warn
10
+ from calkit.cli import AliasGroup, raise_error, warn
11
11
 
12
12
  list_app = typer.Typer(cls=AliasGroup, no_args_is_help=True)
13
13
 
@@ -167,12 +167,28 @@ def list_stages(
167
167
  list[str] | None,
168
168
  typer.Option("--kind", "-k", help="Filter stages by kind."),
169
169
  ] = None,
170
+ stale_only: Annotated[
171
+ bool, typer.Option("--stale", help="Show only stale stages.")
172
+ ] = False,
170
173
  ):
171
174
  """List pipeline stages."""
172
- stages = calkit.load_calkit_info().get("pipeline", {}).get("stages", {})
175
+ ck_info = calkit.load_calkit_info()
176
+ stages = ck_info.get("pipeline", {}).get("stages", {})
177
+ # If we only want stale stages, we need to get the status first.
178
+ # This compiles the pipeline, cleans notebooks, and checks environments,
179
+ # all of which can affect whether a stage is stale, so we don't skip them.
180
+ if stale_only:
181
+ status = calkit.pipeline.get_status(ck_info=ck_info)
182
+ if status.errors:
183
+ raise_error(
184
+ "Failed to determine stale stages: " + "; ".join(status.errors)
185
+ )
186
+ stale_stage_names = status.stale_stage_names
173
187
  for name, stage in stages.items():
174
188
  if kinds is not None and stage.get("kind") not in kinds:
175
189
  continue
190
+ if stale_only and name not in stale_stage_names:
191
+ continue
176
192
  typer.echo(name)
177
193
 
178
194
 
calkit/cli/main/core.py CHANGED
@@ -196,7 +196,12 @@ def init(
196
196
  repo = calkit.git.get_repo()
197
197
  repo.git.add(".dvc")
198
198
  repo.git.commit("-m", "Initialize DVC")
199
- # TODO: Initialize `calkit.yaml`
199
+ # Create an empty calkit.yaml if one doesn't already exist
200
+ if not os.path.isfile("calkit.yaml"):
201
+ with open("calkit.yaml", "w"):
202
+ pass
203
+ repo.git.add("calkit.yaml")
204
+ repo.git.commit("-m", "Initialize Calkit")
200
205
  # TODO: Initialize `dvc.yaml`
201
206
  # TODO: Add a sane .gitignore file
202
207
  # TODO: Add a sane LICENSE file?
@@ -1762,6 +1767,17 @@ def run(
1762
1767
  "--no-push", help="Do not push to Git and DVC after saving."
1763
1768
  ),
1764
1769
  ] = False,
1770
+ mock_scheduler: Annotated[
1771
+ bool,
1772
+ typer.Option(
1773
+ "--mock-scheduler",
1774
+ "-K",
1775
+ help=(
1776
+ "Run job-scheduler (SLURM/PBS) stages locally instead of "
1777
+ "submitting them to a real scheduler."
1778
+ ),
1779
+ ),
1780
+ ] = False,
1765
1781
  ) -> dict:
1766
1782
  """Check dependencies and run the pipeline."""
1767
1783
  import dvc.log
@@ -1779,6 +1795,10 @@ def run(
1779
1795
  if (target_inputs or target_outputs) and targets:
1780
1796
  raise_error("Cannot specify both targets and inputs")
1781
1797
  os.environ["CALKIT_PIPELINE_RUNNING"] = "1"
1798
+ # Mock the scheduler for this run (and any subprocesses) so SLURM/PBS
1799
+ # stages execute locally; child processes inherit it via os.environ
1800
+ if mock_scheduler:
1801
+ os.environ[calkit.cli.scheduler.MOCK_ENV_VAR] = "1"
1782
1802
  dotenv.load_dotenv(dotenv_path=".env", verbose=verbose)
1783
1803
  ck_info = calkit.load_calkit_info()
1784
1804
  # Ensure Git is initialized so DVC can be used.
@@ -2463,12 +2483,15 @@ def run_in_env(
2463
2483
  except subprocess.CalledProcessError:
2464
2484
  raise_error("Failed to run in uv environment")
2465
2485
  elif (kind := env["kind"]) in ["uv-venv", "venv"]:
2466
- if "prefix" not in env:
2467
- raise_error("venv environments require a prefix")
2468
2486
  if "path" not in env:
2469
2487
  raise_error("venv environments require a path")
2470
- prefix = env["prefix"]
2471
2488
  path = env["path"]
2489
+ # Resolve the prefix on the fly if it isn't pinned in calkit.yaml
2490
+ prefix = env.get("prefix")
2491
+ if prefix is None:
2492
+ prefix = calkit.environments.get_default_venv_prefix(
2493
+ envs, path, env_name
2494
+ )
2472
2495
  shell_cmd = _to_shell_cmd(cmd)
2473
2496
  if _platform.system() == "Windows":
2474
2497
  activate_cmd = f"{prefix}\\Scripts\\activate"
@@ -2525,7 +2548,7 @@ def run_in_env(
2525
2548
  # specifying the project
2526
2549
  cmd = [arg for arg in cmd if not arg.startswith("--project=")]
2527
2550
  julia_cmd = [
2528
- "julia",
2551
+ calkit.julia.get_julia_exe(),
2529
2552
  f"+{julia_version}",
2530
2553
  "--project=" + env_dir,
2531
2554
  ] + cmd
calkit/cli/new.py CHANGED
@@ -1604,8 +1604,15 @@ def new_uv_venv(
1604
1604
  str, typer.Option("--path", help="Path for requirements file.")
1605
1605
  ] = "requirements.txt",
1606
1606
  prefix: Annotated[
1607
- str, typer.Option("--prefix", help="Prefix for environment location.")
1608
- ] = ".venv",
1607
+ str | None,
1608
+ typer.Option(
1609
+ "--prefix",
1610
+ help=(
1611
+ "Prefix for environment location (defaults to .venv, or "
1612
+ ".calkit/envs/<name>/.venv if .venv is already taken)."
1613
+ ),
1614
+ ),
1615
+ ] = None,
1609
1616
  python_version: Annotated[
1610
1617
  str | None, typer.Option("--python", "-p", help="Python version.")
1611
1618
  ] = None,
@@ -1654,12 +1661,13 @@ def new_uv_venv(
1654
1661
  f"Environment with name {name} already exists "
1655
1662
  "(use -f to overwrite)"
1656
1663
  )
1657
- # Check prefixes
1658
- if not overwrite:
1659
- for env_name, env in envs.items():
1660
- if env.get("prefix") == prefix:
1664
+ # Only pin a prefix if one was explicitly given; otherwise it is resolved
1665
+ # on the fly (defaulting to .venv) at check/run time
1666
+ if prefix is not None and not overwrite:
1667
+ for other_name, other_env in envs.items():
1668
+ if other_env.get("prefix") == prefix:
1661
1669
  raise_error(
1662
- f"Environment '{env_name}' already exists with "
1670
+ f"Environment '{other_name}' already exists with "
1663
1671
  f"prefix '{prefix}'"
1664
1672
  )
1665
1673
  if packages is not None:
@@ -1669,7 +1677,9 @@ def new_uv_venv(
1669
1677
  with open(path, "w") as f:
1670
1678
  f.write(packages_txt)
1671
1679
  typer.echo("Adding environment to calkit.yaml")
1672
- env = dict(path=path, kind="uv-venv", prefix=prefix)
1680
+ env = dict(path=path, kind="uv-venv")
1681
+ if prefix is not None:
1682
+ env["prefix"] = prefix
1673
1683
  if python_version is not None:
1674
1684
  env["python"] = python_version
1675
1685
  if description is not None:
@@ -1703,8 +1713,15 @@ def new_venv(
1703
1713
  str, typer.Option("--path", help="Path for requirements file.")
1704
1714
  ] = "requirements.txt",
1705
1715
  prefix: Annotated[
1706
- str, typer.Option("--prefix", help="Prefix for environment location.")
1707
- ] = ".venv",
1716
+ str | None,
1717
+ typer.Option(
1718
+ "--prefix",
1719
+ help=(
1720
+ "Prefix for environment location (defaults to .venv, or "
1721
+ ".calkit/envs/<name>/.venv if .venv is already taken)."
1722
+ ),
1723
+ ),
1724
+ ] = None,
1708
1725
  description: Annotated[
1709
1726
  str | None, typer.Option("--description", help="Description.")
1710
1727
  ] = None,
@@ -1745,12 +1762,13 @@ def new_venv(
1745
1762
  f"Environment with name {name} already exists "
1746
1763
  "(use -f to overwrite)"
1747
1764
  )
1748
- # Check prefixes
1749
- if not overwrite:
1750
- for env_name, env in envs.items():
1751
- if env.get("prefix") == prefix:
1765
+ # Only pin a prefix if one was explicitly given; otherwise it is resolved
1766
+ # on the fly (defaulting to .venv) at check/run time
1767
+ if prefix is not None and not overwrite:
1768
+ for other_name, other_env in envs.items():
1769
+ if other_env.get("prefix") == prefix:
1752
1770
  raise_error(
1753
- f"Environment '{env_name}' already exists with "
1771
+ f"Environment '{other_name}' already exists with "
1754
1772
  f"prefix '{prefix}'"
1755
1773
  )
1756
1774
  packages_txt = "\n".join(packages)
@@ -1760,7 +1778,9 @@ def new_venv(
1760
1778
  f.write(packages_txt)
1761
1779
  repo.git.add(path)
1762
1780
  typer.echo("Adding environment to calkit.yaml")
1763
- env = dict(path=path, kind="venv", prefix=prefix)
1781
+ env = dict(path=path, kind="venv")
1782
+ if prefix is not None:
1783
+ env["prefix"] = prefix
1764
1784
  if description is not None:
1765
1785
  env["description"] = description
1766
1786
  envs[name] = env
calkit/cli/notebooks.py CHANGED
@@ -90,7 +90,7 @@ def _check_ijulia_available(
90
90
  env_dir: str,
91
91
  ) -> bool:
92
92
  ijulia_check_cmd = [
93
- "julia",
93
+ calkit.julia.get_julia_exe(),
94
94
  f"+{julia_version}",
95
95
  "--project=" + env_dir,
96
96
  "-e",
@@ -322,7 +322,7 @@ def check_env_kernel(
322
322
  "println(kp);"
323
323
  )
324
324
  cmd = [
325
- "julia",
325
+ calkit.julia.get_julia_exe(),
326
326
  f"+{julia_version}",
327
327
  "--project=" + env_dir,
328
328
  "-e",
calkit/cli/scheduler.py CHANGED
@@ -251,20 +251,19 @@ def _cancel(kind: str, job_id: str) -> tuple[bool, str]:
251
251
 
252
252
 
253
253
  def _wait_until_done(kind: str, job_id: str, name: str) -> None:
254
- """Poll until the job finishes, leaving it running on Ctrl+C.
254
+ """Poll until the job finishes, canceling it on Ctrl+C.
255
255
 
256
- The job is already submitted and tracked, so an interrupt should only stop
257
- local waiting---the scheduler keeps running it and a later ``calkit run``
258
- resumes from here.
256
+ The job is submitted and tracked, so interrupting the local wait cancels
257
+ the scheduler job before exiting rather than leaving it orphaned.
259
258
  """
260
259
  try:
261
260
  while _is_active(kind, job_id):
262
261
  time.sleep(1)
263
262
  except KeyboardInterrupt:
264
- typer.echo(
265
- f"Interrupted; job '{name}' ({job_id}) left running on the "
266
- "scheduler. Resume with `calkit run`."
267
- )
263
+ typer.echo(f"Interrupted; canceling job '{name}' ({job_id})")
264
+ ok, stderr = _cancel(kind, job_id)
265
+ if not ok:
266
+ typer.echo(f"Failed to cancel job '{name}' ({job_id}): {stderr}")
268
267
  raise typer.Exit(130)
269
268
 
270
269