pivot 0.2.1__tar.gz → 0.2.3__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 (108) hide show
  1. {pivot-0.2.1 → pivot-0.2.3}/PKG-INFO +1 -1
  2. {pivot-0.2.1 → pivot-0.2.3}/pyproject.toml +1 -1
  3. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/checkout.py +20 -0
  4. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/engine.py +1 -1
  5. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/sinks.py +76 -10
  6. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/executor/commit.py +1 -1
  7. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/executor/core.py +1 -0
  8. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/executor/worker.py +60 -10
  9. pivot-0.2.3/src/pivot/loaders.py +888 -0
  10. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/remote/storage.py +57 -7
  11. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/show/data.py +3 -4
  12. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/AGENTS.md +10 -0
  13. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/cache.py +362 -262
  14. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/restore.py +12 -14
  15. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/store.py +22 -3
  16. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/types.py +5 -0
  17. pivot-0.2.1/src/pivot/loaders.py +0 -389
  18. {pivot-0.2.1 → pivot-0.2.3}/LICENSE +0 -0
  19. {pivot-0.2.1 → pivot-0.2.3}/README.md +0 -0
  20. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/__init__.py +0 -0
  21. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/ast_utils.py +0 -0
  22. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/AGENTS.md +0 -0
  23. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/CLAUDE.md +0 -0
  24. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/__init__.py +0 -0
  25. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/_run_common.py +0 -0
  26. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/check_ignore.py +0 -0
  27. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/commit.py +0 -0
  28. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/completion.py +0 -0
  29. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/config.py +0 -0
  30. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/console.py +0 -0
  31. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/dag.py +0 -0
  32. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/data.py +0 -0
  33. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/decorators.py +0 -0
  34. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/doctor.py +0 -0
  35. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/errors.py +0 -0
  36. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/fingerprint.py +0 -0
  37. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/helpers.py +0 -0
  38. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/history.py +0 -0
  39. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/import_cmd.py +0 -0
  40. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/init.py +0 -0
  41. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/list.py +0 -0
  42. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/metrics.py +0 -0
  43. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/params.py +0 -0
  44. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/plots.py +0 -0
  45. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/remote.py +0 -0
  46. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/repro.py +0 -0
  47. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/run.py +0 -0
  48. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/status.py +0 -0
  49. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/targets.py +0 -0
  50. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/track.py +0 -0
  51. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/update.py +0 -0
  52. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/cli/verify.py +0 -0
  53. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/compose.py +0 -0
  54. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/config/__init__.py +0 -0
  55. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/config/io.py +0 -0
  56. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/config/models.py +0 -0
  57. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/dag/__init__.py +0 -0
  58. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/dag/render.py +0 -0
  59. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/decorators.py +0 -0
  60. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/discovery.py +0 -0
  61. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/__init__.py +0 -0
  62. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/agent_rpc.py +0 -0
  63. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/graph.py +0 -0
  64. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/scheduler.py +0 -0
  65. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/sources.py +0 -0
  66. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/types.py +0 -0
  67. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/watch.py +0 -0
  68. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/engine/worker_pool.py +0 -0
  69. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/exceptions.py +0 -0
  70. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/executor/AGENTS.md +0 -0
  71. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/executor/CLAUDE.md +0 -0
  72. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/executor/__init__.py +0 -0
  73. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/explain.py +0 -0
  74. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/fingerprint.py +0 -0
  75. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/git.py +0 -0
  76. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/ignore.py +0 -0
  77. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/import_artifact.py +0 -0
  78. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/metrics.py +0 -0
  79. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/names.py +0 -0
  80. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/outputs.py +0 -0
  81. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/parameters.py +0 -0
  82. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/path_policy.py +0 -0
  83. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/project.py +0 -0
  84. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/py.typed +0 -0
  85. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/registry.py +0 -0
  86. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/remote/AGENTS.md +0 -0
  87. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/remote/__init__.py +0 -0
  88. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/remote/config.py +0 -0
  89. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/remote/git_archive.py +0 -0
  90. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/remote/github.py +0 -0
  91. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/remote/sync.py +0 -0
  92. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/run_history.py +0 -0
  93. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/show/__init__.py +0 -0
  94. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/show/common.py +0 -0
  95. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/show/metrics.py +0 -0
  96. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/show/params.py +0 -0
  97. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/show/plots.py +0 -0
  98. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/skip.py +0 -0
  99. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/stage_def.py +0 -0
  100. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/status.py +0 -0
  101. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/CLAUDE.md +0 -0
  102. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/__init__.py +0 -0
  103. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/artifact_lock.py +0 -0
  104. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/lock.py +0 -0
  105. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/presentation.py +0 -0
  106. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/state.py +0 -0
  107. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/storage/track.py +0 -0
  108. {pivot-0.2.1 → pivot-0.2.3}/src/pivot/yaml_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pivot
