pivot 0.3.0__tar.gz → 0.3.2__tar.gz
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.
- {pivot-0.3.0 → pivot-0.3.2}/PKG-INFO +1 -1
- {pivot-0.3.0 → pivot-0.3.2}/pyproject.toml +1 -1
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/checkout.py +11 -4
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/commit.py +3 -1
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/console.py +6 -2
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/doctor.py +32 -2
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/track.py +16 -2
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/engine.py +4 -2
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/watch.py +78 -7
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/executor/commit.py +10 -1
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/executor/core.py +1 -1
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/executor/worker.py +25 -6
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/skip.py +1 -1
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/status.py +2 -1
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/cache.py +104 -22
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/restore.py +14 -8
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/store.py +18 -3
- {pivot-0.3.0 → pivot-0.3.2}/LICENSE +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/README.md +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/ast_utils.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/AGENTS.md +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/CLAUDE.md +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/_run_common.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/check_ignore.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/completion.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/config.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/dag.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/data.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/decorators.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/errors.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/fingerprint.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/helpers.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/history.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/import_cmd.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/init.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/list.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/metrics.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/params.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/plots.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/remote.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/repro.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/run.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/status.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/targets.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/update.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/cli/verify.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/compose.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/config/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/config/io.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/config/models.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/dag/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/dag/render.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/decorators.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/discovery.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/agent_rpc.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/graph.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/scheduler.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/sinks.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/sources.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/types.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/engine/worker_pool.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/exceptions.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/executor/AGENTS.md +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/executor/CLAUDE.md +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/executor/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/explain.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/fingerprint.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/git.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/ignore.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/import_artifact.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/loaders.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/metrics.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/names.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/outputs.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/parameters.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/path_policy.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/project.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/py.typed +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/registry.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/remote/AGENTS.md +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/remote/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/remote/config.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/remote/git_archive.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/remote/github.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/remote/storage.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/remote/sync.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/run_history.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/show/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/show/common.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/show/data.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/show/metrics.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/show/params.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/show/plots.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/stage_def.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/AGENTS.md +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/CLAUDE.md +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/__init__.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/artifact_lock.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/lock.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/presentation.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/state.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/storage/track.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/types.py +0 -0
- {pivot-0.3.0 → pivot-0.3.2}/src/pivot/yaml_config.py +0 -0
|
@@ -67,11 +67,13 @@ def _get_stage_output_info() -> dict[str, HashInfo]:
|
|
|
67
67
|
return result
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def _on_disk_matches_expected(
|
|
70
|
+
def _on_disk_matches_expected(
|
|
71
|
+
path: pathlib.Path, expected_hash: HashInfo, cache_dir: pathlib.Path
|
|
72
|
+
) -> bool:
|
|
71
73
|
if is_dir_hash(expected_hash):
|
|
72
74
|
if not path.is_dir():
|
|
73
75
|
return False
|
|
74
|
-
actual_hash, _ = cache.hash_directory(path)
|
|
76
|
+
actual_hash, _ = cache.hash_directory(path, cache_dir=cache_dir)
|
|
75
77
|
else:
|
|
76
78
|
if not path.is_file():
|
|
77
79
|
return False
|
|
@@ -119,7 +121,7 @@ def _restore_path_locked(
|
|
|
119
121
|
if path.exists():
|
|
120
122
|
match behavior:
|
|
121
123
|
case CheckoutBehavior.ERROR:
|
|
122
|
-
if _on_disk_matches_expected(path, output_hash):
|
|
124
|
+
if _on_disk_matches_expected(path, output_hash, cache_dir):
|
|
123
125
|
return ("skipped", path.name)
|
|
124
126
|
raise click.ClickException(
|
|
125
127
|
f"'{path.name}' already exists with different content. "
|
|
@@ -150,7 +152,12 @@ def _restore_path_locked(
|
|
|
150
152
|
return ("missing", path.name)
|
|
151
153
|
return ("restored" if outcome["restored"] else "skipped", path.name)
|
|
152
154
|
case CheckoutBehavior.FORCE:
|
|
153
|
-
cache.
|
|
155
|
+
missing = cache.missing_cache_objects(output_hash, cache_dir, checkout_modes)
|
|
156
|
+
if missing:
|
|
157
|
+
raise click.ClickException(
|
|
158
|
+
f"Cannot overwrite '{path.name}': cache object missing: {', '.join(missing)}. "
|
|
159
|
+
+ "The workspace output was not changed."
|
|
160
|
+
)
|
|
154
161
|
case _: # pyright: ignore[reportUnnecessaryComparison] - defensive for future enum values
|
|
155
162
|
raise ValueError(f"Unhandled checkout behavior: {behavior}") # pyright: ignore[reportUnreachable]
|
|
156
163
|
|
|
@@ -20,7 +20,9 @@ def commit_command(ctx: click.Context, stages: tuple[str, ...]) -> None:
|
|
|
20
20
|
quiet = cli_ctx["quiet"]
|
|
21
21
|
|
|
22
22
|
stage_names = cli_helpers.resolve_stage_names(list(stages) if stages else None)
|
|
23
|
-
committed, failed = commit.commit_stages(
|
|
23
|
+
committed, failed = commit.commit_stages(
|
|
24
|
+
cli_helpers.get_pipeline(), stage_names, warn_left_out_symlinks=not quiet
|
|
25
|
+
)
|
|
24
26
|
|
|
25
27
|
if not quiet:
|
|
26
28
|
if committed:
|
|
@@ -117,8 +117,12 @@ class Console:
|
|
|
117
117
|
self._echo(f" {key}")
|
|
118
118
|
|
|
119
119
|
if change_type == "modified":
|
|
120
|
-
self._echo(
|
|
121
|
-
|
|
120
|
+
self._echo(
|
|
121
|
+
f" Old: {self._color(str(old_val) if old_val is not None else 'N/A', 'red')}"
|
|
122
|
+
)
|
|
123
|
+
self._echo(
|
|
124
|
+
f" New: {self._color(str(new_val) if new_val is not None else 'N/A', 'green')}"
|
|
125
|
+
)
|
|
122
126
|
elif change_type == "added":
|
|
123
127
|
self._echo(f" {self._color('(added)', 'green')} {new_val}")
|
|
124
128
|
else:
|
|
@@ -4,10 +4,11 @@ import enum
|
|
|
4
4
|
import os
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
|
-
from typing import TYPE_CHECKING, Literal, TypedDict
|
|
7
|
+
from typing import TYPE_CHECKING, Literal, TypedDict, cast
|
|
8
8
|
from urllib.parse import urlparse, urlunparse
|
|
9
9
|
|
|
10
10
|
import click
|
|
11
|
+
import pydantic
|
|
11
12
|
|
|
12
13
|
from pivot.cli import decorators as cli_decorators
|
|
13
14
|
from pivot.cli import helpers as cli_helpers
|
|
@@ -87,6 +88,32 @@ def _sanitize_url(url: str) -> str:
|
|
|
87
88
|
return urlunparse(parsed._replace(netloc=netloc))
|
|
88
89
|
|
|
89
90
|
|
|
91
|
+
def _find_invalid_remote(
|
|
92
|
+
remotes: dict[object, object],
|
|
93
|
+
) -> tuple[object, object, str]:
|
|
94
|
+
"""Return the first remote entry that fails the configuration validator."""
|
|
95
|
+
from pivot.config import models
|
|
96
|
+
|
|
97
|
+
for name, remote in remotes.items():
|
|
98
|
+
try:
|
|
99
|
+
models.PivotConfig.model_validate({"remotes": {name: remote}})
|
|
100
|
+
except pydantic.ValidationError as error:
|
|
101
|
+
detail = error.errors()[0]
|
|
102
|
+
return name, remote, detail["msg"].removeprefix("Value error, ")
|
|
103
|
+
raise AssertionError("invalid remotes mapping contains no invalid entry")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_config_validation_error(error: pydantic.ValidationError) -> str:
|
|
107
|
+
"""Format a config validation error for the doctor command's user-facing boundary."""
|
|
108
|
+
detail = error.errors()[0]
|
|
109
|
+
location = ".".join(str(part) for part in detail["loc"])
|
|
110
|
+
value = detail["input"]
|
|
111
|
+
if location == "remotes" and isinstance(value, dict) and value:
|
|
112
|
+
name, remote, message = _find_invalid_remote(cast("dict[object, object]", value))
|
|
113
|
+
return f"Invalid config remotes.{name}: {remote!r}; {message}"
|
|
114
|
+
return f"Invalid config {location}: {value!r}; expected {detail['msg'].removeprefix('Value error, ')}"
|
|
115
|
+
|
|
116
|
+
|
|
90
117
|
def _relative_path(path: pathlib.Path, project_root: pathlib.Path) -> str:
|
|
91
118
|
"""Return path relative to project root, or absolute if outside."""
|
|
92
119
|
try:
|
|
@@ -316,7 +343,10 @@ def doctor(ctx: click.Context, as_json: bool, check_remote: bool) -> None:
|
|
|
316
343
|
checks.append(project_check)
|
|
317
344
|
|
|
318
345
|
checks.append(_check_pipeline_config(project_root))
|
|
319
|
-
|
|
346
|
+
try:
|
|
347
|
+
checks.append(_check_cache_directory(project_root))
|
|
348
|
+
except pydantic.ValidationError as e:
|
|
349
|
+
raise click.ClickException(_format_config_validation_error(e)) from e
|
|
320
350
|
checks.append(_check_git_repository(project_root))
|
|
321
351
|
|
|
322
352
|
if check_remote:
|
|
@@ -86,9 +86,14 @@ def _track_single_path(
|
|
|
86
86
|
stage_outputs_resolved: dict[str, pathlib.Path],
|
|
87
87
|
existing_tracked: dict[str, track_mod.PvtData],
|
|
88
88
|
force: bool,
|
|
89
|
+
*,
|
|
90
|
+
warn_left_out_symlinks: bool,
|
|
89
91
|
) -> TrackResult:
|
|
90
92
|
"""Track a single file or directory.
|
|
91
93
|
|
|
94
|
+
``warn_left_out_symlinks`` logs the warning naming the symlinks a tracked directory leaves
|
|
95
|
+
out; the caller turns it off under ``--quiet``, like this command's other warning.
|
|
96
|
+
|
|
92
97
|
Returns:
|
|
93
98
|
TrackResult with optional warning and the tracked path for the caller to handle output.
|
|
94
99
|
"""
|
|
@@ -149,7 +154,9 @@ def _track_single_path(
|
|
|
149
154
|
|
|
150
155
|
# Hash and cache
|
|
151
156
|
if path.is_dir():
|
|
152
|
-
tree_hash, manifest = cache.hash_directory(
|
|
157
|
+
tree_hash, manifest = cache.hash_directory(
|
|
158
|
+
path, cache_dir=cache_dir, warn_left_out_symlinks=warn_left_out_symlinks
|
|
159
|
+
)
|
|
153
160
|
total_size = sum(e["size"] for e in manifest)
|
|
154
161
|
num_files = len(manifest)
|
|
155
162
|
|
|
@@ -212,7 +219,14 @@ def track(ctx: click.Context, paths: tuple[str, ...], force: bool) -> None:
|
|
|
212
219
|
existing_tracked = track_mod.discover_pvt_files(project_root)
|
|
213
220
|
|
|
214
221
|
for path_str in paths:
|
|
215
|
-
result = _track_single_path(
|
|
222
|
+
result = _track_single_path(
|
|
223
|
+
path_str,
|
|
224
|
+
cache_dir,
|
|
225
|
+
stage_outputs,
|
|
226
|
+
existing_tracked,
|
|
227
|
+
force,
|
|
228
|
+
warn_left_out_symlinks=not quiet,
|
|
229
|
+
)
|
|
216
230
|
if not quiet:
|
|
217
231
|
if result["warning"]:
|
|
218
232
|
click.echo(result["warning"], err=True)
|
|
@@ -1787,9 +1787,11 @@ class Engine:
|
|
|
1787
1787
|
return lock_service.acquire_many(lock_requests)
|
|
1788
1788
|
|
|
1789
1789
|
lock_handle = await anyio.to_thread.run_sync(_acquire_artifact_locks)
|
|
1790
|
-
await self._set_stage_state(stage_name, StageExecutionState.PREPARING)
|
|
1791
|
-
|
|
1792
1790
|
try:
|
|
1791
|
+
lock_data = await anyio.to_thread.run_sync(_read_lock)
|
|
1792
|
+
if lock_data is None:
|
|
1793
|
+
return False
|
|
1794
|
+
await self._set_stage_state(stage_name, StageExecutionState.PREPARING)
|
|
1793
1795
|
return await self._try_skip_with_locks(
|
|
1794
1796
|
stage_name,
|
|
1795
1797
|
stage_info,
|
|
@@ -84,6 +84,8 @@ class WatchCoordinator:
|
|
|
84
84
|
|
|
85
85
|
_graph: nx.DiGraph[str]
|
|
86
86
|
_artifact_paths: dict[pathlib.Path, pivot_types.ArtifactIdentity]
|
|
87
|
+
_resolved_output_paths: dict[tuple[pathlib.Path, str], list[pivot_types.ArtifactIdentity]]
|
|
88
|
+
_resolved_input_paths: dict[pathlib.Path, list[pivot_types.ArtifactIdentity]]
|
|
87
89
|
|
|
88
90
|
def __init__(
|
|
89
91
|
self,
|
|
@@ -92,6 +94,29 @@ class WatchCoordinator:
|
|
|
92
94
|
) -> None:
|
|
93
95
|
self._graph = graph
|
|
94
96
|
self._artifact_paths = artifact_paths
|
|
97
|
+
self._rebuild_resolved_path_indexes()
|
|
98
|
+
|
|
99
|
+
def _rebuild_resolved_path_indexes(self) -> None:
|
|
100
|
+
"""Rebuild the input and output aliases used for physical watch paths."""
|
|
101
|
+
self._resolved_input_paths = dict[pathlib.Path, list[pivot_types.ArtifactIdentity]]()
|
|
102
|
+
self._resolved_output_paths = dict[
|
|
103
|
+
tuple[pathlib.Path, str], list[pivot_types.ArtifactIdentity]
|
|
104
|
+
]()
|
|
105
|
+
for artifact_path, identity in self._artifact_paths.items():
|
|
106
|
+
if engine_graph.get_producer(self._graph, identity) is None:
|
|
107
|
+
self._resolved_input_paths.setdefault(
|
|
108
|
+
artifact_path.resolve(), list[pivot_types.ArtifactIdentity]()
|
|
109
|
+
).append(identity)
|
|
110
|
+
continue
|
|
111
|
+
key = (artifact_path.parent.resolve(), artifact_path.name)
|
|
112
|
+
self._resolved_output_paths.setdefault(
|
|
113
|
+
key, list[pivot_types.ArtifactIdentity]()
|
|
114
|
+
).append(identity)
|
|
115
|
+
|
|
116
|
+
def _refresh_resolved_path_indexes_for(self, path: pathlib.Path) -> None:
|
|
117
|
+
"""Refresh aliases after a symlink or watched directory changes."""
|
|
118
|
+
if path.is_symlink() or path.is_dir():
|
|
119
|
+
self._rebuild_resolved_path_indexes()
|
|
95
120
|
|
|
96
121
|
@property
|
|
97
122
|
def graph(self) -> nx.DiGraph[str]:
|
|
@@ -104,6 +129,10 @@ class WatchCoordinator:
|
|
|
104
129
|
|
|
105
130
|
def resolve_identity(self, path: pathlib.Path) -> pivot_types.ArtifactIdentity | None:
|
|
106
131
|
"""Resolve a filesystem event path to the matching artifact identity."""
|
|
132
|
+
self._refresh_resolved_path_indexes_for(path)
|
|
133
|
+
outputs = self._matching_resolved_output_identities(path)
|
|
134
|
+
if outputs:
|
|
135
|
+
return outputs[0]
|
|
107
136
|
current = project.normalize_path(path)
|
|
108
137
|
while True:
|
|
109
138
|
identity = self._artifact_paths.get(current)
|
|
@@ -114,6 +143,38 @@ class WatchCoordinator:
|
|
|
114
143
|
return None
|
|
115
144
|
current = parent
|
|
116
145
|
|
|
146
|
+
def _matching_identities(self, path: pathlib.Path) -> list[pivot_types.ArtifactIdentity]:
|
|
147
|
+
"""Return identities whose resolved paths contain a filesystem event path."""
|
|
148
|
+
current = project.normalize_path(path)
|
|
149
|
+
identities = list[pivot_types.ArtifactIdentity]()
|
|
150
|
+
while True:
|
|
151
|
+
identity = self._artifact_paths.get(current)
|
|
152
|
+
if identity is not None:
|
|
153
|
+
identities.append(identity)
|
|
154
|
+
parent = current.parent
|
|
155
|
+
if parent == current:
|
|
156
|
+
return identities
|
|
157
|
+
current = parent
|
|
158
|
+
|
|
159
|
+
def _matching_resolved_input_identities(
|
|
160
|
+
self, path: pathlib.Path
|
|
161
|
+
) -> list[pivot_types.ArtifactIdentity]:
|
|
162
|
+
resolved_path = path.resolve()
|
|
163
|
+
identities = list[pivot_types.ArtifactIdentity]()
|
|
164
|
+
current = resolved_path
|
|
165
|
+
while True:
|
|
166
|
+
identities.extend(self._resolved_input_paths.get(current, []))
|
|
167
|
+
parent = current.parent
|
|
168
|
+
if parent == current:
|
|
169
|
+
return identities
|
|
170
|
+
current = parent
|
|
171
|
+
|
|
172
|
+
def _matching_resolved_output_identities(
|
|
173
|
+
self, path: pathlib.Path
|
|
174
|
+
) -> list[pivot_types.ArtifactIdentity]:
|
|
175
|
+
"""Return output identities by resolved parent directory and logical leaf name."""
|
|
176
|
+
return self._resolved_output_paths.get((path.parent.resolve(), path.name), [])
|
|
177
|
+
|
|
117
178
|
def should_filter_path(
|
|
118
179
|
self,
|
|
119
180
|
path: pathlib.Path,
|
|
@@ -134,13 +195,23 @@ class WatchCoordinator:
|
|
|
134
195
|
"""Get all stages affected by the changed paths, including downstream."""
|
|
135
196
|
affected = set[str]()
|
|
136
197
|
for path in paths:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
198
|
+
self._refresh_resolved_path_indexes_for(path)
|
|
199
|
+
output_identities = set(self._matching_resolved_output_identities(path))
|
|
200
|
+
if output_identities:
|
|
201
|
+
identities = output_identities
|
|
202
|
+
else:
|
|
203
|
+
logical_identities = set(self._matching_identities(path))
|
|
204
|
+
identities = logical_identities.copy()
|
|
205
|
+
if all(
|
|
206
|
+
engine_graph.get_producer(self._graph, identity) is None
|
|
207
|
+
for identity in logical_identities
|
|
208
|
+
):
|
|
209
|
+
identities.update(self._matching_resolved_input_identities(path))
|
|
210
|
+
for identity in identities:
|
|
211
|
+
consumers = engine_graph.get_consumers(self._graph, identity)
|
|
212
|
+
affected.update(consumers)
|
|
213
|
+
for stage in consumers:
|
|
214
|
+
affected.update(engine_graph.get_downstream_stages(self._graph, stage))
|
|
144
215
|
return sorted(affected)
|
|
145
216
|
|
|
146
217
|
def get_producer(self, path: pathlib.Path) -> str | None:
|
|
@@ -24,6 +24,8 @@ def commit_stages(
|
|
|
24
24
|
pipeline: registry.PipelineLike,
|
|
25
25
|
stage_names: list[str] | None = None,
|
|
26
26
|
force: bool = False,
|
|
27
|
+
*,
|
|
28
|
+
warn_left_out_symlinks: bool = True,
|
|
27
29
|
) -> tuple[list[str], list[str]]:
|
|
28
30
|
"""Commit current workspace state for stages.
|
|
29
31
|
|
|
@@ -37,6 +39,8 @@ def commit_stages(
|
|
|
37
39
|
pipeline: The pipeline whose stages are committed.
|
|
38
40
|
stage_names: Specific stages to commit. None means all stale stages.
|
|
39
41
|
force: If True, commit even if lock file is unchanged.
|
|
42
|
+
warn_left_out_symlinks: Log the warning naming symlinks a directory output leaves
|
|
43
|
+
out. A quiet command turns it off.
|
|
40
44
|
|
|
41
45
|
Returns:
|
|
42
46
|
Tuple of (committed, failed) stage name lists.
|
|
@@ -150,7 +154,12 @@ def commit_stages(
|
|
|
150
154
|
identity_key = types.identity_key(out.identity)
|
|
151
155
|
try:
|
|
152
156
|
out_path = store.resolve_display_path(out)
|
|
153
|
-
hash_info = store.commit(
|
|
157
|
+
hash_info = store.commit(
|
|
158
|
+
out,
|
|
159
|
+
out_path,
|
|
160
|
+
state_dir=default_state_dir,
|
|
161
|
+
warn_left_out_symlinks=warn_left_out_symlinks,
|
|
162
|
+
)
|
|
154
163
|
file_hash_entries.extend(worker.output_file_hash_entries(out_path, hash_info))
|
|
155
164
|
output_hashes_by_key[identity_key] = hash_info
|
|
156
165
|
output_hashes_by_identity[out.identity] = hash_info
|
|
@@ -409,7 +409,7 @@ def verify_tracked_files(project_root: pathlib.Path, checkout_missing: bool = Fa
|
|
|
409
409
|
if path.is_file():
|
|
410
410
|
current_hash, _ = cache.hash_file(path, state_db)
|
|
411
411
|
elif path.is_dir():
|
|
412
|
-
current_hash, _ = cache.hash_directory(path, state_db)
|
|
412
|
+
current_hash, _ = cache.hash_directory(path, state_db, cache_dir=cache_dir)
|
|
413
413
|
else:
|
|
414
414
|
# Path doesn't exist
|
|
415
415
|
raise FileNotFoundError(data_path)
|
|
@@ -411,7 +411,9 @@ def execute_stage(
|
|
|
411
411
|
project_root,
|
|
412
412
|
)
|
|
413
413
|
if no_commit:
|
|
414
|
-
output_hashes = _hash_output_paths(
|
|
414
|
+
output_hashes = _hash_output_paths(
|
|
415
|
+
output_paths, cache_dir=_store_cache_dir(store)
|
|
416
|
+
)
|
|
415
417
|
else:
|
|
416
418
|
output_hashes = _commit_outputs_with_store(
|
|
417
419
|
output_paths, store, project_state_dir
|
|
@@ -556,7 +558,7 @@ def restore_outputs_to_hashes(
|
|
|
556
558
|
if expected_hash is not None:
|
|
557
559
|
output_path = store.resolve_display_path(ref)
|
|
558
560
|
try:
|
|
559
|
-
actual_hash = hash_output(output_path, state_db)
|
|
561
|
+
actual_hash = hash_output(output_path, state_db, cache_dir=store.cache_dir)
|
|
560
562
|
except OSError:
|
|
561
563
|
actual_hash = None
|
|
562
564
|
if actual_hash == expected_hash:
|
|
@@ -613,10 +615,23 @@ def _check_skip_or_run(
|
|
|
613
615
|
return "unchanged", "", input_hash, decision["run_cache_allowed"]
|
|
614
616
|
|
|
615
617
|
|
|
616
|
-
def
|
|
617
|
-
"""
|
|
618
|
+
def _store_cache_dir(store: store_mod.Store) -> pathlib.Path | None:
|
|
619
|
+
"""The cache object root whose symlinks count as directory content for ``store``."""
|
|
620
|
+
return store.cache_dir if isinstance(store, store_mod.WorkspaceStore) else None
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def hash_output(
|
|
624
|
+
path: pathlib.Path,
|
|
625
|
+
state_db: state.StateDB | None = None,
|
|
626
|
+
*,
|
|
627
|
+
cache_dir: pathlib.Path | None = None,
|
|
628
|
+
) -> HashInfo:
|
|
629
|
+
"""Compute output hash without saving to cache.
|
|
630
|
+
|
|
631
|
+
``cache_dir`` is the cache object root whose symlinks count as directory content.
|
|
632
|
+
"""
|
|
618
633
|
if path.is_dir():
|
|
619
|
-
tree_hash, manifest = cache.hash_directory(path, state_db)
|
|
634
|
+
tree_hash, manifest = cache.hash_directory(path, state_db, cache_dir=cache_dir)
|
|
620
635
|
return DirHash(hash=tree_hash, manifest=manifest)
|
|
621
636
|
file_hash, _ = cache.hash_file(path, state_db)
|
|
622
637
|
return FileHash(hash=file_hash)
|
|
@@ -1040,13 +1055,17 @@ def _run_stage_function_with_store(
|
|
|
1040
1055
|
def _hash_output_paths(
|
|
1041
1056
|
outputs_by_ref: dict[types.ArtifactRef, pathlib.Path],
|
|
1042
1057
|
state_db: state.StateDB | None = None,
|
|
1058
|
+
*,
|
|
1059
|
+
cache_dir: pathlib.Path | None = None,
|
|
1043
1060
|
) -> dict[str, HashInfo]:
|
|
1044
1061
|
output_hashes = dict[str, HashInfo]()
|
|
1045
1062
|
for ref, path in outputs_by_ref.items():
|
|
1046
1063
|
if not path.exists():
|
|
1047
1064
|
raise exceptions.OutputMissingError(f"Stage did not produce output: {path}")
|
|
1048
1065
|
if path.is_dir():
|
|
1049
|
-
tree_hash, manifest = cache.hash_directory(
|
|
1066
|
+
tree_hash, manifest = cache.hash_directory(
|
|
1067
|
+
path, state_db, cache_dir=cache_dir, warn_left_out_symlinks=True
|
|
1068
|
+
)
|
|
1050
1069
|
output_hashes[types.identity_key(ref.identity)] = DirHash(
|
|
1051
1070
|
hash=tree_hash, manifest=manifest
|
|
1052
1071
|
)
|
|
@@ -190,7 +190,7 @@ def params_equal(old: object, new: object) -> bool:
|
|
|
190
190
|
(``parameters.get_effective_params``), so the text is the canonical encoding. Python
|
|
191
191
|
equality alone would call a NaN param changed on every run, because nan != nan.
|
|
192
192
|
"""
|
|
193
|
-
return
|
|
193
|
+
return json.dumps(old, sort_keys=True) == json.dumps(new, sort_keys=True)
|
|
194
194
|
|
|
195
195
|
|
|
196
196
|
def diff_dep_hashes(
|
|
@@ -348,6 +348,7 @@ def get_tracked_files_status(
|
|
|
348
348
|
results = list[TrackedFileInfo]()
|
|
349
349
|
|
|
350
350
|
# Use state_db for hash caching (mtime-based)
|
|
351
|
+
cache_dir = config.get_cache_dir() / "files"
|
|
351
352
|
with state_mod.StateDB(config.get_state_dir()) as state_db:
|
|
352
353
|
for i, (abs_path_str, track_data) in enumerate(sorted(tracked.items()), 1):
|
|
353
354
|
path = pathlib.Path(abs_path_str)
|
|
@@ -355,7 +356,7 @@ def get_tracked_files_status(
|
|
|
355
356
|
|
|
356
357
|
try:
|
|
357
358
|
if path.is_dir():
|
|
358
|
-
current_hash, _ = cache.hash_directory(path, state_db)
|
|
359
|
+
current_hash, _ = cache.hash_directory(path, state_db, cache_dir=cache_dir)
|
|
359
360
|
else:
|
|
360
361
|
current_hash, _ = cache.hash_file(path, state_db)
|
|
361
362
|
except FileNotFoundError:
|
|
@@ -45,8 +45,8 @@ def _restore_target_token(target_path: pathlib.Path) -> str:
|
|
|
45
45
|
return hashlib.sha256(str(logical_path).encode()).hexdigest()[:32]
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def
|
|
49
|
-
"""Return the deterministic staging path for a file
|
|
48
|
+
def restore_staging_path(path: pathlib.Path, token: str | None = None) -> pathlib.Path:
|
|
49
|
+
"""Return the deterministic staging path for a file restore."""
|
|
50
50
|
return path.parent / f"{_RESTORE_TEMP_PREFIX}{token or _restore_target_token(path)}.file"
|
|
51
51
|
|
|
52
52
|
|
|
@@ -92,26 +92,37 @@ def atomic_write_file(
|
|
|
92
92
|
dest: pathlib.Path,
|
|
93
93
|
write_fn: Callable[[int], None],
|
|
94
94
|
mode: int = 0o644,
|
|
95
|
+
*,
|
|
96
|
+
staging_path: pathlib.Path | None = None,
|
|
95
97
|
) -> None:
|
|
96
|
-
"""Atomically write to dest using
|
|
98
|
+
"""Atomically write to dest using a temporary file and replacement.
|
|
99
|
+
|
|
100
|
+
A caller holding a restore lock can supply its target-tagged staging path so a
|
|
101
|
+
later restore of the same target cleans up a process killed before replacement.
|
|
97
102
|
|
|
98
103
|
Args:
|
|
99
104
|
dest: Target file path.
|
|
100
105
|
write_fn: Function that receives the file descriptor and writes content.
|
|
101
106
|
MUST close fd (typically via os.fdopen which takes ownership).
|
|
102
107
|
mode: File permissions (default 0o644).
|
|
108
|
+
staging_path: Optional deterministic temporary path beside ``dest``.
|
|
103
109
|
"""
|
|
104
110
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
if staging_path is None:
|
|
112
|
+
fd, tmp_path = tempfile.mkstemp(dir=dest.parent, suffix=".tmp")
|
|
113
|
+
tmp = pathlib.Path(tmp_path)
|
|
114
|
+
else:
|
|
115
|
+
tmp = staging_path
|
|
116
|
+
_clear_path(tmp)
|
|
117
|
+
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
107
118
|
fd_closed = False
|
|
108
119
|
try:
|
|
109
120
|
write_fn(fd)
|
|
110
121
|
fd_closed = True # write_fn took ownership via os.fdopen
|
|
111
|
-
os.chmod(
|
|
122
|
+
os.chmod(tmp, mode)
|
|
112
123
|
tmp.replace(dest)
|
|
113
124
|
except BaseException:
|
|
114
|
-
tmp
|
|
125
|
+
_clear_path(tmp)
|
|
115
126
|
raise
|
|
116
127
|
finally:
|
|
117
128
|
# Only close fd if write_fn didn't (e.g., exception before os.fdopen)
|
|
@@ -159,39 +170,83 @@ def hash_file(
|
|
|
159
170
|
return file_hash, file_stat
|
|
160
171
|
|
|
161
172
|
|
|
162
|
-
def
|
|
173
|
+
def _cache_object_key(path: pathlib.Path, cache_dir: pathlib.Path | None) -> str | None:
|
|
174
|
+
"""Return the cache key a symlink resolves to, if its target is an object in ``cache_dir``.
|
|
175
|
+
|
|
176
|
+
Cache objects live at ``<cache_dir>/XX/YYYY...`` named by their content hash; a file-by-file
|
|
177
|
+
symlink checkout restores each directory member as a link to one. A link anywhere else,
|
|
178
|
+
even to a hash-named file, is a user symlink.
|
|
179
|
+
"""
|
|
180
|
+
if cache_dir is None:
|
|
181
|
+
return None
|
|
182
|
+
key = _get_symlink_cache_hash(path, cache_dir)
|
|
183
|
+
if key is None or not is_valid_hash(key) or not path.is_file():
|
|
184
|
+
return None
|
|
185
|
+
return key
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _scandir_recursive(
|
|
189
|
+
path: pathlib.Path, cache_dir: pathlib.Path | None, left_out: list[pathlib.Path]
|
|
190
|
+
) -> Generator[os.DirEntry[str]]:
|
|
163
191
|
"""Yield every file under `path` recursively, using os.scandir() for efficiency.
|
|
164
192
|
|
|
165
193
|
A declared directory artifact is hashed and cached whole: no name is ignored, so a change
|
|
166
194
|
to any file in a directory input reruns its consumers, and every file in a directory output
|
|
167
195
|
survives cache and restore. Ignore rules belong to code fingerprinting and watch mode.
|
|
196
|
+
A symlink to an object in ``cache_dir`` (left by a symlink checkout) counts as the file it
|
|
197
|
+
points to; any other symlink is not stored and is appended to ``left_out``.
|
|
168
198
|
DirEntry objects cache stat results, avoiding redundant syscalls.
|
|
169
|
-
Symlinks are skipped to prevent loops.
|
|
170
199
|
PermissionError propagates to caller - partial hashes would be incorrect.
|
|
171
200
|
|
|
172
201
|
Args:
|
|
173
202
|
path: Directory to scan
|
|
203
|
+
cache_dir: Cache object root whose links count as content, or None for no cache
|
|
204
|
+
left_out: Collects the symlinks that are not part of the artifact
|
|
174
205
|
"""
|
|
175
206
|
with os.scandir(path) as entries:
|
|
176
207
|
for entry in entries:
|
|
177
208
|
if entry.is_symlink():
|
|
178
|
-
|
|
179
|
-
|
|
209
|
+
if _cache_object_key(pathlib.Path(entry.path), cache_dir) is not None:
|
|
210
|
+
yield entry
|
|
211
|
+
else:
|
|
212
|
+
left_out.append(pathlib.Path(entry.path))
|
|
213
|
+
elif entry.is_file():
|
|
180
214
|
yield entry
|
|
181
215
|
elif entry.is_dir():
|
|
182
|
-
yield from _scandir_recursive(pathlib.Path(entry.path))
|
|
216
|
+
yield from _scandir_recursive(pathlib.Path(entry.path), cache_dir, left_out)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _warn_left_out_symlinks(path: pathlib.Path, left_out: list[pathlib.Path]) -> None:
|
|
220
|
+
"""Log one warning naming every symlink a directory artifact does not store."""
|
|
221
|
+
absolute = path.absolute()
|
|
222
|
+
cwd = pathlib.Path.cwd()
|
|
223
|
+
shown = absolute.relative_to(cwd) if absolute.is_relative_to(cwd) else path
|
|
224
|
+
names = ", ".join(sorted(str(link.relative_to(path)) for link in left_out))
|
|
225
|
+
one = len(left_out) == 1
|
|
226
|
+
logger.warning(
|
|
227
|
+
"Directory artifact %s leaves out %s %s (a symlink inside a directory artifact is not "
|
|
228
|
+
+ "stored); replace %s with a real file or directory",
|
|
229
|
+
shown,
|
|
230
|
+
"symlink" if one else "symlinks",
|
|
231
|
+
names,
|
|
232
|
+
"it" if one else "each",
|
|
233
|
+
)
|
|
183
234
|
|
|
184
235
|
|
|
185
236
|
def hash_directory(
|
|
186
237
|
path: pathlib.Path,
|
|
187
238
|
state_db: state_mod.StateDB | None = None,
|
|
239
|
+
*,
|
|
240
|
+
cache_dir: pathlib.Path | None = None,
|
|
241
|
+
warn_left_out_symlinks: bool = False,
|
|
188
242
|
) -> tuple[str, list[DirManifestEntry]]:
|
|
189
243
|
"""Compute tree hash of directory, returning hash and manifest.
|
|
190
244
|
|
|
191
245
|
Symlink handling:
|
|
192
|
-
-
|
|
246
|
+
- A symlink to an object in ``cache_dir`` is hashed by its target's content under its own
|
|
247
|
+
relpath; the content must match the object's name
|
|
248
|
+
- Any other symlink inside the directory is left out of the manifest
|
|
193
249
|
- Base path may be symlinked (resolved for consistency)
|
|
194
|
-
- Content-based hashing only (symlink metadata excluded from fingerprints)
|
|
195
250
|
|
|
196
251
|
Note: For portability, paths are stored as normalized (symlinks preserved)
|
|
197
252
|
in lock files, but resolved here for consistent hashing.
|
|
@@ -199,20 +254,30 @@ def hash_directory(
|
|
|
199
254
|
Args:
|
|
200
255
|
path: Directory to hash
|
|
201
256
|
state_db: Optional StateDB for caching file hashes
|
|
257
|
+
cache_dir: The configured cache's object root (``<cache.dir>/files``). Pass it
|
|
258
|
+
wherever one exists, so recording and re-hashing agree on symlink checkouts;
|
|
259
|
+
None means no cache, and then no symlink is content.
|
|
260
|
+
warn_left_out_symlinks: Log one warning naming the symlinks left out. Set where a
|
|
261
|
+
manifest is recorded (a stage output, a tracked directory), not on every re-hash.
|
|
202
262
|
"""
|
|
203
263
|
_t = metrics.start()
|
|
204
264
|
manifest = list[DirManifestEntry]()
|
|
265
|
+
left_out = list[pathlib.Path]()
|
|
205
266
|
resolved_base = path.resolve()
|
|
206
267
|
|
|
207
|
-
for entry in sorted(_scandir_recursive(path), key=lambda e: e.path):
|
|
268
|
+
for entry in sorted(_scandir_recursive(path, cache_dir, left_out), key=lambda e: e.path):
|
|
208
269
|
file_path = pathlib.Path(entry.path)
|
|
209
|
-
# Verify file is still within the directory (paranoid check)
|
|
210
|
-
|
|
270
|
+
# Verify a regular file is still within the directory (paranoid check). A cache-object
|
|
271
|
+
# link resolves into the cache by design.
|
|
272
|
+
if not entry.is_symlink() and not file_path.resolve().is_relative_to(resolved_base):
|
|
211
273
|
continue
|
|
212
274
|
try:
|
|
213
275
|
rel = file_path.relative_to(path)
|
|
214
276
|
file_stat = entry.stat(follow_symlinks=True)
|
|
215
277
|
file_hash, _ = hash_file(file_path, state_db)
|
|
278
|
+
if entry.is_symlink() and file_hash != _cache_object_key(file_path, cache_dir):
|
|
279
|
+
left_out.append(file_path)
|
|
280
|
+
continue
|
|
216
281
|
manifest_entry: DirManifestEntry = {
|
|
217
282
|
"relpath": str(rel),
|
|
218
283
|
"hash": file_hash,
|
|
@@ -223,6 +288,9 @@ def hash_directory(
|
|
|
223
288
|
except FileNotFoundError:
|
|
224
289
|
continue # File deleted between scan and hash
|
|
225
290
|
|
|
291
|
+
if left_out and warn_left_out_symlinks:
|
|
292
|
+
_warn_left_out_symlinks(path, left_out)
|
|
293
|
+
|
|
226
294
|
manifest_json = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
|
227
295
|
tree_hash = xxhash.xxh64(manifest_json.encode()).hexdigest()
|
|
228
296
|
|
|
@@ -513,6 +581,20 @@ def _resolve_checkout_modes(
|
|
|
513
581
|
return DEFAULT_CHECKOUT_MODE_ORDER.copy()
|
|
514
582
|
|
|
515
583
|
|
|
584
|
+
def missing_cache_objects(
|
|
585
|
+
output_hash: HashInfo, cache_dir: pathlib.Path, checkout_modes: list[CheckoutMode]
|
|
586
|
+
) -> list[str]:
|
|
587
|
+
"""Return hashes required to restore an output that are absent from the cache."""
|
|
588
|
+
if is_dir_hash(output_hash):
|
|
589
|
+
tree_path = get_cache_path(cache_dir, output_hash["hash"])
|
|
590
|
+
if checkout_modes[0] == CheckoutMode.SYMLINK and tree_path.exists():
|
|
591
|
+
return []
|
|
592
|
+
hashes = [entry["hash"] for entry in output_hash["manifest"]]
|
|
593
|
+
else:
|
|
594
|
+
hashes = [output_hash["hash"]]
|
|
595
|
+
return [file_hash for file_hash in hashes if not get_cache_path(cache_dir, file_hash).exists()]
|
|
596
|
+
|
|
597
|
+
|
|
516
598
|
def save_to_cache(
|
|
517
599
|
path: pathlib.Path,
|
|
518
600
|
cache_dir: pathlib.Path,
|
|
@@ -578,7 +660,7 @@ def _store_file(
|
|
|
578
660
|
cache_path = get_cache_path(cache_dir, file_hash)
|
|
579
661
|
copy_to_cache(path, cache_path)
|
|
580
662
|
with restore_lock(path, state_dir) as lock_token:
|
|
581
|
-
staging_path =
|
|
663
|
+
staging_path = restore_staging_path(path, lock_token)
|
|
582
664
|
_clear_path(staging_path)
|
|
583
665
|
_checkout_with_fallback(
|
|
584
666
|
path,
|
|
@@ -604,10 +686,10 @@ def _save_directory_to_cache(
|
|
|
604
686
|
cache_dir_path = get_cache_path(cache_dir, existing_hash)
|
|
605
687
|
if cache_dir_path.exists():
|
|
606
688
|
# Already correctly linked - compute manifest from actual content
|
|
607
|
-
_, manifest = hash_directory(path, state_db)
|
|
689
|
+
_, manifest = hash_directory(path, state_db, cache_dir=cache_dir)
|
|
608
690
|
return DirHash(hash=existing_hash, manifest=manifest)
|
|
609
691
|
|
|
610
|
-
tree_hash, manifest = hash_directory(path, state_db)
|
|
692
|
+
tree_hash, manifest = hash_directory(path, state_db, cache_dir=cache_dir)
|
|
611
693
|
output_hash = DirHash(hash=tree_hash, manifest=manifest)
|
|
612
694
|
_store_directory(
|
|
613
695
|
path,
|
|
@@ -656,7 +738,7 @@ def _store_directory(
|
|
|
656
738
|
for entry in manifest:
|
|
657
739
|
file_path = path / entry["relpath"]
|
|
658
740
|
cache_path = get_cache_path(cache_dir, entry["hash"])
|
|
659
|
-
staging_path =
|
|
741
|
+
staging_path = restore_staging_path(file_path)
|
|
660
742
|
_clear_path(staging_path)
|
|
661
743
|
_checkout_with_fallback(
|
|
662
744
|
file_path,
|
|
@@ -777,7 +859,7 @@ def _restore_file_from_cache(
|
|
|
777
859
|
lock_token=held_token,
|
|
778
860
|
)
|
|
779
861
|
|
|
780
|
-
staging_path =
|
|
862
|
+
staging_path = restore_staging_path(path, lock_token)
|
|
781
863
|
_clear_path(staging_path)
|
|
782
864
|
_checkout_with_fallback(path, cache_path, checkout_modes, staging_path=staging_path)
|
|
783
865
|
return True
|
|
@@ -237,12 +237,18 @@ def _verify_content_hash(content: bytes, expected_hash: str) -> bool:
|
|
|
237
237
|
return actual_hash == expected_hash
|
|
238
238
|
|
|
239
239
|
|
|
240
|
-
def _write_fallback_content(dest_path: pathlib.Path, content: bytes
|
|
241
|
-
"""
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
240
|
+
def _write_fallback_content(dest_path: pathlib.Path, content: bytes) -> None:
|
|
241
|
+
"""Atomically replace a cache-miss target under the cache restore lock."""
|
|
242
|
+
|
|
243
|
+
def write_content(fd: int) -> None:
|
|
244
|
+
with os.fdopen(fd, "wb") as file:
|
|
245
|
+
file.write(content)
|
|
246
|
+
|
|
247
|
+
with cache.restore_lock(dest_path, config.get_state_dir()) as lock_token:
|
|
248
|
+
staging_path = (
|
|
249
|
+
cache.restore_staging_path(dest_path, lock_token) if lock_token is not None else None
|
|
250
|
+
)
|
|
251
|
+
cache.atomic_write_file(dest_path, write_content, staging_path=staging_path)
|
|
246
252
|
|
|
247
253
|
|
|
248
254
|
def restore_file(
|
|
@@ -299,7 +305,7 @@ def restore_file(
|
|
|
299
305
|
content = git.read_file_from_revision(rel_path, rev)
|
|
300
306
|
if content is not None:
|
|
301
307
|
try:
|
|
302
|
-
_write_fallback_content(dest_path, content
|
|
308
|
+
_write_fallback_content(dest_path, content)
|
|
303
309
|
return RestoreResult(
|
|
304
310
|
status=RestoreStatus.RESTORED,
|
|
305
311
|
path=path_str,
|
|
@@ -328,7 +334,7 @@ def restore_file(
|
|
|
328
334
|
),
|
|
329
335
|
)
|
|
330
336
|
try:
|
|
331
|
-
_write_fallback_content(dest_path, remote_content
|
|
337
|
+
_write_fallback_content(dest_path, remote_content)
|
|
332
338
|
except OSError as e:
|
|
333
339
|
return RestoreResult(
|
|
334
340
|
status=RestoreStatus.ERROR,
|
|
@@ -165,6 +165,11 @@ class WorkspaceStore:
|
|
|
165
165
|
self._cache_dir = cache_dir.resolve() if cache_dir is not None else None
|
|
166
166
|
self._checkout_modes = checkout_modes
|
|
167
167
|
|
|
168
|
+
@property
|
|
169
|
+
def cache_dir(self) -> pathlib.Path | None:
|
|
170
|
+
"""The cache object root (``<cache.dir>/files``), or None when this store has no cache."""
|
|
171
|
+
return self._cache_dir
|
|
172
|
+
|
|
168
173
|
def _resolve_input_path(self, ref: types.ArtifactRef) -> pathlib.Path:
|
|
169
174
|
name = ref.identity.producer
|
|
170
175
|
if name in self._input_bindings:
|
|
@@ -216,11 +221,21 @@ class WorkspaceStore:
|
|
|
216
221
|
path: pathlib.Path,
|
|
217
222
|
*,
|
|
218
223
|
state_dir: pathlib.Path | None = None,
|
|
224
|
+
warn_left_out_symlinks: bool = True,
|
|
219
225
|
) -> types.HashInfo:
|
|
220
|
-
"""Hash a written output once and, unless it is a metric, place it in the cache.
|
|
226
|
+
"""Hash a written output once and, unless it is a metric, place it in the cache.
|
|
227
|
+
|
|
228
|
+
``warn_left_out_symlinks`` logs the warning naming the symlinks a directory output
|
|
229
|
+
leaves out; a quiet command turns it off.
|
|
230
|
+
"""
|
|
221
231
|
output_hash: types.HashInfo
|
|
222
232
|
if path.is_dir():
|
|
223
|
-
tree_hash, manifest = cache.hash_directory(
|
|
233
|
+
tree_hash, manifest = cache.hash_directory(
|
|
234
|
+
path,
|
|
235
|
+
None,
|
|
236
|
+
cache_dir=self._cache_dir,
|
|
237
|
+
warn_left_out_symlinks=warn_left_out_symlinks,
|
|
238
|
+
)
|
|
224
239
|
output_hash = types.DirHash(hash=tree_hash, manifest=manifest)
|
|
225
240
|
else:
|
|
226
241
|
file_hash, _ = cache.hash_file(path, None)
|
|
@@ -261,7 +276,7 @@ class WorkspaceStore:
|
|
|
261
276
|
) -> types.HashInfo:
|
|
262
277
|
path = self._resolve_path(ref)
|
|
263
278
|
if ref.tag == types.ArtifactTag.DIRECTORY or path.is_dir():
|
|
264
|
-
tree_hash, manifest = cache.hash_directory(path, state_db)
|
|
279
|
+
tree_hash, manifest = cache.hash_directory(path, state_db, cache_dir=self._cache_dir)
|
|
265
280
|
return types.DirHash(hash=tree_hash, manifest=manifest)
|
|
266
281
|
file_hash, _ = cache.hash_file(path, state_db)
|
|
267
282
|
return types.FileHash(hash=file_hash)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|