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.
- calkit/__init__.py +6 -2
- calkit/cli/check.py +1 -1
- calkit/cli/import_.py +18 -5
- calkit/cli/list.py +13 -0
- calkit/cli/main/core.py +37 -18
- calkit/cli/new.py +98 -2
- calkit/cli/update.py +117 -11
- calkit/core.py +16 -2
- calkit/dvc/core.py +83 -0
- calkit/models/core.py +435 -10
- calkit/models/pipeline.py +54 -6
- calkit/notebooks.py +23 -12
- calkit/procedures.py +53 -0
- calkit/provenance.py +28 -0
- calkit/reproducibility.py +476 -0
- calkit/templates/core.py +27 -3
- calkit/templates/latex/ieee-conference/paper.tex +61 -0
- calkit/templates/latex/report/paper.tex +64 -0
- calkit/tests/cli/main/test_core.py +65 -0
- calkit/tests/cli/main/test_subprojects.py +101 -0
- calkit/tests/cli/test_list.py +5 -0
- calkit/tests/cli/test_new.py +134 -0
- calkit/tests/cli/test_update.py +125 -0
- calkit/tests/dvc/test_core.py +39 -0
- calkit/tests/test_models.py +458 -0
- calkit/tests/test_procedures.py +64 -0
- calkit/tests/test_reproducibility.py +201 -0
- {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/METADATA +1 -1
- {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/RECORD +46 -40
- calkit/check.py +0 -248
- calkit/tests/test_check.py +0 -54
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/909.6d8285ce7c45878ac508.js +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {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
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.d7a43c7948f690d37d19.js +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.45.0.data → calkit_python-0.45.2.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/WHEEL +0 -0
- {calkit_python-0.45.0.dist-info → calkit_python-0.45.2.dist-info}/entry_points.txt +0 -0
- {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
|
-
"
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
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
|
|