3
- Version: 0.2.1
3
+ Version: 0.2.3
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.2.1"
3
+ version = "0.2.3"
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"
@@ -89,6 +89,9 @@ def _restore_path_sync(
89
89
  ) -> tuple[RestoreResult, str]:
90
90
  """Restore a file or directory from cache (sync version).
91
91
 
92
+ Holds the restore lock for path across the existence check and the restore, so a
93
+ concurrent checkout never compares against, or deletes, a half-restored output.
94
+
92
95
  Returns:
93
96
  Tuple of (result, path_name) for the caller to handle output.
94
97
 
@@ -97,6 +100,22 @@ def _restore_path_sync(
97
100
  "already exists" without --force). Cache misses return ("missing", name)
98
101
  instead of raising.
99
102
  """
103
+ with cache.restore_lock(path, state_dir) as lock_token:
104
+ return _restore_path_locked(
105
+ path, output_hash, cache_dir, checkout_modes, behavior, state_dir, lock_token
106
+ )
107
+
108
+
109
+ def _restore_path_locked(
110
+ path: pathlib.Path,
111
+ output_hash: HashInfo,
112
+ cache_dir: pathlib.Path,
113
+ checkout_modes: list[cache.CheckoutMode],
114
+ behavior: CheckoutBehavior,
115
+ state_dir: pathlib.Path | None,
116
+ lock_token: str | None,
117
+ ) -> tuple[RestoreResult, str]:
118
+ """Body of _restore_path_sync; the caller holds the restore lock for path."""
100
119
  keep_ignored = False
101
120
  if path.exists():
102
121
  match behavior:
@@ -146,6 +165,7 @@ def _restore_path_sync(
146
165
  cache_dir,
147
166
  checkout_modes=checkout_modes,
148
167
  state_dir=state_dir,
168
+ lock_token=lock_token,
149
169
  keep_ignored=keep_ignored,
150
170
  )
151
171
  if not success:
@@ -1839,7 +1839,7 @@ class Engine:
1839
1839
  stage_info["outs"],
1840
1840
  lock_data["output_hashes"],
1841
1841
  state_db,
1842
- state_dir=stage_state_dir,
1842
+ state_dir=state_dir,
1843
1843
  file_hash_entries=restored_entries,
1844
1844
  )
1845
1845
  if restored and restored_entries:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import dataclasses
3
4
  import sys
4
5
  import time
5
6
  from typing import TYPE_CHECKING
@@ -10,8 +11,8 @@ import rich.live
10
11
  import rich.markup
11
12
  import rich.text
12
13
 
13
- from pivot.engine.types import StageCompleted
14
- from pivot.types import DisplayCategory, categorize_stage_result
14
+ from pivot.engine.types import LogLine, StageCompleted
15
+ from pivot.types import LOSSY_OUTPUT_PHRASE, DisplayCategory, categorize_stage_result
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from pivot.engine.types import OutputEvent
@@ -84,6 +85,20 @@ def _categorize(event: StageCompleted) -> DisplayCategory:
84
85
  return categorize_stage_result(event["status"])
85
86
 
86
87
 
