calkit-python 0.45.0__py3-none-any.whl → 0.45.2__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 (48) hide show
  1. calkit/__init__.py +6 -2
  2. calkit/cli/check.py +1 -1
  3. calkit/cli/import_.py +18 -5
  4. calkit/cli/list.py +13 -0
  5. calkit/cli/main/core.py +37 -18
  6. calkit/cli/new.py +98 -2
  7. calkit/cli/update.py +117 -11
  8. calkit/core.py +16 -2
  9. calkit/dvc/core.py +83 -0
  10. calkit/models/core.py +435 -10
  11. calkit/models/pipeline.py +54 -6
  12. calkit/notebooks.py +23 -12
  13. calkit/procedures.py +53 -0
  14. calkit/provenance.py +28 -0
  15. calkit/reproducibility.py +476 -0
  16. calkit/templates/core.py +27 -3
  17. calkit/templates/latex/ieee-conference/paper.tex +61 -0
  18. calkit/templates/latex/report/paper.tex +64 -0
  19. calkit/tests/cli/main/test_core.py +65 -0
  20. calkit/tests/cli/main/test_subprojects.py +101 -0
  21. calkit/tests/cli/test_list.py +5 -0
  22. calkit/tests/cli/test_new.py +134 -0
  23. calkit/tests/cli/test_update.py +125 -0
  24. calkit/tests/dvc/test_core.py +39 -0
  25. calkit/tests/test_models.py +458 -0
  26. calkit/tests/test_procedures.py +64 -0
  27. calkit/tests/test_reproducibility.py +201 -0
  28. {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/METADATA +1 -1
  29. {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/RECORD +46 -40
  30. calkit/check.py +0 -248
  31. calkit/tests/test_check.py +0 -54
  32. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  33. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  34. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  35. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  36. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  37. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  38. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  39. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/909.6d8285ce7c45878ac508.js +0 -0
  40. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  41. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  42. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  43. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.d7a43c7948f690d37d19.js +0 -0
  44. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  45. {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  46. {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/WHEEL +0 -0
  47. {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/entry_points.txt +0 -0
  48. {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/licenses/LICENSE +0 -0
calkit/__init__.py CHANGED
@@ -10,7 +10,6 @@ from .core import * # noqa: F403, I001
10
10
  if TYPE_CHECKING:
11
11
  from calkit import ( # noqa: F401
12
12
  calc,
13
- check,
14
13
  conda,
15
14
  config,
16
15
  datasets,
@@ -37,7 +36,10 @@ if TYPE_CHECKING:
37
36
  ops,
38
37
  overleaf,
39
38
  pipeline,
39
+ procedures,
40
+ provenance,
40
41
  releases,
42
+ reproducibility,
41
43
  resources,
42
44
  schema,
43
45
  server,
@@ -59,7 +61,9 @@ _SUBMODULES = {
59
61
  "templates",
60
62
  "conda",
61
63
  "calc",
62
- "check",
64
+ "procedures",
65
+ "provenance",
66
+ "reproducibility",
63
67
  "github",
64
68
  "invenio",
65
69
  "releases",
calkit/cli/check.py CHANGED
@@ -308,7 +308,7 @@ def check_repro(
308
308
  ] = ".",
309
309
  ) -> None:
310
310
  """Check the reproducibility of a project."""
311
- from calkit.check import check_reproducibility
311
+ from calkit.reproducibility import check_reproducibility
312
312
 
313
313
  res = check_reproducibility(wdir=wdir, log_func=typer.echo)
314
314
  calkit.echo(res.to_pretty())
calkit/cli/import_.py CHANGED
@@ -208,7 +208,9 @@ def import_dataset(
208
208
  filter_paths=filter_paths,
209
209
  ),
210
210
  )
211
- datasets.append(new_ds.model_dump())
211
+ # Nulls left out, so the entry reads as what was recorded rather than as
212
+ # a form with most of its fields blank
213
+ datasets.append(new_ds.model_dump(exclude_none=True))
212
214
  ck_info["datasets"] = datasets
213
215
  with open("calkit.yaml", "w") as f:
214
216
  calkit.ryaml.dump(ck_info, f)
@@ -465,13 +467,24 @@ def import_from_zenodo(
465
467
  if kind is not None:
466
468
  ck_info = calkit.load_calkit_info()
467
469
  items = ck_info.get(kind + "s", [])
470
+ # A record without a DOI (not every one has been minted one) is still
471
+ # somewhere in particular, so fall back to its URL rather than
472
+ # writing ``doi: null``, which nothing would then accept
473
+ doi = calkit.invenio.extract_doi(record)
474
+ if doi is not None:
475
+ imported_from: dict = {"doi": doi}
476
+ else:
477
+ imported_from = {
478
+ "url": record.get("links", {}).get("self_html")
479
+ or f"https://zenodo.org/records/{record_id}"
480
+ }
468
481
  item_record = {
469
482
  "path": dest_dir,
470
- "imported_from": {
471
- "doi": record.get("doi"),
472
- },
473
- "title": record.get("metadata", {}).get("title"),
483
+ "imported_from": imported_from,
474
484
  }
485
+ title = record.get("metadata", {}).get("title")
486
+ if title:
487
+ item_record["title"] = title
475
488
  items.append(item_record)
476
489
  ck_info[kind + "s"] = items
477
490
  with open("calkit.yaml", "w") as f:
calkit/cli/list.py CHANGED
@@ -45,6 +45,7 @@ def _list_objects(
45
45
  "figures",
46
46
  "references",
47
47
  "publications",
48
+ "misc",
48
49
  ],
49
50
  json_output: bool = False,
50
51
  ):
@@ -241,6 +242,18 @@ def list_publications(
241
242
  _list_objects("publications", json_output)
242
243
 
243
244
 
245
+ @list_app.command(name="misc")
246
+ def list_misc(
247
+ json_output: Annotated[
248
+ bool, typer.Option("--json", help="Output result as JSON.")
249
+ ] = False,
250
+ ):
251
+ """List misc artifacts in the project, i.e., attributed paths that
252
+ aren't one of the typed kinds.
253
+ """
254
+ _list_objects("misc", json_output)
255
+
256
+
244
257
  @list_app.command(name="references|refs")
245
258
  def list_references(
246
259
  json_output: Annotated[
calkit/cli/main/core.py CHANGED
@@ -286,9 +286,18 @@ def init(
286
286
  needs_own_repo = True
287
287
  if needs_own_repo:
288
288
  subprocess.run(["git", "init"])
289
- result = calkit.dvc.init(force=force)
290
- if result != 0:
291
- raise_error("Failed to initialize DVC")
289
+ # A DVC repo can already exist here without a calkit.yaml, e.g., on
290
+ # platforms that set one up for us, so leave it alone rather than
291
+ # failing on DVC's refusal to initialize over an existing .dvc. The
292
+ # marker is .dvc/config, as elsewhere: a .dvc directory without one is
293
+ # an interrupted initialization, which has nothing worth keeping and
294
+ # which DVC will only initialize over with --force.
295
+ if os.path.isfile(os.path.join(".dvc", "config")) and not force:
296
+ typer.echo("DVC is already initialized")
297
+ else:
298
+ result = calkit.dvc.init(force=force or os.path.isdir(".dvc"))
299
+ if result != 0:
300
+ raise_error("Failed to initialize DVC")
292
301
  # Ensure autostage is enabled for DVC
293
302
  result = calkit.dvc.run_dvc_command(["config", "core.autostage", "true"])
294
303
  if result != 0:
@@ -944,9 +953,11 @@ def add(
944
953
  if dvc_repo is not None:
945
954
  dvc_status = dvc_repo.data_status()
946
955
  uncommitted = dvc_status["uncommitted"]
956
+ dvc_not_in_cache = set(dvc_status.get("not_in_cache", []))
947
957
  dvc_uncommitted_all = uncommitted.get(
948
958
  "modified", []
949
959
  ) + uncommitted.get("deleted", [])
960
+ missing_from_cache = []
950
961
  for dvc_uncommitted in dvc_uncommitted_all:
951
962
  if os.path.exists(dvc_uncommitted):
952
963
  if dry_run:
@@ -955,11 +966,11 @@ def add(
955
966
  )
956
967
  else:
957
968
  typer.echo(f"Adding {dvc_uncommitted} to DVC")
958
- dvc_repo.commit(
959
- dvc_uncommitted,
960
- force=True,
961
- allow_missing=True,
962
- )
969
+ calkit.dvc.commit_path(dvc_repo, dvc_uncommitted)
970
+ elif dvc_uncommitted in dvc_not_in_cache:
971
+ # The data isn't on this machine at all, so this isn't
972
+ # a real deletion and there's nothing to commit
973
+ missing_from_cache.append(dvc_uncommitted)
963
974
  else:
964
975
  if dry_run:
965
976
  typer.echo(
@@ -969,11 +980,20 @@ def add(
969
980
  typer.echo(
970
981
  f"Committing deleted {dvc_uncommitted} to DVC"
971
982
  )
972
- dvc_repo.commit(
973
- dvc_uncommitted,
974
- force=True,
975
- allow_missing=True,
976
- )
983
+ calkit.dvc.commit_path(dvc_repo, dvc_uncommitted)
984
+ if missing_from_cache:
985
+ n_shown = 5
986
+ paths_txt = ", ".join(missing_from_cache[:n_shown])
987
+ if len(missing_from_cache) > n_shown:
988
+ paths_txt += (
989
+ f", and {len(missing_from_cache) - n_shown} more"
990
+ )
991
+ warn(
992
+ f"Not committing {len(missing_from_cache)} missing "
993
+ f"DVC-tracked path(s) since their data is not in the "
994
+ f"cache ({paths_txt}); "
995
+ "run 'calkit pull' to fetch their data"
996
+ )
977
997
  if not disable_auto_ignore:
978
998
  for untracked_file in untracked_git_files:
979
999
  if (
@@ -3506,15 +3526,14 @@ def run_procedure(
3506
3526
  return bool(value)
3507
3527
  return value
3508
3528
 
3509
- from calkit.models import Procedure
3510
-
3511
3529
  ck_info = calkit.load_calkit_info()
3512
3530
  calkit.set_env_vars(ck_info=ck_info)
3513
- procs = ck_info.get("procedures", {})
3514
- if name not in procs:
3531
+ if name not in ck_info.get("procedures", {}):
3515
3532
  raise_error(f"'{name}' is not defined as a procedure")
3533
+ # Read from its own file if that's where it's kept, so the rest of this
3534
+ # sees the same thing either way
3516
3535
  try:
3517
- proc = Procedure.model_validate(procs[name])
3536
+ proc = calkit.procedures.load(name, ck_info=ck_info)
3518
3537
  except Exception as e:
3519
3538
  raise_error(f"Procedure '{name}' is invalid: {e}")
3520
3539
  git_repo = calkit.git.get_repo()
calkit/cli/new.py CHANGED
@@ -32,6 +32,39 @@ def _check_path_dir(path: str):
32
32
  os.makedirs(dirname, exist_ok=True)
33
33
 
34
34
 
35
+ def _person_from_options(
36
+ option: str, email: str | None, orcid: str | None, with_ai: list[str]
37
+ ) -> dict | None:
38
+ """Build a ``created_by`` entry from CLI options.
39
+
40
+ Returns ``None`` when none were given. Validated through the model so
41
+ a mistyped ORCID or a ``--with-ai`` with nobody to answer for it is
42
+ refused here rather than by the next ``calkit.yaml`` validation.
43
+ """
44
+ from pydantic import ValidationError
45
+
46
+ from calkit.models.core import _Person
47
+
48
+ if email is None and orcid is None and not with_ai:
49
+ return None
50
+ # ``with_ai`` is a list, one entry per --with-ai given. A single tool is
51
+ # written as a scalar, which is what the docs show and what reads best
52
+ # in calkit.yaml; several stay a list
53
+ with_ai_value: str | list[str] | None = None
54
+ if len(with_ai) == 1:
55
+ with_ai_value = with_ai[0]
56
+ elif with_ai:
57
+ with_ai_value = with_ai
58
+ try:
59
+ person = _Person(email=email, orcid=orcid, with_ai=with_ai_value)
60
+ except ValidationError as e:
61
+ raise_error(
62
+ f"Invalid --{option}: "
63
+ + "; ".join(str(err["msg"]) for err in e.errors())
64
+ )
65
+ return person.model_dump(exclude_none=True)
66
+
67
+
35
68
  def _split_template_subdir(
36
69
  template: str, git_url: str
37
70
  ) -> tuple[str, str | None]:
@@ -600,6 +633,32 @@ def new_figure(
600
633
  help="Stage name from which to add outputs as dependencies.",
601
634
  ),
602
635
  ] = None,
636
+ created_by_email: Annotated[
637
+ str | None,
638
+ typer.Option(
639
+ "--created-by-email",
640
+ help=(
641
+ "Email of whoever made this figure, for one drawn by hand "
642
+ "rather than produced by a stage."
643
+ ),
644
+ ),
645
+ ] = None,
646
+ created_by_orcid: Annotated[
647
+ str | None,
648
+ typer.Option(
649
+ "--created-by-orcid", help="ORCID of whoever made this figure."
650
+ ),
651
+ ] = None,
652
+ created_with_ai: Annotated[
653
+ list[str],
654
+ typer.Option(
655
+ "--created-with-ai",
656
+ help=(
657
+ "Generative AI tool they used, e.g. 'Claude Opus 5'. "
658
+ "Repeat for several."
659
+ ),
660
+ ),
661
+ ] = [],
603
662
  no_commit: Annotated[bool, typer.Option("--no-commit")] = False,
604
663
  overwrite: Annotated[
605
664
  bool,
@@ -626,11 +685,16 @@ def new_figure(
626
685
  raise_error("Command must be provided")
627
686
  if (deps or outs or outs_from_stage) and not stage_name:
628
687
  raise_error("Stage name must be provided")
629
- obj = dict(path=path, title=title)
688
+ created_by = _person_from_options(
689
+ "created-by", created_by_email, created_by_orcid, created_with_ai
690
+ )
691
+ obj: dict = dict(path=path, title=title)
630
692
  if description is not None:
631
693
  obj["description"] = description
632
694
  if stage_name is not None:
633
695
  obj["stage"] = stage_name
696
+ if created_by is not None:
697
+ obj["created_by"] = created_by
634
698
  if cmd:
635
699
  if outs_from_stage:
636
700
  pipeline = calkit.dvc.read_pipeline()
@@ -1197,6 +1261,33 @@ def new_dataset(
1197
1261
  help="Stage name from which to add outputs as dependencies.",
1198
1262
  ),
1199
1263
  ] = None,
1264
+ created_by_email: Annotated[
1265
+ str | None,
1266
+ typer.Option(
1267
+ "--created-by-email",
1268
+ help=(
1269
+ "Email of whoever collected this data for the project, "
1270
+ "which marks it as primary rather than imported or computed."
1271
+ ),
1272
+ ),
1273
+ ] = None,
1274
+ created_by_orcid: Annotated[
1275
+ str | None,
1276
+ typer.Option(
1277
+ "--created-by-orcid",
1278
+ help="ORCID of whoever collected this data.",
1279
+ ),
1280
+ ] = None,
1281
+ created_with_ai: Annotated[
1282
+ list[str],
1283
+ typer.Option(
1284
+ "--created-with-ai",
1285
+ help=(
1286
+ "Generative AI tool they used, e.g. 'Claude Opus 5'. "
1287
+ "Repeat for several."
1288
+ ),
1289
+ ),
1290
+ ] = [],
1200
1291
  no_commit: Annotated[bool, typer.Option("--no-commit")] = False,
1201
1292
  overwrite: Annotated[
1202
1293
  bool,
@@ -1223,11 +1314,16 @@ def new_dataset(
1223
1314
  raise_error("Command must be provided")
1224
1315
  if (deps or outs or outs_from_stage) and not stage_name:
1225
1316
  raise_error("Stage name must be provided")
1226
- obj = dict(path=path, title=title)
1317
+ created_by = _person_from_options(
1318
+ "created-by", created_by_email, created_by_orcid, created_with_ai
1319
+ )
1320
+ obj: dict = dict(path=path, title=title)
1227
1321
  if description is not None:
1228
1322
  obj["description"] = description
1229
1323
  if stage_name is not None:
1230
1324
  obj["stage"] = stage_name
1325
+ if created_by is not None:
1326
+ obj["created_by"] = created_by
1231
1327
  if cmd:
1232
1328
  if outs_from_stage:
1233
1329
  pipeline = calkit.dvc.read_pipeline()
calkit/cli/update.py CHANGED
@@ -1258,6 +1258,45 @@ def update_dataset(
1258
1258
  help="URL the dataset was imported from.",
1259
1259
  ),
1260
1260
  ] = None,
1261
+ imported_from_doi: Annotated[
1262
+ str | None,
1263
+ typer.Option(
1264
+ "--imported-from-doi",
1265
+ help="DOI the dataset was imported from, e.g. 10.5281/zenodo.1.",
1266
+ ),
1267
+ ] = None,
1268
+ imported_from_git_url: Annotated[
1269
+ str | None,
1270
+ typer.Option(
1271
+ "--imported-from-git-url",
1272
+ help="Clone URL of the Git repo the dataset was imported from.",
1273
+ ),
1274
+ ] = None,
1275
+ imported_from_git_rev: Annotated[
1276
+ str | None,
1277
+ typer.Option(
1278
+ "--imported-from-git-rev",
1279
+ help=(
1280
+ "Commit hash it was taken from. A branch or tag isn't "
1281
+ "accepted, since it would move."
1282
+ ),
1283
+ ),
1284
+ ] = None,
1285
+ imported_from_git_path: Annotated[
1286
+ str | None,
1287
+ typer.Option(
1288
+ "--imported-from-git-path",
1289
+ help="Path within that repo, if it isn't the whole thing.",
1290
+ ),
1291
+ ] = None,
1292
+ imported_from_date: Annotated[
1293
+ datetime | None,
1294
+ typer.Option(
1295
+ "--imported-from-date",
1296
+ formats=["%Y-%m-%d"],
1297
+ help="Date it was downloaded, as YYYY-MM-DD.",
1298
+ ),
1299
+ ] = None,
1261
1300
  stage: Annotated[
1262
1301
  str | None,
1263
1302
  typer.Option(
@@ -1267,23 +1306,90 @@ def update_dataset(
1267
1306
  ] = None,
1268
1307
  ) -> None:
1269
1308
  """Update a dataset entry in calkit.yaml."""
1270
- if imported_from_url is None and stage is None:
1309
+ from pydantic import ValidationError
1310
+
1311
+ from calkit.models.core import (
1312
+ Dataset,
1313
+ _GitSource,
1314
+ _ImportedFromDoi,
1315
+ _ImportedFromGit,
1316
+ _ImportedFromUrl,
1317
+ )
1318
+
1319
+ # One source, since the entry records where the data came from rather
1320
+ # than every place it could be found
1321
+ source_options = {
1322
+ "--imported-from-url": imported_from_url,
1323
+ "--imported-from-doi": imported_from_doi,
1324
+ "--imported-from-git-url": imported_from_git_url,
1325
+ }
1326
+ sources_given = [k for k, v in source_options.items() if v is not None]
1327
+ if len(sources_given) > 1:
1328
+ raise_error("Specify only one of " + ", ".join(source_options) + ".")
1329
+ if not sources_given and (
1330
+ imported_from_git_rev is not None
1331
+ or imported_from_git_path is not None
1332
+ or imported_from_date is not None
1333
+ ):
1334
+ raise_error(
1335
+ "--imported-from-git-rev, --imported-from-git-path, and "
1336
+ "--imported-from-date go with one of "
1337
+ + ", ".join(source_options)
1338
+ + "."
1339
+ )
1340
+ if not sources_given and stage is None:
1271
1341
  raise_error("No updates specified.")
1342
+ imported_from: dict | None = None
1343
+ if sources_given:
1344
+ date = imported_from_date.date() if imported_from_date else None
1345
+ try:
1346
+ source: _ImportedFromUrl | _ImportedFromDoi | _ImportedFromGit
1347
+ if imported_from_url is not None:
1348
+ source = _ImportedFromUrl(url=imported_from_url, date=date)
1349
+ elif imported_from_doi is not None:
1350
+ source = _ImportedFromDoi(doi=imported_from_doi, date=date)
1351
+ else:
1352
+ if imported_from_git_rev is None:
1353
+ raise_error(
1354
+ "--imported-from-git-rev is required with "
1355
+ "--imported-from-git-url."
1356
+ )
1357
+ source = _ImportedFromGit(
1358
+ git=_GitSource(
1359
+ repo_url=calkit.normalize_git_url(
1360
+ imported_from_git_url or ""
1361
+ ),
1362
+ rev=imported_from_git_rev,
1363
+ path=imported_from_git_path,
1364
+ ),
1365
+ date=date,
1366
+ )
1367
+ except ValidationError as e:
1368
+ raise_error(
1369
+ "Invalid import source: "
1370
+ + "; ".join(str(err["msg"]) for err in e.errors())
1371
+ )
1372
+ imported_from = source.model_dump(exclude_none=True)
1272
1373
  ck_info = calkit.load_calkit_info()
1273
1374
  datasets = ck_info.get("datasets", [])
1274
1375
  for ds in datasets:
1275
1376
  if ds.get("path") == path:
1276
- if imported_from_url is not None:
1277
- ds["imported_from"] = {"url": imported_from_url}
1278
- if stage is not None:
1279
- ds["stage"] = stage
1280
1377
  break
1281
1378
  else:
1282
- entry: dict = {"path": path}
1283
- if imported_from_url is not None:
1284
- entry["imported_from"] = {"url": imported_from_url}
1285
- if stage is not None:
1286
- entry["stage"] = stage
1287
- datasets.append(entry)
1379
+ ds = {"path": path}
1380
+ datasets.append(ds)
1288
1381
  ck_info["datasets"] = datasets
1382
+ if imported_from is not None:
1383
+ ds["imported_from"] = imported_from
1384
+ if stage is not None:
1385
+ ds["stage"] = stage
1386
+ # Checked as a whole, so an import added to a dataset someone collected
1387
+ # is refused here rather than left for the next validation to find
1388
+ try:
1389
+ Dataset.model_validate(ds)
1390
+ except ValidationError as e:
1391
+ raise_error(
1392
+ "Invalid dataset: "
1393
+ + "; ".join(str(err["msg"]) for err in e.errors())
1394
+ )
1289
1395
  calkit.save_calkit_info(ck_info)
calkit/core.py CHANGED
@@ -1123,6 +1123,21 @@ def detect_project_name(
1123
1123
  return name
1124
1124
 
1125
1125
 
1126
+ def normalize_git_url(url: str) -> str:
1127
+ """An SSH GitHub URL as its HTTPS form; anything else unchanged.
1128
+
1129
+ ``git@github.com:owner/repo.git`` and ``https://github.com/owner/repo``
1130
+ name the same repo, and only the latter is a link, so that's the one
1131
+ worth recording.
1132
+ """
1133
+ url = url.strip()
1134
+ if url.startswith("git@github.com:"):
1135
+ url = "https://github.com/" + url[len("git@github.com:") :]
1136
+ if url.startswith("https://github.com/") and url.endswith(".git"):
1137
+ url = url[: -len(".git")]
1138
+ return url
1139
+
1140
+
1126
1141
  def detect_project_github_url(wdir: str | None = None) -> str | None:
1127
1142
  """Detect the GitHub URL for the current project."""
1128
1143
  try:
@@ -1134,8 +1149,7 @@ def detect_project_github_url(wdir: str | None = None) -> str | None:
1134
1149
  warnings.warn("Git remote is not a GitHub URL")
1135
1150
  return None
1136
1151
  url = url.removesuffix(".git")
1137
- if url.startswith("git@github.com:"):
1138
- url = url.replace("git@github.com:", "https://github.com/")
1152
+ url = normalize_git_url(url)
1139
1153
  return url
1140
1154
 
1141
1155
 
calkit/dvc/core.py CHANGED
@@ -8,6 +8,7 @@ import json
8
8
  import logging
9
9
  import os
10
10
  import sys
11
+ from itertools import groupby
11
12
  from pathlib import Path
12
13
  from typing import Any, Literal
13
14
 
@@ -97,6 +98,60 @@ if sys.platform == "win32":
97
98
  _tolerate_lock_release_failures()
98
99
 
99
100
 
101
+ def _hash_dirs_inside_nested_repos() -> None:
102
+ """Make DVC hash directories that live inside nested DVC repos.
103
+
104
+ DVC's ``DvcIgnoreFilter`` prunes nested DVC repos (e.g. isolated
105
+ subprojects) when walking, so the parent never indexes a subproject's
106
+ ``dvc.yaml`` and ``.dvc`` files as its own. The same walk hashes directory
107
+ deps and outs, though, and the subrepo check is order-dependent:
108
+ ``_get_trie_pattern`` hands the starting directory's ``dnames`` to every
109
+ ancestor it visits, so a walk that starts inside the nested repo never
110
+ registers it, while the root-level index walk does. Whichever touches the
111
+ ignore trie first wins for the rest of the process. In practice a parent
112
+ stage depending on a directory inside an isolated subproject hashes it as
113
+ *empty* on ``status`` but not always on ``repro``, so ``dvc.lock`` and
114
+ ``dvc status`` disagree and the stage is forever stale (or, worse, changes
115
+ in the subproject go unnoticed).
116
+
117
+ A walk that starts at or below a nested repo can only be a caller hashing
118
+ that path (the index walk always starts at the repo root), and the caller
119
+ named the path deliberately, so its contents must be hashed. Such walks
120
+ are routed through DVC's ``ignore_subrepos=False`` trie, which is built
121
+ separately, is deterministic, and still honors every ``.dvcignore`` on
122
+ the way down, including the nested repo's own.
123
+ """
124
+ from dvc.ignore import DvcIgnoreFilter
125
+ from dvc.repo import Repo
126
+
127
+ original_walk = DvcIgnoreFilter.walk
128
+
129
+ def in_nested_repo(dvcignore: Any, path: str) -> bool:
130
+ fs = dvcignore.fs
131
+ root_dir = dvcignore.root_dir
132
+ current = fs.abspath(path)
133
+ if not fs.isin(current, root_dir):
134
+ return False
135
+ while current != root_dir:
136
+ if fs.exists(fs.join(current, Repo.DVC_DIR)):
137
+ return True
138
+ parent = fs.parent(current)
139
+ if parent == current:
140
+ return False
141
+ current = parent
142
+ return False
143
+
144
+ def walk(self: Any, fs: Any, path: str, **kwargs: Any) -> Any:
145
+ if "ignore_subrepos" not in kwargs and in_nested_repo(self, path):
146
+ kwargs["ignore_subrepos"] = False
147
+ return original_walk(self, fs, path, **kwargs)
148
+
149
+ DvcIgnoreFilter.walk = walk
150
+
151
+
152
+ _hash_dirs_inside_nested_repos()
153
+
154
+
100
155
  # Default seconds to wait for DVC's repo-level lock during a pipeline run.
101
156
  #
102
157
  # DVC's own default is only 3 seconds (``dvc.lock.DEFAULT_TIMEOUT``), after
@@ -354,6 +409,34 @@ def get_dvc_repo(wdir: str | None = None) -> dvc.repo.Repo:
354
409
  return dvc.repo.Repo(wdir)
355
410
 
356
411
 
412
+ def commit_path(
413
+ dvc_repo: dvc.repo.Repo, path: str, allow_missing: bool = True
414
+ ) -> None:
415
+ """Commit a path to DVC, skipping the run cache.
416
+
417
+ This is like ``dvc_repo.commit(path, force=True)``, except stages are not
418
+ saved to the run cache, since doing so requires hashing all of their
419
+ dependencies, which will fail if any are missing from the workspace, e.g.,
420
+ if their data has never been pulled.
421
+ """
422
+ with dvc.repo.lock_repo(dvc_repo):
423
+ groups = groupby(
424
+ dvc_repo.stage.collect_granular(path),
425
+ key=lambda info: info.stage.dvcfile,
426
+ )
427
+ for dvcfile, stages_info_group in groups:
428
+ to_dump = []
429
+ for stage_info in stages_info_group:
430
+ stage = stage_info.stage
431
+ stage.save(allow_missing=allow_missing, run_cache=False)
432
+ stage.commit(
433
+ filter_info=stage_info.filter_info,
434
+ allow_missing=allow_missing,
435
+ )
436
+ to_dump.append(stage)
437
+ dvcfile.dump_stages(to_dump, update_pipeline=False)
438
+
439
+
357
440
  def ensure_dvc_lock_not_ignored(wdir: str | None = None) -> bool:
358
441
  """Ensure ``dvc.lock`` is not Git-ignored.
359
442