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.
- {pivot-0.2.0 → pivot-0.2.2}/PKG-INFO +1 -1
- {pivot-0.2.0 → pivot-0.2.2}/pyproject.toml +1 -1
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/checkout.py +30 -6
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/verify.py +27 -2
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/sinks.py +76 -10
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/worker.py +46 -3
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/import_artifact.py +38 -20
- pivot-0.2.2/src/pivot/loaders.py +888 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/sync.py +38 -2
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/data.py +3 -4
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/cache.py +257 -2
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/types.py +5 -0
- pivot-0.2.0/src/pivot/loaders.py +0 -389
- {pivot-0.2.0 → pivot-0.2.2}/LICENSE +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/README.md +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/ast_utils.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/AGENTS.md +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/CLAUDE.md +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/_run_common.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/check_ignore.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/commit.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/completion.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/config.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/console.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/dag.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/data.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/decorators.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/doctor.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/errors.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/fingerprint.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/helpers.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/history.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/import_cmd.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/init.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/list.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/metrics.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/params.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/plots.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/remote.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/repro.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/run.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/status.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/targets.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/track.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/cli/update.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/compose.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/config/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/config/io.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/config/models.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/dag/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/dag/render.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/decorators.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/discovery.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/agent_rpc.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/engine.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/graph.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/scheduler.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/sources.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/types.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/watch.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/engine/worker_pool.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/exceptions.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/AGENTS.md +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/CLAUDE.md +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/commit.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/executor/core.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/explain.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/fingerprint.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/git.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/ignore.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/metrics.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/names.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/outputs.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/parameters.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/path_policy.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/project.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/py.typed +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/registry.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/AGENTS.md +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/config.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/git_archive.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/github.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/remote/storage.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/run_history.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/common.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/metrics.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/params.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/show/plots.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/skip.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/stage_def.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/status.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/AGENTS.md +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/CLAUDE.md +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/__init__.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/artifact_lock.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/lock.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/presentation.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/restore.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/state.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/store.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/storage/track.py +0 -0
- {pivot-0.2.0 → pivot-0.2.2}/src/pivot/yaml_config.py +0 -0
|
@@ -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
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
if not
|
|
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
|
|
349
|
-
|
|
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
|
-
#
|
|
254
|
+
# Buffer for the report; stream if --show-output
|
|
191
255
|
stage = event["stage"]
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|