calkit-python 0.41.7__py3-none-any.whl → 0.41.8__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 (30) hide show
  1. calkit/cli/main/core.py +10 -1
  2. calkit/cli/new.py +47 -16
  3. calkit/core.py +8 -0
  4. calkit/invenio.py +20 -0
  5. calkit/pipeline.py +113 -2
  6. calkit/tests/cli/main/test_subprojects.py +53 -0
  7. calkit/tests/cli/test_new.py +316 -0
  8. calkit/tests/test_core.py +19 -0
  9. calkit/tests/test_invenio.py +20 -0
  10. calkit/tests/test_pipeline.py +111 -0
  11. {calkit_python-0.41.7.dist-info → calkit_python-0.41.8.dist-info}/METADATA +1 -1
  12. {calkit_python-0.41.7.dist-info → calkit_python-0.41.8.dist-info}/RECORD +30 -30
  13. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  14. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
  15. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  16. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  17. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  18. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  19. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  20. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  21. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
  22. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  23. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  24. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  25. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
  26. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  27. {calkit_python-0.41.7.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  28. {calkit_python-0.41.7.dist-info → calkit_python-0.41.8.dist-info}/WHEEL +0 -0
  29. {calkit_python-0.41.7.dist-info → calkit_python-0.41.8.dist-info}/entry_points.txt +0 -0
  30. {calkit_python-0.41.7.dist-info → calkit_python-0.41.8.dist-info}/licenses/LICENSE +0 -0
calkit/cli/main/core.py CHANGED
@@ -2418,7 +2418,16 @@ def run_in_env(
2418
2418
  )
2419
2419
  # TODO: Prefix should only be in the env file or calkit.yaml, not both?
2420
2420
  prefix = env.get("prefix")
2421
- conda_cmd = ["conda", "run"]
2421
+ # Conda is often not on the PATH (especially on Windows), so search
2422
+ # typical install locations rather than relying on the bare name,
2423
+ # which would fail with a confusing FileNotFoundError traceback.
2424
+ conda_exe = calkit.conda.find_conda_exe()
2425
+ if conda_exe is None:
2426
+ raise_error(
2427
+ "Cannot find Conda executable; "
2428
+ "ensure Conda is installed and on the PATH"
2429
+ )
2430
+ conda_cmd = [conda_exe, "run"]
2422
2431
  if prefix is not None:
2423
2432
  conda_cmd += ["--prefix", os.path.abspath(prefix)]
2424
2433
  else:
calkit/cli/new.py CHANGED
@@ -7,6 +7,7 @@ import os
7
7
  import pathlib
8
8
  import shutil
9
9
  import subprocess
10
+ import time
10
11
  from enum import Enum
11
12
 
12
13
  import typer
@@ -3325,22 +3326,23 @@ def new_release(
3325
3326
  f"/records/{record_id}/draft/files/{filename}/commit",
3326
3327
  service=to, # type: ignore
3327
3328
  )
3328
- # Conditionally publish or reserve DOI based on --draft flag
3329
- if draft_only:
3330
- # Reserve a DOI for the draft record
3331
- typer.echo(f"Reserving DOI for {to} draft record ID {record_id}")
3332
- doi_resp = calkit.invenio.post(
3333
- f"/records/{record_id}/draft/pids/doi",
3334
- service=to, # type: ignore
3329
+ # Reserve a DOI on the draft before publishing. The publish action's
3330
+ # response only echoes back the DOI under "pids" if one was reserved
3331
+ # on the draft first (otherwise "pids" comes back empty), so always
3332
+ # reserve here to get a stable identifier.
3333
+ typer.echo(f"Reserving DOI for {to} draft record ID {record_id}")
3334
+ doi_resp = calkit.invenio.post(
3335
+ f"/records/{record_id}/draft/pids/doi",
3336
+ service=to, # type: ignore
3337
+ )
3338
+ if verbose:
3339
+ typer.echo(f"DOI reservation response:\n{doi_resp}")
3340
+ doi = calkit.invenio.extract_doi(doi_resp)
3341
+ if doi is None:
3342
+ raise_error(
3343
+ f"Failed to reserve DOI for {to} draft record {record_id}"
3335
3344
  )
3336
- if verbose:
3337
- typer.echo(f"DOI response for draft:\n{doi_resp}")
3338
- try:
3339
- doi = doi_resp["pids"]["doi"]["identifier"]
3340
- except KeyError:
3341
- doi = doi_resp["doi"]
3342
- except Exception as e:
3343
- raise_error(f"Failed to reserve DOI for draft: {e}")
3345
+ if draft_only:
3344
3346
  url = f"https://doi.org/{doi}"
3345
3347
  typer.echo(f"Created {to} draft with reserved DOI: {doi}")
3346
3348
  else:
@@ -3350,8 +3352,37 @@ def new_release(
3350
3352
  f"/records/{record_id}/draft/actions/publish",
3351
3353
  service=to, # type: ignore
3352
3354
  )
3355
+ if verbose:
3356
+ typer.echo(f"Publish response:\n{invenio_dep}")
3353
3357
  record_id = invenio_dep["id"]
3354
- doi = invenio_dep["pids"]["doi"]["identifier"]
3358
+ # Prefer the DOI from the publish response, but fall back to the
3359
+ # reserved DOI (and a fetch of the published record) since the
3360
+ # publish action returns 202 and may not echo "pids" immediately.
3361
+ # Polling failures are tolerated: we already have the reserved DOI,
3362
+ # so a transient error while the record settles should not abort
3363
+ # the release.
3364
+ published_doi = calkit.invenio.extract_doi(invenio_dep)
3365
+ for _ in range(10):
3366
+ if published_doi is not None:
3367
+ break
3368
+ time.sleep(1)
3369
+ try:
3370
+ record = calkit.invenio.get(
3371
+ f"/records/{record_id}",
3372
+ service=to, # type: ignore
3373
+ )
3374
+ except Exception as e:
3375
+ if verbose:
3376
+ typer.echo(f"Polling for published DOI failed: {e}")
3377
+ continue
3378
+ published_doi = calkit.invenio.extract_doi(record)
3379
+ if published_doi is not None:
3380
+ doi = published_doi
3381
+ else:
3382
+ typer.echo(
3383
+ "Could not confirm minted DOI from published record; "
3384
+ f"falling back to reserved DOI {doi}"
3385
+ )
3355
3386
  url = f"https://doi.org/{doi}"
3356
3387
  typer.echo(f"Published to {to} with DOI: {doi}")
3357
3388
  else:
calkit/core.py CHANGED
@@ -341,6 +341,14 @@ def check_dep_exists(
341
341
  return True
342
342
  if system_info is not None and system_info.get(f"{name}_version"):
343
343
  return True
344
+ # Conda and mamba are frequently installed but not on the PATH (most
345
+ # commonly on Windows), so search their typical install locations
346
+ # rather than relying on the bare name being directly executable.
347
+ if name in ("conda", "mamba"):
348
+ from calkit.conda import find_conda_exe, find_mamba_exe
349
+
350
+ exe = find_conda_exe() if name == "conda" else find_mamba_exe()
351
+ return exe is not None
344
352
  cmd = [name]
345
353
  # Executables with non-conventional CLIs
346
354
  if name == "matlab":
calkit/invenio.py CHANGED
@@ -107,6 +107,26 @@ put = partial(_request, "put")
107
107
  delete = partial(_request, "delete")
108
108
 
109
109
 
110
+ def extract_doi(record: dict) -> str | None:
111
+ """Extract the DOI identifier from an InvenioRDM record or response.
112
+
113
+ Depending on the endpoint and whether the DOI has been minted yet, the
114
+ identifier can live in different places, so check all known locations and
115
+ return ``None`` if it isn't present yet.
116
+ """
117
+ pids = record.get("pids")
118
+ if isinstance(pids, dict):
119
+ doi = pids.get("doi")
120
+ if isinstance(doi, dict) and doi.get("identifier"):
121
+ return doi["identifier"]
122
+ if record.get("doi"):
123
+ return record["doi"]
124
+ metadata = record.get("metadata")
125
+ if isinstance(metadata, dict) and metadata.get("doi"):
126
+ return metadata["doi"]
127
+ return None
128
+
129
+
110
130
  def get_download_urls(
111
131
  record_id: int | str,
112
132
  service: ServiceName = DEFAULT_SERVICE,
calkit/pipeline.py CHANGED
@@ -96,10 +96,14 @@ class PipelineStatus(BaseModel):
96
96
  def always_run_stage_names(self) -> list[str]:
97
97
  # Only list stages whose sole change indicator is always_run; stages
98
98
  # that also have real changes are reported under stale_stage_names.
99
+ # Subproject stages are excluded: their always-changed status is a
100
+ # delegation detail, not a user-meaningful always-run stage of the
101
+ # parent project.
99
102
  return [
100
103
  name
101
104
  for name, stage in self.stale_stages.items()
102
105
  if stage.always_run
106
+ and not stage.is_subproject
103
107
  and not stage.modified_command
104
108
  and not stage.modified_inputs
105
109
  and not stage.modified_outputs
@@ -145,6 +149,12 @@ class StaleStage(BaseModel):
145
149
  modified_outputs: list[str] = Field(default_factory=list)
146
150
  modified_command: bool = False
147
151
  always_run: bool = False
152
+ # True for stages that originate from a subproject (either an individual
153
+ # "{sp}:{stage}" stage or a kept "{sp} (subproject)" wrapper). Subproject
154
+ # wrapper stages are marked always-changed purely as a delegation
155
+ # mechanism, so they should not be advertised as always-run stages of the
156
+ # parent project.
157
+ is_subproject: bool = False
148
158
 
149
159
  @staticmethod
150
160
  def _as_path_list(paths: object) -> list[str]:
@@ -227,6 +237,7 @@ class StaleStage(BaseModel):
227
237
  status_data: list | dict | str,
228
238
  configured_outputs: list[str] | None = None,
229
239
  path_prefix: str | None = None,
240
+ is_subproject: bool = False,
230
241
  ) -> "StaleStage":
231
242
  modified_inputs = []
232
243
  output_paths = []
@@ -312,6 +323,7 @@ class StaleStage(BaseModel):
312
323
  modified_outputs=modified_outputs,
313
324
  modified_command=modified_command,
314
325
  always_run=always_run,
326
+ is_subproject=is_subproject,
315
327
  )
316
328
 
317
329
 
@@ -546,6 +558,74 @@ def collapse_dvc_stages(
546
558
  return stage
547
559
 
548
560
 
561
+ def _status_target_matches(
562
+ target: str,
563
+ display_name: str,
564
+ bare_name: str,
565
+ subproject: str | None,
566
+ stale_stage: StaleStage,
567
+ ) -> bool:
568
+ """Return True if a status ``target`` selects this stage.
569
+
570
+ Because the full DVC status is now computed and filtered locally (rather
571
+ than asking DVC to filter by target), this is where each ``target`` passed
572
+ to :func:`get_status` is resolved against a single stage. A ``target`` may
573
+ be a stage name in any of calkit's forms (``stage``, ``dvc.yaml:stage``,
574
+ ``subproject:stage``, or a whole ``subproject``) or a repo path, which is
575
+ matched against the stage's stale/modified inputs and outputs.
576
+
577
+ Iterated/matrix stages use a ``name@param`` key in DVC status, so the base
578
+ name (the part before ``@``) is matched too — targeting the base name
579
+ selects every iteration, consistent with how ``calkit run`` expands matrix
580
+ targets.
581
+ """
582
+ base_name = bare_name.split("@")[0]
583
+ # Stage-name matching is done against the raw target so it covers all of
584
+ # calkit's stage-target forms as well as DVC's native ``dvc.yaml:stage``
585
+ # and ``<subproject>/dvc.yaml:stage`` forms (the latter is what
586
+ # translate_run_targets emits for inline subprojects).
587
+ stage_aliases = {display_name, bare_name, base_name}
588
+ if subproject is not None:
589
+ subproject_name = Path(subproject).name
590
+ if target in {subproject, subproject_name}:
591
+ return display_name.startswith(
592
+ f"{subproject}:"
593
+ ) or display_name == (f"{subproject} (subproject)")
594
+ stage_aliases |= {
595
+ f"{subproject}:{bare_name}",
596
+ f"{subproject_name}:{bare_name}",
597
+ f"{subproject}:{base_name}",
598
+ f"{subproject_name}:{base_name}",
599
+ f"{subproject}/dvc.yaml:{bare_name}",
600
+ f"{subproject}/dvc.yaml:{base_name}",
601
+ }
602
+ else:
603
+ stage_aliases |= {
604
+ f"dvc.yaml:{bare_name}",
605
+ f"dvc.yaml:{base_name}",
606
+ }
607
+ if target in stage_aliases:
608
+ return True
609
+ # Otherwise treat the target as a repo path and match it against the
610
+ # stage's stale/modified inputs and outputs. A target is path-like if it
611
+ # exists on disk or contains a separator; this keeps root-level paths
612
+ # without separators (e.g. ``data`` or ``out.csv``) selectable, which a
613
+ # separator-only heuristic would miss. A target that looks like a stage
614
+ # selector (contains ``:``) but matched no stage above is not a path.
615
+ if ":" in target and not os.path.exists(target):
616
+ return False
617
+ is_pathlike = os.path.exists(target) or "/" in target or "\\" in target
618
+ if not is_pathlike:
619
+ return False
620
+ norm_target = Path(target).as_posix().rstrip("/")
621
+ candidate_paths = (
622
+ stale_stage.stale_outputs
623
+ + stale_stage.modified_inputs
624
+ + stale_stage.modified_outputs
625
+ )
626
+ return any(_paths_overlap(norm_target, p) for p in candidate_paths)
627
+
628
+
549
629
  def get_status(
550
630
  ck_info: dict | None = None,
551
631
  targets: list[str] | None = None,
@@ -628,7 +708,11 @@ def get_status(
628
708
  dvc_repo = calkit.dvc.get_dvc_repo()
629
709
  # calkit.dvc.core installs a filter on dvc.repo.status that drops
630
710
  # the noisy frozen-stage warning, so the call here stays quiet.
631
- raw_status = dvc_repo.status(targets=targets)
711
+ # Ask DVC for the complete stale set, then filter locally.
712
+ # DVC's own target filtering can miss stale propagation from
713
+ # isolated subprojects, which makes targeted status disagree with
714
+ # the full-project status view.
715
+ raw_status = dvc_repo.status()
632
716
  raw_status = calkit.dvc.status_as_posix(raw_status)
633
717
  except Exception as e:
634
718
  result["errors"].append(
@@ -794,20 +878,47 @@ def get_status(
794
878
  for output in stage_cfg.get("outputs", [])
795
879
  ]
796
880
  if subproject:
881
+ # Use posix separators so these match DVC's reported paths
882
+ # (which are always forward-slash, repo-root-relative). Using
883
+ # the OS-native separator here would, on Windows, produce a
884
+ # backslash variant that escapes path dedup and shows up as a
885
+ # duplicate stale output alongside DVC's posix path.
797
886
  configured_outputs = [
798
- str(Path(subproject) / p) for p in raw_outputs
887
+ (Path(subproject) / p).as_posix() for p in raw_outputs
799
888
  ]
800
889
  else:
801
890
  configured_outputs = raw_outputs
802
891
  # For isolated subprojects, DVC reports paths relative to the
803
892
  # subproject dir; prefix them so all paths are parent-relative.
893
+ # A stage is subproject-originating if it carries a subproject
894
+ # path ("{sp}:{stage}") or is a kept wrapper for a subproject
895
+ # ("{sp} (subproject)", whose bare name is a known subproject).
896
+ is_subproject = (
897
+ subproject is not None or bare_name in sp_by_stage_name
898
+ )
804
899
  ordered_stale_stages[display_name] = StaleStage.from_status_data(
805
900
  status_data=status_data,
806
901
  configured_outputs=configured_outputs,
807
902
  path_prefix=subproject
808
903
  if subproject in isolated_sp_paths
809
904
  else None,
905
+ is_subproject=is_subproject,
810
906
  )
907
+ if targets:
908
+ ordered_stale_stages = {
909
+ display_name: stale_stage
910
+ for display_name, stale_stage in ordered_stale_stages.items()
911
+ if any(
912
+ _status_target_matches(
913
+ target,
914
+ display_name,
915
+ raw_stale_stages[display_name][0],
916
+ raw_stale_stages[display_name][1],
917
+ stale_stage,
918
+ )
919
+ for target in targets
920
+ )
921
+ }
811
922
  result["stale_stages"] = ordered_stale_stages
812
923
  return PipelineStatus(
813
924
  has_pipeline=result["has_pipeline"],
@@ -296,3 +296,56 @@ def test_isolated_subproject_external_dep(tmp_dir):
296
296
  check_environments=False, compile_to_dvc=False
297
297
  )
298
298
  assert not status.is_stale
299
+
300
+
301
+ def test_targeted_status_uses_full_subproject_status_for_stage_targets(
302
+ tmp_dir, monkeypatch
303
+ ):
304
+ os.makedirs("solver/.dvc", exist_ok=True)
305
+ root_ck = {
306
+ "subprojects": [{"path": "solver"}],
307
+ "pipeline": {
308
+ "stages": {
309
+ "post-process": {
310
+ "outputs": [{"path": "final.txt", "storage": "git"}]
311
+ }
312
+ }
313
+ },
314
+ }
315
+ solver_ck = {
316
+ "pipeline": {
317
+ "stages": {
318
+ "package-paper": {
319
+ "outputs": [{"path": "paper.txt", "storage": "git"}]
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ class FakeRepo:
326
+ def status(self, targets=None):
327
+ if targets is None:
328
+ return {
329
+ "solver/dvc.yaml:package-paper": ["always changed"],
330
+ "post-process": [
331
+ {"changed deps": {"solver/docs/figs": "modified"}}
332
+ ],
333
+ }
334
+ return {}
335
+
336
+ monkeypatch.setattr(
337
+ calkit,
338
+ "load_calkit_info",
339
+ lambda wdir=None: solver_ck if wdir == "solver" else root_ck,
340
+ )
341
+ monkeypatch.setattr(calkit.dvc, "get_dvc_repo", lambda *args: FakeRepo())
342
+ monkeypatch.setattr(calkit.dvc, "status_as_posix", lambda status: status)
343
+ status = calkit.pipeline.get_status(
344
+ ck_info=root_ck,
345
+ targets=["post-process"],
346
+ check_environments=False,
347
+ clean_notebooks=False,
348
+ compile_to_dvc=False,
349
+ )
350
+ assert status.is_stale
351
+ assert status.stale_stage_names == ["post-process"]
@@ -853,6 +853,322 @@ def test_new_release(tmp_dir, monkeypatch, httpserver):
853
853
  # )
854
854
 
855
855
 
856
+ def test_new_release_publish_empty_pids(tmp_dir, monkeypatch, httpserver):
857
+ """Regression test for issue #927.
858
+
859
+ Zenodo's publish action returns 202 and its body may come back with an
860
+ empty ``pids`` payload, so the DOI cannot be read from the publish
861
+ response. We must not assume ``pids.doi.identifier`` is present: instead we
862
+ reserve the DOI on the draft beforehand and recover the minted DOI by
863
+ polling ``GET /records/{id}``. This mocks exactly that shape so a
864
+ regression back to ``resp["pids"]["doi"]["identifier"]`` would fail here.
865
+ """
866
+ record_id = "test-record-empty-pids"
867
+ reserved_doi = "10.5072/zenodo.reserved999"
868
+ monkeypatch.setenv(
869
+ "CALKIT_INVENIO_BASE_URL_ZENODO",
870
+ httpserver.url_for("").rstrip("/"),
871
+ )
872
+ monkeypatch.setenv("ZENODO_TOKEN", "test-token")
873
+ # Create draft
874
+ httpserver.expect_request(
875
+ re.compile(r"^/records$"), method="POST"
876
+ ).respond_with_json({"id": record_id, "pids": {}})
877
+ # File upload slot + content + commit
878
+ httpserver.expect_request(
879
+ re.compile(rf"^/records/{record_id}/draft/files$"), method="POST"
880
+ ).respond_with_json({"entries": []})
881
+ httpserver.expect_request(
882
+ re.compile(rf"^/records/{record_id}/draft/files/.+/content$"),
883
+ method="PUT",
884
+ ).respond_with_data("", status=200)
885
+ httpserver.expect_request(
886
+ re.compile(rf"^/records/{record_id}/draft/files/.+/commit$"),
887
+ method="POST",
888
+ ).respond_with_json({"key": "file", "status": "completed"})
889
+ # Reserve a DOI on the draft – this is where the identifier actually comes
890
+ # from in the #927 scenario
891
+ httpserver.expect_request(
892
+ re.compile(rf"^/records/{record_id}/draft/pids/doi$"), method="POST"
893
+ ).respond_with_json({"pids": {"doi": {"identifier": reserved_doi}}})
894
+ # Publish returns an EMPTY pids payload (the bug scenario)
895
+ httpserver.expect_request(
896
+ re.compile(rf"^/records/{record_id}/draft/actions/publish$"),
897
+ method="POST",
898
+ ).respond_with_json({"id": record_id, "pids": {}})
899
+ # The published record echoes the minted DOI, recovered by the poll
900
+ httpserver.expect_request(
901
+ re.compile(rf"^/records/{record_id}$"), method="GET"
902
+ ).respond_with_json(
903
+ {"id": record_id, "pids": {"doi": {"identifier": reserved_doi}}}
904
+ )
905
+ subprocess.check_call(
906
+ [
907
+ "calkit",
908
+ "new",
909
+ "project",
910
+ ".",
911
+ "--title",
912
+ "Test project",
913
+ "--name",
914
+ "test-project",
915
+ ]
916
+ )
917
+ subprocess.check_call(
918
+ [
919
+ "git",
920
+ "remote",
921
+ "add",
922
+ "origin",
923
+ "https://github.com/calkit/test-project.git",
924
+ ]
925
+ )
926
+ calkit.releases.set_cff_authors(
927
+ [{"first_name": "Alice", "last_name": "Smith", "affiliation": "SomeU"}]
928
+ )
929
+ subprocess.check_call(
930
+ ["calkit", "update", "license", "--copyright-holder", "Some Person"]
931
+ )
932
+ # One-shot publish (no --draft) so the new.py publish/poll path runs
933
+ subprocess.check_call(
934
+ [
935
+ "calkit",
936
+ "new",
937
+ "release",
938
+ "--name",
939
+ "v0.1.0",
940
+ "--description",
941
+ "First release.",
942
+ "--no-github",
943
+ "--no-push",
944
+ "--verbose",
945
+ ]
946
+ )
947
+ release = calkit.load_calkit_info()["releases"]["v0.1.0"]
948
+ # Despite the empty pids in the publish response, the DOI is recovered
949
+ assert release["doi"] == reserved_doi
950
+ assert release["url"] == f"https://doi.org/{reserved_doi}"
951
+
952
+
953
+ def _setup_zenodo_sandbox_project(monkeypatch):
954
+ """Shared setup for the live Zenodo sandbox tests.
955
+
956
+ Skips unless the test is explicitly opted into via
957
+ ``CALKIT_TEST_ZENODO_SANDBOX=1`` (so it never runs in CI -- GitHub Actions
958
+ doesn't set that variable) and a sandbox token is available. Resolves the
959
+ token (allowing it to live in a local ``.env``), points the client at the
960
+ sandbox, and creates a project with authors and a license ready to be
961
+ released. Must be called from within a ``tmp_dir`` test.
962
+ """
963
+ if os.getenv("CALKIT_TEST_ZENODO_SANDBOX") != "1":
964
+ pytest.skip(
965
+ "Live Zenodo sandbox test; set CALKIT_TEST_ZENODO_SANDBOX=1 "
966
+ "(and provide a sandbox ZENODO_TOKEN) to run it"
967
+ )
968
+ # Allow the token to live in a local .env file (the usual way calkit
969
+ # resolves credentials). The calkit subprocesses run in tmp_dir, outside
970
+ # the repo, so their own load_dotenv() won't find it -- we load it here and
971
+ # export it below so they inherit it.
972
+ import dotenv
973
+
974
+ dotenv.load_dotenv()
975
+ token = (
976
+ os.getenv("ZENODO_TOKEN")
977
+ or os.getenv("CALKIT_TEST_ZENODO_TOKEN")
978
+ or os.getenv("CALKIT_ZENODO_TOKEN")
979
+ )
980
+ if not token:
981
+ pytest.skip(
982
+ "No sandbox Zenodo token found; set ZENODO_TOKEN (or "
983
+ "CALKIT_TEST_ZENODO_TOKEN) to a sandbox.zenodo.org token"
984
+ )
985
+ # Export the token as ZENODO_TOKEN so every calkit subprocess picks it up,
986
+ # and make sure nothing forces us onto the production base URL.
987
+ monkeypatch.setenv("ZENODO_TOKEN", token)
988
+ monkeypatch.delenv("CALKIT_USE_PROD_FOR_TESTS", raising=False)
989
+ monkeypatch.delenv("CALKIT_INVENIO_BASE_URL_ZENODO", raising=False)
990
+ # Sanity check: in the test env we should be pointed at the sandbox
991
+ assert (
992
+ calkit.invenio.get_base_url("zenodo")
993
+ == "https://sandbox.zenodo.org/api"
994
+ )
995
+ subprocess.check_call(
996
+ [
997
+ "calkit",
998
+ "new",
999
+ "project",
1000
+ ".",
1001
+ "--title",
1002
+ "Test project",
1003
+ "--name",
1004
+ "test-project",
1005
+ ]
1006
+ )
1007
+ subprocess.check_call(
1008
+ [
1009
+ "git",
1010
+ "remote",
1011
+ "add",
1012
+ "origin",
1013
+ "https://github.com/calkit/test-project.git",
1014
+ ]
1015
+ )
1016
+ calkit.releases.set_cff_authors(
1017
+ [
1018
+ {
1019
+ "first_name": "Alice",
1020
+ "last_name": "Smith",
1021
+ "affiliation": "SomeU",
1022
+ "orcid": "0000-0001-2345-6789",
1023
+ },
1024
+ {
1025
+ "first_name": "Bob",
1026
+ "last_name": "Jones",
1027
+ "affiliation": None,
1028
+ "orcid": None,
1029
+ },
1030
+ ]
1031
+ )
1032
+ subprocess.check_call(
1033
+ ["calkit", "update", "license", "--copyright-holder", "Some Person"]
1034
+ )
1035
+
1036
+
1037
+ def test_new_release_zenodo_sandbox(tmp_dir, monkeypatch):
1038
+ """Live integration test against the real Zenodo sandbox API.
1039
+
1040
+ Unlike ``test_new_release`` (which mocks the Invenio API), this exercises
1041
+ the full release flow against ``sandbox.zenodo.org`` so we can catch
1042
+ regressions the mock can't -- e.g. metadata our client builds that the
1043
+ real API rejects. It is deliberately gated behind
1044
+ ``CALKIT_TEST_ZENODO_SANDBOX=1`` so it never runs in CI (GitHub Actions
1045
+ does not set that variable); it's only for occasional manual local
1046
+ verification.
1047
+
1048
+ Requirements to run locally::
1049
+
1050
+ CALKIT_TEST_ZENODO_SANDBOX=1
1051
+ ZENODO_TOKEN=<a sandbox.zenodo.org personal access token with the
1052
+ deposit:write and deposit:actions scopes>
1053
+
1054
+ The test only ever creates (and then deletes) a *draft* record -- it never
1055
+ publishes, because a published sandbox record cannot be removed via the
1056
+ API and would litter the account.
1057
+ """
1058
+ _setup_zenodo_sandbox_project(monkeypatch)
1059
+ record_id = None
1060
+ try:
1061
+ subprocess.check_call(
1062
+ [
1063
+ "calkit",
1064
+ "new",
1065
+ "release",
1066
+ "--name",
1067
+ "v0.1.0",
1068
+ "--description",
1069
+ "First release.",
1070
+ "--draft",
1071
+ "--no-github",
1072
+ "--verbose",
1073
+ ]
1074
+ )
1075
+ ck_info = calkit.load_calkit_info()
1076
+ assert "v0.1.0" in ck_info["releases"]
1077
+ release = ck_info["releases"]["v0.1.0"]
1078
+ record_id = release["record_id"]
1079
+ assert record_id is not None
1080
+ # A DOI should have been reserved for the draft
1081
+ assert release["doi"] is not None
1082
+ # The draft really exists on the sandbox with the metadata we sent;
1083
+ # fetching it confirms our client and the real API agree on shape
1084
+ draft = calkit.invenio.get(f"/records/{record_id}/draft")
1085
+ metadata = draft["metadata"]
1086
+ assert metadata["title"] == "Test project"
1087
+ # NOTE: our client POSTs the InvenioRDM metadata schema (creators with
1088
+ # person_or_org/given_name/family_name), but the Zenodo draft GET
1089
+ # endpoint echoes back the *legacy* Zenodo serialization, where each
1090
+ # creator is a "Last, First" name string. Asserting on the real shape
1091
+ # here is the whole point of this test -- the mock can't reveal it.
1092
+ creators = metadata["creators"]
1093
+ assert creators[0]["name"] == "Smith, Alice"
1094
+ assert creators[0]["affiliation"] == "SomeU"
1095
+ assert creators[1]["name"] == "Jones, Bob"
1096
+ # License and the GitHub related-identifier round-trip correctly
1097
+ assert metadata["license"]["id"] == "cc-by-4.0"
1098
+ related = metadata["related_identifiers"]
1099
+ assert (
1100
+ related[0]["identifier"]
1101
+ == "https://github.com/calkit/test-project"
1102
+ )
1103
+ # Reuploading the draft should also succeed against the real API
1104
+ subprocess.check_call(
1105
+ ["calkit", "update", "release", "--name", "v0.1.0", "--reupload"]
1106
+ )
1107
+ finally:
1108
+ # Always clean up the draft so we don't accumulate records on the
1109
+ # sandbox, even if an assertion above failed.
1110
+ if record_id is not None:
1111
+ try:
1112
+ calkit.invenio.delete(
1113
+ f"/records/{record_id}/draft", as_json=False
1114
+ )
1115
+ except Exception as e:
1116
+ print(f"Warning: failed to clean up sandbox draft: {e}")
1117
+
1118
+
1119
+ def test_new_release_zenodo_sandbox_publish(tmp_dir, monkeypatch):
1120
+ """Live test of the full *publish* path against the Zenodo sandbox.
1121
+
1122
+ This is the companion to ``test_new_release_zenodo_sandbox``: instead of a
1123
+ draft, it runs a one-shot ``new release`` (no ``--draft``), which is the
1124
+ only path that exercises the DOI-minting logic this branch hardens --
1125
+ reserving a DOI on the draft, publishing, and then (because the publish
1126
+ action returns 202 and may not echo the DOI immediately) retrying a fetch
1127
+ of the published record and pulling the DOI out with
1128
+ ``invenio.extract_doi``.
1129
+
1130
+ Same opt-in gate as the draft test (``CALKIT_TEST_ZENODO_SANDBOX=1``), so
1131
+ it never runs in CI. WARNING: unlike the draft test, this PUBLISHES a real
1132
+ sandbox record, which cannot be deleted via the API, so each run leaves one
1133
+ behind on the sandbox account. It is intentionally separate so the draft
1134
+ test can stay self-cleaning.
1135
+ """
1136
+ _setup_zenodo_sandbox_project(monkeypatch)
1137
+ # No --draft: reserve + publish + mint DOI in one shot. --no-push skips the
1138
+ # git push and GitHub release entirely.
1139
+ subprocess.check_call(
1140
+ [
1141
+ "calkit",
1142
+ "new",
1143
+ "release",
1144
+ "--name",
1145
+ "v0.1.0",
1146
+ "--description",
1147
+ "First release.",
1148
+ "--no-github",
1149
+ "--no-push",
1150
+ "--verbose",
1151
+ ]
1152
+ )
1153
+ ck_info = calkit.load_calkit_info()
1154
+ assert "v0.1.0" in ck_info["releases"]
1155
+ release = ck_info["releases"]["v0.1.0"]
1156
+ record_id = release["record_id"]
1157
+ assert record_id is not None
1158
+ # The minted DOI should have been resolved and saved (this is exactly what
1159
+ # the publish-path fix is about)
1160
+ doi = release["doi"]
1161
+ assert doi is not None
1162
+ assert doi.startswith("10.")
1163
+ print(f"Published sandbox record {record_id} with DOI {doi}")
1164
+ # The published record really exists and exposes the same DOI via the API,
1165
+ # confirming extract_doi agrees with the real published record
1166
+ record = calkit.invenio.get(f"/records/{record_id}")
1167
+ assert calkit.invenio.extract_doi(record) == doi
1168
+ # A Git tag for the release should have been created locally
1169
+ assert "v0.1.0" in [tag.name for tag in git.Repo().tags]
1170
+
1171
+
856
1172
  def test_new_release_is_runnable(tmp_dir, monkeypatch):
857
1173
  # Provide a dummy Zenodo token so `new_release` can pass its early
858
1174
  # token-validation step even in dry-run mode.
calkit/tests/test_core.py CHANGED
@@ -108,6 +108,25 @@ def test_check_system_deps(tmp_dir):
108
108
  calkit.check_system_deps()
109
109
 
110
110
 
111
+ def test_check_dep_exists_conda_off_path(monkeypatch):
112
+ # Conda is frequently installed but not on the PATH (especially on
113
+ # Windows). check_dep_exists should locate it via find_conda_exe rather
114
+ # than relying on ``conda --version`` being directly runnable.
115
+ import calkit.conda
116
+
117
+ # Simulate conda being absent from the PATH but present in a typical
118
+ # install location that find_conda_exe discovers.
119
+ monkeypatch.setattr(
120
+ calkit.conda, "find_conda_exe", lambda: "/opt/miniconda3/bin/conda"
121
+ )
122
+ monkeypatch.setattr(calkit.conda, "find_mamba_exe", lambda: None)
123
+ assert calkit.check_dep_exists("conda", "app")
124
+ assert not calkit.check_dep_exists("mamba", "app")
125
+ # When neither is findable, it should report missing.
126
+ monkeypatch.setattr(calkit.conda, "find_conda_exe", lambda: None)
127
+ assert not calkit.check_dep_exists("conda", "app")
128
+
129
+
111
130
  def test_get_md5(tmp_dir):
112
131
  with open("file1.txt", "w") as f:
113
132
  f.write("Hello world")
@@ -13,3 +13,23 @@ def test_get_base_url():
13
13
  def test_get_token():
14
14
  token = calkit.invenio.get_token("zenodo")
15
15
  assert isinstance(token, str)
16
+
17
+
18
+ def test_extract_doi():
19
+ extract_doi = calkit.invenio.extract_doi
20
+ # DOI under pids (e.g. publish response or reserved draft)
21
+ assert (
22
+ extract_doi({"pids": {"doi": {"identifier": "10.5281/zenodo.1"}}})
23
+ == "10.5281/zenodo.1"
24
+ )
25
+ # DOI at the top level
26
+ assert extract_doi({"doi": "10.5281/zenodo.2"}) == "10.5281/zenodo.2"
27
+ # DOI under metadata
28
+ assert (
29
+ extract_doi({"metadata": {"doi": "10.5281/zenodo.3"}})
30
+ == "10.5281/zenodo.3"
31
+ )
32
+ # Empty pids (publish response without a reserved DOI) returns None
33
+ assert extract_doi({"pids": {}}) is None
34
+ assert extract_doi({"pids": {"doi": {}}}) is None
35
+ assert extract_doi({}) is None
@@ -10,7 +10,9 @@ import calkit
10
10
  import calkit.pipeline
11
11
  from calkit.environments import get_env_lock_fpath
12
12
  from calkit.pipeline import (
13
+ StaleStage,
13
14
  _expand_dep_excluding_subprojects,
15
+ _status_target_matches,
14
16
  collapse_dvc_stages,
15
17
  stages_are_similar,
16
18
  )
@@ -1595,6 +1597,115 @@ def test_stale_stage_path_prefix():
1595
1597
  assert stale_cross.modified_inputs == ["shared.txt"]
1596
1598
 
1597
1599
 
1600
+ def test_subproject_configured_outputs_posix_no_duplicate():
1601
+ # A non-isolated subproject stage's configured outputs are prefixed with
1602
+ # the subproject path. DVC reports those same outputs as repo-root-relative
1603
+ # posix paths. Building the configured path with the OS-native separator
1604
+ # produced, on Windows, a backslash variant ("sub\out.csv") that escaped
1605
+ # path dedup and showed up as a duplicate stale output alongside DVC's
1606
+ # posix path ("sub/out.csv"). The configured path must therefore be posix.
1607
+ from pathlib import PureWindowsPath
1608
+
1609
+ # The exact construction get_status performs; as_posix() normalizes the
1610
+ # separator regardless of platform (PureWindowsPath stands in for Windows).
1611
+ configured_output = (
1612
+ PureWindowsPath("example-basic") / "data/raw/data.csv"
1613
+ ).as_posix()
1614
+ assert configured_output == "example-basic/data/raw/data.csv"
1615
+ # DVC flagged the stage via a changed command and reports the output as a
1616
+ # modified out using the same posix, repo-root-relative path.
1617
+ stale_stage = calkit.pipeline.StaleStage.from_status_data(
1618
+ status_data=[
1619
+ {"changed outs": {"example-basic/data/raw/data.csv": "modified"}},
1620
+ "changed command",
1621
+ ],
1622
+ configured_outputs=[configured_output],
1623
+ )
1624
+ # The configured output and the DVC-reported output are the same file, so
1625
+ # it must appear exactly once -- no separator-variant duplicate.
1626
+ assert stale_stage.stale_outputs == ["example-basic/data/raw/data.csv"]
1627
+
1628
+
1629
+ def test_always_run_excludes_subproject_stages():
1630
+ # A subproject wrapper stage is marked always-changed purely as a
1631
+ # delegation mechanism, so it must not be advertised as an always-run
1632
+ # stage of the parent project. A genuine root always-run stage still is.
1633
+ status = calkit.pipeline.PipelineStatus(
1634
+ has_pipeline=True,
1635
+ stale_stages={
1636
+ "ticker": StaleStage(always_run=True),
1637
+ "example-basic (subproject)": StaleStage(
1638
+ always_run=True, is_subproject=True
1639
+ ),
1640
+ },
1641
+ )
1642
+ assert status.always_run_stage_names == ["ticker"]
1643
+ # The subproject wrapper isn't stale either (pure always-run delegation).
1644
+ assert "example-basic (subproject)" not in status.stale_stage_names
1645
+ assert not status.is_stale
1646
+
1647
+
1648
+ def test_status_target_matches():
1649
+ ss = StaleStage(
1650
+ stale_outputs=["data/out.csv"],
1651
+ modified_inputs=["data/in.csv"],
1652
+ )
1653
+ # Root stage selected by its name, the dvc.yaml: form, but not by an
1654
+ # unrelated name.
1655
+ assert _status_target_matches("build", "build", "build", None, ss)
1656
+ assert _status_target_matches("dvc.yaml:build", "build", "build", None, ss)
1657
+ assert not _status_target_matches("other", "build", "build", None, ss)
1658
+ # A path target (one containing a separator, so it isn't mistaken for a
1659
+ # bare stage name) matches a stage with overlapping stale/modified paths.
1660
+ assert _status_target_matches("data/out.csv", "build", "build", None, ss)
1661
+ assert _status_target_matches("data/in.csv", "build", "build", None, ss)
1662
+ assert not _status_target_matches(
1663
+ "other/path.csv", "build", "build", None, ss
1664
+ )
1665
+ # Iterated/matrix stages use a name@param key — targeting the base name
1666
+ # (or its dvc.yaml: form) must select each iteration, matching how DVC
1667
+ # filtered matrix targets before status was filtered locally.
1668
+ assert _status_target_matches("sim", "sim@a", "sim@a", None, ss)
1669
+ assert _status_target_matches("dvc.yaml:sim", "sim@a", "sim@a", None, ss)
1670
+ assert _status_target_matches("sim@a", "sim@a", "sim@a", None, ss)
1671
+ # Subproject stages selected by sp:stage, the whole subproject, and the
1672
+ # matrix base name in either form.
1673
+ assert _status_target_matches("sp:run", "sp:run", "run", "sp", ss)
1674
+ assert _status_target_matches("sp", "sp:run", "run", "sp", ss)
1675
+ assert _status_target_matches("sp:sim", "sp:sim@a", "sim@a", "sp", ss)
1676
+ assert not _status_target_matches("sp:other", "sp:run", "run", "sp", ss)
1677
+ # DVC's native inline-subproject stage form (what translate_run_targets
1678
+ # emits for inline subprojects) must also select the stage.
1679
+ assert _status_target_matches("sp/dvc.yaml:run", "sp:run", "run", "sp", ss)
1680
+ assert _status_target_matches(
1681
+ "sp/dvc.yaml:sim", "sp:sim@a", "sim@a", "sp", ss
1682
+ )
1683
+ assert not _status_target_matches(
1684
+ "sp/dvc.yaml:other", "sp:run", "run", "sp", ss
1685
+ )
1686
+
1687
+
1688
+ def test_status_target_matches_root_level_path(tmp_path, monkeypatch):
1689
+ # A root-level path target without any separator (e.g. ``out.csv`` or a
1690
+ # directory ``data``) must still be selectable when it exists on disk; a
1691
+ # separator-only heuristic would wrongly treat it as a bare stage name.
1692
+ monkeypatch.chdir(tmp_path)
1693
+ (tmp_path / "out.csv").write_text("x")
1694
+ (tmp_path / "data").mkdir()
1695
+ ss = StaleStage(
1696
+ stale_outputs=["out.csv"],
1697
+ modified_inputs=["data/in.csv"],
1698
+ )
1699
+ assert _status_target_matches("out.csv", "build", "build", None, ss)
1700
+ assert _status_target_matches("./out.csv", "build", "build", None, ss)
1701
+ # A directory target overlaps a modified input nested under it.
1702
+ assert _status_target_matches("data", "build", "build", None, ss)
1703
+ assert _status_target_matches("data/", "build", "build", None, ss)
1704
+ # A separator-less target that is neither a stage name nor an existing
1705
+ # path is not selected.
1706
+ assert not _status_target_matches("missing", "build", "build", None, ss)
1707
+
1708
+
1598
1709
  def test_collapse_dvc_stages():
1599
1710
  def out_paths(stage):
1600
1711
  return {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calkit-python
3
- Version: 0.41.7
3
+ Version: 0.41.8
4
4
  Summary: Reproducibility simplified.
5
5
  Project-URL: Homepage, https://calkit.org
6
6
  Project-URL: Issues, https://github.com/calkit/calkit/issues
@@ -5,7 +5,7 @@ calkit/check.py,sha256=VVJErHeCzX-bcdRtu0dgMpLLMU7M38SZI_wqyMgdzwg,9080
5
5
  calkit/cloud.py,sha256=65380HbUVEINxXue9YZnbOX5PsnszdgR9ZCli0DR2Wg,15772
6
6
  calkit/conda.py,sha256=Jb1Fz3jwkk4SZ0NgoWXd4lf50gEC8d8U87ElrYjdiwk,30752
7
7
  calkit/config.py,sha256=tkrdAQU1m3__B9efpYgK6IhtLtS3WWFcVpY2zEQpBio,7573
8
- calkit/core.py,sha256=ykT1CPzaCnUruErEHCl-UawLYFUxtUPdcFgTD1qBstY,27457
8
+ calkit/core.py,sha256=NbuNHcSRzmvovSIuCKB05aX_XZj3Hl38Szk-VQXCuM4,27873
9
9
  calkit/datasets.py,sha256=9lOlOreey2aJGMw6MiV9MDzSHp-fYkCf1k1AZbWTdQE,2235
10
10
  calkit/dependencies.py,sha256=y5N_4ctwp9rFZVg82Si9_2x8Yh64Ex-WqP0iiN5NNBE,13276
11
11
  calkit/detect.py,sha256=w8H8r2p0R38645TUWFz9xtmeinKic_T8E6eTuldN6OM,62418
@@ -16,7 +16,7 @@ calkit/git.py,sha256=rHAWdI13vA35m7q-MhlHj2KHMa42s4ecisTvKbdXKMw,13321
16
16
  calkit/github.py,sha256=8_yZ0ej8TKM4qk8iPeGq47R1I4MHdSoJ_Zpr1gRwNEM,1701
17
17
  calkit/gui.py,sha256=2UCrMyosc5v4CLlDcHO7oDwW9eLn5_WbNaLaG5bjMTc,23
18
18
  calkit/install.py,sha256=n989NLyZSTndj_rEaHceSip5aXP7zZIlG9QwbFs8kTc,7913
19
- calkit/invenio.py,sha256=ZCFCikrYLCffhbahwvVclPcFX4XdKJtfa8k4p9yK-jI,3580
19
+ calkit/invenio.py,sha256=aJH04-X9eFzxnrnbrPs71XBeFYBgKYYPiVM2KtyCXEE,4294
20
20
  calkit/julia.py,sha256=fYj4KuwEPDianrDf6k5DHvfMYqs09v4ts7MOQTWa3OI,3329
21
21
  calkit/jupyter.py,sha256=lE8kKmauHqspY7aPOiNpLGQyw9jSn4taGZScm6aR87I,1157
22
22
  calkit/licenses.py,sha256=3ZNBFxwRrfSjx3poG3G07z7GRCKAkdWiZ1AFe1yr8Gg,6853
@@ -26,7 +26,7 @@ calkit/notebooks.py,sha256=obKRJaVf-rrQslcu76yx8lP18QKWGJOD_Irw7yHw9ek,8913
26
26
  calkit/office.py,sha256=mZqCxkIsmWthBAeJZ5SiGe21fn5zD2zXsy1E6nQIpWE,1291
27
27
  calkit/ops.py,sha256=lLlZAFeplU8uLeMavr99d__Iof-YYwKijus74WfLipk,854
28
28
  calkit/overleaf.py,sha256=5SI8HwglLVtVlzj7d15Bft_3ZC6AHn3IrAbqMKOAPX0,30170
29
- calkit/pipeline.py,sha256=7w1bN9rCUcqTfakEFHAJGIlk9aSHJr66_cPFgHSsHrQ,57994
29
+ calkit/pipeline.py,sha256=PYOk_RHQAQL_SiSh-nV69OFxQ6Da7zbC4mBd2dw5rCo,63212
30
30
  calkit/releases.py,sha256=YK-PLD5fQ7Zapw0lrYP0tY_QRPTfKf9-8rkwHnK-1Nk,13742
31
31
  calkit/server.py,sha256=GQm1bI5Q1NLFNwQL-iP6j4iWKUpKpP3cLEyfj5IMwe4,22009
32
32
  calkit/cli/__init__.py,sha256=pP_1wFuewdUUvEWTtaEcdKblPaSYuTvthQb2Ef08UMo,1921
@@ -39,14 +39,14 @@ calkit/cli/dev.py,sha256=cu4QsfDiA_KurGx4WJcgA2qgfghMoZutE8kl4IZbUcM,1070
39
39
  calkit/cli/import_.py,sha256=5j2ANLq6v7HOku9kJUWylwvQnJfkb_OUKCNOdv1BQXU,18264
40
40
  calkit/cli/latex.py,sha256=dhcbVNcR3YjVSD5mo-OBtZN1FhH4mv4mlGnuP19EbjU,6220
41
41
  calkit/cli/list.py,sha256=iWP3gfXnl13ZciXVpbZQlcu4hAQkBnfvp_D--4T08-0,6084
42
- calkit/cli/new.py,sha256=8S_3nT-oiXK1_QT8oPmU3xxaPwnnK_oMzeU30s71CGY,124018
42
+ calkit/cli/new.py,sha256=i3_kaWAW78vhSEdbdoGyMj0sqkaZcvFPQPBiQqKJMbQ,125465
43
43
  calkit/cli/notebooks.py,sha256=dEjJ1qeh6SG99chhgUaxe_Cpv9hGERR98fBE6dscZmo,21381
44
44
  calkit/cli/office.py,sha256=jVSTvbl91TCzlglST5O2b8hdApZA_QHw_UiEQFJqxdc,1754
45
45
  calkit/cli/overleaf.py,sha256=bS-SkYsSqqhC9YsP-NCzpYJCGS3820w18w_s8JOFihg,25216
46
46
  calkit/cli/scheduler.py,sha256=cxWpXJ71O8Wx4afEKkxoUPTK0RTJLLmvls2UlYVTS9E,28971
47
47
  calkit/cli/update.py,sha256=GX8vcO46JDCDRrDK4YmZJ2ODLgGz6_s5pTjao7S_trI,25543
48
48
  calkit/cli/main/__init__.py,sha256=qu1POZPyqs33ZKfasOxv_Wc-EzVcEgK3Dt_vwFL8Bi8,65
49
- calkit/cli/main/core.py,sha256=6KWgCgAgV8MTm8EEd5NujVCCrQc9A33vpjtaXt8TwVI,120681
49
+ calkit/cli/main/core.py,sha256=_QgV8c-9LDd3xZyFQQhZ6JRITVKadQ2U4tzAZnuenGc,121134
50
50
  calkit/cli/main/xr.py,sha256=Bug5qJuiIq0tusmdu30t-ZdyaT_Sy5RwPSTiHvCtIqE,25600
51
51
  calkit/dvc/__init__.py,sha256=-B6tilCb_jw7QFmJ2srILez24wDhNKUzIBhZUMt01dE,381
52
52
  calkit/dvc/core.py,sha256=54s-LwttJ6GWWwkwhAkBcwJBXpVajEoYPcJGxLD5zlc,23780
@@ -73,7 +73,7 @@ calkit/tests/test_calc.py,sha256=h-wOUIRqfyi06LTzhY_rS-a1xChZTkmwdEfBybMUM7E,203
73
73
  calkit/tests/test_check.py,sha256=ngRZq1_dSRQoa0oSjcx_QpR94omG9Bpas_h0wqjwrK0,1475
74
74
  calkit/tests/test_cloud.py,sha256=583oy2zH0fLexBBB_cmrxCgu7qsouBe7tKY-kkbzhXE,10227
75
75
  calkit/tests/test_conda.py,sha256=t-Oy1mL3Vb5njj_t3LQ8HwMhsmLN64HAzSlebnN1A9s,14204
76
- calkit/tests/test_core.py,sha256=MDSQCsUI4RkZ6VZGWhDELdebZujLuLn8o6BLBSaBb5w,4607
76
+ calkit/tests/test_core.py,sha256=qZ090GbIu-gqu-0qJqwPj47t7k1Q_N_5mv0sT2VAeZc,5496
77
77
  calkit/tests/test_dependencies.py,sha256=HskXLzbvvRJz2TXnKPpPAo9lPhoZGYQFy4BmEF31nvk,11139
78
78
  calkit/tests/test_detect.py,sha256=1QLei1SjLdxy3IWwFZi3PIz8WHpKmG50Hjygc_HxYxg,32907
79
79
  calkit/tests/test_docker.py,sha256=27d0K1GOWiCNv_5TkZSob6EaC0EtQ9DSBIDfkEAEzGk,2077
@@ -81,14 +81,14 @@ calkit/tests/test_environments.py,sha256=KlIQ7tZhhPSFlJ3cM4nLCpCxShB18MCHUBW0y2K
81
81
  calkit/tests/test_fs.py,sha256=8XFlC6bsw_aGodhmDq87OnI60L2licz459yUlDcAieY,9448
82
82
  calkit/tests/test_git.py,sha256=bxPVTn-0DD9mhlarBjEnm6V8XSJHfvm2etl9Z5Pq-qg,15076
83
83
  calkit/tests/test_install.py,sha256=Yh7-OFQ2L4S0uRkYPfQpqe4V2y1A9DwZddMEi8pQbMU,9228
84
- calkit/tests/test_invenio.py,sha256=RpiwgcYu0yIpl2gOMTkAQNsSStYPZGa2ReFnA4KCt94,296
84
+ calkit/tests/test_invenio.py,sha256=aDI8cFOUUQEkfgHSK_Nv1cMNMsnmNr2W2weuZhrxshI,998
85
85
  calkit/tests/test_julia.py,sha256=fw8pTkXJEUYow3B8q-_oWw859MH4UDy9AuDk6nNaDQA,3188
86
86
  calkit/tests/test_jupyter.py,sha256=YTL6zI740UM2KUjskSipzvSKxsyQ8rVPFQIX5cb2tMQ,114
87
87
  calkit/tests/test_licenses.py,sha256=AxoydeUG1Z6rDVeJJ64YXZ8xCJeyQyJdq6GSkDjlwJE,4001
88
88
  calkit/tests/test_magics.py,sha256=1O_hYPLMBVQJj8rLCFV3sU2DejlLLnk6Tv4_Tr-FZHQ,2101
89
89
  calkit/tests/test_matlab.py,sha256=7yFKOYDCtxYIkRyFOwTIxlvzx03Pac0FKGIUT4Zx7zA,7589
90
90
  calkit/tests/test_notebooks.py,sha256=6xUpHjCbCv08oJkArj-NkTUMlz9zzm8pf1qam3s27M0,2692
91
- calkit/tests/test_pipeline.py,sha256=TYBzuywlHFjpwI7CZOoXKZZCroDWilSmjd4ZmTSE6OY,78119
91
+ calkit/tests/test_pipeline.py,sha256=crqI5YJcSb0_-imFubxv-ZWnRJ1j8NYgsuNjI-z9iUg,83661
92
92
  calkit/tests/test_releases.py,sha256=_sh7s3hjfAAw5vJ7Zdjp5CRfE8uv4j63jZu_I-N_CPc,10338
93
93
  calkit/tests/test_templates.py,sha256=RN4RIgmuFlmFYQFgfJUYS-KaLmie8fnWzmfg5Ay1egk,430
94
94
  calkit/tests/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -98,14 +98,14 @@ calkit/tests/cli/test_config.py,sha256=Tl4LWfi81eQo6fUgJsoZRNPj_LfkKH-s42HRg5Mxh
98
98
  calkit/tests/cli/test_import.py,sha256=e2BToVPebf4NalFQuIgyTNJHwbc9VMc6AYO-FAhYbQ0,663
99
99
  calkit/tests/cli/test_latex.py,sha256=SqDbenoFtnsgkfqFjR2Jhw6TJsYQBtLqIGjM3PJmddc,3604
100
100
  calkit/tests/cli/test_list.py,sha256=vwPGDANLzFR9neWmeT4idS8vaYS2w7UoZZ0Q3ItF9kw,1801
101
- calkit/tests/cli/test_new.py,sha256=mH1l6o75Z0541SJNF_WtW58fioJN_V7yKIOCT3d7IfA,32148
101
+ calkit/tests/cli/test_new.py,sha256=aFIoqLSPF43rOU9zend6NDhjP3CylerF0y23fwq2dwk,44448
102
102
  calkit/tests/cli/test_notebooks.py,sha256=pJX6KV45tNBd-ZpqyoayDzNop9-maRIN_fC2j3EcPf0,7899
103
103
  calkit/tests/cli/test_overleaf.py,sha256=Y-ud4kZHh3nhyoZlXrtMfx61z-GPNytUpfDHMad3tuA,16474
104
104
  calkit/tests/cli/test_scheduler.py,sha256=orCXPBbn9Mx4moemWNSfsil5cGCOcNgRgw6Yy5zDwjk,6061
105
105
  calkit/tests/cli/test_update.py,sha256=68Dzrm4eO4lZZ0zcpdQCo-1mgwrxE6n9lQao0UJx9ag,3212
106
106
  calkit/tests/cli/main/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
107
107
  calkit/tests/cli/main/test_core.py,sha256=Zv0Vyt6WZbJP9Z2PdP4ZTwtQ6WckoeTGfNSO9lE8dZA,59106
108
- calkit/tests/cli/main/test_subprojects.py,sha256=l08ZS4Dah4TqtnupFBBJbgSBJu-xBgOj5EJIH05OHWs,11146
108
+ calkit/tests/cli/main/test_subprojects.py,sha256=ZR2ZQvZ4XZOi-iW8nlZEYu_2CUyUWoc0MABmnFrF9DQ,12730
109
109
  calkit/tests/cli/main/test_xr.py,sha256=shk8LHS33bZpr_iAW5eA5sMD3nQDKm8fIwXlE6dA2uI,22527
110
110
  calkit/tests/dvc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
111
111
  calkit/tests/dvc/test_core.py,sha256=YkN0VsB8wB68K5N_L3V-Wq9EyGxOaZil4_FISf6iuB0,10623
@@ -118,23 +118,23 @@ calkit/tests/models/test_pipeline.py,sha256=AJsMAX4928U3_Omt0_8K96U5LU_KoNErgpHe
118
118
  calkit/agent_skills/add-pipeline-stage/SKILL.md,sha256=kpYYb-rJsS4B9p1Ub3gL21irCG5ZZ4DvWZpdJeEwPZk,3772
119
119
  calkit/agent_skills/conventions/SKILL.md,sha256=nAM2FjSMk6ED56Dr5zh2bL_dhfzDra-h2ZgzHU7ruS4,9524
120
120
  calkit/agent_skills/create-pipeline/SKILL.md,sha256=2FGw1iDQVRk5FUq79GP3lPdgvgof2Wi1O9O-abGGtgs,5422
121
- calkit_python-0.41.7.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
122
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/package.json,sha256=INxx8QhvSl7KP6yL7A7-m2-FIsnB__mYEWG_Eq5RP-c,6651
123
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=6w0b5y8LztEv2tErajJ5d39yBryld02crX0CG3zZFNs,6509
124
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
125
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js,sha256=mixXcqFUZukj7_jQVxHTavsYl7cdZv83sEe4phJgkh0,59893
126
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js,sha256=LEEAOkUtQ9KzWEHn-QFv5yGi70B-sBOnrvztzHr0Shw,223
127
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js,sha256=pCoEaqUQj1T4-zAc9SUd__9ZGm7uL2wHQve2xwL89NI,8156
128
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js,sha256=4_nMNAiDSn_cw7UjIHEwone4fizrvqrqSgbwUWvjmoU,114571
129
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js,sha256=n_a0yu_gE7l6fauyoXXv9BZ7ZEYt2387mTfs7CbLcs4,51939
130
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt,sha256=eNJ8gc9n9IF8nW1d9sI9niuHstYzjNz5vqXx9UgWSPc,249
131
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
132
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js,sha256=ZUaa-ZbnqWqpgxIpcQcRX7auvhOBkPH2YHNID5udGQs,8737
133
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=r89Jlk5v1drcwhCpv9FnC3Jig_JH4k24kZNIvxh6Htk,149
134
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
135
- calkit_python-0.41.7.data/data/share/jupyter/labextensions/calkit/install.json,sha256=DK9d8G-q-rMVlcT3rAeGIyo3REWKw6FySBZceLU9yaw,187
136
- calkit_python-0.41.7.dist-info/METADATA,sha256=l95iwXq8XVINTdV00PGTPu9NmrpwxY-wV5mq6QjuH5c,12070
137
- calkit_python-0.41.7.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
138
- calkit_python-0.41.7.dist-info/entry_points.txt,sha256=2iQBBzjTAOdk66CwS-f3ecXXW5DjUqkG1KBRj8mHI1I,133
139
- calkit_python-0.41.7.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
140
- calkit_python-0.41.7.dist-info/RECORD,,
121
+ calkit_python-0.41.8.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
122
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/package.json,sha256=INxx8QhvSl7KP6yL7A7-m2-FIsnB__mYEWG_Eq5RP-c,6651
123
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=6w0b5y8LztEv2tErajJ5d39yBryld02crX0CG3zZFNs,6509
124
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
125
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js,sha256=mixXcqFUZukj7_jQVxHTavsYl7cdZv83sEe4phJgkh0,59893
126
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js,sha256=LEEAOkUtQ9KzWEHn-QFv5yGi70B-sBOnrvztzHr0Shw,223
127
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js,sha256=pCoEaqUQj1T4-zAc9SUd__9ZGm7uL2wHQve2xwL89NI,8156
128
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js,sha256=4_nMNAiDSn_cw7UjIHEwone4fizrvqrqSgbwUWvjmoU,114571
129
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js,sha256=n_a0yu_gE7l6fauyoXXv9BZ7ZEYt2387mTfs7CbLcs4,51939
130
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt,sha256=eNJ8gc9n9IF8nW1d9sI9niuHstYzjNz5vqXx9UgWSPc,249
131
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
132
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js,sha256=ZUaa-ZbnqWqpgxIpcQcRX7auvhOBkPH2YHNID5udGQs,8737
133
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=r89Jlk5v1drcwhCpv9FnC3Jig_JH4k24kZNIvxh6Htk,149
134
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
135
+ calkit_python-0.41.8.data/data/share/jupyter/labextensions/calkit/install.json,sha256=DK9d8G-q-rMVlcT3rAeGIyo3REWKw6FySBZceLU9yaw,187
136
+ calkit_python-0.41.8.dist-info/METADATA,sha256=rQxTqA_zGG2rCn-JWp5zyi1w5RrBRIk0FVfH3zfaWUU,12070
137
+ calkit_python-0.41.8.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
138
+ calkit_python-0.41.8.dist-info/entry_points.txt,sha256=2iQBBzjTAOdk66CwS-f3ecXXW5DjUqkG1KBRj8mHI1I,133
139
+ calkit_python-0.41.8.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
140
+ calkit_python-0.41.8.dist-info/RECORD,,