calkit-python 0.41.9__py3-none-any.whl → 0.41.10__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 (37) hide show
  1. calkit/cli/check.py +90 -131
  2. calkit/cli/main/core.py +22 -4
  3. calkit/cli/new.py +36 -16
  4. calkit/cli/notebooks.py +2 -2
  5. calkit/cli/scheduler.py +7 -8
  6. calkit/dvc/core.py +19 -0
  7. calkit/environments.py +62 -6
  8. calkit/julia.py +36 -3
  9. calkit/models/core.py +12 -5
  10. calkit/pipeline.py +3 -0
  11. calkit/tests/cli/main/test_core.py +33 -0
  12. calkit/tests/cli/test_new.py +2 -1
  13. calkit/tests/cli/test_scheduler.py +31 -0
  14. calkit/tests/dvc/test_core.py +13 -0
  15. calkit/tests/test_environments.py +63 -3
  16. calkit/tests/test_julia.py +34 -0
  17. calkit/tests/test_pipeline.py +33 -0
  18. {calkit_python-0.41.9.dist-info → calkit_python-0.41.10.dist-info}/METADATA +1 -1
  19. {calkit_python-0.41.9.dist-info → calkit_python-0.41.10.dist-info}/RECORD +37 -37
  20. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  21. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
  22. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  23. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  24. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  25. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  26. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  27. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  28. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
  29. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  30. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  31. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  32. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
  33. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  34. {calkit_python-0.41.9.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  35. {calkit_python-0.41.9.dist-info → calkit_python-0.41.10.dist-info}/WHEEL +0 -0
  36. {calkit_python-0.41.9.dist-info → calkit_python-0.41.10.dist-info}/entry_points.txt +0 -0
  37. {calkit_python-0.41.9.dist-info → calkit_python-0.41.10.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/main/core.py CHANGED
@@ -1762,6 +1762,17 @@ def run(
1762
1762
  "--no-push", help="Do not push to Git and DVC after saving."
1763
1763
  ),
1764
1764
  ] = False,
1765
+ mock_scheduler: Annotated[
1766
+ bool,
1767
+ typer.Option(
1768
+ "--mock-scheduler",
1769
+ "-K",
1770
+ help=(
1771
+ "Run job-scheduler (SLURM/PBS) stages locally instead of "
1772
+ "submitting them to a real scheduler."
1773
+ ),
1774
+ ),
1775
+ ] = False,
1765
1776
  ) -> dict:
1766
1777
  """Check dependencies and run the pipeline."""
1767
1778
  import dvc.log
