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.
Files changed (33) hide show
  1. calkit/cli/list.py +35 -1
  2. calkit/cli/main/core.py +66 -6
  3. calkit/cli/new.py +4 -8
  4. calkit/cli/overleaf.py +12 -2
  5. calkit/detect.py +102 -25
  6. calkit/git.py +29 -0
  7. calkit/models/core.py +11 -1
  8. calkit/tests/cli/main/test_core.py +146 -0
  9. calkit/tests/cli/test_list.py +41 -1
  10. calkit/tests/cli/test_new.py +14 -19
  11. calkit/tests/cli/test_overleaf.py +47 -0
  12. calkit/tests/models/test_core.py +22 -1
  13. calkit/tests/test_detect.py +59 -2
  14. {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/METADATA +8 -7
  15. {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/RECORD +32 -33
  16. {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/WHEEL +1 -1
  17. calkit_python-0.41.20.data/data/share/jupyter/labextensions/calkit/install.json +0 -5
  18. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  19. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  20. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  21. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  22. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  23. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  24. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  25. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
  26. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  27. {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
  28. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  29. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.ac9035764d5b2adbb542.js +0 -0
  30. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  31. {calkit_python-0.41.20.data → calkit_python-0.41.22.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  32. {calkit_python-0.41.20.dist-info → calkit_python-0.41.22.dist-info}/entry_points.txt +0 -0
  33. {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
- typer.echo(f"{n}. {question}")
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 = dvc_cli_main(["repro"] + sp_args)
2115
+ sp_res = _run_dvc_repro(["repro"] + sp_args)
2079
2116
  finally:
2080
2117
  os.chdir(original_dir)
2081
- if sp_res != 0:
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 run
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 = dvc_cli_main(["repro"] + args)
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
- stem, ext = os.path.splitext(os.path.basename(path))
3270
- stored_filename = f"{project_name}-{stem}-{name}{ext}"
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 = [os.path.dirname(p) if os.path.isfile(p) else p for p in 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 = [os.path.dirname(p) if os.path.isfile(p) else p for p in 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(top_level)
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(top_level)
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(rel_path: str) -> bool:
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(rel_path: str) -> bool:
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(rel_path: str) -> bool:
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(rel_path: str) -> bool:
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 is_figure_path(rel_path):
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(rel_path: str) -> bool:
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 is_figure_path(rel_path) or is_presentation_path(rel_path):
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(rel_path: str) -> str | None:
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(candidates, reserved_paths=reserved)
2331
+ figures = detect_figures(
2332
+ candidates, reserved_paths=reserved, ignore=ignore
2333
+ )
2264
2334
  datasets = detect_datasets(
2265
- candidates, reserved_paths=reserved, figure_paths=figures
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: list[FigureEvidence | ResultsEvidence] | None = None
368
+ evidence: (
369
+ list[FigureEvidence | ResultsEvidence | PublicationEvidence] | None
370
+ ) = None
361
371
 
362
372
 
363
373
  class Dependency(BaseModel):