calkit-python 0.41.20__py3-none-any.whl → 0.41.22__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/cli/list.py +35 -1
- calkit/cli/main/core.py +66 -6
- calkit/cli/new.py +4 -8
- calkit/cli/overleaf.py +12 -2
- calkit/detect.py +102 -25
- calkit/git.py +29 -0
- calkit/models/core.py +11 -1
- calkit/tests/cli/main/test_core.py +146 -0
- calkit/tests/cli/test_list.py +41 -1
- calkit/tests/cli/test_new.py +14 -19
- calkit/tests/cli/test_overleaf.py +47 -0
- calkit/tests/models/test_core.py +22 -1
- calkit/tests/test_detect.py +59 -2
- {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/METADATA +8 -7
- {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/RECORD +32 -33
- {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/WHEEL +1 -1
- calkit_python-0.41.20.data/data/share/jupyter/labextensions/calkit/install.json +0 -5
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.ac9035764d5b2adbb542.js +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/licenses/LICENSE +0 -0
calkit/cli/list.py
CHANGED
|
@@ -174,6 +174,40 @@ def list_presentations(
|
|
|
174
174
|
_list_artifacts("presentations", json_output, declared_only)
|
|
175
175
|
|
|
176
176
|
|
|
177
|
+
def _echo_question(n: int, question: str | dict) -> None:
|
|
178
|
+
"""Print a single question in the human-readable YAML-ish listing format.
|
|
179
|
+
|
|
180
|
+
String questions are shown as a numbered line. Rich (dict) questions are
|
|
181
|
+
shown with their text on the first line followed by hypothesis, answer,
|
|
182
|
+
evidence, and any other fields in YAML-like indented form.
|
|
183
|
+
"""
|
|
184
|
+
if isinstance(question, str):
|
|
185
|
+
typer.echo(f"{n}. {question}")
|
|
186
|
+
return
|
|
187
|
+
# Copy so popping 'question' doesn't mutate the caller's dict.
|
|
188
|
+
question = dict(question)
|
|
189
|
+
text = question.pop("question", "")
|
|
190
|
+
typer.echo(f"{n}. question: {text}")
|
|
191
|
+
for k, v in question.items():
|
|
192
|
+
if isinstance(v, dict):
|
|
193
|
+
typer.echo(f" {k}:")
|
|
194
|
+
for k1, v1 in v.items():
|
|
195
|
+
typer.echo(f" {k1}: {v1}")
|
|
196
|
+
elif isinstance(v, list):
|
|
197
|
+
typer.echo(f" {k}:")
|
|
198
|
+
for item in v:
|
|
199
|
+
if isinstance(item, dict):
|
|
200
|
+
for n1, (k1, v1) in enumerate(item.items()):
|
|
201
|
+
if n1 == 0:
|
|
202
|
+
typer.echo(f" - {k1}: {v1}")
|
|
203
|
+
else:
|
|
204
|
+
typer.echo(f" {k1}: {v1}")
|
|
205
|
+
else:
|
|
206
|
+
typer.echo(f" - {item}")
|
|
207
|
+
else:
|
|
208
|
+
typer.echo(f" {k}: {v}")
|
|
209
|
+
|
|
210
|
+
|
|
177
211
|
@list_app.command(name="questions")
|
|
178
212
|
def list_questions(
|
|
179
213
|
json_output: Annotated[
|
|
@@ -186,7 +220,7 @@ def list_questions(
|
|
|
186
220
|
typer.echo(json.dumps(questions))
|
|
187
221
|
return
|
|
188
222
|
for n, question in enumerate(questions, start=1):
|
|
189
|
-
|
|
223
|
+
_echo_question(n, question)
|
|
190
224
|
|
|
191
225
|
|
|
192
226
|
@list_app.command(name="publications|pubs")
|
calkit/cli/main/core.py
CHANGED
|
@@ -786,6 +786,8 @@ def add(
|
|
|
786
786
|
elif to == "git":
|
|
787
787
|
subprocess.call(["git", "add"] + paths)
|
|
788
788
|
elif to == "dvc":
|
|
789
|
+
for path in paths:
|
|
790
|
+
calkit.git.ensure_dvc_pointer_is_not_ignored(repo, path)
|
|
789
791
|
calkit.dvc.run_dvc_command(["add"] + paths)
|
|
790
792
|
elif to == "dvc-zip":
|
|
791
793
|
for path in paths:
|
|
@@ -899,6 +901,7 @@ def add(
|
|
|
899
901
|
f"Adding {path} to DVC since it's already tracked "
|
|
900
902
|
"with DVC"
|
|
901
903
|
)
|
|
904
|
+
calkit.git.ensure_dvc_pointer_is_not_ignored(repo, path)
|
|
902
905
|
calkit.dvc.run_dvc_command(["add", path])
|
|
903
906
|
elif posix_path in pipeline_output_storage:
|
|
904
907
|
# Respect storage explicitly set in the pipeline definition
|
|
@@ -943,6 +946,7 @@ def add(
|
|
|
943
946
|
typer.echo(f"Would add {path} to DVC (per extension)")
|
|
944
947
|
else:
|
|
945
948
|
typer.echo(f"Adding {path} to DVC per its extension")
|
|
949
|
+
calkit.git.ensure_dvc_pointer_is_not_ignored(repo, path)
|
|
946
950
|
calkit.dvc.run_dvc_command(["add", path])
|
|
947
951
|
elif calkit.dvc.zip.is_zip_candidate(path):
|
|
948
952
|
if dry_run:
|
|
@@ -963,6 +967,7 @@ def add(
|
|
|
963
967
|
typer.echo(
|
|
964
968
|
f"Adding {path} to DVC since it's greater than 1 MB"
|
|
965
969
|
)
|
|
970
|
+
calkit.git.ensure_dvc_pointer_is_not_ignored(repo, path)
|
|
966
971
|
calkit.dvc.run_dvc_command(["add", path])
|
|
967
972
|
else:
|
|
968
973
|
if dry_run:
|
|
@@ -1414,6 +1419,39 @@ def _stage_run_info_from_log_content(log_content: str) -> dict:
|
|
|
1414
1419
|
return res
|
|
1415
1420
|
|
|
1416
1421
|
|
|
1422
|
+
def _run_dvc_repro(argv: list[str]) -> int | None:
|
|
1423
|
+
"""Run ``dvc repro`` via the DVC CLI, tolerating teardown failures.
|
|
1424
|
+
|
|
1425
|
+
Returns DVC's exit code, or ``None`` if the command ran but DVC's
|
|
1426
|
+
post-command teardown failed to import a module. After ``do_run`` finishes,
|
|
1427
|
+
``dvc.cli.main`` reports anonymous analytics and cleans up cached repos,
|
|
1428
|
+
importing ``dvc.daemon`` and ``dvc.repo.open_repo`` only at that point. In
|
|
1429
|
+
some broken or mixed installs those submodules can't be imported, so the
|
|
1430
|
+
teardown raises a ``ModuleNotFoundError`` that escapes DVC entirely and
|
|
1431
|
+
crashes the run with a confusing traceback once the pipeline has already
|
|
1432
|
+
finished (see issue #1018). That doesn't affect the pipeline result, so
|
|
1433
|
+
swallow it and signal ``None`` so the caller derives success/failure from
|
|
1434
|
+
the run log instead of a lost exit code.
|
|
1435
|
+
|
|
1436
|
+
Only those two modules are tolerated, and deliberately so. ``dvc.cli.main``
|
|
1437
|
+
runs the command inside a broad ``except Exception`` that turns any failure
|
|
1438
|
+
into an exit code, so nothing from the command itself reaches here; but it
|
|
1439
|
+
also imports ``dvc._debug``/``dvc.config``/``dvc.logger`` *before* that
|
|
1440
|
+
block, and a broken install can fail there too. Swallowing those would leave
|
|
1441
|
+
an empty run log and report a pipeline that never ran as successful, so
|
|
1442
|
+
anything but a teardown module must propagate and fail loudly.
|
|
1443
|
+
"""
|
|
1444
|
+
from dvc.cli import main as dvc_cli_main
|
|
1445
|
+
|
|
1446
|
+
try:
|
|
1447
|
+
return int(dvc_cli_main(argv))
|
|
1448
|
+
except ModuleNotFoundError as e:
|
|
1449
|
+
if e.name not in ("dvc.daemon", "dvc.repo.open_repo"):
|
|
1450
|
+
raise
|
|
1451
|
+
warn(f"DVC post-run teardown failed and was ignored ({e})")
|
|
1452
|
+
return None
|
|
1453
|
+
|
|
1454
|
+
|
|
1417
1455
|
def _prune_run_logs(
|
|
1418
1456
|
logs_dir: str, keep: int = 10, protect: str | None = None
|
|
1419
1457
|
) -> None:
|
|
@@ -1823,7 +1861,6 @@ def run(
|
|
|
1823
1861
|
import dvc.repo
|
|
1824
1862
|
import dvc.repo.reproduce
|
|
1825
1863
|
import dvc.ui
|
|
1826
|
-
from dvc.cli import main as dvc_cli_main
|
|
1827
1864
|
from git.exc import InvalidGitRepositoryError
|
|
1828
1865
|
|
|
1829
1866
|
import calkit.dvc.zip
|
|
@@ -2075,13 +2112,21 @@ def run(
|
|
|
2075
2112
|
with calkit.dvc.dvc_lock_timeout(
|
|
2076
2113
|
calkit.dvc.DEFAULT_RUN_LOCK_TIMEOUT
|
|
2077
2114
|
):
|
|
2078
|
-
sp_res =
|
|
2115
|
+
sp_res = _run_dvc_repro(["repro"] + sp_args)
|
|
2079
2116
|
finally:
|
|
2080
2117
|
os.chdir(original_dir)
|
|
2081
|
-
|
|
2118
|
+
# ``None`` means the stage ran but DVC's teardown failed; treat that
|
|
2119
|
+
# as success since the exit code is unrecoverable here.
|
|
2120
|
+
if sp_res is not None and sp_res != 0:
|
|
2082
2121
|
failed = True
|
|
2083
2122
|
if not args or all(a.startswith("--") for a in args):
|
|
2084
|
-
# Only isolated subproject stage targets were given; skip parent
|
|
2123
|
+
# Only isolated subproject stage targets were given; skip the parent
|
|
2124
|
+
# run, but still report failure the way the parent path does below,
|
|
2125
|
+
# else a failing subproject stage would exit zero.
|
|
2126
|
+
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
2127
|
+
if failed:
|
|
2128
|
+
raise_error("Pipeline failed")
|
|
2129
|
+
calkit.echo("Pipeline completed successfully ✅")
|
|
2085
2130
|
return {}
|
|
2086
2131
|
if pipeline is not None:
|
|
2087
2132
|
args += ["--pipeline", pipeline]
|
|
@@ -2152,14 +2197,21 @@ def run(
|
|
|
2152
2197
|
# the instant it finishes) can all hold it momentarily. Without this,
|
|
2153
2198
|
# such a collision aborts the whole run with "Unable to acquire lock".
|
|
2154
2199
|
with calkit.dvc.dvc_lock_timeout(calkit.dvc.DEFAULT_RUN_LOCK_TIMEOUT):
|
|
2155
|
-
res =
|
|
2200
|
+
res = _run_dvc_repro(["repro"] + args)
|
|
2156
2201
|
finally:
|
|
2157
2202
|
os.environ.pop("CALKIT_FORCE", None)
|
|
2158
|
-
failed = failed or res != 0
|
|
2159
2203
|
# Parse log to get timing and which stages ran
|
|
2160
2204
|
with open(log_fpath, "r") as f:
|
|
2161
2205
|
log_content = f.read()
|
|
2162
2206
|
stage_run_info = _stage_run_info_from_log_content(log_content)
|
|
2207
|
+
if res is None:
|
|
2208
|
+
# DVC's exit code was lost to a teardown failure; fall back to the log,
|
|
2209
|
+
# which records any stage that failed to reproduce.
|
|
2210
|
+
failed = failed or any(
|
|
2211
|
+
info.get("status") == "failed" for info in stage_run_info.values()
|
|
2212
|
+
)
|
|
2213
|
+
else:
|
|
2214
|
+
failed = failed or res != 0
|
|
2163
2215
|
# Zip dvc-zip outputs for stages that actually ran
|
|
2164
2216
|
if stage_run_info:
|
|
2165
2217
|
from calkit.models.io import PathOutput
|
|
@@ -3218,6 +3270,14 @@ def call_dvc(
|
|
|
3218
3270
|
and DVC is not installed.
|
|
3219
3271
|
"""
|
|
3220
3272
|
result = calkit.dvc.run_dvc_command(sys.argv[2:])
|
|
3273
|
+
if result != 0 and len(sys.argv) > 2 and sys.argv[2] == "add":
|
|
3274
|
+
typer.secho(
|
|
3275
|
+
"Hint: If DVC failed because a .dvc pointer file is git-ignored, "
|
|
3276
|
+
"use `calkit add --to=dvc <path>` instead so Calkit can "
|
|
3277
|
+
"automatically manage the gitignore exception.",
|
|
3278
|
+
fg=typer.colors.YELLOW,
|
|
3279
|
+
err=True,
|
|
3280
|
+
)
|
|
3221
3281
|
sys.exit(result)
|
|
3222
3282
|
|
|
3223
3283
|
|
calkit/cli/new.py
CHANGED
|
@@ -3174,7 +3174,7 @@ def new_release(
|
|
|
3174
3174
|
detected_kind, artifact = calkit.releases.find_artifact(path, ck_info)
|
|
3175
3175
|
if detected_kind is None:
|
|
3176
3176
|
detected_kind = calkit.detect.detect_artifact_kind(
|
|
3177
|
-
pathlib.Path(path).as_posix()
|
|
3177
|
+
pathlib.Path(path).as_posix(),
|
|
3178
3178
|
)
|
|
3179
3179
|
if release_kind is None:
|
|
3180
3180
|
if detected_kind is None:
|
|
@@ -3258,16 +3258,12 @@ def new_release(
|
|
|
3258
3258
|
# stored as-is (renamed); folders and whole-project releases are
|
|
3259
3259
|
# zipped.
|
|
3260
3260
|
project_name = calkit.detect_project_name(prepend_owner=False)
|
|
3261
|
-
if path == ".":
|
|
3261
|
+
if path == "." or os.path.isdir(path):
|
|
3262
3262
|
stored_filename = f"{project_name}-{name}.zip"
|
|
3263
3263
|
is_zip = True
|
|
3264
|
-
elif os.path.isdir(path):
|
|
3265
|
-
folder_name = os.path.basename(os.path.normpath(path))
|
|
3266
|
-
stored_filename = f"{project_name}-{folder_name}-{name}.zip"
|
|
3267
|
-
is_zip = True
|
|
3268
3264
|
elif os.path.isfile(path):
|
|
3269
|
-
|
|
3270
|
-
stored_filename = f"{project_name}-{
|
|
3265
|
+
_, ext = os.path.splitext(os.path.basename(path))
|
|
3266
|
+
stored_filename = f"{project_name}-{name}{ext}"
|
|
3271
3267
|
is_zip = False
|
|
3272
3268
|
else:
|
|
3273
3269
|
raise_error(f"Release path '{path}' does not exist")
|
calkit/cli/overleaf.py
CHANGED
|
@@ -437,7 +437,12 @@ def sync(
|
|
|
437
437
|
raise_error("No Overleaf sync info found")
|
|
438
438
|
overleaf_sync_dirs = list(overleaf_info.keys())
|
|
439
439
|
if paths is not None:
|
|
440
|
-
paths = [
|
|
440
|
+
paths = [
|
|
441
|
+
(os.path.dirname(p) if os.path.isfile(p) else p)
|
|
442
|
+
.strip()
|
|
443
|
+
.rstrip("/\\")
|
|
444
|
+
for p in paths
|
|
445
|
+
]
|
|
441
446
|
for path in paths:
|
|
442
447
|
if path not in overleaf_sync_dirs:
|
|
443
448
|
raise_error(f"Path '{path}' is not synced with Overleaf")
|
|
@@ -611,7 +616,12 @@ def get_status(
|
|
|
611
616
|
raise_error("No Overleaf sync info found")
|
|
612
617
|
overleaf_sync_dirs = list(overleaf_info.keys())
|
|
613
618
|
if paths is not None:
|
|
614
|
-
paths = [
|
|
619
|
+
paths = [
|
|
620
|
+
(os.path.dirname(p) if os.path.isfile(p) else p)
|
|
621
|
+
.strip()
|
|
622
|
+
.rstrip("/\\")
|
|
623
|
+
for p in paths
|
|
624
|
+
]
|
|
615
625
|
for path in paths:
|
|
616
626
|
if path not in overleaf_sync_dirs:
|
|
617
627
|
raise_error(f"Path '{path}' is not synced with Overleaf")
|
calkit/detect.py
CHANGED
|
@@ -13,6 +13,8 @@ import sys
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import Literal
|
|
15
15
|
|
|
16
|
+
import pathspec
|
|
17
|
+
|
|
16
18
|
NotebookLanguage = Literal["python", "julia", "r"]
|
|
17
19
|
|
|
18
20
|
|
|
@@ -1549,6 +1551,20 @@ def _is_stdlib_module(module_name: str) -> bool:
|
|
|
1549
1551
|
return base_module in common_stdlib
|
|
1550
1552
|
|
|
1551
1553
|
|
|
1554
|
+
_PYTHON_IMPORT_TO_DISTRIBUTION = {
|
|
1555
|
+
"sklearn": "scikit-learn",
|
|
1556
|
+
"skimage": "scikit-image",
|
|
1557
|
+
"cv2": "opencv-python",
|
|
1558
|
+
"PIL": "Pillow",
|
|
1559
|
+
"bs4": "beautifulsoup4",
|
|
1560
|
+
"yaml": "PyYAML",
|
|
1561
|
+
"dateutil": "python-dateutil",
|
|
1562
|
+
"dotenv": "python-dotenv",
|
|
1563
|
+
"serial": "pyserial",
|
|
1564
|
+
"git": "GitPython",
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
|
|
1552
1568
|
def detect_python_dependencies(
|
|
1553
1569
|
script_path: str | None = None,
|
|
1554
1570
|
code: str | None = None,
|
|
@@ -1614,13 +1630,21 @@ def detect_python_dependencies(
|
|
|
1614
1630
|
if not _is_stdlib_module(module_name):
|
|
1615
1631
|
# Get the top-level package name
|
|
1616
1632
|
top_level = module_name.split(".")[0]
|
|
1617
|
-
dependencies.add(
|
|
1633
|
+
dependencies.add(
|
|
1634
|
+
_PYTHON_IMPORT_TO_DISTRIBUTION.get(
|
|
1635
|
+
top_level, top_level
|
|
1636
|
+
)
|
|
1637
|
+
)
|
|
1618
1638
|
elif isinstance(node, ast.ImportFrom):
|
|
1619
1639
|
if node.module and node.level == 0:
|
|
1620
1640
|
module_name = node.module
|
|
1621
1641
|
if not _is_stdlib_module(module_name):
|
|
1622
1642
|
top_level = module_name.split(".")[0]
|
|
1623
|
-
dependencies.add(
|
|
1643
|
+
dependencies.add(
|
|
1644
|
+
_PYTHON_IMPORT_TO_DISTRIBUTION.get(
|
|
1645
|
+
top_level, top_level
|
|
1646
|
+
)
|
|
1647
|
+
)
|
|
1624
1648
|
return sorted(list(dependencies))
|
|
1625
1649
|
|
|
1626
1650
|
|
|
@@ -1927,6 +1951,21 @@ PRESENTATION_NAMES = {
|
|
|
1927
1951
|
"talk.pdf",
|
|
1928
1952
|
}
|
|
1929
1953
|
|
|
1954
|
+
DETECTION_IGNORE_FPATH = os.path.join(".calkit", "ignore")
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
def load_detection_ignore(wdir: str | None = None) -> pathspec.PathSpec | None:
|
|
1958
|
+
"""Load ``.calkit/ignore`` artifact detection exclusions."""
|
|
1959
|
+
import pathspec
|
|
1960
|
+
|
|
1961
|
+
fpath = DETECTION_IGNORE_FPATH
|
|
1962
|
+
if wdir is not None:
|
|
1963
|
+
fpath = os.path.join(wdir, fpath)
|
|
1964
|
+
if not os.path.isfile(fpath):
|
|
1965
|
+
return None
|
|
1966
|
+
with open(fpath, encoding="utf-8") as f:
|
|
1967
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", f)
|
|
1968
|
+
|
|
1930
1969
|
|
|
1931
1970
|
def _ancestor_dir_names(rel_path: str) -> set[str]:
|
|
1932
1971
|
"""Lower-cased names of the ancestor directories of a "/"-separated path."""
|
|
@@ -1938,8 +1977,12 @@ def _path_ext(rel_path: str) -> str:
|
|
|
1938
1977
|
return ("." + name.rsplit(".", 1)[-1].lower()) if "." in name else ""
|
|
1939
1978
|
|
|
1940
1979
|
|
|
1941
|
-
def is_figure_path(
|
|
1980
|
+
def is_figure_path(
|
|
1981
|
+
rel_path: str, *, ignore: pathspec.PathSpec | None = None
|
|
1982
|
+
) -> bool:
|
|
1942
1983
|
"""Whether a repo-relative path looks like an auto-detectable figure."""
|
|
1984
|
+
if ignore is not None and ignore.match_file(rel_path):
|
|
1985
|
+
return False
|
|
1943
1986
|
ancestors = _ancestor_dir_names(rel_path)
|
|
1944
1987
|
ext = _path_ext(rel_path)
|
|
1945
1988
|
if ext in FIGURE_EXTENSIONS and ancestors & FIGURE_DIRS:
|
|
@@ -1951,37 +1994,49 @@ def is_figure_path(rel_path: str) -> bool:
|
|
|
1951
1994
|
return False
|
|
1952
1995
|
|
|
1953
1996
|
|
|
1954
|
-
def is_dataset_path(
|
|
1997
|
+
def is_dataset_path(
|
|
1998
|
+
rel_path: str, *, ignore: pathspec.PathSpec | None = None
|
|
1999
|
+
) -> bool:
|
|
1955
2000
|
"""Whether a repo-relative path looks like an auto-detectable dataset."""
|
|
2001
|
+
if ignore is not None and ignore.match_file(rel_path):
|
|
2002
|
+
return False
|
|
1956
2003
|
return (
|
|
1957
2004
|
_path_ext(rel_path) in DATASET_EXTENSIONS
|
|
1958
2005
|
and bool(_ancestor_dir_names(rel_path) & DATA_DIRS)
|
|
1959
|
-
and not is_figure_path(rel_path)
|
|
2006
|
+
and not is_figure_path(rel_path, ignore=ignore)
|
|
1960
2007
|
)
|
|
1961
2008
|
|
|
1962
2009
|
|
|
1963
|
-
def is_result_path(
|
|
2010
|
+
def is_result_path(
|
|
2011
|
+
rel_path: str, *, ignore: pathspec.PathSpec | None = None
|
|
2012
|
+
) -> bool:
|
|
1964
2013
|
"""Whether a repo-relative path looks like an auto-detectable result.
|
|
1965
2014
|
|
|
1966
2015
|
A result is a data-like file (JSON, CSV, etc.) under a ``results``-style
|
|
1967
2016
|
directory. Anything already detected as a figure or dataset is excluded.
|
|
1968
2017
|
"""
|
|
2018
|
+
if ignore is not None and ignore.match_file(rel_path):
|
|
2019
|
+
return False
|
|
1969
2020
|
return (
|
|
1970
2021
|
_path_ext(rel_path) in RESULT_EXTENSIONS
|
|
1971
2022
|
and bool(_ancestor_dir_names(rel_path) & RESULT_DIRS)
|
|
1972
|
-
and not is_figure_path(rel_path)
|
|
1973
|
-
and not is_dataset_path(rel_path)
|
|
2023
|
+
and not is_figure_path(rel_path, ignore=ignore)
|
|
2024
|
+
and not is_dataset_path(rel_path, ignore=ignore)
|
|
1974
2025
|
)
|
|
1975
2026
|
|
|
1976
2027
|
|
|
1977
|
-
def is_presentation_path(
|
|
2028
|
+
def is_presentation_path(
|
|
2029
|
+
rel_path: str, *, ignore: pathspec.PathSpec | None = None
|
|
2030
|
+
) -> bool:
|
|
1978
2031
|
"""Whether a repo-relative path looks like an auto-detectable presentation.
|
|
1979
2032
|
|
|
1980
2033
|
Either a slide-deck file (PDF/PPTX/KEY) under a ``slides``/``presentations``
|
|
1981
2034
|
directory, or a file with a presentation-like name (e.g. ``slides.pdf``,
|
|
1982
2035
|
``presentation.pdf``) anywhere. Figures are excluded.
|
|
1983
2036
|
"""
|
|
1984
|
-
if
|
|
2037
|
+
if ignore is not None and ignore.match_file(rel_path):
|
|
2038
|
+
return False
|
|
2039
|
+
if is_figure_path(rel_path, ignore=ignore):
|
|
1985
2040
|
return False
|
|
1986
2041
|
name = rel_path.rsplit("/", 1)[-1].lower()
|
|
1987
2042
|
if name in PRESENTATION_NAMES:
|
|
@@ -2018,7 +2073,9 @@ PUBLICATION_NAMES = {
|
|
|
2018
2073
|
}
|
|
2019
2074
|
|
|
2020
2075
|
|
|
2021
|
-
def is_publication_path(
|
|
2076
|
+
def is_publication_path(
|
|
2077
|
+
rel_path: str, *, ignore: pathspec.PathSpec | None = None
|
|
2078
|
+
) -> bool:
|
|
2022
2079
|
"""Whether a repo-relative path looks like an auto-detectable publication.
|
|
2023
2080
|
|
|
2024
2081
|
Either a document (PDF/TeX/DOCX) under a ``paper``/``publication``/
|
|
@@ -2026,7 +2083,11 @@ def is_publication_path(rel_path: str) -> bool:
|
|
|
2026
2083
|
``manuscript.pdf``, ``main.tex``) anywhere. Figures and presentations are
|
|
2027
2084
|
excluded.
|
|
2028
2085
|
"""
|
|
2029
|
-
if
|
|
2086
|
+
if ignore is not None and ignore.match_file(rel_path):
|
|
2087
|
+
return False
|
|
2088
|
+
if is_figure_path(rel_path, ignore=ignore) or is_presentation_path(
|
|
2089
|
+
rel_path, ignore=ignore
|
|
2090
|
+
):
|
|
2030
2091
|
return False
|
|
2031
2092
|
name = rel_path.rsplit("/", 1)[-1].lower()
|
|
2032
2093
|
if name in PUBLICATION_NAMES:
|
|
@@ -2036,19 +2097,21 @@ def is_publication_path(rel_path: str) -> bool:
|
|
|
2036
2097
|
)
|
|
2037
2098
|
|
|
2038
2099
|
|
|
2039
|
-
def detect_artifact_kind(
|
|
2100
|
+
def detect_artifact_kind(
|
|
2101
|
+
rel_path: str, *, ignore: pathspec.PathSpec | None = None
|
|
2102
|
+
) -> str | None:
|
|
2040
2103
|
"""Infer an artifact's release kind from its repo-relative path.
|
|
2041
2104
|
|
|
2042
2105
|
Returns one of ``"figure"``, ``"presentation"``, ``"publication"``, or
|
|
2043
2106
|
``"dataset"``, or ``None`` if the path doesn't look like any of these.
|
|
2044
2107
|
"""
|
|
2045
|
-
if is_figure_path(rel_path):
|
|
2108
|
+
if is_figure_path(rel_path, ignore=ignore):
|
|
2046
2109
|
return "figure"
|
|
2047
|
-
if is_presentation_path(rel_path):
|
|
2110
|
+
if is_presentation_path(rel_path, ignore=ignore):
|
|
2048
2111
|
return "presentation"
|
|
2049
|
-
if is_publication_path(rel_path):
|
|
2112
|
+
if is_publication_path(rel_path, ignore=ignore):
|
|
2050
2113
|
return "publication"
|
|
2051
|
-
if is_dataset_path(rel_path):
|
|
2114
|
+
if is_dataset_path(rel_path, ignore=ignore):
|
|
2052
2115
|
return "dataset"
|
|
2053
2116
|
return None
|
|
2054
2117
|
|
|
@@ -2090,6 +2153,7 @@ def _collapse_dataset_folders(paths: list[str]) -> list[str]:
|
|
|
2090
2153
|
def detect_figures(
|
|
2091
2154
|
candidate_paths: list[str],
|
|
2092
2155
|
reserved_paths: list[str] | tuple[str, ...] = (),
|
|
2156
|
+
ignore: pathspec.PathSpec | None = None,
|
|
2093
2157
|
) -> list[str]:
|
|
2094
2158
|
"""Auto-detected figure paths among ``candidate_paths``.
|
|
2095
2159
|
|
|
@@ -2103,7 +2167,7 @@ def detect_figures(
|
|
|
2103
2167
|
for p in candidate_paths
|
|
2104
2168
|
if not _is_hidden_path(p)
|
|
2105
2169
|
and not _is_under_any_dir(p, reserved)
|
|
2106
|
-
and is_figure_path(p)
|
|
2170
|
+
and is_figure_path(p, ignore=ignore)
|
|
2107
2171
|
}
|
|
2108
2172
|
)
|
|
2109
2173
|
|
|
@@ -2112,6 +2176,7 @@ def detect_datasets(
|
|
|
2112
2176
|
candidate_paths: list[str],
|
|
2113
2177
|
reserved_paths: list[str] | tuple[str, ...] = (),
|
|
2114
2178
|
figure_paths: list[str] | tuple[str, ...] = (),
|
|
2179
|
+
ignore: pathspec.PathSpec | None = None,
|
|
2115
2180
|
) -> list[str]:
|
|
2116
2181
|
"""Auto-detected dataset paths among ``candidate_paths``.
|
|
2117
2182
|
|
|
@@ -2126,7 +2191,7 @@ def detect_datasets(
|
|
|
2126
2191
|
if not _is_hidden_path(p)
|
|
2127
2192
|
and not _is_under_any_dir(p, reserved)
|
|
2128
2193
|
and p not in figset
|
|
2129
|
-
and is_dataset_path(p)
|
|
2194
|
+
and is_dataset_path(p, ignore=ignore)
|
|
2130
2195
|
}
|
|
2131
2196
|
return _collapse_dataset_folders(sorted(files))
|
|
2132
2197
|
|
|
@@ -2134,6 +2199,7 @@ def detect_datasets(
|
|
|
2134
2199
|
def detect_results(
|
|
2135
2200
|
candidate_paths: list[str],
|
|
2136
2201
|
reserved_paths: list[str] | tuple[str, ...] = (),
|
|
2202
|
+
ignore: pathspec.PathSpec | None = None,
|
|
2137
2203
|
) -> list[str]:
|
|
2138
2204
|
"""Auto-detected result paths among ``candidate_paths``.
|
|
2139
2205
|
|
|
@@ -2147,7 +2213,7 @@ def detect_results(
|
|
|
2147
2213
|
for p in candidate_paths
|
|
2148
2214
|
if not _is_hidden_path(p)
|
|
2149
2215
|
and not _is_under_any_dir(p, reserved)
|
|
2150
|
-
and is_result_path(p)
|
|
2216
|
+
and is_result_path(p, ignore=ignore)
|
|
2151
2217
|
}
|
|
2152
2218
|
)
|
|
2153
2219
|
|
|
@@ -2155,6 +2221,7 @@ def detect_results(
|
|
|
2155
2221
|
def detect_presentations(
|
|
2156
2222
|
candidate_paths: list[str],
|
|
2157
2223
|
reserved_paths: list[str] | tuple[str, ...] = (),
|
|
2224
|
+
ignore: pathspec.PathSpec | None = None,
|
|
2158
2225
|
) -> list[str]:
|
|
2159
2226
|
"""Auto-detected presentation paths among ``candidate_paths``."""
|
|
2160
2227
|
reserved = list(reserved_paths)
|
|
@@ -2164,7 +2231,7 @@ def detect_presentations(
|
|
|
2164
2231
|
for p in candidate_paths
|
|
2165
2232
|
if not _is_hidden_path(p)
|
|
2166
2233
|
and not _is_under_any_dir(p, reserved)
|
|
2167
|
-
and is_presentation_path(p)
|
|
2234
|
+
and is_presentation_path(p, ignore=ignore)
|
|
2168
2235
|
}
|
|
2169
2236
|
)
|
|
2170
2237
|
|
|
@@ -2254,18 +2321,28 @@ def detect_project_artifacts(
|
|
|
2254
2321
|
|
|
2255
2322
|
if ck_info is None:
|
|
2256
2323
|
ck_info = calkit.load_calkit_info(wdir=wdir)
|
|
2324
|
+
ignore = load_detection_ignore(wdir=wdir)
|
|
2257
2325
|
reserved = _reserved_artifact_paths(wdir=wdir, ck_info=ck_info)
|
|
2258
2326
|
candidates = list(
|
|
2259
2327
|
dict.fromkeys(
|
|
2260
2328
|
[*list_repo_files(wdir=wdir), *list_dvc_tracked_files(wdir=wdir)]
|
|
2261
2329
|
)
|
|
2262
2330
|
)
|
|
2263
|
-
figures = detect_figures(
|
|
2331
|
+
figures = detect_figures(
|
|
2332
|
+
candidates, reserved_paths=reserved, ignore=ignore
|
|
2333
|
+
)
|
|
2264
2334
|
datasets = detect_datasets(
|
|
2265
|
-
candidates,
|
|
2335
|
+
candidates,
|
|
2336
|
+
reserved_paths=reserved,
|
|
2337
|
+
figure_paths=figures,
|
|
2338
|
+
ignore=ignore,
|
|
2339
|
+
)
|
|
2340
|
+
results = detect_results(
|
|
2341
|
+
candidates, reserved_paths=reserved, ignore=ignore
|
|
2342
|
+
)
|
|
2343
|
+
presentations = detect_presentations(
|
|
2344
|
+
candidates, reserved_paths=reserved, ignore=ignore
|
|
2266
2345
|
)
|
|
2267
|
-
results = detect_results(candidates, reserved_paths=reserved)
|
|
2268
|
-
presentations = detect_presentations(candidates, reserved_paths=reserved)
|
|
2269
2346
|
return {
|
|
2270
2347
|
"figures": figures,
|
|
2271
2348
|
"datasets": datasets,
|
calkit/git.py
CHANGED
|
@@ -337,3 +337,32 @@ def ensure_path_is_not_ignored(
|
|
|
337
337
|
if target_repo.ignored(target_path) and _depth < 10:
|
|
338
338
|
ensure_path_is_not_ignored(target_repo, target_path, _depth + 1)
|
|
339
339
|
return True
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def ensure_dvc_pointer_is_not_ignored(repo, path: str) -> None:
|
|
343
|
+
"""Ensure the .dvc pointer for ``path`` will not be Git-ignored.
|
|
344
|
+
|
|
345
|
+
A broad pattern in a ``.gitignore`` (e.g. ``*.pdf*``) can also match the
|
|
346
|
+
``<path>.dvc`` pointer DVC commits to Git, causing ``dvc add`` to fail with
|
|
347
|
+
"bad DVC file name ... is git-ignored". This appends a ``!*.dvc`` negation
|
|
348
|
+
to the ``.gitignore`` in the pointer's own directory (which wins under Git
|
|
349
|
+
precedence) so pointers stay tracked. Idempotent.
|
|
350
|
+
"""
|
|
351
|
+
path = path.replace("\\", "/").rstrip("/")
|
|
352
|
+
pointer = path + ".dvc"
|
|
353
|
+
if pointer not in repo.ignored(pointer):
|
|
354
|
+
return
|
|
355
|
+
pointer_dir = os.path.dirname(pointer)
|
|
356
|
+
gitignore_path = os.path.join(repo.working_dir, pointer_dir, ".gitignore")
|
|
357
|
+
exception = "!*.dvc"
|
|
358
|
+
existing_lines = []
|
|
359
|
+
if os.path.isfile(gitignore_path):
|
|
360
|
+
with open(gitignore_path, "r", encoding="utf-8") as f:
|
|
361
|
+
existing_lines = f.read().splitlines()
|
|
362
|
+
if exception in existing_lines:
|
|
363
|
+
return # Already present
|
|
364
|
+
os.makedirs(os.path.dirname(gitignore_path), exist_ok=True)
|
|
365
|
+
with open(gitignore_path, "a", encoding="utf-8") as f:
|
|
366
|
+
if existing_lines and existing_lines[-1] != "":
|
|
367
|
+
f.write("\n")
|
|
368
|
+
f.write(exception + "\n")
|
calkit/models/core.py
CHANGED
|
@@ -351,13 +351,23 @@ class ResultsEvidence(BaseModel):
|
|
|
351
351
|
explanation: str | None = None
|
|
352
352
|
|
|
353
353
|
|
|
354
|
+
class PublicationEvidence(BaseModel):
|
|
355
|
+
"""Evidence in the form of a publication."""
|
|
356
|
+
|
|
357
|
+
kind: Literal["publication"] = "publication"
|
|
358
|
+
path: str
|
|
359
|
+
explanation: str | None = None
|
|
360
|
+
|
|
361
|
+
|
|
354
362
|
class Question(BaseModel):
|
|
355
363
|
"""A question the project hopes to answer."""
|
|
356
364
|
|
|
357
365
|
question: str
|
|
358
366
|
hypothesis: str | None = None
|
|
359
367
|
answer: str | None = None
|
|
360
|
-
evidence:
|
|
368
|
+
evidence: (
|
|
369
|
+
list[FigureEvidence | ResultsEvidence | PublicationEvidence] | None
|
|
370
|
+
) = None
|
|
361
371
|
|
|
362
372
|
|
|
363
373
|
class Dependency(BaseModel):
|