@@ -1779,6 +1790,10 @@ def run(
1779
1790
  if (target_inputs or target_outputs) and targets:
1780
1791
  raise_error("Cannot specify both targets and inputs")
1781
1792
  os.environ["CALKIT_PIPELINE_RUNNING"] = "1"
1793
+ # Mock the scheduler for this run (and any subprocesses) so SLURM/PBS
1794
+ # stages execute locally; child processes inherit it via os.environ
1795
+ if mock_scheduler:
1796
+ os.environ[calkit.cli.scheduler.MOCK_ENV_VAR] = "1"
1782
1797
  dotenv.load_dotenv(dotenv_path=".env", verbose=verbose)
1783
1798
  ck_info = calkit.load_calkit_info()
1784
1799
  # Ensure Git is initialized so DVC can be used.
@@ -2463,12 +2478,15 @@ def run_in_env(
2463
2478
  except subprocess.CalledProcessError:
2464
2479
  raise_error("Failed to run in uv environment")
2465
2480
  elif (kind := env["kind"]) in ["uv-venv", "venv"]:
2466
- if "prefix" not in env:
2467
- raise_error("venv environments require a prefix")
2468
2481
  if "path" not in env:
2469
2482
  raise_error("venv environments require a path")
2470
- prefix = env["prefix"]
2471
2483
  path = env["path"]
2484
+ # Resolve the prefix on the fly if it isn't pinned in calkit.yaml
2485
+ prefix = env.get("prefix")
2486
+ if prefix is None:
2487
+ prefix = calkit.environments.get_default_venv_prefix(
2488
+ envs, path, env_name
2489
+ )
2472
2490
  shell_cmd = _to_shell_cmd(cmd)
2473
2491
  if _platform.system() == "Windows":
2474
2492
  activate_cmd = f"{prefix}\\Scripts\\activate"
@@ -2525,7 +2543,7 @@ def run_in_env(
2525
2543
  # specifying the project
2526
2544
  cmd = [arg for arg in cmd if not arg.startswith("--project=")]
2527
2545
  julia_cmd = [
2528
- "julia",
2546
+ calkit.julia.get_julia_exe(),
2529
2547
  f"+{julia_version}",
2530
2548
  "--project=" + env_dir,
2531
2549
  ] + 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
 
calkit/dvc/core.py CHANGED
@@ -261,6 +261,25 @@ def get_dvc_repo(wdir: str | None = None) -> dvc.repo.Repo:
261
261
  return dvc.repo.Repo(wdir)
262
262
 
263
263
 
264
+ def ensure_dvc_lock_not_ignored(wdir: str | None = None) -> bool:
265
+ """Ensure ``dvc.lock`` is not Git-ignored.
266
+
267
+ DVC raises ``FileIsGitIgnored`` when ``dvc.lock`` is excluded by Git, which
268
+ breaks ``dvc status`` and pipeline runs. This un-ignores it (editing the
269
+ relevant ``.gitignore`` as needed) so those operations keep working.
270
+
271
+ Returns True if a ``.gitignore`` was modified.
272
+ """
273
+ import calkit.git
274
+
275
+ lock_path = os.path.join(wdir, "dvc.lock") if wdir else "dvc.lock"
276
+ try:
277
+ repo = calkit.git.get_repo(wdir)
278
+ except Exception:
279
+ return False
280
+ return bool(calkit.git.ensure_path_is_not_ignored(repo, path=lock_path))
281
+
282
+
264
283
  def get_running_pipeline_processes(wdir: str | None = None) -> list[dict]:
265
284
  """Return live processes holding DVC's read/write lock.
266
285
 
calkit/environments.py CHANGED
@@ -138,7 +138,7 @@ def _get_julia_version() -> str:
138
138
  """
139
139
  try:
140
140
  result = subprocess.run(
141
- ["julia", "--version"],
141
+ [calkit.julia.get_julia_exe(), "--version"],
142
142
  capture_output=True,
143
143
  text=True,
144
144
  timeout=5,
@@ -449,7 +449,15 @@ def write_scheduler_env_lock(
449
449
  parent = os.path.dirname(full_path)
450
450
  if parent:
451
451
  os.makedirs(parent, exist_ok=True)
452
- content = json.dumps(env, indent=2, sort_keys=True) + "\n"
452
+ lock_data = dict(env)
453
+ # Record when the scheduler is mocked so switching between a mocked run
454
+ # (executed locally) and a real scheduler run changes the lock file and
455
+ # invalidates the cached result
456
+ from calkit.cli.scheduler import _mock_enabled
457
+
458
+ if _mock_enabled():
459
+ lock_data["mocked"] = True
460
+ content = json.dumps(lock_data, indent=2, sort_keys=True) + "\n"
453
461
  if os.path.isfile(full_path):
454
462
  with open(full_path, "r") as f:
455
463
  existing = f.read()
@@ -744,6 +752,55 @@ def make_env_name(path: str, all_env_names: list[str], kind: str) -> str:
744
752
  return name
745
753
 
746
754
 
755
+ def get_default_venv_prefix(envs: dict, path: str, name: str) -> str:
756
+ """Return the default prefix for a venv or uv-venv environment.
757
+
758
+ The prefix defaults to ``.venv`` in the same directory as ``path``,
759
+ unless that location is already claimed by another environment, in which
760
+ case the virtualenv is nested under ``.calkit/envs/{name}/.venv``. This
761
+ is resolved on the fly so that the prefix need not be stored in
762
+ ``calkit.yaml``.
763
+
764
+ A location is considered claimed by another environment if it pins that
765
+ explicit ``prefix``, or if it is a ``uv``, ``venv``, or ``uv-venv``
766
+ environment whose ``.venv`` would live there (``uv`` always creates its
767
+ virtualenv at ``.venv`` in its project directory, so a flexible venv
768
+ yields to it). Sibling venvs that would otherwise collide all nest under
769
+ their own name-scoped location, which is collision-free.
770
+
771
+ Parameters
772
+ ----------
773
+ envs : dict
774
+ All environments, keyed by name.
775
+ path : str
776
+ Path to the spec file the environment lives alongside.
777
+ name : str
778
+ Name of the environment being resolved, used both to exclude it from
779
+ the claimed locations and for the nested fallback.
780
+
781
+ Returns
782
+ -------
783
+ str
784
+ A POSIX-style prefix that does not collide with another environment.
785
+ """
786
+ base = os.path.join(os.path.dirname(path), ".venv")
787
+ # Collect .venv locations claimed by the other environments
788
+ claimed = set()
789
+ for other_name, env in envs.items():
790
+ if other_name == name:
791
+ continue
792
+ prefix = env.get("prefix")
793
+ if prefix is not None:
794
+ claimed.add(os.path.normpath(prefix))
795
+ elif env.get("kind") in ("uv", "venv", "uv-venv"):
796
+ other_dir = os.path.dirname(env.get("path", ""))
797
+ claimed.add(os.path.normpath(os.path.join(other_dir, ".venv")))
798
+ # Nest under .calkit/envs/{name} if the default location is taken
799
+ if os.path.normpath(base) in claimed:
800
+ base = os.path.join(".calkit", "envs", name, ".venv")
801
+ return Path(base).as_posix()
802
+
803
+
747
804
  def env_from_name_or_path(
748
805
  name_or_path: str | None = None,
749
806
  ck_info: dict | None = None,
@@ -851,15 +908,14 @@ def env_from_name_or_path(
851
908
  if os.path.isfile(env_path):
852
909
  if env_path.endswith("requirements.txt"):
853
910
  # TODO: Detect if uv is installed, and use a plain venv if not
911
+ # The prefix is left unset and resolved on the fly at check/run
912
+ # time via get_default_venv_prefix
854
913
  return EnvDetectResult(
855
914
  name=make_env_name(env_path, all_env_names, kind="uv-venv"),
856
915
  env={
857
916
  "kind": "uv-venv",
858
917
  "path": env_path,
859
918
  "python": DEFAULT_PYTHON_VERSION,
860
- "prefix": Path(env_path)
861
- .parent.joinpath(".venv")
862
- .as_posix(),
863
919
  },
864
920
  exists=False,
865
921
  )
@@ -1325,7 +1381,7 @@ end
1325
1381
  # Run Julia with the script
1326
1382
  result = subprocess.run(
1327
1383
  [
1328
- "julia",
1384
+ calkit.julia.get_julia_exe(),
1329
1385
  script_path,
1330
1386
  ",".join(package_names),
1331
1387
  ],