pivot 0.3.3__tar.gz → 0.3.4__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.3 → pivot-0.3.4}/PKG-INFO +1 -1
- {pivot-0.3.3 → pivot-0.3.4}/pyproject.toml +1 -1
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/checkout.py +9 -5
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/track.py +5 -2
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/core.py +1 -1
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/worker.py +3 -9
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/explain.py +4 -8
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/sync.py +2 -4
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/run_history.py +7 -1
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/status.py +1 -1
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/cache.py +205 -41
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/lock.py +38 -15
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/restore.py +4 -17
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/store.py +5 -9
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/track.py +23 -4
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/types.py +13 -3
- {pivot-0.3.3 → pivot-0.3.4}/LICENSE +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/README.md +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/ast_utils.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/AGENTS.md +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/CLAUDE.md +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/_run_common.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/check_ignore.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/commit.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/completion.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/config.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/console.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/dag.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/data.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/decorators.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/doctor.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/errors.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/fingerprint.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/helpers.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/history.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/import_cmd.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/init.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/list.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/metrics.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/params.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/plots.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/remote.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/repro.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/run.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/status.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/targets.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/update.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/verify.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/compose.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/config/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/config/io.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/config/models.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/dag/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/dag/render.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/decorators.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/discovery.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/agent_rpc.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/engine.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/graph.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/scheduler.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/sinks.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/sources.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/types.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/watch.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/worker_pool.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/exceptions.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/AGENTS.md +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/CLAUDE.md +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/commit.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/fingerprint.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/git.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/ignore.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/import_artifact.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/loaders.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/metrics.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/names.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/outputs.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/parameters.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/path_policy.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/project.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/py.typed +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/registry.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/AGENTS.md +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/config.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/git_archive.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/github.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/storage.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/common.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/data.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/metrics.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/params.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/plots.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/skip.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/stage_def.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/AGENTS.md +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/CLAUDE.md +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/__init__.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/artifact_lock.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/presentation.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/state.py +0 -0
- {pivot-0.3.3 → pivot-0.3.4}/src/pivot/yaml_config.py +0 -0
|
@@ -74,11 +74,15 @@ def _on_disk_matches_expected(
|
|
|
74
74
|
if is_dir_hash(expected_hash):
|
|
75
75
|
if not path.is_dir():
|
|
76
76
|
return False
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
actual = cache.hash_directory(path, cache_dir=cache_dir)
|
|
78
|
+
# A hash pivot 0.3.3 recorded lists no empty directories, and repro still honors it.
|
|
79
|
+
# When only empty directories differ, checkout would change no file, so it matches.
|
|
80
|
+
return actual["hash"] == expected_hash["hash"] or (
|
|
81
|
+
"empty_dirs" not in expected_hash and actual["manifest"] == expected_hash["manifest"]
|
|
82
|
+
)
|
|
83
|
+
if not path.is_file():
|
|
84
|
+
return False
|
|
85
|
+
actual_hash, _ = cache.hash_file(path)
|
|
82
86
|
return actual_hash == expected_hash["hash"]
|
|
83
87
|
|
|
84
88
|
|
|
@@ -154,9 +154,10 @@ def _track_single_path(
|
|
|
154
154
|
|
|
155
155
|
# Hash and cache
|
|
156
156
|
if path.is_dir():
|
|
157
|
-
|
|
157
|
+
dir_hash = cache.hash_directory(
|
|
158
158
|
path, cache_dir=cache_dir, warn_left_out_symlinks=warn_left_out_symlinks
|
|
159
159
|
)
|
|
160
|
+
manifest = dir_hash["manifest"]
|
|
160
161
|
total_size = sum(e["size"] for e in manifest)
|
|
161
162
|
num_files = len(manifest)
|
|
162
163
|
|
|
@@ -171,11 +172,13 @@ def _track_single_path(
|
|
|
171
172
|
|
|
172
173
|
pvt_data: track_mod.PvtData = {
|
|
173
174
|
"path": path.name,
|
|
174
|
-
"hash":
|
|
175
|
+
"hash": dir_hash["hash"],
|
|
175
176
|
"size": total_size,
|
|
176
177
|
"num_files": num_files,
|
|
177
178
|
"manifest": manifest,
|
|
178
179
|
}
|
|
180
|
+
if "empty_dirs" in dir_hash:
|
|
181
|
+
pvt_data["empty_dirs"] = dir_hash["empty_dirs"]
|
|
179
182
|
else:
|
|
180
183
|
file_hash, _ = cache.hash_file(path)
|
|
181
184
|
file_size = path.stat().st_size
|
|
@@ -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
|
|
412
|
+
current_hash = cache.hash_directory(path, state_db, cache_dir=cache_dir)["hash"]
|
|
413
413
|
else:
|
|
414
414
|
# Path doesn't exist
|
|
415
415
|
raise FileNotFoundError(data_path)
|
|
@@ -39,7 +39,6 @@ from pivot.storage import artifact_lock, cache, lock, state
|
|
|
39
39
|
from pivot.storage import store as store_mod
|
|
40
40
|
from pivot.types import (
|
|
41
41
|
DeferredWrites,
|
|
42
|
-
DirHash,
|
|
43
42
|
FileHash,
|
|
44
43
|
HashInfo,
|
|
45
44
|
LockData,
|
|
@@ -631,8 +630,7 @@ def hash_output(
|
|
|
631
630
|
``cache_dir`` is the cache object root whose symlinks count as directory content.
|
|
632
631
|
"""
|
|
633
632
|
if path.is_dir():
|
|
634
|
-
|
|
635
|
-
return DirHash(hash=tree_hash, manifest=manifest)
|
|
633
|
+
return cache.hash_directory(path, state_db, cache_dir=cache_dir)
|
|
636
634
|
file_hash, _ = cache.hash_file(path, state_db)
|
|
637
635
|
return FileHash(hash=file_hash)
|
|
638
636
|
|
|
@@ -1063,12 +1061,9 @@ def _hash_output_paths(
|
|
|
1063
1061
|
if not path.exists():
|
|
1064
1062
|
raise exceptions.OutputMissingError(f"Stage did not produce output: {path}")
|
|
1065
1063
|
if path.is_dir():
|
|
1066
|
-
|
|
1064
|
+
output_hashes[types.identity_key(ref.identity)] = cache.hash_directory(
|
|
1067
1065
|
path, state_db, cache_dir=cache_dir, warn_left_out_symlinks=True
|
|
1068
1066
|
)
|
|
1069
|
-
output_hashes[types.identity_key(ref.identity)] = DirHash(
|
|
1070
|
-
hash=tree_hash, manifest=manifest
|
|
1071
|
-
)
|
|
1072
1067
|
else:
|
|
1073
1068
|
file_hash, _ = cache.hash_file(path, state_db)
|
|
1074
1069
|
output_hashes[types.identity_key(ref.identity)] = FileHash(hash=file_hash)
|
|
@@ -1290,8 +1285,7 @@ def hash_dependencies(
|
|
|
1290
1285
|
path = pathlib.Path(normalized_path)
|
|
1291
1286
|
try:
|
|
1292
1287
|
if path.is_dir():
|
|
1293
|
-
|
|
1294
|
-
hashes[dep] = DirHash(hash=tree_hash, manifest=manifest)
|
|
1288
|
+
hashes[dep] = cache.hash_directory(path, state_db)
|
|
1295
1289
|
else:
|
|
1296
1290
|
file_hash, fs_stat = cache.hash_file(path, state_db)
|
|
1297
1291
|
hashes[dep] = FileHash(hash=file_hash)
|
|
@@ -14,7 +14,7 @@ import pydantic
|
|
|
14
14
|
|
|
15
15
|
from pivot import parameters, project, registry, skip, types
|
|
16
16
|
from pivot.executor import worker
|
|
17
|
-
from pivot.storage import lock, state
|
|
17
|
+
from pivot.storage import lock, state, track
|
|
18
18
|
from pivot.storage import store as store_mod
|
|
19
19
|
from pivot.types import (
|
|
20
20
|
ArtifactIdentity,
|
|
@@ -30,8 +30,6 @@ if TYPE_CHECKING:
|
|
|
30
30
|
|
|
31
31
|
import pygtrie
|
|
32
32
|
|
|
33
|
-
from pivot.storage.track import PvtData
|
|
34
|
-
|
|
35
33
|
OUTPUTS_MISSING_REASON = "Outputs missing from cache"
|
|
36
34
|
"""Reason for a stage whose lock is current but whose cacheable outputs are absent locally.
|
|
37
35
|
|
|
@@ -105,7 +103,7 @@ def _find_tracked_ancestor(dep: Path, tracked_trie: pygtrie.Trie[str]) -> Path |
|
|
|
105
103
|
|
|
106
104
|
def _find_tracked_hash(
|
|
107
105
|
dep: Path,
|
|
108
|
-
tracked_files: dict[str, PvtData],
|
|
106
|
+
tracked_files: dict[str, track.PvtData],
|
|
109
107
|
tracked_trie: pygtrie.Trie[str],
|
|
110
108
|
) -> HashInfo | None:
|
|
111
109
|
"""Find hash for dep from tracked files data.
|
|
@@ -121,9 +119,7 @@ def _find_tracked_hash(
|
|
|
121
119
|
|
|
122
120
|
# Exact match - use top-level hash
|
|
123
121
|
if dep == tracked_path:
|
|
124
|
-
|
|
125
|
-
return {"hash": pvt_data["hash"], "manifest": pvt_data["manifest"]}
|
|
126
|
-
return {"hash": pvt_data["hash"]}
|
|
122
|
+
return track.pvt_to_hash_info(pvt_data)
|
|
127
123
|
|
|
128
124
|
# Nested path - find in manifest
|
|
129
125
|
if "manifest" not in pvt_data:
|
|
@@ -155,7 +151,7 @@ def get_stage_explanation(
|
|
|
155
151
|
lock_data: LockData | None,
|
|
156
152
|
force: bool = False,
|
|
157
153
|
allow_missing: bool = False,
|
|
158
|
-
tracked_files: dict[str, PvtData] | None = None,
|
|
154
|
+
tracked_files: dict[str, track.PvtData] | None = None,
|
|
159
155
|
tracked_trie: pygtrie.Trie[str] | None = None,
|
|
160
156
|
producer_hashes: dict[ArtifactIdentity, HashInfo] | None = None,
|
|
161
157
|
) -> StageExplanation:
|
|
@@ -11,7 +11,7 @@ from pivot.remote import config as remote_config
|
|
|
11
11
|
from pivot.remote import storage as remote_mod
|
|
12
12
|
from pivot.storage import cache, lock, track
|
|
13
13
|
from pivot.storage import store as store_mod
|
|
14
|
-
from pivot.types import
|
|
14
|
+
from pivot.types import HashInfo, RemoteStatus, TransferSummary, is_dir_hash
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
17
|
from collections.abc import Callable
|
|
@@ -175,9 +175,7 @@ def _get_file_hash_from_pvt(rel_path: str, proj_root: pathlib.Path) -> HashInfo
|
|
|
175
175
|
if track_data is None:
|
|
176
176
|
return None
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
return DirHash(hash=track_data["hash"], manifest=track_data["manifest"])
|
|
180
|
-
return FileHash(hash=track_data["hash"])
|
|
178
|
+
return track.pvt_to_hash_info(track_data)
|
|
181
179
|
|
|
182
180
|
|
|
183
181
|
def _get_tracked_hashes(proj_root: pathlib.Path) -> set[str]:
|
|
@@ -64,6 +64,7 @@ class OutputHashEntry(TypedDict):
|
|
|
64
64
|
path: str
|
|
65
65
|
hash: str
|
|
66
66
|
manifest: NotRequired[list[DirManifestEntry]]
|
|
67
|
+
empty_dirs: NotRequired[list[str]]
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
def output_hash_to_entry(path: str, oh: HashInfo) -> OutputHashEntry:
|
|
@@ -71,6 +72,8 @@ def output_hash_to_entry(path: str, oh: HashInfo) -> OutputHashEntry:
|
|
|
71
72
|
entry = OutputHashEntry(path=path, hash=oh["hash"])
|
|
72
73
|
if is_dir_hash(oh):
|
|
73
74
|
entry["manifest"] = oh["manifest"]
|
|
75
|
+
if "empty_dirs" in oh:
|
|
76
|
+
entry["empty_dirs"] = oh["empty_dirs"]
|
|
74
77
|
return entry
|
|
75
78
|
|
|
76
79
|
|
|
@@ -78,7 +81,10 @@ def entry_to_output_hash(entry: OutputHashEntry) -> FileHash | DirHash:
|
|
|
78
81
|
"""Convert serialized OutputHashEntry back to internal HashInfo."""
|
|
79
82
|
# Null check guards against corrupted JSON where manifest could be null
|
|
80
83
|
if "manifest" in entry and entry["manifest"] is not None: # pyright: ignore[reportUnnecessaryComparison]
|
|
81
|
-
|
|
84
|
+
dir_hash = DirHash(hash=entry["hash"], manifest=entry["manifest"])
|
|
85
|
+
if "empty_dirs" in entry and entry["empty_dirs"]:
|
|
86
|
+
dir_hash["empty_dirs"] = entry["empty_dirs"]
|
|
87
|
+
return dir_hash
|
|
82
88
|
return FileHash(hash=entry["hash"])
|
|
83
89
|
|
|
84
90
|
|
|
@@ -356,7 +356,7 @@ def get_tracked_files_status(
|
|
|
356
356
|
|
|
357
357
|
try:
|
|
358
358
|
if path.is_dir():
|
|
359
|
-
current_hash
|
|
359
|
+
current_hash = cache.hash_directory(path, state_db, cache_dir=cache_dir)["hash"]
|
|
360
360
|
else:
|
|
361
361
|
current_hash, _ = cache.hash_file(path, state_db)
|
|
362
362
|
except FileNotFoundError:
|
|
@@ -9,6 +9,7 @@ import logging
|
|
|
9
9
|
import mmap
|
|
10
10
|
import os
|
|
11
11
|
import pathlib
|
|
12
|
+
import posixpath
|
|
12
13
|
import secrets
|
|
13
14
|
import shutil
|
|
14
15
|
import stat
|
|
@@ -25,7 +26,7 @@ from pivot.types import DirHash, DirManifestEntry, FileHash, HashInfo, is_dir_ha
|
|
|
25
26
|
logger = logging.getLogger(__name__)
|
|
26
27
|
|
|
27
28
|
if TYPE_CHECKING:
|
|
28
|
-
from collections.abc import Callable, Generator
|
|
29
|
+
from collections.abc import Callable, Generator, Iterable
|
|
29
30
|
|
|
30
31
|
from pivot.storage import state as state_mod
|
|
31
32
|
|
|
@@ -186,7 +187,10 @@ def _cache_object_key(path: pathlib.Path, cache_dir: pathlib.Path | None) -> str
|
|
|
186
187
|
|
|
187
188
|
|
|
188
189
|
def _scandir_recursive(
|
|
189
|
-
path: pathlib.Path,
|
|
190
|
+
path: pathlib.Path,
|
|
191
|
+
cache_dir: pathlib.Path | None,
|
|
192
|
+
left_out: list[pathlib.Path],
|
|
193
|
+
dirs: list[pathlib.Path],
|
|
190
194
|
) -> Generator[os.DirEntry[str]]:
|
|
191
195
|
"""Yield every file under `path` recursively, using os.scandir() for efficiency.
|
|
192
196
|
|
|
@@ -202,6 +206,7 @@ def _scandir_recursive(
|
|
|
202
206
|
path: Directory to scan
|
|
203
207
|
cache_dir: Cache object root whose links count as content, or None for no cache
|
|
204
208
|
left_out: Collects the symlinks that are not part of the artifact
|
|
209
|
+
dirs: Collects every directory under ``path`` (symlinked directories are not followed)
|
|
205
210
|
"""
|
|
206
211
|
with os.scandir(path) as entries:
|
|
207
212
|
for entry in entries:
|
|
@@ -213,7 +218,38 @@ def _scandir_recursive(
|
|
|
213
218
|
elif entry.is_file():
|
|
214
219
|
yield entry
|
|
215
220
|
elif entry.is_dir():
|
|
216
|
-
|
|
221
|
+
dirs.append(pathlib.Path(entry.path))
|
|
222
|
+
yield from _scandir_recursive(pathlib.Path(entry.path), cache_dir, left_out, dirs)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parent_dirs(relpaths: Iterable[str]) -> set[str]:
|
|
226
|
+
"""Every directory above each relpath, short of the root, as relpaths."""
|
|
227
|
+
return {
|
|
228
|
+
str(parent) for relpath in relpaths for parent in pathlib.PurePath(relpath).parents[:-1]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _recorded_empty_dirs(output_hash: DirHash) -> list[str]:
|
|
233
|
+
return output_hash["empty_dirs"] if "empty_dirs" in output_hash else []
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _restored_dirs(output_hash: DirHash) -> set[str]:
|
|
237
|
+
"""Every directory a restore of ``output_hash`` creates under its root, as relpaths."""
|
|
238
|
+
empty_dirs = _recorded_empty_dirs(output_hash)
|
|
239
|
+
file_parents = _parent_dirs(entry["relpath"] for entry in output_hash["manifest"])
|
|
240
|
+
return file_parents | _parent_dirs(empty_dirs) | set(empty_dirs)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _empty_dir_path(root: pathlib.Path, relpath: str) -> pathlib.Path:
|
|
244
|
+
"""Return where the recorded empty directory ``relpath`` belongs under ``root``.
|
|
245
|
+
|
|
246
|
+
``relpath`` may come straight from a lock file, so one leading outside ``root`` is refused,
|
|
247
|
+
as the file-by-file restore refuses a manifest entry's.
|
|
248
|
+
"""
|
|
249
|
+
target = root / relpath
|
|
250
|
+
if not target.resolve().is_relative_to(root.resolve()):
|
|
251
|
+
raise exceptions.SecurityValidationError(f"Manifest contains path traversal: {relpath!r}")
|
|
252
|
+
return target
|
|
217
253
|
|
|
218
254
|
|
|
219
255
|
def _warn_left_out_symlinks(path: pathlib.Path, left_out: list[pathlib.Path]) -> None:
|
|
@@ -239,8 +275,12 @@ def hash_directory(
|
|
|
239
275
|
*,
|
|
240
276
|
cache_dir: pathlib.Path | None = None,
|
|
241
277
|
warn_left_out_symlinks: bool = False,
|
|
242
|
-
) ->
|
|
243
|
-
"""Compute tree hash of directory, returning
|
|
278
|
+
) -> DirHash:
|
|
279
|
+
"""Compute the tree hash of a directory, returning it with the manifest.
|
|
280
|
+
|
|
281
|
+
The hash covers the manifest and the directory's empty directories (see ``DirHash``). With
|
|
282
|
+
no empty directories it hashes the manifest alone, so recording them changed no hash of a
|
|
283
|
+
directory that holds none.
|
|
244
284
|
|
|
245
285
|
Symlink handling:
|
|
246
286
|
- A symlink to an object in ``cache_dir`` is hashed by its target's content under its own
|
|
@@ -263,9 +303,10 @@ def hash_directory(
|
|
|
263
303
|
_t = metrics.start()
|
|
264
304
|
manifest = list[DirManifestEntry]()
|
|
265
305
|
left_out = list[pathlib.Path]()
|
|
306
|
+
dirs = list[pathlib.Path]()
|
|
266
307
|
resolved_base = path.resolve()
|
|
267
308
|
|
|
268
|
-
for entry in sorted(_scandir_recursive(path, cache_dir, left_out), key=lambda e: e.path):
|
|
309
|
+
for entry in sorted(_scandir_recursive(path, cache_dir, left_out, dirs), key=lambda e: e.path):
|
|
269
310
|
file_path = pathlib.Path(entry.path)
|
|
270
311
|
# Verify a regular file is still within the directory (paranoid check). A cache-object
|
|
271
312
|
# link resolves into the cache by design.
|
|
@@ -291,11 +332,20 @@ def hash_directory(
|
|
|
291
332
|
if left_out and warn_left_out_symlinks:
|
|
292
333
|
_warn_left_out_symlinks(path, left_out)
|
|
293
334
|
|
|
294
|
-
|
|
295
|
-
|
|
335
|
+
# A deepest empty directory holds no stored file and no other directory.
|
|
336
|
+
found_dirs = {str(directory.relative_to(path)) for directory in dirs}
|
|
337
|
+
file_dirs = {posixpath.dirname(entry["relpath"]) for entry in manifest}
|
|
338
|
+
has_child = {posixpath.dirname(directory) for directory in found_dirs}
|
|
339
|
+
empty_dirs = sorted(found_dirs - file_dirs - has_child)
|
|
340
|
+
|
|
341
|
+
hashed: object = {"empty_dirs": empty_dirs, "manifest": manifest} if empty_dirs else manifest
|
|
342
|
+
hashed_json = json.dumps(hashed, sort_keys=True, separators=(",", ":"))
|
|
343
|
+
output_hash = DirHash(hash=xxhash.xxh64(hashed_json.encode()).hexdigest(), manifest=manifest)
|
|
344
|
+
if empty_dirs:
|
|
345
|
+
output_hash["empty_dirs"] = empty_dirs
|
|
296
346
|
|
|
297
347
|
metrics.end("cache.hash_directory", _t)
|
|
298
|
-
return
|
|
348
|
+
return output_hash
|
|
299
349
|
|
|
300
350
|
|
|
301
351
|
_VALID_HASH_CHARS = frozenset("0123456789abcdef")
|
|
@@ -586,6 +636,85 @@ def _repair_tree_dir_isexec(cache_dir_path: pathlib.Path, manifest: list[DirMani
|
|
|
586
636
|
os.chmod(f, 0o555)
|
|
587
637
|
|
|
588
638
|
|
|
639
|
+
_TREE_LAYOUT_MARKER_SUFFIX = ".layout-checked"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _tree_layout_marker(cache_dir_path: pathlib.Path) -> pathlib.Path:
|
|
643
|
+
"""Return the file beside a whole-directory SYMLINK cache tree that records its layout
|
|
644
|
+
holds exactly what its hash records. The name is never a cache key, so cache scans skip it.
|
|
645
|
+
"""
|
|
646
|
+
return cache_dir_path.with_name(cache_dir_path.name + _TREE_LAYOUT_MARKER_SUFFIX)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _ensure_tree_dir_layout(cache_dir_path: pathlib.Path, output_hash: DirHash) -> None:
|
|
650
|
+
"""Repair a whole-directory SYMLINK cache tree's layout once, not on every restore.
|
|
651
|
+
|
|
652
|
+
Only this pivot records ``empty_dirs``, so a tree whose hash records them was built by it
|
|
653
|
+
and needs no check. Any other tree is checked once and then marked.
|
|
654
|
+
"""
|
|
655
|
+
if "empty_dirs" in output_hash:
|
|
656
|
+
return
|
|
657
|
+
marker = _tree_layout_marker(cache_dir_path)
|
|
658
|
+
if marker.exists():
|
|
659
|
+
return
|
|
660
|
+
_repair_tree_dir_layout(cache_dir_path, output_hash)
|
|
661
|
+
# The marker only saves a walk; a cache the restoring user cannot write still restores.
|
|
662
|
+
with contextlib.suppress(OSError):
|
|
663
|
+
marker.touch()
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _repair_tree_dir_layout(cache_dir_path: pathlib.Path, output_hash: DirHash) -> None:
|
|
667
|
+
"""Give a whole-directory SYMLINK cache tree exactly the files and directories
|
|
668
|
+
``output_hash`` records.
|
|
669
|
+
|
|
670
|
+
A tree an older pivot built can hold entries its hash does not record: directories its hash
|
|
671
|
+
left out because they were empty, and copies of the targets of symlinks its hash left out.
|
|
672
|
+
The tree's name never changes, so neither _store_directory's ``not cache_dir_path.exists()``
|
|
673
|
+
branch nor the symlink_fast_path restore would otherwise revisit it, and an output restored
|
|
674
|
+
through it would not hash to the hash it was restored for. Changing the tree in place is
|
|
675
|
+
safe for the reason ``_repair_tree_dir_isexec`` gives.
|
|
676
|
+
"""
|
|
677
|
+
found_files, found = _scan_entries(cache_dir_path)
|
|
678
|
+
found_dirs = set(found)
|
|
679
|
+
unlisted = set(found_files) - {entry["relpath"] for entry in output_hash["manifest"]}
|
|
680
|
+
extra_dirs = found_dirs - _restored_dirs(output_hash)
|
|
681
|
+
missing = [
|
|
682
|
+
_empty_dir_path(cache_dir_path, relpath)
|
|
683
|
+
for relpath in _recorded_empty_dirs(output_hash)
|
|
684
|
+
if relpath not in found_dirs
|
|
685
|
+
]
|
|
686
|
+
if not unlisted and not extra_dirs and not missing:
|
|
687
|
+
return
|
|
688
|
+
modes = dict[pathlib.Path, int]()
|
|
689
|
+
|
|
690
|
+
def _make_writable(directory: pathlib.Path) -> None:
|
|
691
|
+
if directory not in modes:
|
|
692
|
+
modes[directory] = directory.stat().st_mode
|
|
693
|
+
os.chmod(directory, modes[directory] | stat.S_IWUSR)
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
for relpath in unlisted:
|
|
697
|
+
target = cache_dir_path / relpath
|
|
698
|
+
_make_writable(target.parent)
|
|
699
|
+
target.unlink(missing_ok=True)
|
|
700
|
+
# Reverse order reaches a directory's children before the directory itself.
|
|
701
|
+
for relpath in sorted(extra_dirs, reverse=True):
|
|
702
|
+
target = cache_dir_path / relpath
|
|
703
|
+
_make_writable(target.parent)
|
|
704
|
+
modes.pop(target, None)
|
|
705
|
+
with contextlib.suppress(FileNotFoundError): # a concurrent repair got there first
|
|
706
|
+
target.rmdir()
|
|
707
|
+
for target in missing:
|
|
708
|
+
existing = target.parent
|
|
709
|
+
while not existing.exists():
|
|
710
|
+
existing = existing.parent
|
|
711
|
+
_make_writable(existing)
|
|
712
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
713
|
+
finally:
|
|
714
|
+
for directory, mode in modes.items():
|
|
715
|
+
os.chmod(directory, mode)
|
|
716
|
+
|
|
717
|
+
|
|
589
718
|
def _checkout_with_fallback(
|
|
590
719
|
path: pathlib.Path,
|
|
591
720
|
cache_path: pathlib.Path,
|
|
@@ -763,18 +892,17 @@ def _save_directory_to_cache(
|
|
|
763
892
|
state_dir: pathlib.Path | None,
|
|
764
893
|
) -> DirHash:
|
|
765
894
|
"""Save directory to cache."""
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
output_hash = DirHash(hash=tree_hash, manifest=manifest)
|
|
895
|
+
output_hash = hash_directory(path, state_db, cache_dir=cache_dir)
|
|
896
|
+
# Idempotency check for SYMLINK mode: already linked to the tree its content hashes to
|
|
897
|
+
if (
|
|
898
|
+
checkout_modes
|
|
899
|
+
and checkout_modes[0] == CheckoutMode.SYMLINK
|
|
900
|
+
and path.is_symlink()
|
|
901
|
+
and _get_symlink_cache_hash(path, cache_dir) == output_hash["hash"]
|
|
902
|
+
and get_cache_path(cache_dir, output_hash["hash"]).exists()
|
|
903
|
+
):
|
|
904
|
+
return output_hash
|
|
905
|
+
|
|
778
906
|
_store_directory(
|
|
779
907
|
path,
|
|
780
908
|
output_hash,
|
|
@@ -808,7 +936,17 @@ def _store_directory(
|
|
|
808
936
|
cache_dir_path = get_cache_path(cache_dir, output_hash["hash"])
|
|
809
937
|
if not cache_dir_path.exists():
|
|
810
938
|
cache_dir_path.parent.mkdir(parents=True, exist_ok=True)
|
|
811
|
-
|
|
939
|
+
|
|
940
|
+
def _user_symlinks(directory: str, names: list[str]) -> list[str]:
|
|
941
|
+
# Not stored (see _scandir_recursive); copytree would copy each target.
|
|
942
|
+
return [
|
|
943
|
+
name
|
|
944
|
+
for name in names
|
|
945
|
+
if (link := pathlib.Path(directory, name)).is_symlink()
|
|
946
|
+
and _cache_object_key(link, cache_dir) is None
|
|
947
|
+
]
|
|
948
|
+
|
|
949
|
+
shutil.copytree(path, cache_dir_path, ignore=_user_symlinks)
|
|
812
950
|
isexec_by_relpath = {entry["relpath"]: entry["isexec"] for entry in manifest}
|
|
813
951
|
for f in cache_dir_path.rglob("*"):
|
|
814
952
|
# Skip symlinks to avoid changing permissions on target files outside cache
|
|
@@ -817,11 +955,14 @@ def _store_directory(
|
|
|
817
955
|
executable = isexec_by_relpath.get(relpath, False)
|
|
818
956
|
os.chmod(f, 0o555 if executable else 0o444)
|
|
819
957
|
os.chmod(cache_dir_path, 0o555)
|
|
958
|
+
with contextlib.suppress(OSError):
|
|
959
|
+
_tree_layout_marker(cache_dir_path).touch()
|
|
820
960
|
|
|
821
|
-
# A tree an older pivot built keeps its 0o444-everywhere files
|
|
822
|
-
#
|
|
823
|
-
# existing tree directory above.
|
|
961
|
+
# A tree an older pivot built keeps its 0o444-everywhere files, and entries its hash
|
|
962
|
+
# does not record, forever otherwise: its name never changes, so this branch never
|
|
963
|
+
# revisits an existing tree directory above.
|
|
824
964
|
_repair_tree_dir_isexec(cache_dir_path, manifest)
|
|
965
|
+
_ensure_tree_dir_layout(cache_dir_path, output_hash)
|
|
825
966
|
|
|
826
967
|
_clear_path(path)
|
|
827
968
|
path.symlink_to(cache_dir_path.resolve())
|
|
@@ -994,10 +1135,11 @@ def _restore_directory_from_cache(
|
|
|
994
1135
|
restore_prefix = f"{_RESTORE_TEMP_PREFIX}{target_token}."
|
|
995
1136
|
|
|
996
1137
|
if symlink_fast_path:
|
|
997
|
-
# Same
|
|
998
|
-
# 0o444-everywhere files
|
|
999
|
-
#
|
|
1138
|
+
# Same repairs as _store_directory: a tree an older pivot built keeps its
|
|
1139
|
+
# 0o444-everywhere files, and entries its hash does not record, forever otherwise,
|
|
1140
|
+
# since this path never rebuilds an existing tree directory.
|
|
1000
1141
|
_repair_tree_dir_isexec(cache_dir_path, output_hash["manifest"])
|
|
1142
|
+
_ensure_tree_dir_layout(cache_dir_path, output_hash)
|
|
1001
1143
|
temp_symlink = path.parent / f"{restore_prefix}symlink_{secrets.token_hex(4)}"
|
|
1002
1144
|
try:
|
|
1003
1145
|
temp_symlink.symlink_to(cache_dir_path.resolve())
|
|
@@ -1039,6 +1181,9 @@ def _restore_directory_from_cache(
|
|
|
1039
1181
|
except OSError as e:
|
|
1040
1182
|
logger.debug(f"mtime refresh failed (may be NFS with root_squash): {e}")
|
|
1041
1183
|
|
|
1184
|
+
for relpath in _recorded_empty_dirs(output_hash):
|
|
1185
|
+
_empty_dir_path(temp_path, relpath).mkdir(parents=True, exist_ok=True)
|
|
1186
|
+
|
|
1042
1187
|
try:
|
|
1043
1188
|
os.utime(temp_path, None)
|
|
1044
1189
|
except OSError as e:
|
|
@@ -1090,7 +1235,7 @@ def remove_output(path: pathlib.Path) -> None:
|
|
|
1090
1235
|
class MissingRestore(TypedDict):
|
|
1091
1236
|
"""Outcome of restore_missing_in_directory."""
|
|
1092
1237
|
|
|
1093
|
-
restored: int # missing files restored in place
|
|
1238
|
+
restored: int # missing files and empty directories restored in place
|
|
1094
1239
|
unavailable: int # missing files whose blob is not in the cache
|
|
1095
1240
|
local_changes: list[str] # changed or extra files whose content the cache lacks
|
|
1096
1241
|
replace: bool # directory holds only cached content but not the target: replace it whole
|
|
@@ -1104,12 +1249,13 @@ def _is_cached(cache_dir: pathlib.Path, content_hash: str | None) -> bool:
|
|
|
1104
1249
|
)
|
|
1105
1250
|
|
|
1106
1251
|
|
|
1107
|
-
def _scan_entries(path: pathlib.Path) -> list[str]:
|
|
1108
|
-
"""Return every file and symlink under ``path`` as relative paths.
|
|
1252
|
+
def _scan_entries(path: pathlib.Path) -> tuple[list[str], list[str]]:
|
|
1253
|
+
"""Return every file and symlink, and every directory, under ``path`` as relative paths.
|
|
1109
1254
|
|
|
1110
|
-
Symlinked directories are not followed.
|
|
1255
|
+
Symlinked directories count as symlinks and are not followed.
|
|
1111
1256
|
"""
|
|
1112
1257
|
files = list[str]()
|
|
1258
|
+
dirs = list[str]()
|
|
1113
1259
|
|
|
1114
1260
|
def _walk(directory: pathlib.Path) -> None:
|
|
1115
1261
|
with os.scandir(directory) as entries:
|
|
@@ -1119,10 +1265,11 @@ def _scan_entries(path: pathlib.Path) -> list[str]:
|
|
|
1119
1265
|
if entry.is_symlink() or entry.is_file():
|
|
1120
1266
|
files.append(relpath)
|
|
1121
1267
|
elif entry.is_dir():
|
|
1268
|
+
dirs.append(relpath)
|
|
1122
1269
|
_walk(entry_path)
|
|
1123
1270
|
|
|
1124
1271
|
_walk(path)
|
|
1125
|
-
return files
|
|
1272
|
+
return files, dirs
|
|
1126
1273
|
|
|
1127
1274
|
|
|
1128
1275
|
def _is_same_file(file_stat: os.stat_result, cache_path: pathlib.Path) -> bool:
|
|
@@ -1142,12 +1289,13 @@ def restore_missing_in_directory(
|
|
|
1142
1289
|
checkout_modes: list[CheckoutMode],
|
|
1143
1290
|
state_db: state_mod.StateDB | None = None,
|
|
1144
1291
|
) -> MissingRestore:
|
|
1145
|
-
"""Restore missing target files without overwriting local changes.
|
|
1292
|
+
"""Restore missing target files and empty directories without overwriting local changes.
|
|
1146
1293
|
|
|
1147
|
-
When every existing file matches the target, missing files
|
|
1148
|
-
file differs from the target or an extra file
|
|
1149
|
-
restore the target. The swap is safe only when
|
|
1150
|
-
|
|
1294
|
+
When every existing file matches the target, missing files and empty directories are
|
|
1295
|
+
restored in place. When a file differs from the target or an extra file or directory
|
|
1296
|
+
exists, only a whole-directory swap can restore the target. The swap is safe only when
|
|
1297
|
+
every file it removes is in the cache (an extra empty directory removes none); otherwise
|
|
1298
|
+
the local changes are returned without writing anything.
|
|
1151
1299
|
|
|
1152
1300
|
A retained file is read only when nothing else proves its content: a symlink to the
|
|
1153
1301
|
target's cache path, a hardlink to the target's cache blob, or a matching ``state_db``
|
|
@@ -1198,7 +1346,15 @@ def restore_missing_in_directory(
|
|
|
1198
1346
|
)
|
|
1199
1347
|
missing.append((entry, file_path, file_cache_path))
|
|
1200
1348
|
|
|
1201
|
-
|
|
1349
|
+
missing_dirs = [
|
|
1350
|
+
dir_path
|
|
1351
|
+
for dir_path in (
|
|
1352
|
+
_empty_dir_path(path, relpath) for relpath in _recorded_empty_dirs(output_hash)
|
|
1353
|
+
)
|
|
1354
|
+
if not dir_path.exists() and not dir_path.is_symlink()
|
|
1355
|
+
]
|
|
1356
|
+
|
|
1357
|
+
files, dirs = _scan_entries(path)
|
|
1202
1358
|
for relpath in files:
|
|
1203
1359
|
if relpath in expected_paths:
|
|
1204
1360
|
continue
|
|
@@ -1207,8 +1363,12 @@ def restore_missing_in_directory(
|
|
|
1207
1363
|
changed[relpath] = _get_symlink_cache_hash(extra_path, cache_dir)
|
|
1208
1364
|
else:
|
|
1209
1365
|
changed[relpath], _ = hash_file(extra_path, state_db)
|
|
1366
|
+
# A hash pivot 0.3.3 recorded lists no empty directories, so on disk none of them is extra.
|
|
1367
|
+
extra_dirs = (
|
|
1368
|
+
set(dirs) - _restored_dirs(output_hash) if "empty_dirs" in output_hash else set[str]()
|
|
1369
|
+
)
|
|
1210
1370
|
|
|
1211
|
-
if changed:
|
|
1371
|
+
if changed or extra_dirs:
|
|
1212
1372
|
# Replacing the directory deletes every file it holds; refuse if any is not cached.
|
|
1213
1373
|
local_changes = sorted(
|
|
1214
1374
|
rel for rel, content_hash in changed.items() if not _is_cached(cache_dir, content_hash)
|
|
@@ -1228,8 +1388,12 @@ def restore_missing_in_directory(
|
|
|
1228
1388
|
_checkout_with_fallback(
|
|
1229
1389
|
file_path, file_cache_path, checkout_modes, executable=entry["isexec"]
|
|
1230
1390
|
)
|
|
1391
|
+
for dir_path in missing_dirs:
|
|
1392
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
1231
1393
|
|
|
1232
|
-
return MissingRestore(
|
|
1394
|
+
return MissingRestore(
|
|
1395
|
+
restored=len(missing) + len(missing_dirs), unavailable=0, local_changes=[], replace=False
|
|
1396
|
+
)
|
|
1233
1397
|
|
|
1234
1398
|
|
|
1235
1399
|
def protect(path: pathlib.Path) -> None:
|