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.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. 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
+ )