calkit-python 0.45.0__py3-none-any.whl → 0.45.1__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 (47) 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 +25 -15
  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 +29 -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 +46 -0
  20. calkit/tests/cli/test_list.py +5 -0
  21. calkit/tests/cli/test_new.py +134 -0
  22. calkit/tests/cli/test_update.py +125 -0
  23. calkit/tests/dvc/test_core.py +39 -0
  24. calkit/tests/test_models.py +458 -0
  25. calkit/tests/test_procedures.py +64 -0
  26. calkit/tests/test_reproducibility.py +201 -0
  27. {calkit_python-0.45.0.dist-info → calkit_python-0.45.1.dist-info}/METADATA +1 -1
  28. {calkit_python-0.45.0.dist-info → calkit_python-0.45.1.dist-info}/RECORD +45 -39
  29. calkit/check.py +0 -248
  30. calkit/tests/test_check.py +0 -54
  31. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  32. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  33. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  34. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  35. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  36. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  37. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  38. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/909.6d8285ce7c45878ac508.js +0 -0
  39. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  40. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  41. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  42. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.d7a43c7948f690d37d19.js +0 -0
  43. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  44. {calkit_python-0.45.0.data → calkit_python-0.45.1.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  45. {calkit_python-0.45.0.dist-info → calkit_python-0.45.1.dist-info}/WHEEL +0 -0
  46. {calkit_python-0.45.0.dist-info → calkit_python-0.45.1.dist-info}/entry_points.txt +0 -0
  47. {calkit_python-0.45.0.dist-info → calkit_python-0.45.1.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
@@ -944,9 +944,11 @@ def add(
944
944
  if dvc_repo is not None:
945
945
  dvc_status = dvc_repo.data_status()
946
946
  uncommitted = dvc_status["uncommitted"]
947
+ dvc_not_in_cache = set(dvc_status.get("not_in_cache", []))
947
948
  dvc_uncommitted_all = uncommitted.get(
948
949
  "modified", []
949
950
  ) + uncommitted.get("deleted", [])
951
+ missing_from_cache = []
950
952
  for dvc_uncommitted in dvc_uncommitted_all:
951
953
  if os.path.exists(dvc_uncommitted):
952
954
  if dry_run:
@@ -955,11 +957,11 @@ def add(
955
957
  )
956
958
  else:
957
959
  typer.echo(f"Adding {dvc_uncommitted} to DVC")
958
- dvc_repo.commit(
959
- dvc_uncommitted,
960
- force=True,
961
- allow_missing=True,
962
- )
960
+ calkit.dvc.commit_path(dvc_repo, dvc_uncommitted)
961
+ elif dvc_uncommitted in dvc_not_in_cache:
962
+ # The data isn't on this machine at all, so this isn't
963
+ # a real deletion and there's nothing to commit
964
+ missing_from_cache.append(dvc_uncommitted)
963
965
  else:
964
966
  if dry_run:
965
967
  typer.echo(
@@ -969,11 +971,20 @@ def add(
969
971
  typer.echo(
970
972
  f"Committing deleted {dvc_uncommitted} to DVC"
971
973
  )
972
- dvc_repo.commit(
973
- dvc_uncommitted,
974
- force=True,
975
- allow_missing=True,
976
- )
974
+ calkit.dvc.commit_path(dvc_repo, dvc_uncommitted)
975
+ if missing_from_cache:
976
+ n_shown = 5
977
+ paths_txt = ", ".join(missing_from_cache[:n_shown])
978
+ if len(missing_from_cache) > n_shown:
979
+ paths_txt += (
980
+ f", and {len(missing_from_cache) - n_shown} more"
981
+ )
982
+ warn(
983
+ f"Not committing {len(missing_from_cache)} missing "
984
+ f"DVC-tracked path(s) since their data is not in the "
985
+ f"cache ({paths_txt}); "
986
+ "run 'calkit pull' to fetch their data"
987
+ )
977
988
  if not disable_auto_ignore:
978
989
  for untracked_file in untracked_git_files:
979
990
  if (
@@ -3506,15 +3517,14 @@ def run_procedure(
3506
3517
  return bool(value)
3507
3518
  return value
3508
3519
 
3509
- from calkit.models import Procedure
3510
-
3511
3520
  ck_info = calkit.load_calkit_info()
3512
3521
  calkit.set_env_vars(ck_info=ck_info)
3513
- procs = ck_info.get("procedures", {})
3514
- if name not in procs:
3522
+ if name not in ck_info.get("procedures", {}):
3515
3523
  raise_error(f"'{name}' is not defined as a procedure")
3524
+ # Read from its own file if that's where it's kept, so the rest of this
3525
+ # sees the same thing either way
3516
3526
  try:
3517
- proc = Procedure.model_validate(procs[name])
3527
+ proc = calkit.procedures.load(name, ck_info=ck_info)
3518
3528
  except Exception as e:
3519
3529
  raise_error(f"Procedure '{name}' is invalid: {e}")
3520
3530
  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
 
@@ -354,6 +355,34 @@ def get_dvc_repo(wdir: str | None = None) -> dvc.repo.Repo:
354
355
  return dvc.repo.Repo(wdir)
355
356
 
356
357
 
358
+ def commit_path(
359
+ dvc_repo: dvc.repo.Repo, path: str, allow_missing: bool = True
360
+ ) -> None:
361
+ """Commit a path to DVC, skipping the run cache.
362
+
363
+ This is like ``dvc_repo.commit(path, force=True)``, except stages are not
364
+ saved to the run cache, since doing so requires hashing all of their
365
+ dependencies, which will fail if any are missing from the workspace, e.g.,
366
+ if their data has never been pulled.
367
+ """
368
+ with dvc.repo.lock_repo(dvc_repo):
369
+ groups = groupby(
370
+ dvc_repo.stage.collect_granular(path),
371
+ key=lambda info: info.stage.dvcfile,
372
+ )
373
+ for dvcfile, stages_info_group in groups:
374
+ to_dump = []
375
+ for stage_info in stages_info_group:
376
+ stage = stage_info.stage
377
+ stage.save(allow_missing=allow_missing, run_cache=False)
378
+ stage.commit(
379
+ filter_info=stage_info.filter_info,
380
+ allow_missing=allow_missing,
381
+ )
382
+ to_dump.append(stage)
383
+ dvcfile.dump_stages(to_dump, update_pipeline=False)
384
+
385
+
357
386
  def ensure_dvc_lock_not_ignored(wdir: str | None = None) -> bool:
358
387
  """Ensure ``dvc.lock`` is not Git-ignored.
359
388