88
+ @dataclasses.dataclass
89
+ class _StageWarnings:
90
+ """A stage's ``WARNING:`` lines for the report.
91
+
92
+ Pivot's own output-write warnings are always kept and print first. The stage's own
93
+ warnings are counted per distinct line (in first-seen order); the report shows the
94
+ first ``_MAX_LOG_LINES_PER_STAGE`` distinct lines and counts the rest.
95
+ """
96
+
97
+ stage: dict[str, int] = dataclasses.field(default_factory=dict[str, int])
98
+ lossy_outputs: list[str] = dataclasses.field(default_factory=list[str])
99
+ dropped: int = 0
100
+
101
+
87
102
  def _print_completions(
88
103
  console: rich.console.Console,
89
104
  events: list[StageCompleted],
@@ -91,13 +106,19 @@ def _print_completions(
91
106
  total: int,
92
107
  max_name_width: int,
93
108
  stage_logs: dict[str, list[str]] | None = None,
109
+ stage_warnings: dict[str, _StageWarnings] | None = None,
94
110
  ) -> None:
95
- """Print buffered completions sorted by index with skip collapsing."""
111
+ """Print buffered completions sorted by index with skip collapsing.
112
+
113
+ A failed stage shows its captured output; any other stage shows the ``WARNING:``
114
+ lines in ``stage_warnings`` (pass None when those already streamed).
115
+ """
96
116
  if not events:
97
117
  return
98
118
 
99
119
  sorted_events = sorted(events, key=lambda e: e["index"])
100
120
  logs = stage_logs or {}
121
+ warnings_by_stage = stage_warnings or {}
101
122
 
102
123
  i = 0
103
124
  while i < len(sorted_events):
@@ -152,12 +173,53 @@ def _print_completions(
152
173
  if event["reason"]:
153
174
  for detail in _format_error_detail(event["reason"], total=total):
154
175
  console.print(detail)
176
+ elif event["stage"] in warnings_by_stage:
177
+ buffered = warnings_by_stage[event["stage"]]
178
+ stage_lines = [
179
+ line if count == 1 else f"{line} (×{count})"
180
+ for line, count in buffered.stage.items()
181
+ ]
182
+ for warning_line in [*buffered.lossy_outputs, *stage_lines]:
183
+ escaped = rich.markup.escape(warning_line)
184
+ console.print(f" [yellow]{escaped}[/yellow]")
185
+ if buffered.dropped:
186
+ console.print(
187
+ f" [yellow]… {buffered.dropped} more warnings "
188
+ + "(run with --show-output)[/yellow]"
189
+ )
155
190
  i += 1
156
191
 
157
192
 
158
193
  _MAX_LOG_LINES_PER_STAGE = 50
159
194
 
160
195
 
196
+ def _buffer_log_line(
197
+ event: LogLine,
198
+ stage_logs: dict[str, list[str]],
199
+ stage_warnings: dict[str, _StageWarnings],
200
+ ) -> None:
201
+ """Keep a stage's output for a failure report and its warnings for every report.
202
+
203
+ Warnings have their own buffer: output writes log them after the stage's own output,
204
+ which can already fill the log buffer.
205
+ """
206
+ line = event["line"]
207
+ buf = stage_logs.setdefault(event["stage"], [])
208
+ if len(buf) < _MAX_LOG_LINES_PER_STAGE:
209
+ buf.append(line)
210
+ if not line.startswith("WARNING:"):
211
+ return
212
+ buffered = stage_warnings.setdefault(event["stage"], _StageWarnings())
213
+ if LOSSY_OUTPUT_PHRASE in line and line.startswith("WARNING: Stage '"):
214
+ buffered.lossy_outputs.append(line)
215
+ elif line in buffered.stage:
216
+ buffered.stage[line] += 1
217
+ elif len(buffered.stage) < _MAX_LOG_LINES_PER_STAGE:
218
+ buffered.stage[line] = 1
219
+ else:
220
+ buffered.dropped += 1
221
+
222
+
161
223
  class StaticConsoleSink:
162
224
  """Pipe/CI sink: buffers completions, prints a sorted report when each run ends."""
163
225
 
@@ -166,6 +228,7 @@ class StaticConsoleSink:
166
228
  _max_name_width: int
167
229
  _completion_buffer: list[StageCompleted]
168
230
  _stage_logs: dict[str, list[str]]
231
+ _stage_warnings: dict[str, _StageWarnings]
169
232
  _total: int
170
233
 
171
234
  def __init__(self, *, console: rich.console.Console, show_output: bool = False) -> None:
@@ -174,6 +237,7 @@ class StaticConsoleSink:
174
237
  self._max_name_width = 0
175
238
  self._completion_buffer = list[StageCompleted]()
176
239
  self._stage_logs = dict[str, list[str]]()
240
+ self._stage_warnings = dict[str, _StageWarnings]()
177
241
  self._total = 0
178
242
 
179
243
  async def handle(self, event: OutputEvent) -> None:
@@ -187,11 +251,9 @@ class StaticConsoleSink:
187
251
  self._max_name_width = max(self._max_name_width, len(event["stage"]))
188
252
  self._completion_buffer.append(event)
189
253
  case "log_line":
190
- # Always buffer for failed-stage output; stream if --show-output
254
+ # Buffer for the report; stream if --show-output
191
255
  stage = event["stage"]
192
- buf = self._stage_logs.setdefault(stage, [])
193
- if len(buf) < _MAX_LOG_LINES_PER_STAGE:
194
- buf.append(event["line"])
256
+ _buffer_log_line(event, self._stage_logs, self._stage_warnings)
195
257
  if self._show_output:
196
258
  line = rich.markup.escape(event["line"])
197
259
  if event["is_stderr"]:
@@ -218,10 +280,12 @@ class StaticConsoleSink:
218
280
  total=self._total,
219
281
  max_name_width=self._max_name_width,
220
282
  stage_logs=self._stage_logs,
283
+ stage_warnings=None if self._show_output else self._stage_warnings,
221
284
  )
222
285
  self._max_name_width = 0
223
286
  self._completion_buffer.clear()
224
287
  self._stage_logs.clear()
288
+ self._stage_warnings.clear()
225
289
  self._total = 0
226
290
 
227
291
  async def close(self) -> None:
@@ -254,6 +318,7 @@ class LiveConsoleSink:
254
318
  _max_name_width: int
255
319
  _completion_buffer: list[StageCompleted]
256
320
  _stage_logs: dict[str, list[str]]
321
+ _stage_warnings: dict[str, _StageWarnings]
257
322
  _total: int
258
323
  _live: rich.live.Live | None
259
324
 
@@ -268,6 +333,7 @@ class LiveConsoleSink:
268
333
  self._max_name_width = 0
269
334
  self._completion_buffer = list[StageCompleted]()
270
335
  self._stage_logs = dict[str, list[str]]()
336
+ self._stage_warnings = dict[str, _StageWarnings]()
271
337
  self._total = 0
272
338
  self._live = None
273
339
 
@@ -372,9 +438,7 @@ class LiveConsoleSink:
372
438
  self._update_live()
373
439
  case "log_line":
374
440
  stage = event["stage"]
375
- buf = self._stage_logs.setdefault(stage, [])
376
- if len(buf) < _MAX_LOG_LINES_PER_STAGE:
377
- buf.append(event["line"])
441
+ _buffer_log_line(event, self._stage_logs, self._stage_warnings)
378
442
  if self._show_output:
379
443
  line = rich.markup.escape(event["line"])
380
444
  if event["is_stderr"]:
@@ -405,6 +469,7 @@ class LiveConsoleSink:
405
469
  total=self._total,
406
470
  max_name_width=self._max_name_width,
407
471
  stage_logs=self._stage_logs,
472
+ stage_warnings=None if self._show_output else self._stage_warnings,
408
473
  )
409
474
  self._running.clear()
410
475
  self._completed_count = 0
@@ -412,6 +477,7 @@ class LiveConsoleSink:
412
477
  self._max_name_width = 0
413
478
  self._completion_buffer.clear()
414
479
  self._stage_logs.clear()
480
+ self._stage_warnings.clear()
415
481
  self._total = 0
416
482
 
417
483
  async def close(self) -> None:
@@ -150,7 +150,7 @@ def commit_stages(
150
150
  identity_key = types.identity_key(out.identity)
151
151
  try:
152
152
  out_path = store.resolve_display_path(out)
153
- hash_info = store.commit(out, out_path)
153
+ hash_info = store.commit(out, out_path, state_dir=default_state_dir)
154
154
  file_hash_entries.extend(worker.output_file_hash_entries(out_path, hash_info))
155
155
  output_hashes_by_key[identity_key] = hash_info
156
156
  output_hashes_by_identity[out.identity] = hash_info
@@ -337,6 +337,7 @@ def prepare_worker_info(
337
337
  params_arg_name=stage_info["params_arg_name"],
338
338
  project_root=project_root,
339
339
  state_dir=state_dir,
340
+ project_state_dir=default_state_dir,
340
341
  collection_params=stage_info["collection_params"],
341
342
  )
342
343
 
@@ -18,6 +18,7 @@ import random
18
18
  import sys
19
19
  import threading
20
20
  import traceback
21
+ import warnings
21
22
  from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, cast, overload, override
22
23
 
23
24
  import pydantic
@@ -153,6 +154,7 @@ class WorkerStageInfo(TypedDict):
153
154
  params_arg_name: str | None
154
155
  project_root: pathlib.Path
155
156
  state_dir: pathlib.Path
157
+ project_state_dir: pathlib.Path
156
158
  collection_params: dict[str, str]
157
159
  incremental_inputs: dict[str, types.ArtifactIdentity]
158
160
 
@@ -193,6 +195,7 @@ def execute_stage(
193
195
  _ = cache_dir
194
196
  ring_buffer = _OutputRingBuffer()
195
197
  state_dir = stage_info["state_dir"]
198
+ project_state_dir = stage_info["project_state_dir"]
196
199
  project_root = stage_info["project_root"]
197
200
  store = store_mod.store_from_spec(stage_info["store_spec"])
198
201
 
@@ -275,7 +278,7 @@ def execute_stage(
275
278
  stage_outs,
276
279
  lock_data["output_hashes"],
277
280
  state_db,
278
- state_dir=stage_info["state_dir"],
281
+ state_dir=project_state_dir,
279
282
  file_hash_entries=restored_entries,
280
283
  )
281
284
  if restored:
@@ -330,7 +333,7 @@ def execute_stage(
330
333
  stage_outs,
331
334
  lock_data["output_hashes"],
332
335
  state_db,
333
- state_dir=stage_info["state_dir"],
336
+ state_dir=project_state_dir,
334
337
  file_hash_entries=restored_entries,
335
338
  )
336
339
  if restored:
@@ -351,7 +354,7 @@ def execute_stage(
351
354
  stage_outs,
352
355
  store,
353
356
  state_db,
354
- state_dir=stage_info["state_dir"],
357
+ state_dir=project_state_dir,
355
358
  )
356
359
  if run_cache_skip is not None:
357
360
  if no_commit:
@@ -404,12 +407,15 @@ def execute_stage(
404
407
  stage_info["incremental_inputs"],
405
408
  lock_data,
406
409
  state_dir,
410
+ project_state_dir,
407
411
  project_root,
408
412
  )
409
413
  if no_commit:
410
414
  output_hashes = _hash_output_paths(output_paths)
411
415
  else:
412
- output_hashes = _commit_outputs_with_store(output_paths, store)
416
+ output_hashes = _commit_outputs_with_store(
417
+ output_paths, store, project_state_dir
418
+ )
413
419
  for ref, path in output_paths.items():
414
420
  file_hash_entries.extend(
415
421
  output_file_hash_entries(
@@ -819,6 +825,42 @@ def _write_output_with_store(
819
825
  _reject_path_inside_project(ref, value, project_root, dependency_paths)
820
826
  output_path = store.prepare_output(ref)
821
827
  writer = cast("Any", ref.format)
828
+
829
+ output_label = (
830
+ f"its {type(writer).__name__} output"
831
+ if ref.identity.key is None
832
+ else f"output {ref.identity.key!r}"
833
+ )
834
+
835
+ def save_output(output_value: Any, destination: pathlib.Path) -> None:
836
+ with warnings.catch_warnings(record=True) as caught:
837
+ warnings.simplefilter("always", loaders.LossyOutputWarning)
838
+ try:
839
+ writer.save(output_value, destination)
840
+ except TypeError as e:
841
+ if isinstance(writer, loaders.YAML):
842
+ raise TypeError(
843
+ f"Stage '{ref.identity.producer}' cannot write {output_label}: {e}"
844
+ ) from e
845
+ raise
846
+ for caught_warning in caught:
847
+ if issubclass(caught_warning.category, loaders.LossyOutputWarning):
848
+ logger.warning(
849
+ "Stage '%s' wrote %s %s: %s",
850
+ ref.identity.producer,
851
+ output_label,
852
+ types.LOSSY_OUTPUT_PHRASE,
853
+ caught_warning.message,
854
+ )
855
+ else:
856
+ warnings.warn_explicit(
857
+ caught_warning.message,
858
+ caught_warning.category,
859
+ caught_warning.filename,
860
+ caught_warning.lineno,
861
+ source=caught_warning.source,
862
+ )
863
+
822
864
  if ref.tag is types.ArtifactTag.DIRECTORY:
823
865
  if not isinstance(value, dict):
824
866
  raise RuntimeError(
@@ -845,11 +887,11 @@ def _write_output_with_store(
845
887
  members.append((output_path / normalized, item_value))
846
888
  for full_path, item_value in members:
847
889
  full_path.parent.mkdir(parents=True, exist_ok=True)
848
- writer.save(item_value, full_path)
890
+ save_output(item_value, full_path)
849
891
  return output_path
850
892
 
851
893
  output_path.parent.mkdir(parents=True, exist_ok=True)
852
- writer.save(value, output_path)
894
+ save_output(value, output_path)
853
895
  return output_path
854
896
 
855
897
 
@@ -885,7 +927,8 @@ def _prepare_incremental_inputs(
885
927
  incremental_inputs: dict[str, types.ArtifactIdentity],
886
928
  lock_data: LockData | None,
887
929
  store: store_mod.Store,
888
- state_dir: pathlib.Path,
930
+ stage_state_dir: pathlib.Path,
931
+ project_state_dir: pathlib.Path,
889
932
  ) -> dict[str, Any]:
890
933
  """Restore previous incremental outputs and load them for the stage call."""
891
934
  if not incremental_inputs:
@@ -903,7 +946,7 @@ def _prepare_incremental_inputs(
903
946
  continue
904
947
 
905
948
  output_hash = lock_data["output_hashes"].get(identity)
906
- stage_lock = lock.StageLock(stage_name, lock.get_stages_dir(state_dir))
949
+ stage_lock = lock.StageLock(stage_name, lock.get_stages_dir(stage_state_dir))
907
950
  if output_hash is None:
908
951
  raise exceptions.CacheRestoreError(
909
952
  f"Cache missing for incremental output '{types.identity_key(identity)}'. "
@@ -915,7 +958,7 @@ def _prepare_incremental_inputs(
915
958
  if not store.restore_from_cache(
916
959
  ref,
917
960
  output_hash,
918
- state_dir=state_dir,
961
+ state_dir=project_state_dir,
919
962
  checkout_modes=[config.CheckoutMode.COPY],
920
963
  ):
921
964
  raise exceptions.CacheRestoreError(
@@ -941,6 +984,7 @@ def _run_stage_function_with_store(
941
984
  incremental_inputs: dict[str, types.ArtifactIdentity],
942
985
  lock_data: LockData | None,
943
986
  state_dir: pathlib.Path,
987
+ project_state_dir: pathlib.Path,
944
988
  project_root: pathlib.Path,
945
989
  ) -> dict[types.ArtifactRef, pathlib.Path]:
946
990
  with (
@@ -969,6 +1013,7 @@ def _run_stage_function_with_store(
969
1013
  lock_data,
970
1014
  store,
971
1015
  state_dir,
1016
+ project_state_dir,
972
1017
  )
973
1018
  )
974
1019
 
@@ -1014,12 +1059,17 @@ def _hash_output_paths(
1014
1059
  def _commit_outputs_with_store(
1015
1060
  outputs_by_ref: dict[types.ArtifactRef, pathlib.Path],
1016
1061
  store: store_mod.Store,
1062
+ project_state_dir: pathlib.Path,
1017
1063
  ) -> dict[str, HashInfo]:
1018
1064
  output_hashes = dict[str, HashInfo]()
1019
1065
  for ref, path in outputs_by_ref.items():
1020
1066
  if not path.exists():
1021
1067
  raise exceptions.OutputMissingError(f"Stage did not produce output: {path}")
1022
- output_hashes[types.identity_key(ref.identity)] = store.commit(ref, path)
1068
+ if isinstance(store, store_mod.WorkspaceStore):
1069
+ output_hash = store.commit(ref, path, state_dir=project_state_dir)
1070
+ else:
1071
+ output_hash = store.commit(ref, path)
1072
+ output_hashes[types.identity_key(ref.identity)] = output_hash
1023
1073
  return output_hashes
1024
1074
 
1025
1075