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/bundle/create.py
ADDED
|
@@ -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
|
steppy/bundle/hashing.py
ADDED
|
@@ -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()
|