calkit-python 0.47.12__py3-none-any.whl → 0.48.0__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/__init__.py +2 -0
  2. calkit/cli/dev.py +23 -0
  3. calkit/cli/main/core.py +39 -41
  4. calkit/config.py +3 -0
  5. calkit/core.py +59 -3
  6. calkit/dvc/core.py +113 -0
  7. calkit/pipeline.py +89 -68
  8. calkit/questions.py +59 -5
  9. calkit/tests/cli/main/test_core.py +3 -1
  10. calkit/tests/cli/main/test_subprojects.py +9 -0
  11. calkit/tests/dvc/test_core.py +60 -0
  12. calkit/tests/test_core.py +13 -0
  13. calkit/tests/test_questions.py +13 -0
  14. calkit/tests/test_upgrade.py +226 -0
  15. calkit/upgrade.py +493 -0
  16. {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/METADATA +28 -1
  17. {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/RECORD +33 -31
  18. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  19. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  20. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  21. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  22. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  23. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  24. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  25. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  26. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  27. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  28. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  29. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  30. {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  31. {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/WHEEL +0 -0
  32. {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/entry_points.txt +0 -0
  33. {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/licenses/LICENSE +0 -0
calkit/__init__.py CHANGED
@@ -48,6 +48,7 @@ if TYPE_CHECKING:
48
48
  schema,
49
49
  server,
50
50
  templates,
51
+ upgrade,
51
52
  workspace,
52
53
  )
53
54
 
@@ -94,6 +95,7 @@ _SUBMODULES = {
94
95
  "schema",
95
96
  "server",
96
97
  "templates",
98
+ "upgrade",
97
99
  "workspace",
98
100
  }
99
101
 
calkit/cli/dev.py CHANGED
@@ -45,3 +45,26 @@ def run_ipython(
45
45
  from IPython import start_ipython
46
46
 
47
47
  start_ipython(argv=sys.argv[3:], user_ns={"calkit": calkit})
48
+
49
+
50
+ @dev_app.command(name="upgrade")
51
+ def upgrade(
52
+ branch: Annotated[
53
+ str | None,
54
+ typer.Argument(
55
+ help=(
56
+ "Branch to install from: the worktree that has it checked "
57
+ "out, or else the install's own, if it has no changes. "
58
+ "Defaults to the current branch."
59
+ )
60
+ ),
61
+ ] = None,
62
+ ) -> None:
63
+ """Update a dev (editable) install: pull the branch and reinstall."""
64
+ from calkit.cli.core import raise_error
65
+ from calkit.upgrade import get_dev_upgrade_cmds, run_upgrade_cmds
66
+
67
+ try:
68
+ run_upgrade_cmds(get_dev_upgrade_cmds(branch))
69
+ except Exception as e:
70
+ raise_error(str(e))
calkit/cli/main/core.py CHANGED
@@ -125,6 +125,7 @@ def _to_shell_cmd(cmd: list[str]) -> str:
125
125
 
126
126
  @app.callback()
127
127
  def main(
128
+ ctx: typer.Context,
128
129
  version: Annotated[
129
130
  bool,
130
131
  typer.Option("--version", help="Show version and exit."),
@@ -169,6 +170,7 @@ def main(
169
170
  # didn't, so a CALKIT_HUB in .env sent the two to different hubs.
170
171
  dotenv.load_dotenv(dotenv_path=".env")
171
172
  _warn_on_stale_calkit_env()
173
+ calkit.upgrade.check(ctx.invoked_subcommand)
172
174
 
173
175
 
174
176
  def _warn_on_stale_calkit_env() -> None:
@@ -510,8 +512,18 @@ def get_status(
510
512
  import git
511
513
  from git.exc import InvalidGitRepositoryError
512
514
 
515
+ def get_dvc_data_status() -> dict:
516
+ # Reuse what the pipeline status computed under the same DVC lock
517
+ if (
518
+ pipeline_status is not None
519
+ and pipeline_status.dvc_data_status is not None
520
+ ):
521
+ return dict(pipeline_status.dvc_data_status)
522
+ return dict(calkit.dvc.get_dvc_repo().data_status())
523
+
513
524
  dotenv.load_dotenv(dotenv_path=".env")
514
- ck_info = calkit.load_calkit_info()
525
+ # Status only reads calkit.yaml, so skip the slower round-trip parser
526
+ ck_info = calkit.load_calkit_info(read_only=True)
515
527
  # Status is usually the first command someone runs on a project that
516
528
  # isn't theirs, which is exactly when the variables it declares are
517
529
  # missing. Ask while there's somebody to answer -- the environment
@@ -576,6 +588,7 @@ def get_status(
576
588
  check_environments=not no_check_envs,
577
589
  clean_notebooks=True,
578
590
  compile_to_dvc=True,
591
+ with_data_status="dvc" in categories,
579
592
  )
580
593
  if pipeline_status.failed_environment_checks:
581
594
  warn(
@@ -608,10 +621,13 @@ def get_status(
608
621
  frozen_stages = frozen_tainted_stage_names(ck_info=ck_info)
609
622
  except Exception:
610
623
  frozen_stages = set()
624
+ # The summary doesn't say what changed since an answer was edited,
625
+ # which is the only part that needs history, so skip reading it
611
626
  questions_status = check_questions(
612
627
  ck_info=ck_info,
613
628
  stale_stages=stale_stages,
614
629
  frozen_stages=frozen_stages,
630
+ check_history=False,
615
631
  )
616
632
  if as_json:
617
633
  status_dict: dict[str, Any] = {}
@@ -671,8 +687,7 @@ def get_status(
671
687
  status_dict["git"] = {"error": "Not a Git repository"}
672
688
  if "dvc" in categories:
673
689
  try:
674
- dvc_repo = calkit.dvc.get_dvc_repo()
675
- data_status = dict(dvc_repo.data_status())
690
+ data_status = get_dvc_data_status()
676
691
  data_status.pop("git", None)
677
692
  status_dict["dvc"] = calkit.dvc.data_status_as_posix(
678
693
  data_status
@@ -741,8 +756,7 @@ def get_status(
741
756
  )
742
757
  else:
743
758
  zip_path_map = calkit.dvc.zip.get_zip_path_map()
744
- dvc_repo = calkit.dvc.get_dvc_repo()
745
- raw = dict(dvc_repo.data_status())
759
+ raw = get_dvc_data_status()
746
760
  raw.pop("git", None)
747
761
  raw = calkit.dvc.data_status_as_posix(raw)
748
762
  typer.echo(_format_dvc_data_status(raw, zip_path_map))
@@ -4327,42 +4341,26 @@ def upgrade(
4327
4341
  bool, typer.Option("--skills", help="Upgrade agent skills as well.")
4328
4342
  ] = False,
4329
4343
  ) -> None:
4330
- """Upgrade Calkit."""
4331
- # First detect how Calkit is installed
4332
- # If installed with uv tool, calkit will be located at something like
4333
- # ~/.local/bin/calkit
4334
- which_calkit = shutil.which("calkit")
4335
- if which_calkit is None:
4336
- raise_error("Calkit is not installed")
4337
- split_path = os.path.normpath(str(which_calkit)).split(os.sep)
4338
- if (
4339
- ".local" in split_path
4340
- and "bin" in split_path
4341
- and calkit.check_dep_exists("uv")
4342
- ):
4343
- # This is a uv tool install
4344
- cmd = [
4345
- "uv",
4346
- "tool",
4347
- "install",
4348
- "--upgrade",
4349
- "calkit-python",
4350
- ]
4351
- elif "pipx" in split_path and calkit.check_dep_exists("pipx"):
4352
- cmd = ["pipx", "upgrade", "calkit-python"]
4353
- else:
4354
- cmd = [
4355
- sys.executable,
4356
- "-m",
4357
- "pip",
4358
- "install",
4359
- "--upgrade",
4360
- "calkit-python",
4361
- ]
4362
- res = subprocess.run(cmd)
4363
- if res.returncode != 0:
4364
- raise_error("Upgrade failed")
4365
- typer.echo("Success!")
4344
+ """Upgrade Calkit.
4345
+
4346
+ A dev (editable) install is updated from the branch it's on, as with
4347
+ 'calkit dev upgrade'.
4348
+ """
4349
+ from calkit.upgrade import (
4350
+ get_dev_upgrade_cmds,
4351
+ get_editable_path,
4352
+ get_upgrade_cmd,
4353
+ run_upgrade_cmds,
4354
+ )
4355
+
4356
+ try:
4357
+ if get_editable_path() is not None:
4358
+ cmds = get_dev_upgrade_cmds()
4359
+ else:
4360
+ cmds = [get_upgrade_cmd()]
4361
+ run_upgrade_cmds(cmds)
4362
+ except Exception as e:
4363
+ raise_error(str(e))
4366
4364
  if skills:
4367
4365
  from calkit.cli.update import update_agent_skills
4368
4366
 
calkit/config.py CHANGED
@@ -439,6 +439,9 @@ class Settings(BaseSettings):
439
439
  # 'calkit describe system' reports the ID actually in effect.
440
440
  machine_id: str | None = None
441
441
  dataframe_engine: Literal["pandas", "polars"] = "pandas"
442
+ # Upgrade Calkit when a daily check finds a new version; when false,
443
+ # the check only notifies, and 'no-check' turns the check off
444
+ auto_upgrade: bool | Literal["no-check"] = True
442
445
  run_history_length: int = 10
443
446
  github_token: KeyringOptionalSecret | None = None
444
447
  # Kept apart from github_token, since pushing to the GitHub Container
calkit/core.py CHANGED
@@ -125,9 +125,65 @@ try:
125
125
  # PyYAML ships prebuilt wheels with libyaml on every platform we target,
126
126
  # so this is the normal path; the fallback keeps a source install or an
127
127
  # exotic build working, just slower.
128
- from yaml import CSafeLoader as _YamlLoader
128
+ from yaml import CSafeLoader as _BaseYamlLoader
129
129
  except ImportError: # pragma: no cover - depends on the libyaml build
130
- from yaml import SafeLoader as _YamlLoader
130
+ from yaml import SafeLoader as _BaseYamlLoader
131
+
132
+
133
+ class _YamlLoader(_BaseYamlLoader): # type: ignore[no-any-unimported]
134
+ """The C loader, reading scalars as YAML 1.2 like ``ryaml`` does.
135
+
136
+ PyYAML follows YAML 1.1, where ``no`` and ``on`` are booleans, ``1:30``
137
+ is 90, and ``010`` is octal, so the same file would load differently
138
+ depending on which parser read it.
139
+ """
140
+
141
+
142
+ def _construct_yaml12_int(loader: Any, node: Any) -> int:
143
+ value = str(loader.construct_scalar(node)).replace("_", "")
144
+ sign = -1 if value.startswith("-") else 1
145
+ value = value.lstrip("+-")
146
+ for prefix, base in (("0b", 2), ("0o", 8), ("0x", 16)):
147
+ if value.startswith(prefix):
148
+ return sign * int(value[2:], base)
149
+ return sign * int(value)
150
+
151
+
152
+ # ruamel.yaml's YAML 1.2 resolvers for the scalars that differ from 1.1
153
+ _YAML12_RESOLVERS = {
154
+ "tag:yaml.org,2002:bool": (
155
+ r"^(?:true|True|TRUE|false|False|FALSE)$",
156
+ "tTfF",
157
+ ),
158
+ "tag:yaml.org,2002:float": (
159
+ r"""^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)?
160
+ |[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+)
161
+ |[-+]?\.[0-9_]+(?:[eE][-+][0-9]+)?
162
+ |[-+]?\.(?:inf|Inf|INF)
163
+ |\.(?:nan|NaN|NAN))$""",
164
+ "-+0123456789.",
165
+ ),
166
+ "tag:yaml.org,2002:int": (
167
+ r"""^(?:[-+]?0b[0-1_]+
168
+ |[-+]?0o?[0-7_]+
169
+ |[-+]?[0-9_]+
170
+ |[-+]?0x[0-9a-fA-F_]+)$""",
171
+ "-+0123456789",
172
+ ),
173
+ }
174
+ _YamlLoader.yaml_implicit_resolvers = {
175
+ first: [
176
+ (tag, regexp)
177
+ for tag, regexp in resolvers
178
+ if tag not in _YAML12_RESOLVERS
179
+ ]
180
+ for first, resolvers in _BaseYamlLoader.yaml_implicit_resolvers.items()
181
+ }
182
+ for _tag, (_pattern, _first) in _YAML12_RESOLVERS.items():
183
+ _YamlLoader.add_implicit_resolver(
184
+ _tag, re.compile(_pattern, re.X), list(_first)
185
+ )
186
+ _YamlLoader.add_constructor("tag:yaml.org,2002:int", _construct_yaml12_int)
131
187
 
132
188
 
133
189
  def _load_yaml_readonly(stream: Any) -> Any:
@@ -1157,7 +1213,7 @@ def detect_project_name(
1157
1213
  If ``prepend_owner`` is False, fall back to the working directory name
1158
1214
  if there is no Git remote or name specified in ``calkit.yaml``.
1159
1215
  """
1160
- ck_info = load_calkit_info(wdir=wdir)
1216
+ ck_info = load_calkit_info(wdir=wdir, read_only=True)
1161
1217
  name = ck_info.get("name")
1162
1218
  if name is not None and not prepend_owner:
1163
1219
  return name
calkit/dvc/core.py CHANGED
@@ -8,6 +8,8 @@ import json
8
8
  import logging
9
9
  import os
10
10
  import sys
11
+ import threading
12
+ from collections.abc import Iterator
11
13
  from itertools import groupby
12
14
  from pathlib import Path
13
15
  from typing import Any, Literal
@@ -209,6 +211,117 @@ def dvc_lock_timeout(seconds: float):
209
211
  dvc.lock.DEFAULT_TIMEOUT = previous
210
212
 
211
213
 
214
+ _memoized_hashes_lock = threading.RLock()
215
+
216
+
217
+ @contextlib.contextmanager
218
+ def memoized_hashes() -> Iterator[None]:
219
+ """Hash each output or dependency path only once while active.
220
+
221
+ DVC hashes a directory again for every stage that depends on it, walking
222
+ every file each time, so a dataset that feeds twenty stages is walked
223
+ twenty times by one ``status``. Its ``data_status`` then walks the outputs
224
+ once more, for nothing but their directory hashes unless asked for a
225
+ granular status. Nothing on disk changes during a status check, so the
226
+ first answer stands for the rest.
227
+ """
228
+ import importlib
229
+
230
+ import dvc.repo.data
231
+ import dvc.repo.index
232
+ from dvc.output import Output
233
+
234
+ # By name, since dvc_data.index exports a build function that shadows it
235
+ index_build: Any = importlib.import_module("dvc_data.index.build")
236
+
237
+ # The patches are process-wide, so threads take turns rather than
238
+ # capturing and restoring each other's
239
+ with _memoized_hashes_lock:
240
+ original_get_hash_meta = Output._get_hash_meta
241
+ original_diff = dvc.repo.data._diff_index_to_wtree
242
+ original_build_index = dvc.repo.index.build_data_index
243
+ original_build_entries = index_build.build_entries
244
+ memo: dict[tuple, Any] = {}
245
+ # Local directories already hashed, as (meta, hash_info) by repo,
246
+ # path, and hash name, and whether the walk in progress only needs
247
+ # those
248
+ dirs: dict[tuple[int | None, str, str], Any] = {}
249
+ shallow = False
250
+ walking: int | None = None
251
+ skipped: dict[str, Any] = {}
252
+
253
+ def _get_hash_meta(self: Any) -> Any:
254
+ key = (
255
+ id(self.repo),
256
+ self.fs.protocol,
257
+ self.fs_path,
258
+ self.hash_name,
259
+ self.use_cache,
260
+ )
261
+ if key not in memo:
262
+ memo[key] = original_get_hash_meta(self)
263
+ meta, hash_info = memo[key]
264
+ if (
265
+ self.fs.protocol == "local"
266
+ and hash_info
267
+ and hash_info.isdir
268
+ ):
269
+ dirs[(id(self.repo), self.fs_path, self.hash_name)] = memo[
270
+ key
271
+ ]
272
+ return memo[key]
273
+
274
+ def _diff_index_to_wtree(*args: Any, **kwargs: Any) -> Any:
275
+ nonlocal shallow
276
+ shallow = not kwargs.get("granular", False)
277
+ try:
278
+ return original_diff(*args, **kwargs)
279
+ finally:
280
+ shallow = False
281
+
282
+ def build_entries(
283
+ path: str, fs: Any, *args: Any, **kwargs: Any
284
+ ) -> Any:
285
+ key = (walking, path, kwargs.get("hash_name", "md5"))
286
+ if key in dirs:
287
+ skipped[path] = dirs[key]
288
+ return iter(())
289
+ return original_build_entries(path, fs, *args, **kwargs)
290
+
291
+ def build_data_index(
292
+ index: Any, path: str, fs: Any, *args: Any, **kwargs: Any
293
+ ) -> Any:
294
+ nonlocal walking
295
+ if not (shallow and kwargs.get("compute_hash")):
296
+ return original_build_index(index, path, fs, *args, **kwargs)
297
+ walking = id(index.repo)
298
+ skipped.clear()
299
+ try:
300
+ data = original_build_index(index, path, fs, *args, **kwargs)
301
+ finally:
302
+ walking = None
303
+ # Outputs whose walk was skipped get the hashes already computed
304
+ # rather than those of their now-empty listing
305
+ for dir_path, (meta, hash_info) in skipped.items():
306
+ entry = data.get(fs.relparts(dir_path, path))
307
+ if entry is not None:
308
+ entry.meta = meta
309
+ entry.hash_info = hash_info
310
+ return data
311
+
312
+ Output._get_hash_meta = _get_hash_meta
313
+ dvc.repo.data._diff_index_to_wtree = _diff_index_to_wtree
314
+ dvc.repo.index.build_data_index = build_data_index
315
+ index_build.build_entries = build_entries
316
+ try:
317
+ yield
318
+ finally:
319
+ Output._get_hash_meta = original_get_hash_meta
320
+ dvc.repo.data._diff_index_to_wtree = original_diff
321
+ dvc.repo.index.build_data_index = original_build_index
322
+ index_build.build_entries = original_build_entries
323
+
324
+
212
325
  class CalkitDVCFileSystem(ObjectFileSystem):
213
326
  """DVC-facing filesystem wrapper for the ``ck://`` scheme."""
214
327
 
calkit/pipeline.py CHANGED
@@ -151,6 +151,9 @@ class PipelineStatus(BaseModel):
151
151
  ignored_files_in_inputs: dict[str, dict[str, list[str]]] = Field(
152
152
  default_factory=dict
153
153
  )
154
+ # DVC's data status, when asked for, computed alongside the stage status
155
+ # so it reuses that index and those hashes; not part of the output
156
+ dvc_data_status: dict | None = Field(default=None, exclude=True)
154
157
 
155
158
  @field_validator("stale_stages", mode="before")
156
159
  @classmethod
@@ -853,11 +856,14 @@ def get_status(
853
856
  clean_notebooks: bool = True,
854
857
  compile_to_dvc: bool = True,
855
858
  force_env_check: bool = False,
859
+ with_data_status: bool = False,
856
860
  ) -> PipelineStatus:
857
861
  """Get pipeline status after optional prep checks.
858
862
 
859
863
  This can compile the Calkit pipeline to DVC, clean notebook outputs,
860
864
  check pipeline environments, then query DVC for out-of-date stages.
865
+ With ``with_data_status``, DVC's data status is attached as well, left
866
+ None if it fails so the caller can ask again and report why.
861
867
  """
862
868
  import calkit.environments
863
869
  import calkit.markdown
@@ -934,6 +940,7 @@ def get_status(
934
940
  f"{e.__class__.__name__}: {e}"
935
941
  )
936
942
  return PipelineStatus.model_validate(result)
943
+ import dvc.repo
937
944
  from dvc.lock import LockError
938
945
 
939
946
  try:
@@ -944,7 +951,23 @@ def get_status(
944
951
  # DVC's own target filtering can miss stale propagation from
945
952
  # isolated subprojects, which makes targeted status disagree with
946
953
  # the full-project status view.
947
- raw_status = dvc_repo.status()
954
+ # Held across all of these so DVC builds its index and hashes
955
+ # each path once, rather than dropping them between calls
956
+ dvc_data_status = None
957
+ with (
958
+ dvc.repo.lock_repo(dvc_repo),
959
+ calkit.dvc.memoized_hashes(),
960
+ ):
961
+ raw_status = dvc_repo.status()
962
+ dvc_out_paths = [
963
+ Path(os.path.relpath(out.fs_path)).as_posix()
964
+ for out in dvc_repo.index.outs
965
+ ]
966
+ if with_data_status:
967
+ try:
968
+ dvc_data_status = dict(dvc_repo.data_status())
969
+ except Exception:
970
+ pass
948
971
  raw_status = calkit.dvc.status_as_posix(raw_status)
949
972
  except LockError:
950
973
  # Another DVC process is holding the repo lock---most often a
@@ -1207,9 +1230,7 @@ def get_status(
1207
1230
  # actually needs listing, so a pipeline without directory inputs
1208
1231
  # never pays for them.
1209
1232
  ignored_files_by_dir: dict[str, list[str]] = {}
1210
- git_repo = None
1211
- git_root = ""
1212
- dvc_out_paths: list[str] = []
1233
+ stage_dirs: list[tuple[str, str]] = []
1213
1234
  for name, stage_def in dvc_yaml_stage_defs.items():
1214
1235
  if not isinstance(stage_def, dict):
1215
1236
  continue
@@ -1230,71 +1251,70 @@ def get_status(
1230
1251
  for target in targets
1231
1252
  ):
1232
1253
  continue
1233
- if dep_path not in ignored_files_by_dir:
1234
- if git_repo is None:
1235
- git_repo = calkit.git.get_repo()
1236
- git_root = str(git_repo.working_tree_dir)
1237
- dvc_out_paths = [
1238
- Path(os.path.relpath(out.fs_path)).as_posix()
1239
- for out in dvc_repo.index.outs
1240
- ]
1241
- # Git prints paths relative to the repo root, which may be
1242
- # above the project, so make them project-relative; -z
1243
- # keeps unusual file names from being quoted
1244
- listing = git_repo.git.ls_files(
1245
- "-z",
1246
- "--others",
1247
- "--ignored",
1248
- "--exclude-standard",
1249
- "--",
1250
- os.path.abspath(dep_path),
1251
- )
1252
- files: list[str] = []
1253
- for ignored_path in listing.split("\0"):
1254
- if not ignored_path:
1255
- continue
1256
- ignored_path = Path(
1257
- os.path.relpath(
1258
- os.path.join(git_root, ignored_path)
1259
- )
1260
- ).as_posix()
1261
- if any(
1262
- ignored_path == out
1263
- or ignored_path.startswith(out + "/")
1264
- for out in dvc_out_paths
1265
- ):
1266
- continue
1267
- if dvc_repo.dvcignore.is_ignored_file(
1268
- os.path.abspath(ignored_path)
1269
- ):
1270
- continue
1271
- files.append(ignored_path)
1272
- ignored_files_by_dir[dep_path] = files
1273
- files = ignored_files_by_dir[dep_path]
1274
- if not files:
1254
+ stage_dirs.append((name, dep_path))
1255
+ if stage_dirs:
1256
+ dirs = list(dict.fromkeys(d for _, d in stage_dirs))
1257
+ ignored_files_by_dir = {d: [] for d in dirs}
1258
+ git_repo = calkit.git.get_repo()
1259
+ git_root = str(git_repo.working_tree_dir)
1260
+ # One listing for every directory, chunked to stay under
1261
+ # command line length limits, rather than a subprocess each
1262
+ listing = ""
1263
+ for i in range(0, len(dirs), 100):
1264
+ # Git prints paths relative to the repo root, which may be
1265
+ # above the project, so make them project-relative; -z
1266
+ # keeps unusual file names from being quoted
1267
+ listing += git_repo.git.ls_files(
1268
+ "-z",
1269
+ "--others",
1270
+ "--ignored",
1271
+ "--exclude-standard",
1272
+ "--",
1273
+ *[os.path.abspath(d) for d in dirs[i : i + 100]],
1274
+ )
1275
+ listing += "\0"
1276
+ for ignored_path in dict.fromkeys(listing.split("\0")):
1277
+ if not ignored_path:
1275
1278
  continue
1276
- # When DVC reports the directory (or, if the dep was expanded
1277
- # around a subproject, a path inside it) as modified, annotate
1278
- # that input on the stale stage; otherwise it goes in the
1279
- # status-wide list as something that will bite elsewhere
1280
- annotated = False
1281
- for stale_stage in stale_by_base.get(name, []):
1282
- for modified_input in stale_stage.modified_inputs:
1283
- inside = [
1284
- p
1285
- for p in files
1286
- if p == modified_input
1287
- or p.startswith(modified_input.rstrip("/") + "/")
1288
- ]
1289
- if inside:
1290
- stale_stage.ignored_files_in_inputs[
1291
- modified_input
1292
- ] = inside
1293
- annotated = True
1294
- if not annotated:
1295
- ignored_files_in_inputs.setdefault(name, {})[dep_path] = (
1296
- files
1297
- )
1279
+ ignored_path = Path(
1280
+ os.path.relpath(os.path.join(git_root, ignored_path))
1281
+ ).as_posix()
1282
+ if any(
1283
+ ignored_path == out or ignored_path.startswith(out + "/")
1284
+ for out in dvc_out_paths
1285
+ ):
1286
+ continue
1287
+ if dvc_repo.dvcignore.is_ignored_file(
1288
+ os.path.abspath(ignored_path)
1289
+ ):
1290
+ continue
1291
+ for d in dirs:
1292
+ if ignored_path.startswith(d + "/") or d == ".":
1293
+ ignored_files_by_dir[d].append(ignored_path)
1294
+ for name, dep_path in stage_dirs:
1295
+ files = ignored_files_by_dir[dep_path]
1296
+ if not files:
1297
+ continue
1298
+ # When DVC reports the directory (or, if the dep was expanded
1299
+ # around a subproject, a path inside it) as modified, annotate
1300
+ # that input on the stale stage; otherwise it goes in the
1301
+ # status-wide list as something that will bite elsewhere
1302
+ annotated = False
1303
+ for stale_stage in stale_by_base.get(name, []):
1304
+ for modified_input in stale_stage.modified_inputs:
1305
+ inside = [
1306
+ p
1307
+ for p in files
1308
+ if p == modified_input
1309
+ or p.startswith(modified_input.rstrip("/") + "/")
1310
+ ]
1311
+ if inside:
1312
+ stale_stage.ignored_files_in_inputs[modified_input] = (
1313
+ inside
1314
+ )
1315
+ annotated = True
1316
+ if not annotated:
1317
+ ignored_files_in_inputs.setdefault(name, {})[dep_path] = files
1298
1318
  result["stale_stages"] = ordered_stale_stages
1299
1319
  return PipelineStatus(
1300
1320
  has_pipeline=result["has_pipeline"],
@@ -1303,6 +1323,7 @@ def get_status(
1303
1323
  stale_stages=result["stale_stages"],
1304
1324
  errors=result["errors"],
1305
1325
  ignored_files_in_inputs=ignored_files_in_inputs,
1326
+ dvc_data_status=dvc_data_status,
1306
1327
  )
1307
1328
  finally:
1308
1329
  if wdir is not None: