pivot 0.2.0__tar.gz → 0.2.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 (108) hide show
  1. {pivot-0.2.0 → pivot-0.2.2}/PKG-INFO +1 -1
  2. {pivot-0.2.0 → pivot-0.2.2}/pyproject.toml +1 -1
  3. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/checkout.py +30 -6
  4. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/verify.py +27 -2
  5. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/sinks.py +76 -10
  6. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/worker.py +46 -3
  7. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/import_artifact.py +38 -20
  8. pivot-0.2.2/src/pivot/loaders.py +888 -0
  9. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/sync.py +38 -2
  10. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/data.py +3 -4
  11. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/cache.py +257 -2
  12. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/types.py +5 -0
  13. pivot-0.2.0/src/pivot/loaders.py +0 -389
  14. {pivot-0.2.0 → pivot-0.2.2}/LICENSE +0 -0
  15. {pivot-0.2.0 → pivot-0.2.2}/README.md +0 -0
  16. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/__init__.py +0 -0
  17. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/ast_utils.py +0 -0
  18. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/AGENTS.md +0 -0
  19. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/CLAUDE.md +0 -0
  20. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/__init__.py +0 -0
  21. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/_run_common.py +0 -0
  22. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/check_ignore.py +0 -0
  23. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/commit.py +0 -0
  24. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/completion.py +0 -0
  25. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/config.py +0 -0
  26. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/console.py +0 -0
  27. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/dag.py +0 -0
  28. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/data.py +0 -0
  29. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/decorators.py +0 -0
  30. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/doctor.py +0 -0
  31. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/errors.py +0 -0
  32. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/fingerprint.py +0 -0
  33. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/helpers.py +0 -0
  34. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/history.py +0 -0
  35. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/import_cmd.py +0 -0
  36. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/init.py +0 -0
  37. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/list.py +0 -0
  38. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/metrics.py +0 -0
  39. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/params.py +0 -0
  40. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/plots.py +0 -0
  41. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/remote.py +0 -0
  42. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/repro.py +0 -0
  43. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/run.py +0 -0
  44. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/status.py +0 -0
  45. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/targets.py +0 -0
  46. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/track.py +0 -0
  47. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/update.py +0 -0
  48. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/compose.py +0 -0
  49. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/config/__init__.py +0 -0
  50. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/config/io.py +0 -0
  51. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/config/models.py +0 -0
  52. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/dag/__init__.py +0 -0
  53. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/dag/render.py +0 -0
  54. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/decorators.py +0 -0
  55. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/discovery.py +0 -0
  56. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/__init__.py +0 -0
  57. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/agent_rpc.py +0 -0
  58. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/engine.py +0 -0
  59. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/graph.py +0 -0
  60. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/scheduler.py +0 -0
  61. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/sources.py +0 -0
  62. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/types.py +0 -0
  63. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/watch.py +0 -0
  64. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/worker_pool.py +0 -0
  65. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/exceptions.py +0 -0
  66. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/AGENTS.md +0 -0
  67. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/CLAUDE.md +0 -0
  68. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/__init__.py +0 -0
  69. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/commit.py +0 -0
  70. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/core.py +0 -0
  71. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/explain.py +0 -0
  72. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/fingerprint.py +0 -0
  73. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/git.py +0 -0
  74. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/ignore.py +0 -0
  75. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/metrics.py +0 -0
  76. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/names.py +0 -0
  77. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/outputs.py +0 -0
  78. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/parameters.py +0 -0
  79. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/path_policy.py +0 -0
  80. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/project.py +0 -0
  81. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/py.typed +0 -0
  82. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/registry.py +0 -0
  83. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/AGENTS.md +0 -0
  84. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/__init__.py +0 -0
  85. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/config.py +0 -0
  86. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/git_archive.py +0 -0
  87. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/github.py +0 -0
  88. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/storage.py +0 -0
  89. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/run_history.py +0 -0
  90. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/__init__.py +0 -0
  91. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/common.py +0 -0
  92. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/metrics.py +0 -0
  93. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/params.py +0 -0
  94. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/plots.py +0 -0
  95. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/skip.py +0 -0
  96. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/stage_def.py +0 -0
  97. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/status.py +0 -0
  98. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/AGENTS.md +0 -0
  99. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/CLAUDE.md +0 -0
  100. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/__init__.py +0 -0
  101. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/artifact_lock.py +0 -0
  102. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/lock.py +0 -0
  103. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/presentation.py +0 -0
  104. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/restore.py +0 -0
  105. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/state.py +0 -0
  106. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/store.py +0 -0
  107. {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/track.py +0 -0
  108. {pivot-0.2.0 → pivot-0.2.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.2.0
3
+ Version: 0.2.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.2.0"
3
+ version = "0.2.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"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import enum
5
6
  import pathlib
6
7
  from typing import TYPE_CHECKING, Literal
@@ -12,7 +13,7 @@ from pivot.cli import completion
12
13
  from pivot.cli import decorators as cli_decorators
13
14
  from pivot.cli import helpers as cli_helpers
14
15
  from pivot.cli import targets as cli_targets
15
- from pivot.storage import cache, lock, track
16
+ from pivot.storage import cache, lock, state, track
16
17
  from pivot.types import HashInfo, is_dir_hash
17
18
 
18
19
  if TYPE_CHECKING:
@@ -96,6 +97,7 @@ def _restore_path_sync(
96
97
  "already exists" without --force). Cache misses return ("missing", name)
97
98
  instead of raising.
98
99
  """
100
+ keep_ignored = False
99
101
  if path.exists():
100
102
  match behavior:
101
103
  case CheckoutBehavior.ERROR:
@@ -106,12 +108,33 @@ def _restore_path_sync(
106
108
  + "Use --force to overwrite or --only-missing to skip existing files."
107
109
  )
108
110
  case CheckoutBehavior.SKIP_EXISTING:
109
- # For directories with manifests, don't skip - files inside may be missing.
110
- # Let restore_from_cache() handle it (does full directory restoration).
111
- # DirHash has "manifest" key, FileHash does not.
112
- is_directory = is_dir_hash(output_hash)
113
- if not is_directory:
111
+ # Existing file: skip. Existing real directory: fill in its missing files, or
112
+ # swap in the target when every differing file is pivot's own cached output,
113
+ # keeping the entries the directory hash skips. Never overwrite content the
114
+ # cache lacks (a local change).
115
+ if not is_dir_hash(output_hash) or path.is_symlink() or not path.is_dir():
114
116
  return ("skipped", path.name)
117
+ # Reuse hashes `pivot status` and `pivot track` recorded; never create the DB.
118
+ with (
119
+ state.StateDB(state_dir, readonly=True)
120
+ if state_dir is not None and (state_dir / "state.lmdb").exists()
121
+ else contextlib.nullcontext(None)
122
+ ) as state_db:
123
+ outcome = cache.restore_missing_in_directory(
124
+ path, output_hash, cache_dir, checkout_modes, state_db
125
+ )
126
+ if outcome["local_changes"]:
127
+ raise click.ClickException(
128
+ f"'{path.name}' has local changes that are not in the cache: "
129
+ + f"{', '.join(outcome['local_changes'])}. Use --force to overwrite."
130
+ )
131
+ # An interrupted restore may have staged kept entries beside the directory;
132
+ # the restore below puts them back, so take it even when nothing else differs.
133
+ if not outcome["replace"] and cache.staged_kept_entries(path) is None:
134
+ if outcome["unavailable"]:
135
+ return ("missing", path.name)
136
+ return ("restored" if outcome["restored"] else "skipped", path.name)
137
+ keep_ignored = True
115
138
  case CheckoutBehavior.FORCE:
116
139
  cache.remove_output(path)
117
140
  case _: # pyright: ignore[reportUnnecessaryComparison] - defensive for future enum values
@@ -123,6 +146,7 @@ def _restore_path_sync(
123
146
  cache_dir,
124
147
  checkout_modes=checkout_modes,
125
148
  state_dir=state_dir,
149
+ keep_ignored=keep_ignored,
126
150
  )
127
151
  if not success:
128
152
  return ("missing", path.name)
@@ -190,6 +190,29 @@ def _create_remote_if_needed(allow_missing: bool) -> remote_mod.S3Remote | None:
190
190
  return remote
191
191
 
192
192
 
193
+ def _status_from_explanations(
194
+ explanations: list[types.StageExplanation],
195
+ ) -> list[PipelineStatusInfo]:
196
+ """Convert rich explanations to verification status, retaining changed dep identities."""
197
+ statuses = list[PipelineStatusInfo]()
198
+ for explanation in explanations:
199
+ reason = explanation["reason"]
200
+ if reason == "Input dependencies changed" and explanation["dep_changes"]:
201
+ changed = ", ".join(
202
+ types.identity_key(change["identity"]) for change in explanation["dep_changes"]
203
+ )
204
+ reason = f"{reason}: {changed}"
205
+ statuses.append(
206
+ PipelineStatusInfo(
207
+ name=explanation["stage_name"],
208
+ status=(PipelineStatus.STALE if explanation["will_run"] else PipelineStatus.CACHED),
209
+ reason=reason,
210
+ upstream_stale=explanation["upstream_stale"],
211
+ )
212
+ )
213
+ return statuses
214
+
215
+
193
216
  def _verify_stages(
194
217
  pipeline_status: list[PipelineStatusInfo],
195
218
  cache_dir: pathlib.Path,
@@ -345,14 +368,16 @@ def verify(
345
368
 
346
369
  cache_dir = config.get_cache_dir()
347
370
 
348
- # Get pipeline status (uses default state directory internally)
349
- pipeline_status, _ = status_mod.get_pipeline_status(
371
+ # Get rich explanations so lock-only dependency mismatches identify the
372
+ # producer artifact in verification output.
373
+ explanations = status_mod.get_pipeline_explanations(
350
374
  stages_list,
351
375
  single_stage=False,
352
376
  all_stages=all_stages,
353
377
  pipeline=cli_helpers.get_pipeline(),
354
378
  allow_missing=allow_missing,
355
379
  )
380
+ pipeline_status = _status_from_explanations(explanations)
356
381
 
357
382
  if not pipeline_status:
358
383
  raise click.ClickException("No stages to verify.")
@@ -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:
@@ -6,6 +6,7 @@ Must be module-level and picklable.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import codecs
9
10
  import collections
10
11
  import collections.abc as collections_abc
11
12
  import contextlib
@@ -17,6 +18,7 @@ import random
17
18
  import sys
18
19
  import threading
19
20
  import traceback
21
+ import warnings
20
22
  from typing import IO, TYPE_CHECKING, Any, Literal, NamedTuple, TypedDict, cast, overload, override
21
23
 
22
24
  import pydantic
@@ -818,6 +820,42 @@ def _write_output_with_store(
818
820
  _reject_path_inside_project(ref, value, project_root, dependency_paths)
819
821
  output_path = store.prepare_output(ref)
820
822
  writer = cast("Any", ref.format)
823
+
824
+ output_label = (
825
+ f"its {type(writer).__name__} output"
826
+ if ref.identity.key is None
827
+ else f"output {ref.identity.key!r}"
828
+ )
829
+
830
+ def save_output(output_value: Any, destination: pathlib.Path) -> None:
831
+ with warnings.catch_warnings(record=True) as caught:
832
+ warnings.simplefilter("always", loaders.LossyOutputWarning)
833
+ try:
834
+ writer.save(output_value, destination)
835
+ except TypeError as e:
836
+ if isinstance(writer, loaders.YAML):
837
+ raise TypeError(
838
+ f"Stage '{ref.identity.producer}' cannot write {output_label}: {e}"
839
+ ) from e
840
+ raise
841
+ for caught_warning in caught:
842
+ if issubclass(caught_warning.category, loaders.LossyOutputWarning):
843
+ logger.warning(
844
+ "Stage '%s' wrote %s %s: %s",
845
+ ref.identity.producer,
846
+ output_label,
847
+ types.LOSSY_OUTPUT_PHRASE,
848
+ caught_warning.message,
849
+ )
850
+ else:
851
+ warnings.warn_explicit(
852
+ caught_warning.message,
853
+ caught_warning.category,
854
+ caught_warning.filename,
855
+ caught_warning.lineno,
856
+ source=caught_warning.source,
857
+ )
858
+
821
859
  if ref.tag is types.ArtifactTag.DIRECTORY:
822
860
  if not isinstance(value, dict):
823
861
  raise RuntimeError(
@@ -844,11 +882,11 @@ def _write_output_with_store(
844
882
  members.append((output_path / normalized, item_value))
845
883
  for full_path, item_value in members:
846
884
  full_path.parent.mkdir(parents=True, exist_ok=True)
847
- writer.save(item_value, full_path)
885
+ save_output(item_value, full_path)
848
886
  return output_path
849
887
 
850
888
  output_path.parent.mkdir(parents=True, exist_ok=True)
851
- writer.save(value, output_path)
889
+ save_output(value, output_path)
852
890
  return output_path
853
891
 
854
892
 
@@ -1095,14 +1133,19 @@ class _QueueWriter:
1095
1133
 
1096
1134
  def _pipe_reader(self) -> None:
1097
1135
  assert self._read_fd is not None # Guaranteed by _ensure_pipe() before thread starts
1136
+ # One incremental decoder per pipe: a multi-byte character split across two reads
1137
+ # is held until its remaining bytes arrive instead of becoming U+FFFD.
1138
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
1098
1139
  try:
1099
1140
  while True:
1100
1141
  data = os.read(self._read_fd, 8192)
1101
1142
  if not data:
1102
1143
  break
1103
- self.write(data.decode("utf-8", errors="replace"))
1144
+ self.write(decoder.decode(data))
1104
1145
  except OSError:
1105
1146
  pass
1147
+ # Flush a truncated trailing character so it is reported rather than dropped.
1148
+ self.write(decoder.decode(b"", final=True))
1106
1149
 
1107
1150
  def _ensure_pipe(self) -> None:
1108
1151
  if self._read_fd is not None:
@@ -1,8 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import functools
5
6
  import logging
7
+ import os
8
+ import pathlib
9
+ import tempfile
6
10
  from typing import TYPE_CHECKING, TypedDict, cast
7
11
 
8
12
  import aiohttp
@@ -16,7 +20,6 @@ from pivot.storage import cache, track
16
20
  from pivot.storage import lock as storage_lock
17
21
 
18
22
  if TYPE_CHECKING:
19
- import pathlib
20
23
  from collections.abc import Callable
21
24
 
22
25
  from pivot.types import DirManifestEntry, OutEntry, StorageLockData
@@ -39,6 +42,31 @@ class ImportResult(TypedDict):
39
42
  downloaded: bool
40
43
 
41
44
 
45
+ async def _download_verified(remote_url: str, file_hash: str, data_path: pathlib.Path) -> int:
46
+ """Download ``file_hash`` to ``data_path`` only if its content matches; return its size.
47
+
48
+ The file is downloaded to a temp file in the destination directory and moved over
49
+ ``data_path`` after its hash is checked, so a corrupt remote copy leaves any existing
50
+ file (the last good import) untouched.
51
+ """
52
+ fd, tmp_name = tempfile.mkstemp(
53
+ dir=data_path.parent, prefix=f".{data_path.name}.", suffix=".tmp"
54
+ )
55
+ os.close(fd)
56
+ tmp_path = pathlib.Path(tmp_name)
57
+ try:
58
+ await remote_storage.S3Remote(remote_url).download_file(file_hash, tmp_path)
59
+ actual_hash, _ = cache.hash_file(tmp_path)
60
+ if actual_hash != file_hash:
61
+ raise exceptions.RemoteError(f"Hash mismatch: expected {file_hash}, got {actual_hash}")
62
+ size = tmp_path.stat().st_size
63
+ os.replace(tmp_path, data_path)
64
+ finally:
65
+ with contextlib.suppress(FileNotFoundError):
66
+ tmp_path.unlink()
67
+ return size
68
+
69
+
42
70
  def _maybe_token(repo_url: str, token: str | None) -> str | None:
43
71
  if token is not None:
44
72
  return token
@@ -111,7 +139,13 @@ async def read_remote_config(
111
139
  remotes = {str(k): str(v) for k, v in remotes_data.items()}
112
140
 
113
141
  default_remote = config_data.get("default_remote")
114
- if not isinstance(default_remote, str):
142
+ if default_remote is not None and not isinstance(default_remote, str):
143
+ # Warn-first: the source config is a saved input outside this repo.
144
+ logger.warning(
145
+ "Remote config default_remote in %s must be a remote name, got %r; ignoring it",
146
+ repo_url,
147
+ default_remote,
148
+ )
115
149
  default_remote = None
116
150
  if default_remote is None:
117
151
  if len(remotes) == 1:
@@ -370,16 +404,8 @@ async def import_artifact(
370
404
  downloaded = False
371
405
  file_size = resolved["size"]
372
406
  if not no_download:
373
- remote = remote_storage.S3Remote(resolved["remote_url"])
374
- await remote.download_file(resolved["hash"], data_path)
407
+ file_size = await _download_verified(resolved["remote_url"], resolved["hash"], data_path)
375
408
  downloaded = True
376
- actual_hash, _ = cache.hash_file(data_path)
377
- if actual_hash != resolved["hash"]:
378
- data_path.unlink(missing_ok=True)
379
- raise exceptions.RemoteError(
380
- f"Downloaded file hash mismatch: expected {resolved['hash']}, got {actual_hash}"
381
- )
382
- file_size = data_path.stat().st_size
383
409
 
384
410
  source = track.ImportSource(
385
411
  repo=repo_url,
@@ -456,16 +482,8 @@ async def update_import(pvt_path: pathlib.Path, *, new_rev: str | None = None) -
456
482
 
457
483
  file_size = resolved["size"]
458
484
  if need_download:
459
- remote = remote_storage.S3Remote(resolved["remote_url"])
460
- await remote.download_file(resolved["hash"], data_path)
461
- actual_hash, _ = cache.hash_file(data_path)
462
- if actual_hash != resolved["hash"]:
463
- data_path.unlink(missing_ok=True)
464
- raise exceptions.RemoteError(
465
- f"Hash mismatch: expected {resolved['hash']}, got {actual_hash}"
466
- )
485
+ file_size = await _download_verified(resolved["remote_url"], resolved["hash"], data_path)
467
486
  downloaded = True
468
- file_size = data_path.stat().st_size
469
487
  elif data_path.exists():
470
488
  file_size = data_path.stat().st_size
471
489