steppy-dev 0.3.0__py3-none-any.whl
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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/cloud/upload.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""``steppy bundle upload`` — push one run's results to a control plane.
|
|
2
|
+
|
|
3
|
+
The protocol is content-addressed and has three steps::
|
|
4
|
+
|
|
5
|
+
① POST /v1/runs/{id}/bundle manifest → PUT URLs for missing digests
|
|
6
|
+
② PUT <presigned url> bytes → the object store, directly
|
|
7
|
+
③ POST /v1/runs/{id}/bundle/finalize verify → ingestion is queued
|
|
8
|
+
|
|
9
|
+
Step ① is the whole point. With locales x layouts x device scale factors an
|
|
10
|
+
unchanged screenshot dominates a bundle, so the server answers with URLs *only*
|
|
11
|
+
for digests it does not already hold — megabytes instead of hundreds of them.
|
|
12
|
+
|
|
13
|
+
**No archive is written.** The upload is per-file, so a ``.tar.zst`` would be
|
|
14
|
+
created only to be read back and thrown away. Uploading from a live ``output/``
|
|
15
|
+
tree is therefore the default; an existing bundle is accepted too, for a CI job
|
|
16
|
+
that built one in an earlier step.
|
|
17
|
+
|
|
18
|
+
Around those three calls sits the run lifecycle, and the shape of it is not
|
|
19
|
+
arbitrary: a locally-executed run is **one whole-run task that the uploader
|
|
20
|
+
claims**. That is what gives the plane's watchdog, cancellation, task counts and
|
|
21
|
+
verdict SQL something to operate on, all of which already exist. It is also
|
|
22
|
+
exactly what ``steppy ci run`` will do, minus the running::
|
|
23
|
+
|
|
24
|
+
POST /v1/runs executor=local → run + one kind='run' task
|
|
25
|
+
POST /v1/runs/{id}/claim → claim token
|
|
26
|
+
①②③
|
|
27
|
+
POST /v1/tasks/{tid}/complete → the run reaches a verdict
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import time
|
|
33
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from io import BytesIO
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from threading import Lock
|
|
38
|
+
from typing import Any, BinaryIO, Callable, Iterator, Optional, Protocol
|
|
39
|
+
|
|
40
|
+
from ..bundle import BundlePlan, inspect_bundle
|
|
41
|
+
from ..bundle.archive import open_archive
|
|
42
|
+
from ..bundle.create import MANIFEST_NAME
|
|
43
|
+
from ..contracts.bundle import BundleManifest
|
|
44
|
+
from .client import CloudClient, CloudError
|
|
45
|
+
|
|
46
|
+
Opener = Callable[[], BinaryIO]
|
|
47
|
+
|
|
48
|
+
#: A presigned URL that has expired. S3 and MinIO both answer 403; a malformed
|
|
49
|
+
#: one answers 400. Either way the fix is the same — ask for fresh URLs — so
|
|
50
|
+
#: they are treated together.
|
|
51
|
+
_EXPIRED = frozenset({400, 403})
|
|
52
|
+
|
|
53
|
+
#: Test outcomes that make a run red. `interrupted` and `worker_died` are
|
|
54
|
+
#: included: a test that never finished is not a pass, and reporting one as
|
|
55
|
+
#: green is the single worst thing this function could do.
|
|
56
|
+
_FAILING_OUTCOMES = frozenset({"failed", "error", "interrupted", "worker_died"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def derive_exit_code(manifest: BundleManifest) -> int:
|
|
60
|
+
"""The run's verdict, read off the manifest.
|
|
61
|
+
|
|
62
|
+
Derived rather than observed, because upload happens after the fact and the
|
|
63
|
+
real exit code is long gone. A caller that *does* know it — ``steppy ci
|
|
64
|
+
run``, later — passes it explicitly instead.
|
|
65
|
+
|
|
66
|
+
An empty ``tests[]`` yields 0. That is honest (the bundle records no
|
|
67
|
+
failure) but it is also why ``steppy bundle create`` warns when the list is
|
|
68
|
+
empty: without ``steppy run --bundle`` there are no outcomes to read.
|
|
69
|
+
"""
|
|
70
|
+
return 1 if any(t.outcome in _FAILING_OUTCOMES for t in manifest.tests) else 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Blob:
|
|
75
|
+
"""One file's bytes, addressed by digest and opened on demand."""
|
|
76
|
+
|
|
77
|
+
sha256: str
|
|
78
|
+
size: int
|
|
79
|
+
open: Opener
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BlobSource(Protocol):
|
|
83
|
+
"""Where the bytes named by a manifest come from."""
|
|
84
|
+
|
|
85
|
+
manifest: BundleManifest
|
|
86
|
+
#: 1 when the source can only be read sequentially, so uploads must be
|
|
87
|
+
#: serial. Anything else means the openers are independent and safe to call
|
|
88
|
+
#: from several threads.
|
|
89
|
+
max_concurrency: int
|
|
90
|
+
|
|
91
|
+
def blobs(self, wanted: frozenset[str]) -> Iterator[Blob]:
|
|
92
|
+
"""Yield each wanted digest exactly once, in whatever order suits."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TreeSource:
|
|
97
|
+
"""A live ``output/`` tree, via a :class:`~steppy.bundle.BundlePlan`.
|
|
98
|
+
|
|
99
|
+
The default, and the fast one: every blob is a file on disk (or, for the
|
|
100
|
+
two generated members, bytes already in hand), so openers are independent
|
|
101
|
+
and uploads run concurrently.
|
|
102
|
+
|
|
103
|
+
Path resolution goes through :func:`~steppy.bundle.archive_members` rather
|
|
104
|
+
than joining ``output_dir`` with each manifest path — ``junit.xml`` and the
|
|
105
|
+
``events/*.jsonl`` members are generated and have no file under ``output/``
|
|
106
|
+
at all, so a second implementation of the mapping would quietly drop them.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
max_concurrency = 0
|
|
110
|
+
|
|
111
|
+
def __init__(self, plan: BundlePlan) -> None:
|
|
112
|
+
self.plan = plan
|
|
113
|
+
self.manifest = plan.manifest
|
|
114
|
+
sha_by_path = {f.path: f.sha256 for f in plan.manifest.files}
|
|
115
|
+
openers: dict[str, Opener] = {}
|
|
116
|
+
for member in plan.members():
|
|
117
|
+
if member.arcname == MANIFEST_NAME:
|
|
118
|
+
# The manifest is the request body, not an uploaded file — and
|
|
119
|
+
# it cannot contain its own digest, so it is not in files[].
|
|
120
|
+
continue
|
|
121
|
+
sha = sha_by_path.get(member.arcname)
|
|
122
|
+
# Two paths can share a digest (an unchanged screenshot captured for
|
|
123
|
+
# two layouts). One upload serves both; first opener wins.
|
|
124
|
+
if sha is None or sha in openers:
|
|
125
|
+
continue
|
|
126
|
+
openers[sha] = self._opener(member)
|
|
127
|
+
self._openers = openers
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _opener(member) -> Opener:
|
|
131
|
+
if member.data is not None:
|
|
132
|
+
data = member.data
|
|
133
|
+
return lambda: BytesIO(data)
|
|
134
|
+
source = member.source
|
|
135
|
+
return lambda: source.open("rb")
|
|
136
|
+
|
|
137
|
+
def blobs(self, wanted: frozenset[str]) -> Iterator[Blob]:
|
|
138
|
+
by_sha = {f.sha256: f for f in self.manifest.files}
|
|
139
|
+
for sha in wanted:
|
|
140
|
+
opener = self._openers.get(sha)
|
|
141
|
+
entry = by_sha.get(sha)
|
|
142
|
+
if opener is None or entry is None:
|
|
143
|
+
# The server asked for a digest this bundle does not describe.
|
|
144
|
+
# Impossible unless the manifest and the plan disagree, which is
|
|
145
|
+
# worth failing on rather than uploading something else.
|
|
146
|
+
raise CloudError(f"the server asked for digest {sha[:12]}…, which is not in this bundle")
|
|
147
|
+
yield Blob(sha256=sha, size=entry.size, open=opener)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ArchiveSource:
|
|
151
|
+
"""An existing ``.tar.zst`` / ``.tar.gz`` bundle.
|
|
152
|
+
|
|
153
|
+
A zstd bundle is opened as a **stream** (``r|``), so members arrive in
|
|
154
|
+
archive order and only once — which is why uploads from an archive are
|
|
155
|
+
serial, and why each member is buffered in memory for the duration of its
|
|
156
|
+
own PUT. Buffering is what makes a retried upload able to rewind; streaming
|
|
157
|
+
a non-seekable tar member into a retry would resend from wherever the failed
|
|
158
|
+
attempt stopped.
|
|
159
|
+
|
|
160
|
+
One member at a time, never the whole archive.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
max_concurrency = 1
|
|
164
|
+
|
|
165
|
+
def __init__(self, path: Path) -> None:
|
|
166
|
+
self.path = Path(path)
|
|
167
|
+
self.manifest = inspect_bundle(self.path)
|
|
168
|
+
|
|
169
|
+
def blobs(self, wanted: frozenset[str]) -> Iterator[Blob]:
|
|
170
|
+
entries = {f.path: f for f in self.manifest.files if f.sha256 in wanted}
|
|
171
|
+
seen: set[str] = set()
|
|
172
|
+
with open_archive(self.path) as tar:
|
|
173
|
+
for member in tar:
|
|
174
|
+
if not member.isfile():
|
|
175
|
+
continue
|
|
176
|
+
entry = entries.get(member.name)
|
|
177
|
+
if entry is None or entry.sha256 in seen:
|
|
178
|
+
continue
|
|
179
|
+
handle = tar.extractfile(member)
|
|
180
|
+
if handle is None:
|
|
181
|
+
continue
|
|
182
|
+
data = handle.read()
|
|
183
|
+
seen.add(entry.sha256)
|
|
184
|
+
yield Blob(sha256=entry.sha256, size=entry.size, open=lambda d=data: BytesIO(d))
|
|
185
|
+
|
|
186
|
+
missing = wanted - seen
|
|
187
|
+
if missing:
|
|
188
|
+
raise CloudError(
|
|
189
|
+
f"{len(missing)} file(s) the server asked for are missing from {self.path.name}. "
|
|
190
|
+
"Re-create the bundle, or run `steppy bundle verify` on it"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class UploadOutcome:
|
|
196
|
+
"""What happened, in the shape the CLI prints and `--json` emits."""
|
|
197
|
+
|
|
198
|
+
run_id: str
|
|
199
|
+
run_number: int
|
|
200
|
+
run_status: str
|
|
201
|
+
created_run: bool
|
|
202
|
+
task_id: Optional[str] = None
|
|
203
|
+
#: Blobs the server confirmed were present in the object store.
|
|
204
|
+
verified: int = 0
|
|
205
|
+
total_files: int = 0
|
|
206
|
+
#: Distinct digests among those files. Lower than `total_files` whenever a
|
|
207
|
+
#: screenshot is byte-identical across locales or layouts — and **this**, not
|
|
208
|
+
#: `total_files`, is what `uploaded` and `already_held` are counted against.
|
|
209
|
+
#: The server's `already_held` is in digests while its `total_files` is in
|
|
210
|
+
#: files, so reporting one against the other reads as a discrepancy.
|
|
211
|
+
total_blobs: int = 0
|
|
212
|
+
uploaded: int = 0
|
|
213
|
+
uploaded_bytes: int = 0
|
|
214
|
+
already_held: int = 0
|
|
215
|
+
#: Bytes the content-addressed protocol saved — the whole argument for it,
|
|
216
|
+
#: in numbers the user can see.
|
|
217
|
+
skipped_bytes: int = 0
|
|
218
|
+
payload_bytes: int = 0
|
|
219
|
+
exit_code: int = 0
|
|
220
|
+
warnings: list[str] = field(default_factory=list)
|
|
221
|
+
|
|
222
|
+
def as_dict(self) -> dict[str, Any]:
|
|
223
|
+
return {
|
|
224
|
+
"run_id": self.run_id,
|
|
225
|
+
"run_number": self.run_number,
|
|
226
|
+
"run_status": self.run_status,
|
|
227
|
+
"created_run": self.created_run,
|
|
228
|
+
"task_id": self.task_id,
|
|
229
|
+
"verified": self.verified,
|
|
230
|
+
"total_files": self.total_files,
|
|
231
|
+
"total_blobs": self.total_blobs,
|
|
232
|
+
"uploaded": self.uploaded,
|
|
233
|
+
"uploaded_bytes": self.uploaded_bytes,
|
|
234
|
+
"already_held": self.already_held,
|
|
235
|
+
"skipped_bytes": self.skipped_bytes,
|
|
236
|
+
"payload_bytes": self.payload_bytes,
|
|
237
|
+
"exit_code": self.exit_code,
|
|
238
|
+
"warnings": self.warnings,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _run_spec(manifest: BundleManifest) -> dict[str, Any]:
|
|
243
|
+
"""`BundleSpec` → the plane's `RunSpec`.
|
|
244
|
+
|
|
245
|
+
Not a passthrough: the bundle records a single ``tag`` (whatever the run was
|
|
246
|
+
invoked with) while the plane's spec takes a ``tags`` list, and the plane
|
|
247
|
+
carries reporting fields a bundle never sees. `RunSpec` is `extra="forbid"`,
|
|
248
|
+
so sending the bundle's shape verbatim would be a 422.
|
|
249
|
+
"""
|
|
250
|
+
spec = manifest.run.spec
|
|
251
|
+
raw_tag = spec.tag
|
|
252
|
+
if raw_tag is None:
|
|
253
|
+
tags: list[str] = []
|
|
254
|
+
elif isinstance(raw_tag, (list, tuple)):
|
|
255
|
+
tags = [str(t) for t in raw_tag]
|
|
256
|
+
else:
|
|
257
|
+
tags = [str(raw_tag)]
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
"scenario": spec.scenario,
|
|
261
|
+
"tags": tags,
|
|
262
|
+
"guide": spec.guide,
|
|
263
|
+
"no_guide": spec.no_guide,
|
|
264
|
+
"no_asserts": spec.no_asserts,
|
|
265
|
+
"screenshots": spec.screenshots,
|
|
266
|
+
"coverage": spec.coverage,
|
|
267
|
+
"locales": list(spec.locales),
|
|
268
|
+
"layouts": list(spec.layouts),
|
|
269
|
+
"workers": spec.workers,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _scenarios(manifest: BundleManifest) -> list[str]:
|
|
274
|
+
return sorted({t.scenario for t in manifest.tests if t.scenario})
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _create_run(client: CloudClient, manifest: BundleManifest, *, executor: str, trigger: str) -> dict[str, Any]:
|
|
278
|
+
"""Create the plane-side run, idempotently.
|
|
279
|
+
|
|
280
|
+
The bundle's own ``run_id`` is the idempotency key. It is stable across
|
|
281
|
+
retries of the same upload and unique across runs, which is exactly what
|
|
282
|
+
``Idempotency-Key`` wants — so a second `steppy bundle upload` after a
|
|
283
|
+
network failure attaches to the run the first one made instead of burning a
|
|
284
|
+
second run number for one set of results.
|
|
285
|
+
"""
|
|
286
|
+
payload = {
|
|
287
|
+
"trigger": trigger,
|
|
288
|
+
"executor": executor,
|
|
289
|
+
"spec": _run_spec(manifest),
|
|
290
|
+
"git": {
|
|
291
|
+
"sha": manifest.run.git.sha,
|
|
292
|
+
"branch": manifest.run.git.branch,
|
|
293
|
+
"pr_number": manifest.run.git.pr,
|
|
294
|
+
},
|
|
295
|
+
"scenarios": _scenarios(manifest),
|
|
296
|
+
}
|
|
297
|
+
return client.post("/runs", json=payload, headers={"Idempotency-Key": manifest.run.run_id})
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _claim(client: CloudClient, run_id: str, *, steppy_version: str, runner: str, timeout: float) -> dict[str, Any]:
|
|
301
|
+
"""Take ownership of the run's whole-run task.
|
|
302
|
+
|
|
303
|
+
Retries while the run is still ``queued``, because dispatch is a background
|
|
304
|
+
job: the run exists the instant ``POST /runs`` answers, but its task is
|
|
305
|
+
written a moment later by a worker. Retrying blind would be wrong — a 409 on
|
|
306
|
+
an already-dispatched run means somebody else owns it — so the run's status
|
|
307
|
+
is what decides whether to wait.
|
|
308
|
+
"""
|
|
309
|
+
deadline = time.monotonic() + timeout
|
|
310
|
+
delay = 0.25
|
|
311
|
+
while True:
|
|
312
|
+
try:
|
|
313
|
+
return client.post(
|
|
314
|
+
f"/runs/{run_id}/claim",
|
|
315
|
+
json={"steppy_version": steppy_version, "runner": runner},
|
|
316
|
+
)
|
|
317
|
+
except CloudError as e:
|
|
318
|
+
if e.status != 409:
|
|
319
|
+
raise
|
|
320
|
+
status = (client.get(f"/runs/{run_id}") or {}).get("status")
|
|
321
|
+
if status != "queued":
|
|
322
|
+
raise CloudError(
|
|
323
|
+
f"run {run_id} is {status!r} and cannot be claimed — another executor owns it, "
|
|
324
|
+
"or it was cancelled. Upload to a new run, or pass --run-id for a different one",
|
|
325
|
+
status=409,
|
|
326
|
+
) from None
|
|
327
|
+
if time.monotonic() >= deadline:
|
|
328
|
+
raise CloudError(
|
|
329
|
+
f"run {run_id} was still queued after {timeout:.0f}s — the control plane's "
|
|
330
|
+
"background worker may not be running",
|
|
331
|
+
status=409,
|
|
332
|
+
) from None
|
|
333
|
+
time.sleep(delay)
|
|
334
|
+
delay = min(delay * 2, 2.0)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _offer(client: CloudClient, run_id: str, manifest: BundleManifest, *, token: str) -> dict[str, Any]:
|
|
338
|
+
return client.post(f"/runs/{run_id}/bundle", json=manifest.model_dump(mode="json"), token=token)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass
|
|
342
|
+
class _Sent:
|
|
343
|
+
"""Upload progress, counted as it happens rather than inferred afterwards.
|
|
344
|
+
|
|
345
|
+
Mutable and shared with :func:`_put_all` on purpose: when a presigned URL
|
|
346
|
+
expires mid-batch the exception carries no partial result, and reconstructing
|
|
347
|
+
"how far did we get" from the next offer is guesswork. This is the one place
|
|
348
|
+
the answer is known.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
count: int = 0
|
|
352
|
+
nbytes: int = 0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _put_all(
|
|
356
|
+
client: CloudClient,
|
|
357
|
+
source: BlobSource,
|
|
358
|
+
targets: list[dict[str, Any]],
|
|
359
|
+
*,
|
|
360
|
+
concurrency: int,
|
|
361
|
+
sent: _Sent,
|
|
362
|
+
on_progress: Optional[Callable[[Blob], None]],
|
|
363
|
+
) -> None:
|
|
364
|
+
"""PUT every target, recording progress into *sent* as each one lands."""
|
|
365
|
+
by_sha = {t["sha256"]: t for t in targets}
|
|
366
|
+
lock = Lock()
|
|
367
|
+
|
|
368
|
+
def put(blob: Blob) -> None:
|
|
369
|
+
client.put_blob(by_sha[blob.sha256]["url"], blob.open, blob.size)
|
|
370
|
+
with lock:
|
|
371
|
+
sent.count += 1
|
|
372
|
+
sent.nbytes += blob.size
|
|
373
|
+
if on_progress is not None:
|
|
374
|
+
on_progress(blob)
|
|
375
|
+
|
|
376
|
+
wanted = frozenset(by_sha)
|
|
377
|
+
if source.max_concurrency == 1 or concurrency <= 1:
|
|
378
|
+
for blob in source.blobs(wanted):
|
|
379
|
+
put(blob)
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
# Openers are independent here, so listing them costs nothing — no bytes are
|
|
383
|
+
# read until a worker calls one.
|
|
384
|
+
blobs = list(source.blobs(wanted))
|
|
385
|
+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
386
|
+
# list() rather than a bare map: map is lazy, and an exception in a
|
|
387
|
+
# worker would otherwise surface only if someone iterated the result.
|
|
388
|
+
list(pool.map(put, blobs))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def upload_bundle(
|
|
392
|
+
client: CloudClient,
|
|
393
|
+
source: BlobSource,
|
|
394
|
+
*,
|
|
395
|
+
run_id: Optional[str] = None,
|
|
396
|
+
executor: str = "local",
|
|
397
|
+
trigger: str = "manual",
|
|
398
|
+
exit_code: Optional[int] = None,
|
|
399
|
+
duration_ms: Optional[int] = None,
|
|
400
|
+
concurrency: int = 4,
|
|
401
|
+
claim_timeout: float = 60.0,
|
|
402
|
+
settle_timeout: float = 30.0,
|
|
403
|
+
on_progress: Optional[Callable[[Blob], None]] = None,
|
|
404
|
+
) -> UploadOutcome:
|
|
405
|
+
"""Push ``source``'s bundle to the plane and take the run to a verdict."""
|
|
406
|
+
manifest = source.manifest
|
|
407
|
+
created = run_id is None
|
|
408
|
+
|
|
409
|
+
run = (
|
|
410
|
+
_create_run(client, manifest, executor=executor, trigger=trigger) if created else client.get(f"/runs/{run_id}")
|
|
411
|
+
)
|
|
412
|
+
run_id = str(run["id"])
|
|
413
|
+
|
|
414
|
+
job = _claim(
|
|
415
|
+
client,
|
|
416
|
+
run_id,
|
|
417
|
+
steppy_version=manifest.run.steppy_version,
|
|
418
|
+
runner=f"{executor} upload",
|
|
419
|
+
timeout=claim_timeout,
|
|
420
|
+
)
|
|
421
|
+
token = job["claim_token"]
|
|
422
|
+
task_id = job["task_id"]
|
|
423
|
+
|
|
424
|
+
offer = _offer(client, run_id, manifest, token=token)
|
|
425
|
+
sent = _Sent()
|
|
426
|
+
|
|
427
|
+
# One re-offer, no more. A URL that expires mid-upload is ordinary on a slow
|
|
428
|
+
# link; the offer is keyed on (run, task) and reports what is already held,
|
|
429
|
+
# so re-offering returns only the remainder and nothing is re-sent. A second
|
|
430
|
+
# expiry means the URL lifetime is shorter than a single blob takes, which
|
|
431
|
+
# is a server setting, not something to retry through.
|
|
432
|
+
for attempt in (0, 1):
|
|
433
|
+
if not offer["uploads"]:
|
|
434
|
+
break
|
|
435
|
+
try:
|
|
436
|
+
_put_all(client, source, offer["uploads"], concurrency=concurrency, sent=sent, on_progress=on_progress)
|
|
437
|
+
break
|
|
438
|
+
except CloudError as e:
|
|
439
|
+
if e.status not in _EXPIRED or attempt == 1:
|
|
440
|
+
raise
|
|
441
|
+
offer = _offer(client, run_id, manifest, token=token)
|
|
442
|
+
|
|
443
|
+
finalized = client.post(f"/runs/{run_id}/bundle/finalize", token=token)
|
|
444
|
+
|
|
445
|
+
# **Finalize before complete, always.** Completing first would let
|
|
446
|
+
# `close_if_finished` run with no bundle pending, close the run, and then
|
|
447
|
+
# refuse to move it to `ingesting` when the bundle lands — the plane's close
|
|
448
|
+
# statement guards on the run not already being terminal. The results would
|
|
449
|
+
# still be ingested; the run's status would just be a lie.
|
|
450
|
+
resolved_exit = derive_exit_code(manifest) if exit_code is None else exit_code
|
|
451
|
+
client.post(
|
|
452
|
+
f"/tasks/{task_id}/complete",
|
|
453
|
+
json={"exit_code": resolved_exit, "duration_ms": duration_ms},
|
|
454
|
+
token=token,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
status = _settle(client, run_id, timeout=settle_timeout)
|
|
458
|
+
payload_bytes = sum(f.size for f in manifest.files)
|
|
459
|
+
|
|
460
|
+
return UploadOutcome(
|
|
461
|
+
run_id=run_id,
|
|
462
|
+
run_number=int(run.get("number", 0)),
|
|
463
|
+
run_status=status,
|
|
464
|
+
created_run=created,
|
|
465
|
+
task_id=task_id,
|
|
466
|
+
verified=int(finalized.get("verified", 0)) if isinstance(finalized, dict) else 0,
|
|
467
|
+
total_files=int(offer.get("total_files", len(manifest.files))),
|
|
468
|
+
total_blobs=len(manifest.digests()),
|
|
469
|
+
uploaded=sent.count,
|
|
470
|
+
uploaded_bytes=sent.nbytes,
|
|
471
|
+
already_held=int(offer.get("already_held", 0)),
|
|
472
|
+
# Derived from what was sent, not from the offer: after a re-offer the
|
|
473
|
+
# offer's `already_held` describes the second round only.
|
|
474
|
+
skipped_bytes=max(0, payload_bytes - sent.nbytes),
|
|
475
|
+
payload_bytes=payload_bytes,
|
|
476
|
+
exit_code=resolved_exit,
|
|
477
|
+
warnings=list(manifest.warnings),
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _settle(client: CloudClient, run_id: str, *, timeout: float) -> str:
|
|
482
|
+
"""Wait briefly for ingestion to land the run's verdict.
|
|
483
|
+
|
|
484
|
+
Ingestion is a background job, so `complete` leaves the run at `ingesting`
|
|
485
|
+
for a moment. Waiting makes the command's last line the truth rather than a
|
|
486
|
+
waypoint — but only briefly: a slow queue is not this command's failure, and
|
|
487
|
+
the bundle is safely stored either way. Whatever status we have when the
|
|
488
|
+
clock runs out is reported as-is.
|
|
489
|
+
"""
|
|
490
|
+
deadline = time.monotonic() + timeout
|
|
491
|
+
status = "unknown"
|
|
492
|
+
while True:
|
|
493
|
+
run = client.get(f"/runs/{run_id}") or {}
|
|
494
|
+
status = str(run.get("status", status))
|
|
495
|
+
if status not in ("ingesting", "running", "queued", "dispatched") or time.monotonic() >= deadline:
|
|
496
|
+
return status
|
|
497
|
+
time.sleep(1.0)
|
steppy/compat.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Machine-readable deprecation registry (FLAWS §7.7 prerequisite).
|
|
2
|
+
|
|
3
|
+
This is the seed data behind a future ``steppy update --check`` command (FLAWS
|
|
4
|
+
§7.7): a per-version list of things that were renamed or deprecated, so a project
|
|
5
|
+
can be scanned against the installed Steppy version and told, concretely, what
|
|
6
|
+
will stop working and when — instead of a CHANGELOG paragraph.
|
|
7
|
+
|
|
8
|
+
The command itself is deliberately deferred to the first post-launch 0.x (FLAWS
|
|
9
|
+
§9). Only this data ships now, because the entries must be recorded *as the
|
|
10
|
+
deprecations happen* — retrofitting a "what changed in 0.4" registry after the
|
|
11
|
+
fact is exactly the ambiguity §6.4's version field exists to avoid.
|
|
12
|
+
|
|
13
|
+
Each entry is intentionally declarative (no live matcher callables yet): the
|
|
14
|
+
``matches`` field describes, in prose, what a scanner should look for. When the
|
|
15
|
+
``update`` command lands it will grow real matchers keyed off these entries.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
Severity = Literal["info", "warning", "error"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Deprecation:
|
|
28
|
+
"""One deprecated spelling and its replacement.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
id: Stable slug (used as the future ``--check`` message anchor).
|
|
32
|
+
introduced_in: Steppy version that introduced the replacement.
|
|
33
|
+
removed_in: Version the deprecated form is scheduled to stop working, or
|
|
34
|
+
``None`` for "kept-forever" aliases.
|
|
35
|
+
surface: Where the deprecated form appears (``scenario`` | ``config``).
|
|
36
|
+
matches: Prose description of what a scanner should flag.
|
|
37
|
+
message: User-facing guidance printed by ``steppy update --check``.
|
|
38
|
+
severity: How loud to be while the alias still works.
|
|
39
|
+
auto_fixable: Whether a future ``--migrate`` could rewrite it mechanically.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
id: str
|
|
43
|
+
introduced_in: str
|
|
44
|
+
removed_in: str | None
|
|
45
|
+
surface: Literal["scenario", "config"]
|
|
46
|
+
matches: str
|
|
47
|
+
message: str
|
|
48
|
+
severity: Severity
|
|
49
|
+
auto_fixable: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# The three §6 aliases, normalized transparently at load time today (see
|
|
53
|
+
# models.py), plus the §4.6 inert `output.reports_dir`. All are "info": the
|
|
54
|
+
# aliases still resolve and the dead key still parses, so nothing breaks yet.
|
|
55
|
+
DEPRECATIONS: tuple[Deprecation, ...] = (
|
|
56
|
+
Deprecation(
|
|
57
|
+
id="auth-mode-auth0-to-form",
|
|
58
|
+
introduced_in="0.4.0",
|
|
59
|
+
removed_in=None, # kept-forever alias
|
|
60
|
+
surface="config",
|
|
61
|
+
matches="apps.<role>.auth.mode == 'auth0'",
|
|
62
|
+
message=(
|
|
63
|
+
"auth mode 'auth0' was renamed to 'form' (the form-fill works against "
|
|
64
|
+
"any IdP, not just Auth0). The 'auth0' spelling is a kept-forever alias."
|
|
65
|
+
),
|
|
66
|
+
severity="info",
|
|
67
|
+
auto_fixable=True,
|
|
68
|
+
),
|
|
69
|
+
Deprecation(
|
|
70
|
+
id="auth-global-key-auth0-to-form",
|
|
71
|
+
introduced_in="0.4.0",
|
|
72
|
+
removed_in=None,
|
|
73
|
+
surface="config",
|
|
74
|
+
matches="top-level auth.auth0: block (should be auth.form:)",
|
|
75
|
+
message=(
|
|
76
|
+
"the global 'auth.auth0:' block was renamed to 'auth.form:'. The old "
|
|
77
|
+
"key is still accepted and copied to 'form' when 'form' is absent."
|
|
78
|
+
),
|
|
79
|
+
severity="info",
|
|
80
|
+
auto_fixable=True,
|
|
81
|
+
),
|
|
82
|
+
Deprecation(
|
|
83
|
+
id="output-reports-dir-inert",
|
|
84
|
+
introduced_in="0.4.0",
|
|
85
|
+
removed_in="1.0.0",
|
|
86
|
+
surface="config",
|
|
87
|
+
matches="output.reports_dir",
|
|
88
|
+
message=(
|
|
89
|
+
"output.reports_dir is deprecated and ignored — nothing writes output/reports. "
|
|
90
|
+
"Traces and videos go to output/traces and output/videos; JUnit and HTML reports "
|
|
91
|
+
"take their paths from report.junit_xml / report.html. Remove the key "
|
|
92
|
+
"(output.screenshots_dir and output.guides_dir are live and unaffected)."
|
|
93
|
+
),
|
|
94
|
+
severity="info",
|
|
95
|
+
auto_fixable=True,
|
|
96
|
+
),
|
|
97
|
+
Deprecation(
|
|
98
|
+
id="action-auth0-login-to-login",
|
|
99
|
+
introduced_in="0.4.0",
|
|
100
|
+
removed_in=None,
|
|
101
|
+
surface="scenario",
|
|
102
|
+
matches="step action 'auth0-login'",
|
|
103
|
+
message=(
|
|
104
|
+
"the step action 'auth0-login' was renamed to 'login'. The 'auth0-login' spelling is a kept-forever alias."
|
|
105
|
+
),
|
|
106
|
+
severity="info",
|
|
107
|
+
auto_fixable=True,
|
|
108
|
+
),
|
|
109
|
+
)
|