calkit-python 0.41.8__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 (39) hide show
  1. calkit/cli/check.py +90 -131
  2. calkit/cli/main/core.py +22 -4
  3. calkit/cli/new.py +69 -55
  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/releases.py +103 -0
  12. calkit/tests/cli/main/test_core.py +33 -0
  13. calkit/tests/cli/test_new.py +17 -1
  14. calkit/tests/cli/test_scheduler.py +31 -0
  15. calkit/tests/dvc/test_core.py +13 -0
  16. calkit/tests/test_environments.py +63 -3
  17. calkit/tests/test_julia.py +34 -0
  18. calkit/tests/test_pipeline.py +33 -0
  19. calkit/tests/test_releases.py +85 -0
  20. {calkit_python-0.41.8.dist-info → calkit_python-0.41.10.dist-info}/METADATA +1 -1
  21. {calkit_python-0.41.8.dist-info → calkit_python-0.41.10.dist-info}/RECORD +39 -39
  22. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  23. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
  24. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  25. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  26. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  27. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  28. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  29. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  30. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
  31. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  32. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  33. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  34. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
  35. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  36. {calkit_python-0.41.8.data → calkit_python-0.41.10.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  37. {calkit_python-0.41.8.dist-info → calkit_python-0.41.10.dist-info}/WHEEL +0 -0
  38. {calkit_python-0.41.8.dist-info → calkit_python-0.41.10.dist-info}/entry_points.txt +0 -0
  39. {calkit_python-0.41.8.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
@@ -3143,7 +3163,7 @@ def new_release(
3143
3163
  )
3144
3164
  with open("LICENSE", "w") as f:
3145
3165
  f.write(license_txt)
3146
- repo.git.add("LICENSE")
3166
+ repo.git.add("LICENSE")
3147
3167
  license_ids = ["mit", "cc-by-4.0"]
3148
3168
  else:
3149
3169
  typer.echo(f"Detecting license(s) from {license_file}")
@@ -3394,38 +3414,23 @@ def new_release(
3394
3414
  doi_base_url = calkit.releases.SERVICES[to]["url"]
3395
3415
  doi_md = (
3396
3416
  f"[![DOI]({doi_base_url}/badge/DOI/{doi}.svg)]"
3397
- f"(https://handle.stage.datacite.org/{doi})"
3417
+ f"(https://doi.org/{doi})"
3398
3418
  )
3399
3419
  if os.path.isfile("README.md"):
3400
3420
  with open("README.md") as f:
3401
3421
  readme_txt = f.read()
3402
3422
  else:
3403
- readme_txt = f"# {title}\n"
3404
- existing_lines = readme_txt.split("\n")
3405
- new_lines = []
3406
- first_content_line_index = None
3407
- for n, line in enumerate(existing_lines):
3408
- if line.startswith(doi_md[:6]):
3409
- pass # Skip DOI lines
3410
- else:
3411
- if (
3412
- n != 0
3413
- and line.strip()
3414
- and first_content_line_index is None
3415
- ):
3416
- first_content_line_index = len(new_lines)
3417
- new_lines.append(line)
3418
- # Ensure first 3 lines are title, blank, DOI lines
3419
- new_lines = (
3420
- [new_lines[0]]
3421
- + ["", doi_md, ""]
3422
- + new_lines[first_content_line_index:]
3423
+ readme_txt = ""
3424
+ readme_txt = calkit.releases.add_doi_badge_to_readme(
3425
+ readme_txt, badge=doi_md, title=title
3423
3426
  )
3424
- readme_txt = "\n".join(new_lines)
3425
3427
  if not dry_run:
3426
3428
  with open("README.md", "w") as f:
3427
3429
  f.write(readme_txt)
3428
- repo.git.add("README.md")
3430
+ # Stage only after the file is closed; otherwise the buffered
3431
+ # contents may not be flushed to disk yet and Git would stage an
3432
+ # empty file
3433
+ repo.git.add("README.md")
3429
3434
  else:
3430
3435
  typer.echo(f"Would have updated README.md to:\n{readme_txt}")
3431
3436
  # Create Git tag
@@ -3480,11 +3485,13 @@ def new_release(
3480
3485
  references = reference_collections[0]
3481
3486
  ref_path = references.get("path", "references.bib")
3482
3487
  try:
3488
+ # Read the existing references as raw text so we can append the new
3489
+ # entry without reformatting the user's existing entries
3483
3490
  if os.path.isfile(ref_path):
3484
3491
  with open(ref_path) as f:
3485
- reflib = bibtexparser.load(f)
3492
+ existing_text = f.read()
3486
3493
  else:
3487
- reflib = bibtexparser.bibdatabase.BibDatabase()
3494
+ existing_text = ""
3488
3495
  bibtex_doi = doi
3489
3496
  if bibtex_doi is None and dry_run:
3490
3497
  mock_suffix = "".join(
@@ -3504,31 +3511,38 @@ def new_release(
3504
3511
  if not new_entries:
3505
3512
  raise ValueError("Failed to parse generated BibTeX entry")
3506
3513
  new_entry = new_entries[0]
3507
- # Search through entries for one with the same DOI, and replace if
3508
- # there is a match
3514
+ # Search through existing entries for any with the same DOI, and
3515
+ # replace them (by citation key) if there is a match. Tolerate parse
3516
+ # errors here (e.g., non-standard or temporarily-invalid BibTeX) by
3517
+ # skipping replacement detection and still appending the new entry.
3509
3518
  new_doi = new_entry.get("doi")
3510
- matching_indices = []
3519
+ replace_ids = []
3511
3520
  if new_doi:
3512
- matching_indices = [
3513
- n
3514
- for n, entry in enumerate(reflib.entries)
3521
+ try:
3522
+ existing_entries = bibtexparser.loads(existing_text).entries
3523
+ except Exception as e:
3524
+ warn(f"Could not parse existing references to dedupe: {e}")
3525
+ existing_entries = []
3526
+ replace_ids = [
3527
+ entry["ID"]
3528
+ for entry in existing_entries
3515
3529
  if entry.get("doi") == new_doi
3516
3530
  ]
3517
- if matching_indices:
3531
+ if replace_ids:
3518
3532
  typer.echo(
3519
3533
  "Found matching DOI in existing references "
3520
- f"({len(matching_indices)} entr"
3521
- f"{'y' if len(matching_indices) == 1 else 'ies'}); "
3534
+ f"({len(replace_ids)} entr"
3535
+ f"{'y' if len(replace_ids) == 1 else 'ies'}); "
3522
3536
  "replacing"
3523
3537
  )
3524
- for n in reversed(matching_indices):
3525
- _ = reflib.entries.pop(n)
3526
- reflib.entries.append(new_entry)
3538
+ new_text = calkit.releases.add_bibtex_entry(
3539
+ existing_text, invenio_bibtex, replace_ids=replace_ids
3540
+ )
3527
3541
  if dry_run:
3528
3542
  typer.echo(f"Would write updated references to {ref_path}")
3529
3543
  else:
3530
3544
  with open(ref_path, "w") as f:
3531
- bibtexparser.dump(reflib, f)
3545
+ f.write(new_text)
3532
3546
  repo.git.add(ref_path)
3533
3547
  except Exception as e:
3534
3548
  warn(f"Failed to add to references: {e}")
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