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.
- calkit/__init__.py +2 -0
- calkit/cli/dev.py +23 -0
- calkit/cli/main/core.py +39 -41
- calkit/config.py +3 -0
- calkit/core.py +59 -3
- calkit/dvc/core.py +113 -0
- calkit/pipeline.py +89 -68
- calkit/questions.py +59 -5
- calkit/tests/cli/main/test_core.py +3 -1
- calkit/tests/cli/main/test_subprojects.py +9 -0
- calkit/tests/dvc/test_core.py +60 -0
- calkit/tests/test_core.py +13 -0
- calkit/tests/test_questions.py +13 -0
- calkit/tests/test_upgrade.py +226 -0
- calkit/upgrade.py +493 -0
- {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/METADATA +28 -1
- {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/RECORD +33 -31
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.47.12.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/WHEEL +0 -0
- {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.47.12.dist-info → calkit_python-0.48.0.dist-info}/licenses/LICENSE +0 -0
calkit/__init__.py
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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:
|