pivot 0.3.1__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.
Files changed (107) hide show
  1. {pivot-0.3.1 → pivot-0.3.2}/PKG-INFO +1 -1
  2. {pivot-0.3.1 → pivot-0.3.2}/pyproject.toml +1 -1
  3. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/checkout.py +11 -4
  4. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/commit.py +3 -1
  5. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/track.py +16 -2
  6. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/executor/commit.py +10 -1
  7. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/executor/core.py +1 -1
  8. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/executor/worker.py +25 -6
  9. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/status.py +2 -1
  10. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/cache.py +104 -22
  11. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/restore.py +14 -8
  12. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/store.py +18 -3
  13. {pivot-0.3.1 → pivot-0.3.2}/LICENSE +0 -0
  14. {pivot-0.3.1 → pivot-0.3.2}/README.md +0 -0
  15. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/__init__.py +0 -0
  16. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/ast_utils.py +0 -0
  17. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/AGENTS.md +0 -0
  18. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/CLAUDE.md +0 -0
  19. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/__init__.py +0 -0
  20. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/_run_common.py +0 -0
  21. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/check_ignore.py +0 -0
  22. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/completion.py +0 -0
  23. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/config.py +0 -0
  24. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/console.py +0 -0
  25. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/dag.py +0 -0
  26. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/data.py +0 -0
  27. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/decorators.py +0 -0
  28. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/doctor.py +0 -0
  29. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/errors.py +0 -0
  30. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/fingerprint.py +0 -0
  31. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/helpers.py +0 -0
  32. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/history.py +0 -0
  33. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/import_cmd.py +0 -0
  34. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/init.py +0 -0
  35. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/list.py +0 -0
  36. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/metrics.py +0 -0
  37. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/params.py +0 -0
  38. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/plots.py +0 -0
  39. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/remote.py +0 -0
  40. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/repro.py +0 -0
  41. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/run.py +0 -0
  42. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/status.py +0 -0
  43. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/targets.py +0 -0
  44. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/update.py +0 -0
  45. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/cli/verify.py +0 -0
  46. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/compose.py +0 -0
  47. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/config/__init__.py +0 -0
  48. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/config/io.py +0 -0
  49. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/config/models.py +0 -0
  50. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/dag/__init__.py +0 -0
  51. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/dag/render.py +0 -0
  52. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/decorators.py +0 -0
  53. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/discovery.py +0 -0
  54. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/__init__.py +0 -0
  55. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/agent_rpc.py +0 -0
  56. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/engine.py +0 -0
  57. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/graph.py +0 -0
  58. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/scheduler.py +0 -0
  59. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/sinks.py +0 -0
  60. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/sources.py +0 -0
  61. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/types.py +0 -0
  62. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/watch.py +0 -0
  63. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/engine/worker_pool.py +0 -0
  64. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/exceptions.py +0 -0
  65. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/executor/AGENTS.md +0 -0
  66. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/executor/CLAUDE.md +0 -0
  67. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/executor/__init__.py +0 -0
  68. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/explain.py +0 -0
  69. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/fingerprint.py +0 -0
  70. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/git.py +0 -0
  71. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/ignore.py +0 -0
  72. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/import_artifact.py +0 -0
  73. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/loaders.py +0 -0
  74. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/metrics.py +0 -0
  75. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/names.py +0 -0
  76. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/outputs.py +0 -0
  77. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/parameters.py +0 -0
  78. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/path_policy.py +0 -0
  79. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/project.py +0 -0
  80. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/py.typed +0 -0
  81. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/registry.py +0 -0
  82. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/remote/AGENTS.md +0 -0
  83. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/remote/__init__.py +0 -0
  84. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/remote/config.py +0 -0
  85. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/remote/git_archive.py +0 -0
  86. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/remote/github.py +0 -0
  87. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/remote/storage.py +0 -0
  88. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/remote/sync.py +0 -0
  89. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/run_history.py +0 -0
  90. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/show/__init__.py +0 -0
  91. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/show/common.py +0 -0
  92. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/show/data.py +0 -0
  93. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/show/metrics.py +0 -0
  94. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/show/params.py +0 -0
  95. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/show/plots.py +0 -0
  96. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/skip.py +0 -0
  97. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/stage_def.py +0 -0
  98. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/AGENTS.md +0 -0
  99. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/CLAUDE.md +0 -0
  100. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/__init__.py +0 -0
  101. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/artifact_lock.py +0 -0
  102. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/lock.py +0 -0
  103. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/presentation.py +0 -0
  104. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/state.py +0 -0
  105. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/storage/track.py +0 -0
  106. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/types.py +0 -0
  107. {pivot-0.3.1 → pivot-0.3.2}/src/pivot/yaml_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pivot
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: High-performance Python pipeline tool with automatic code change detection
5
5
  Author: Sami Jawhar
6
6
  Author-email: Sami Jawhar <sami@metr.org>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pivot"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "High-performance Python pipeline tool with automatic code change detection"
5
5
  requires-python = ">=3.13,<3.14"
6
6
  readme = "README.md"
@@ -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(path: pathlib.Path, expected_hash: HashInfo) -> bool:
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.remove_output(path)
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(cli_helpers.get_pipeline(), stage_names)
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:
@@ -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(path)
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(path_str, cache_dir, stage_outputs, existing_tracked, force)
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)
@@ -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(out, out_path, state_dir=default_state_dir)
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(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 hash_output(path: pathlib.Path, state_db: state.StateDB | None = None) -> HashInfo:
617
- """Compute output hash without saving to cache."""
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(path, state_db)
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
  )
@@ -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 _restore_staging_path(path: pathlib.Path, token: str | None = None) -> pathlib.Path:
49
- """Return the deterministic staging path for a file checkout."""
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 temp file + rename pattern.
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
- fd, tmp_path = tempfile.mkstemp(dir=dest.parent, suffix=".tmp")
106
- tmp = pathlib.Path(tmp_path)
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(tmp_path, mode)
122
+ os.chmod(tmp, mode)
112
123
  tmp.replace(dest)
113
124
  except BaseException:
114
- tmp.unlink(missing_ok=True)
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 _scandir_recursive(path: pathlib.Path) -> Generator[os.DirEntry[str]]:
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
- continue
179
- if entry.is_file():
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
- - Symlinks INSIDE directories are skipped (prevents infinite loops)
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
- if not file_path.resolve().is_relative_to(resolved_base):
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 = _restore_staging_path(path, lock_token)
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 = _restore_staging_path(file_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 = _restore_staging_path(path, lock_token)
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, force: bool) -> None:
241
- """Replace a cache-miss target while holding the same lock as cache restores."""
242
- with cache.restore_lock(dest_path, config.get_state_dir()):
243
- if (force or dest_path.is_symlink()) and (dest_path.exists() or dest_path.is_symlink()):
244
- cache.remove_output(dest_path)
245
- dest_path.write_bytes(content)
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, force)
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, force)
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(path, None)
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