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
@@ -0,0 +1,490 @@
1
+ """Orchestration for ``steppy bundle create`` / ``inspect`` / ``verify``.
2
+
3
+ The only module here that knows about a project's config, and the one that
4
+ decides a run's identity.
5
+
6
+ :func:`plan_bundle` is the half ``steppy bundle upload`` shares — see
7
+ :class:`BundlePlan`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from ..contracts.bundle import BundleManifest, parse_manifest
18
+ from ..runmeta import EVENTS_SUBDIR, JUNIT_SUBDIR, bundle_dirs, new_run_id, read_run_sidecar
19
+ from . import archive as _archive
20
+ from . import manifest as _manifest
21
+ from . import results as _results
22
+ from .archive import ArchiveError, ArchiveMember
23
+
24
+ MANIFEST_NAME = "manifest.json"
25
+
26
+
27
+ class BundleError(Exception):
28
+ """A user-facing failure — always says what to do next."""
29
+
30
+
31
+ @dataclass
32
+ class BundlePlan:
33
+ """A bundle decided but not yet written.
34
+
35
+ Everything :func:`create_bundle` knows just before it starts writing bytes.
36
+ Split out because ``steppy bundle upload`` needs exactly this and no archive:
37
+ the upload protocol is per-file and content-addressed, so a tarball would be
38
+ written only to be read back and thrown away.
39
+
40
+ :attr:`assembly` carries the ``Candidate`` list, whose ``source`` paths are
41
+ the *only* mapping from a manifest entry back to the bytes it describes.
42
+ Reach it through :meth:`members`, never by re-deriving a path from
43
+ ``BundleFile.path`` — the generated ``junit.xml`` and ``events/*.jsonl``
44
+ members have no file under ``output/`` at all.
45
+ """
46
+
47
+ assembly: _manifest.Assembly
48
+ run_id: str
49
+ run_id_source: str
50
+ fmt: str
51
+
52
+ @property
53
+ def manifest(self) -> BundleManifest:
54
+ return self.assembly.manifest
55
+
56
+ def members(self, target: Optional[Path] = None) -> list[ArchiveMember]:
57
+ """Every archive member, manifest first. See :func:`archive_members`."""
58
+ return archive_members(self.assembly, target)
59
+
60
+
61
+ @dataclass
62
+ class BundleResult:
63
+ path: Optional[Path]
64
+ manifest: BundleManifest
65
+ run_id: str
66
+ run_id_source: str
67
+ fmt: str
68
+ compressed_size: int = 0
69
+ warnings: list[str] = field(default_factory=list)
70
+
71
+ @property
72
+ def payload_size(self) -> int:
73
+ return sum(f.size for f in self.manifest.files)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Roots:
78
+ """Where this project's artifacts actually live.
79
+
80
+ Note "actually". The directory names below are the ones the *writers* use.
81
+ Since FLAWS §4.6 that is the same thing ``config.output`` declares for
82
+ screenshots, guides and coverage records — see :func:`resolve_roots`.
83
+ """
84
+
85
+ output: Path
86
+ screenshots_dirname: str = "screenshots"
87
+ coverage_dirname: str = "coverage"
88
+ guides_dirname: str = "guides"
89
+ reports_dirname: str = "reports"
90
+ locales: frozenset[str] = frozenset()
91
+ layouts: frozenset[str] = frozenset()
92
+
93
+
94
+ def _escapes_output(configured: str, output_dir: Path) -> bool:
95
+ """True when a configured artifact root resolves outside ``output/``."""
96
+ try:
97
+ return not (output_dir.parent / configured).resolve().is_relative_to(output_dir.resolve())
98
+ except OSError:
99
+ return True
100
+
101
+
102
+ def _dirname_under_output(configured: str, output_dir: Path, default: str) -> str:
103
+ """Reduce a configured path like ``./output/coverage`` to ``coverage``.
104
+
105
+ Falls back to the default when the project pointed the root outside
106
+ ``output/`` — the bundle archives ``output/`` and cannot reach beyond it.
107
+ The caller warns in that case (:func:`resolve_roots`); silently defaulting
108
+ would have the bundler report an empty directory as an empty run.
109
+ """
110
+ try:
111
+ resolved = (output_dir.parent / configured).resolve()
112
+ rel = resolved.relative_to(output_dir.resolve())
113
+ except (OSError, ValueError):
114
+ return default
115
+ parts = rel.parts
116
+ return parts[0] if parts else default
117
+
118
+
119
+ def resolve_roots(project_root: Path, output_dir: Path) -> tuple[Roots, list[str]]:
120
+ """Resolve the artifact roots and the project's declared locales/layouts.
121
+
122
+ ``config.output.screenshots_dir`` / ``guides_dir`` and
123
+ ``coverage.records_dir`` are all **honoured**, because since FLAWS §4.6 the
124
+ writers honour them too: the generated conftest resolves the capture root
125
+ from config at runtime, ``steppy guide`` defaults both of its path flags
126
+ from it, and ``steppy build`` falls back to it. Following them here is what
127
+ keeps the bundler pointed at the directory the run actually wrote.
128
+
129
+ What the bundler cannot do is reach outside ``output/`` — it archives that
130
+ tree and nothing else. A root configured elsewhere is therefore reported as
131
+ a warning and skipped rather than silently producing an empty section: a
132
+ consumer must be able to tell "no guides were built" from "the guides are
133
+ somewhere this archive can't see".
134
+
135
+ ``reports_dir`` is not consulted: it is deprecated and inert (see
136
+ :class:`steppy.models.OutputConfig`) — nothing has ever written
137
+ ``output/reports``.
138
+
139
+ A missing or invalid config is not fatal: the defaults are what the vast
140
+ majority of projects use, and refusing to bundle a finished run because its
141
+ config no longer parses would be a poor trade.
142
+ """
143
+ warnings: list[str] = []
144
+ try:
145
+ from ..config import load_config
146
+
147
+ config = load_config(project_root / "config.yaml")
148
+ except Exception as e:
149
+ # First line only: ConfigError carries a multi-line hint aimed at
150
+ # someone editing config.yaml, which is not what this reader is doing.
151
+ reason = str(e).splitlines()[0]
152
+ warnings.append(f"config.yaml not loaded ({reason}); assuming default output paths and no declared locales")
153
+ return Roots(output=output_dir), warnings
154
+
155
+ roots = Roots(
156
+ output=output_dir,
157
+ screenshots_dirname=_dirname_under_output(config.output.screenshots_dir, output_dir, "screenshots"),
158
+ guides_dirname=_dirname_under_output(config.output.guides_dir, output_dir, "guides"),
159
+ coverage_dirname=(
160
+ _dirname_under_output(config.coverage.records_dir, output_dir, "coverage")
161
+ if config.coverage
162
+ else "coverage"
163
+ ),
164
+ locales=frozenset(config.locales or ()),
165
+ layouts=frozenset(config.screenshot.layouts.keys()),
166
+ )
167
+
168
+ outside = {
169
+ "output.screenshots_dir": config.output.screenshots_dir,
170
+ "output.guides_dir": config.output.guides_dir,
171
+ **({"coverage.records_dir": config.coverage.records_dir} if config.coverage else {}),
172
+ }
173
+ escaped = [f"config.{name} ({value})" for name, value in outside.items() if _escapes_output(value, output_dir)]
174
+ if escaped:
175
+ warnings.append(
176
+ f"{', '.join(escaped)} resolves outside {output_dir}; a bundle archives the output/ tree only, "
177
+ "so those artifacts are NOT in this archive"
178
+ )
179
+ return roots, warnings
180
+
181
+
182
+ def resolve_run_id(
183
+ project_root: Path, output_dir: Path, explicit: Optional[str], coverage_dirname: str = "coverage"
184
+ ) -> tuple[str, str]:
185
+ """``(run_id, source)`` — the ladder that gives a bundle its identity.
186
+
187
+ Explicit wins because a control plane mints its own identity and passes it
188
+ in; everything below it is a convenience for a human at a terminal.
189
+ """
190
+ if explicit:
191
+ return explicit, "explicit"
192
+
193
+ sidecar = read_run_sidecar(project_root)
194
+ if sidecar and sidecar.get("run_id"):
195
+ return str(sidecar["run_id"]), "sidecar"
196
+
197
+ # Newest recorded run, by name. This is why ids are timestamp-prefixed.
198
+ for parent in (output_dir / EVENTS_SUBDIR, output_dir / coverage_dirname / "raw"):
199
+ if parent.is_dir():
200
+ names = sorted(p.name for p in parent.iterdir() if p.is_dir() and p.name != "_maps")
201
+ if names:
202
+ return names[-1], "discovered"
203
+
204
+ return new_run_id(), "minted"
205
+
206
+
207
+ def _excluded_kinds(*, guides: bool, traces: bool, videos: bool, coverage: bool) -> frozenset[str]:
208
+ excluded: set[str] = set()
209
+ for flag, key in ((guides, "guides"), (traces, "traces"), (videos, "videos"), (coverage, "coverage")):
210
+ if not flag:
211
+ excluded |= _manifest.OPTIONAL_KINDS[key]
212
+ return frozenset(excluded)
213
+
214
+
215
+ def plan_bundle(
216
+ *,
217
+ project_root: Path,
218
+ output_dir: Optional[Path] = None,
219
+ run_id: Optional[str] = None,
220
+ fmt: str = _archive.FORMAT_AUTO,
221
+ executor: str = "local",
222
+ git_sha: Optional[str] = None,
223
+ git_branch: Optional[str] = None,
224
+ pr: Optional[int] = None,
225
+ guides: bool = True,
226
+ traces: bool = True,
227
+ videos: bool = True,
228
+ coverage: bool = True,
229
+ max_size: int = 0,
230
+ ) -> BundlePlan:
231
+ """Walk, classify, hash and budget a run's ``output/`` tree.
232
+
233
+ Everything :func:`create_bundle` does except writing the archive, and the
234
+ entry point ``steppy bundle upload`` uses instead — see :class:`BundlePlan`.
235
+ ``fmt`` is carried through so a caller can report it; nothing here depends
236
+ on the codec.
237
+ """
238
+ project_root = Path(project_root).resolve()
239
+ output_dir = Path(output_dir).resolve() if output_dir else project_root / "output"
240
+
241
+ if not output_dir.is_dir():
242
+ raise BundleError(
243
+ f"no output directory at {output_dir} — run your scenarios first "
244
+ "(`steppy run --bundle`), then create the bundle"
245
+ )
246
+
247
+ roots, warnings = resolve_roots(project_root, output_dir)
248
+ resolved_run_id, run_id_source = resolve_run_id(project_root, output_dir, run_id, roots.coverage_dirname)
249
+
250
+ events_dir, junit_dir = bundle_dirs(output_dir.parent, resolved_run_id)
251
+ if output_dir.name != "output":
252
+ # A relocated output dir: bundle_dirs assumes <root>/output.
253
+ events_dir = output_dir / EVENTS_SUBDIR / resolved_run_id
254
+ junit_dir = output_dir / JUNIT_SUBDIR / resolved_run_id
255
+
256
+ tests, test_warnings = _results.collect_tests(events_dir, junit_dir)
257
+ warnings.extend(test_warnings)
258
+ if not tests:
259
+ warnings.append(
260
+ "no test outcomes recorded — manifest.tests[] is empty. "
261
+ "Re-run with `steppy run --bundle` so per-test event records are captured"
262
+ )
263
+
264
+ junit_xml = _results.merge_junit(junit_dir)
265
+ events = [(f"{EVENTS_SUBDIR}/{p.name}", p) for p in _results.read_event_files(events_dir)]
266
+
267
+ assembly = _manifest.build(
268
+ project_root=project_root,
269
+ output_dir=output_dir,
270
+ run_id=resolved_run_id,
271
+ tests=tests,
272
+ locales=roots.locales,
273
+ layouts=roots.layouts,
274
+ screenshots_dirname=roots.screenshots_dirname,
275
+ coverage_dirname=roots.coverage_dirname,
276
+ guides_dirname=roots.guides_dirname,
277
+ reports_dirname=roots.reports_dirname,
278
+ sidecar=read_run_sidecar(output_dir.parent),
279
+ executor=executor,
280
+ git=_manifest.detect_git(project_root, sha=git_sha, branch=git_branch, pr=pr),
281
+ exclude_kinds=_excluded_kinds(guides=guides, traces=traces, videos=videos, coverage=coverage),
282
+ max_size=max_size,
283
+ junit_xml=junit_xml,
284
+ events=events,
285
+ extra_warnings=warnings,
286
+ )
287
+
288
+ return BundlePlan(assembly=assembly, run_id=resolved_run_id, run_id_source=run_id_source, fmt=fmt)
289
+
290
+
291
+ def create_bundle(
292
+ *,
293
+ project_root: Path,
294
+ output_dir: Optional[Path] = None,
295
+ target: Optional[Path] = None,
296
+ run_id: Optional[str] = None,
297
+ fmt: str = _archive.FORMAT_AUTO,
298
+ executor: str = "local",
299
+ git_sha: Optional[str] = None,
300
+ git_branch: Optional[str] = None,
301
+ pr: Optional[int] = None,
302
+ guides: bool = True,
303
+ traces: bool = True,
304
+ videos: bool = True,
305
+ coverage: bool = True,
306
+ max_size: int = 0,
307
+ dry_run: bool = False,
308
+ ) -> BundleResult:
309
+ """Build a results bundle from a run's ``output/`` tree."""
310
+ # An explicitly named target dictates the codec, so `-o run.tar.zst` can
311
+ # never produce a file whose contents disagree with its name — which is
312
+ # what a silent gzip fallback would do to anything sniffing the extension.
313
+ #
314
+ # Resolved before the walk, deliberately: an unusable codec should fail in
315
+ # milliseconds, not after hashing a gigabyte of screenshots.
316
+ named_fmt = _named_format(target)
317
+ if named_fmt and fmt == _archive.FORMAT_AUTO:
318
+ fmt = named_fmt
319
+ elif named_fmt and named_fmt != fmt:
320
+ raise BundleError(
321
+ f"--format {fmt} contradicts the output filename {Path(target).name!r}; "
322
+ "drop --format, or name the file to match"
323
+ )
324
+
325
+ try:
326
+ resolved_fmt = _archive.resolve_format(fmt)
327
+ except ArchiveError as e:
328
+ raise BundleError(str(e)) from e
329
+
330
+ plan = plan_bundle(
331
+ project_root=project_root,
332
+ output_dir=output_dir,
333
+ run_id=run_id,
334
+ fmt=resolved_fmt,
335
+ executor=executor,
336
+ git_sha=git_sha,
337
+ git_branch=git_branch,
338
+ pr=pr,
339
+ guides=guides,
340
+ traces=traces,
341
+ videos=videos,
342
+ coverage=coverage,
343
+ max_size=max_size,
344
+ )
345
+
346
+ if dry_run:
347
+ return BundleResult(
348
+ path=None,
349
+ manifest=plan.manifest,
350
+ run_id=plan.run_id,
351
+ run_id_source=plan.run_id_source,
352
+ fmt=resolved_fmt,
353
+ warnings=plan.manifest.warnings,
354
+ )
355
+
356
+ target_path = _resolve_target(target, Path(project_root).resolve(), plan.run_id, resolved_fmt)
357
+ compressed = _archive.write_archive(target_path, plan.members(target_path), fmt=resolved_fmt)
358
+
359
+ return BundleResult(
360
+ path=target_path,
361
+ manifest=plan.manifest,
362
+ run_id=plan.run_id,
363
+ run_id_source=plan.run_id_source,
364
+ fmt=resolved_fmt,
365
+ compressed_size=compressed,
366
+ warnings=plan.manifest.warnings,
367
+ )
368
+
369
+
370
+ def _named_format(target: Optional[Path]) -> Optional[str]:
371
+ """The codec an explicit output filename asks for, if it names one."""
372
+ if target is None:
373
+ return None
374
+ suffix = Path(target).suffix.lower()
375
+ if suffix == ".zst":
376
+ return _archive.FORMAT_ZSTD
377
+ if suffix == ".gz":
378
+ return _archive.FORMAT_GZIP
379
+ return None
380
+
381
+
382
+ def _resolve_target(target: Optional[Path], project_root: Path, run_id: str, fmt: str) -> Path:
383
+ extension = _archive.extension_for(fmt)
384
+ if target is None:
385
+ return project_root / "output" / _manifest.BUNDLES_SUBDIR / f"steppy-bundle-{run_id}{extension}"
386
+ target = Path(target)
387
+ if target.is_dir() or not target.suffix:
388
+ return target / f"steppy-bundle-{run_id}{extension}"
389
+ return target
390
+
391
+
392
+ def manifest_bytes(manifest: BundleManifest) -> bytes:
393
+ """Canonical serialisation — sorted, indented, trailing newline.
394
+
395
+ Same convention as ``.steppy/scaffold.json`` and the config schema, so the
396
+ file reads well and diffs cleanly when someone commits one as a fixture.
397
+ """
398
+ payload = manifest.model_dump(mode="json", exclude_none=False)
399
+ return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")
400
+
401
+
402
+ def archive_members(assembly: _manifest.Assembly, target: Optional[Path] = None) -> list[ArchiveMember]:
403
+ """Archive members, manifest first then everything else in path order.
404
+
405
+ The single mapping from a manifest entry's ``path`` back to its bytes, and
406
+ the reason ``steppy bundle upload`` goes through here rather than joining
407
+ ``output_dir`` with each path: ``junit.xml`` and the ``events/*.jsonl``
408
+ members are generated, and two of the three member kinds do not live under
409
+ ``output/`` at all.
410
+
411
+ ``target`` is the archive being written, excluded from its own contents.
412
+ ``None`` when nothing is being written (the upload path).
413
+ """
414
+ members = [ArchiveMember(arcname=MANIFEST_NAME, data=manifest_bytes(assembly.manifest))]
415
+ if assembly.junit_xml is not None:
416
+ members.append(ArchiveMember(arcname="junit.xml", data=assembly.junit_xml))
417
+ for arcname, source in sorted(assembly.events):
418
+ members.append(ArchiveMember(arcname=arcname, source=source))
419
+ target_resolved = target.resolve() if target is not None else None
420
+ for candidate in sorted(assembly.candidates, key=lambda c: c.entry.path):
421
+ # Never archive the bundle into itself when --output points inside output/.
422
+ if target_resolved is not None and candidate.source.resolve() == target_resolved:
423
+ continue
424
+ members.append(ArchiveMember(arcname=candidate.entry.path, source=candidate.source))
425
+ return members
426
+
427
+
428
+ def inspect_bundle(path: Path) -> BundleManifest:
429
+ """Read a bundle's manifest without extracting it."""
430
+ path = Path(path)
431
+ if not path.is_file():
432
+ raise BundleError(f"no such bundle: {path}")
433
+ try:
434
+ with _archive.open_archive(path) as tar:
435
+ for member in tar:
436
+ if member.name == MANIFEST_NAME:
437
+ handle = tar.extractfile(member)
438
+ if handle is None:
439
+ break
440
+ return parse_manifest(json.loads(handle.read().decode("utf-8")))
441
+ except ArchiveError as e:
442
+ raise BundleError(str(e)) from e
443
+ except (OSError, ValueError) as e:
444
+ raise BundleError(f"could not read {path.name}: {e}") from e
445
+ raise BundleError(f"{path.name} contains no {MANIFEST_NAME} — is it a steppy bundle?")
446
+
447
+
448
+ def verify_bundle(path: Path) -> tuple[BundleManifest, list[str]]:
449
+ """Re-hash every member against the manifest. Returns ``(manifest, problems)``.
450
+
451
+ Streams: a bundle can be hundreds of megabytes and there is no reason to
452
+ extract it to check it.
453
+ """
454
+ import hashlib
455
+
456
+ path = Path(path)
457
+ manifest = inspect_bundle(path)
458
+ expected = {f.path: (f.sha256, f.size) for f in manifest.files}
459
+ seen: set[str] = set()
460
+ problems: list[str] = []
461
+
462
+ try:
463
+ with _archive.open_archive(path) as tar:
464
+ for member in tar:
465
+ if not member.isfile() or member.name == MANIFEST_NAME:
466
+ continue
467
+ if member.name not in expected:
468
+ problems.append(f"{member.name}: present in the archive but absent from the manifest")
469
+ continue
470
+ want_sha, want_size = expected[member.name]
471
+ handle = tar.extractfile(member)
472
+ if handle is None:
473
+ problems.append(f"{member.name}: unreadable")
474
+ continue
475
+ digest = hashlib.sha256()
476
+ size = 0
477
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
478
+ digest.update(chunk)
479
+ size += len(chunk)
480
+ seen.add(member.name)
481
+ if digest.hexdigest() != want_sha:
482
+ problems.append(f"{member.name}: sha256 mismatch")
483
+ elif size != want_size:
484
+ problems.append(f"{member.name}: size mismatch ({size} != {want_size})")
485
+ except ArchiveError as e:
486
+ raise BundleError(str(e)) from e
487
+
488
+ for missing in sorted(set(expected) - seen):
489
+ problems.append(f"{missing}: listed in the manifest but missing from the archive")
490
+ return manifest, problems
@@ -0,0 +1,28 @@
1
+ """Streaming content hashing.
2
+
3
+ ``init_cli._sha256`` hashes *text* and is fine for scaffold files; a bundle
4
+ walks screenshot trees that reach hundreds of megabytes, so it needs a chunked
5
+ reader that never holds a file in memory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from pathlib import Path
12
+
13
+ #: 1 MiB. Large enough that syscall overhead disappears, small enough that a
14
+ #: many-worker run does not multiply into meaningful resident memory.
15
+ CHUNK_SIZE = 1024 * 1024
16
+
17
+
18
+ def sha256_file(path: Path, *, chunk_size: int = CHUNK_SIZE) -> str:
19
+ """Hex sha256 of a file's bytes, read in chunks."""
20
+ digest = hashlib.sha256()
21
+ with open(path, "rb") as fh:
22
+ for chunk in iter(lambda: fh.read(chunk_size), b""):
23
+ digest.update(chunk)
24
+ return digest.hexdigest()
25
+
26
+
27
+ def sha256_bytes(data: bytes) -> str:
28
+ return hashlib.sha256(data).hexdigest()