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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Where the report's coverage numbers come from, in order of trustworthiness.
|
|
2
|
+
|
|
3
|
+
Named ``coverage_source`` rather than ``coverage`` so ``from . import coverage``
|
|
4
|
+
inside this package cannot shadow :mod:`steppy.coverage`.
|
|
5
|
+
|
|
6
|
+
The problem this module exists to solve. The coverage map is a sqlite store at
|
|
7
|
+
``.steppy/coverage.db``, which lives **outside** ``output/`` on purpose — cleaning
|
|
8
|
+
run artifacts must never wipe hand-made triage flags — and is therefore never
|
|
9
|
+
inside a results bundle. The *declared* surface it is scored against comes from
|
|
10
|
+
``coverage refresh``, which fetches an OpenAPI document over the network. So a
|
|
11
|
+
bundle downloaded from someone else's CI carries the raw per-scenario records and
|
|
12
|
+
nothing that says what they should be compared to.
|
|
13
|
+
|
|
14
|
+
Four tiers, best first:
|
|
15
|
+
|
|
16
|
+
=========== ========================================== ===============================
|
|
17
|
+
tier source when
|
|
18
|
+
=========== ========================================== ===============================
|
|
19
|
+
``bundled`` ``output/coverage/gaps.json`` in the run any input that carries one
|
|
20
|
+
``store`` the project's live ``.steppy/coverage.db`` **live-tree input only**
|
|
21
|
+
``records`` a throwaway store rebuilt from raw records records, no gaps.json, no store
|
|
22
|
+
``none`` no coverage section at all nothing was recorded
|
|
23
|
+
=========== ========================================== ===============================
|
|
24
|
+
|
|
25
|
+
Two of those rows are guardrails against a plausible-looking lie, and both are the
|
|
26
|
+
kind of bug that would never surface as a crash:
|
|
27
|
+
|
|
28
|
+
**``store`` is live-tree only.** Reading this machine's map to describe a bundle
|
|
29
|
+
that came from elsewhere would attribute *our* coverage to *their* run and print a
|
|
30
|
+
confident percentage about it.
|
|
31
|
+
|
|
32
|
+
**``records`` cannot report assert strength.** Rebuilding via ``merge_run`` calls
|
|
33
|
+
``count_scenario_asserts``, which reads ``scenarios/*.md`` — and a bundle carries
|
|
34
|
+
none, so it returns 0 for every scenario and ``gaps._strength`` marks every tested
|
|
35
|
+
endpoint "weak". So that tier suppresses strength outright and says why, rather
|
|
36
|
+
than publishing a number that looks like a finding.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Optional, Sequence
|
|
44
|
+
|
|
45
|
+
from ..contracts.report import ReportBadge, ReportCoverage, ReportNonUi
|
|
46
|
+
|
|
47
|
+
TIER_BUNDLED = "bundled"
|
|
48
|
+
TIER_STORE = "store"
|
|
49
|
+
TIER_RECORDS = "records"
|
|
50
|
+
TIER_NONE = "none"
|
|
51
|
+
|
|
52
|
+
#: Manifest kind of the gap report that travels with a run (``bundle/classify``).
|
|
53
|
+
GAPS_KIND = "coverage_gaps"
|
|
54
|
+
RECORD_KIND = "coverage_record"
|
|
55
|
+
|
|
56
|
+
STRENGTH_UNKNOWN_WARNING = (
|
|
57
|
+
"assert strength is unavailable: this coverage was rebuilt from raw records, and a bundle "
|
|
58
|
+
"carries no scenarios/*.md to count assert steps in — every tested endpoint would otherwise "
|
|
59
|
+
"read as weakly covered"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_coverage(
|
|
64
|
+
source,
|
|
65
|
+
*,
|
|
66
|
+
project_root: Path,
|
|
67
|
+
non_ui: Sequence[str] = (),
|
|
68
|
+
surface: str = "all",
|
|
69
|
+
badge_label: Optional[str] = None,
|
|
70
|
+
href: Optional[str] = None,
|
|
71
|
+
) -> ReportCoverage:
|
|
72
|
+
"""Best available coverage for *source*. Never raises — degrades and says so."""
|
|
73
|
+
from ..coverage.report import DEFAULT_BADGE_LABEL
|
|
74
|
+
|
|
75
|
+
label = badge_label or DEFAULT_BADGE_LABEL
|
|
76
|
+
warnings: list[str] = []
|
|
77
|
+
|
|
78
|
+
gaps, tier = _from_bundle(source, warnings)
|
|
79
|
+
if gaps is None and source.is_live_tree:
|
|
80
|
+
gaps, tier = _from_store(project_root, non_ui, warnings)
|
|
81
|
+
if gaps is None:
|
|
82
|
+
gaps, tier = _from_records(source, project_root, non_ui, warnings)
|
|
83
|
+
if gaps is None:
|
|
84
|
+
return ReportCoverage(tier=TIER_NONE, warnings=warnings)
|
|
85
|
+
|
|
86
|
+
if tier == TIER_RECORDS:
|
|
87
|
+
warnings.append(STRENGTH_UNKNOWN_WARNING)
|
|
88
|
+
|
|
89
|
+
return _view(gaps, tier=tier, surface=surface, label=label, href=href, warnings=warnings)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _view(
|
|
93
|
+
gaps: dict,
|
|
94
|
+
*,
|
|
95
|
+
tier: str,
|
|
96
|
+
surface: str,
|
|
97
|
+
label: str,
|
|
98
|
+
href: Optional[str],
|
|
99
|
+
warnings: list[str],
|
|
100
|
+
) -> ReportCoverage:
|
|
101
|
+
from ..coverage.report import badge_payload
|
|
102
|
+
|
|
103
|
+
ratio = (gaps.get("e2e_surface") or {}).get(surface) or {}
|
|
104
|
+
payload = badge_payload(gaps, surface=surface, label=label)
|
|
105
|
+
return ReportCoverage(
|
|
106
|
+
tier=tier,
|
|
107
|
+
counts=gaps.get("counts") or {},
|
|
108
|
+
e2e_surface=gaps.get("e2e_surface") or {},
|
|
109
|
+
buckets={kind: gaps[kind] for kind in ("endpoints", "routes", "modules") if isinstance(gaps.get(kind), dict)},
|
|
110
|
+
non_ui=_non_ui_summary(gaps),
|
|
111
|
+
badge=ReportBadge(
|
|
112
|
+
surface=surface,
|
|
113
|
+
label=label,
|
|
114
|
+
tested=ratio.get("tested", 0),
|
|
115
|
+
declared=ratio.get("declared", 0),
|
|
116
|
+
ratio=ratio.get("ratio"),
|
|
117
|
+
percent=ratio.get("percent"),
|
|
118
|
+
color=payload["color"],
|
|
119
|
+
),
|
|
120
|
+
href=href,
|
|
121
|
+
warnings=warnings,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _non_ui_summary(gaps: dict) -> ReportNonUi:
|
|
126
|
+
"""How much was discounted, and on whose authority.
|
|
127
|
+
|
|
128
|
+
``by_source`` is what lets a reader see a percentage was raised by committed
|
|
129
|
+
config rather than by a flag somebody set locally and never shared.
|
|
130
|
+
"""
|
|
131
|
+
counts = gaps.get("counts") or {}
|
|
132
|
+
declared = sum((counts.get(kind) or {}).get("declared_non_ui", 0) for kind in ("endpoints", "routes"))
|
|
133
|
+
tested = sum((counts.get(kind) or {}).get("tested_non_ui", 0) for kind in ("endpoints", "routes"))
|
|
134
|
+
by_source: dict[str, int] = {}
|
|
135
|
+
for kind in ("endpoints", "routes"):
|
|
136
|
+
section = gaps.get(kind) or {}
|
|
137
|
+
for bucket in ("tested", "declared_unreached"):
|
|
138
|
+
for entry in section.get(bucket) or []:
|
|
139
|
+
if entry.get("triage") == "non_ui":
|
|
140
|
+
key = entry.get("triage_source") or "unspecified"
|
|
141
|
+
by_source[key] = by_source.get(key, 0) + 1
|
|
142
|
+
return ReportNonUi(declared=declared, tested=tested, by_source=dict(sorted(by_source.items())))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _from_bundle(source, warnings: list[str]) -> tuple[Optional[dict], str]:
|
|
146
|
+
"""The gap report that travelled with the run, if it did."""
|
|
147
|
+
entries = [f for f in source.manifest.files if f.kind == GAPS_KIND]
|
|
148
|
+
if not entries:
|
|
149
|
+
return None, TIER_NONE
|
|
150
|
+
try:
|
|
151
|
+
fetched, fetch_warnings = source.fetch(texts={entries[0].path})
|
|
152
|
+
except Exception as e:
|
|
153
|
+
warnings.append(f"coverage: could not read {entries[0].path} from the bundle ({e})")
|
|
154
|
+
return None, TIER_NONE
|
|
155
|
+
warnings.extend(fetch_warnings)
|
|
156
|
+
payload = fetched.get(entries[0].path)
|
|
157
|
+
if not payload:
|
|
158
|
+
return None, TIER_NONE
|
|
159
|
+
try:
|
|
160
|
+
gaps = json.loads(payload.decode("utf-8"))
|
|
161
|
+
except (UnicodeDecodeError, ValueError) as e:
|
|
162
|
+
warnings.append(f"coverage: {entries[0].path} is not readable JSON ({e})")
|
|
163
|
+
return None, TIER_NONE
|
|
164
|
+
if not isinstance(gaps, dict):
|
|
165
|
+
return None, TIER_NONE
|
|
166
|
+
return gaps, TIER_BUNDLED
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _from_store(project_root: Path, non_ui: Sequence[str], warnings: list[str]) -> tuple[Optional[dict], str]:
|
|
170
|
+
"""The project's own map. Only ever reached for a live ``output/`` tree."""
|
|
171
|
+
try:
|
|
172
|
+
from ..config import load_config
|
|
173
|
+
from ..coverage.gaps import compute_gaps
|
|
174
|
+
from ..coverage.merge import CoverageSettings
|
|
175
|
+
from ..coverage.store import open_store, read_map
|
|
176
|
+
from ..coverage.triage import apply_config_triage
|
|
177
|
+
|
|
178
|
+
settings = CoverageSettings.from_config(load_config(project_root / "config.yaml"), project_root)
|
|
179
|
+
store_path = Path(settings.store_path)
|
|
180
|
+
if not store_path.is_file():
|
|
181
|
+
return None, TIER_NONE
|
|
182
|
+
patterns = tuple(non_ui) or settings.non_ui
|
|
183
|
+
conn = open_store(store_path)
|
|
184
|
+
try:
|
|
185
|
+
gaps = compute_gaps(apply_config_triage(read_map(conn), patterns))
|
|
186
|
+
finally:
|
|
187
|
+
conn.close()
|
|
188
|
+
return gaps, TIER_STORE
|
|
189
|
+
except Exception as e:
|
|
190
|
+
warnings.append(f"coverage: the local map at .steppy/coverage.db could not be read ({e})")
|
|
191
|
+
return None, TIER_NONE
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _from_records(source, project_root: Path, non_ui: Sequence[str], warnings: list[str]) -> tuple[Optional[dict], str]:
|
|
195
|
+
"""Last resort: rebuild a throwaway map from the raw records in the bundle.
|
|
196
|
+
|
|
197
|
+
Observed nodes only. There is no declared surface here (that needs the network)
|
|
198
|
+
and no triage table, so the buckets are honest about what was *exercised* and
|
|
199
|
+
silent about what was missed.
|
|
200
|
+
"""
|
|
201
|
+
entries = [f for f in source.manifest.files if f.kind == RECORD_KIND]
|
|
202
|
+
if not entries:
|
|
203
|
+
return None, TIER_NONE
|
|
204
|
+
|
|
205
|
+
import tempfile
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
from ..config import ConfigError, load_config
|
|
209
|
+
from ..coverage.gaps import compute_gaps
|
|
210
|
+
from ..coverage.merge import CoverageSettings, merge_run
|
|
211
|
+
from ..coverage.store import open_store, read_map
|
|
212
|
+
from ..coverage.triage import apply_config_triage
|
|
213
|
+
except Exception as e: # pragma: no cover - import failure is not a report failure
|
|
214
|
+
warnings.append(f"coverage: records present but the coverage modules could not be loaded ({e})")
|
|
215
|
+
return None, TIER_NONE
|
|
216
|
+
|
|
217
|
+
run_id = source.manifest.run.run_id
|
|
218
|
+
with tempfile.TemporaryDirectory(prefix="steppy-report-cov-") as tmp:
|
|
219
|
+
tmp_root = Path(tmp)
|
|
220
|
+
records_dir = tmp_root / "coverage"
|
|
221
|
+
wanted = {entry.path: records_dir / _record_rel(entry.path) for entry in entries}
|
|
222
|
+
try:
|
|
223
|
+
_, fetch_warnings = source.fetch(into=wanted)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
warnings.append(f"coverage: raw records could not be read ({e})")
|
|
226
|
+
return None, TIER_NONE
|
|
227
|
+
warnings.extend(fetch_warnings)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
base = load_config(project_root / "config.yaml")
|
|
231
|
+
except (ConfigError, OSError):
|
|
232
|
+
base = None
|
|
233
|
+
settings = (
|
|
234
|
+
CoverageSettings.from_config(base, project_root)
|
|
235
|
+
if base is not None
|
|
236
|
+
else CoverageSettings(store_path=tmp_root / "map.db", records_dir=records_dir)
|
|
237
|
+
)
|
|
238
|
+
settings = _relocated(settings, store_path=tmp_root / "map.db", records_dir=records_dir)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
merge_run(settings, run_id)
|
|
242
|
+
conn = open_store(settings.store_path)
|
|
243
|
+
try:
|
|
244
|
+
patterns = tuple(non_ui) or settings.non_ui
|
|
245
|
+
gaps = compute_gaps(apply_config_triage(read_map(conn), patterns))
|
|
246
|
+
finally:
|
|
247
|
+
conn.close()
|
|
248
|
+
except Exception as e:
|
|
249
|
+
warnings.append(f"coverage: raw records could not be merged ({e})")
|
|
250
|
+
return None, TIER_NONE
|
|
251
|
+
return gaps, TIER_RECORDS
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _relocated(settings, *, store_path: Path, records_dir: Path):
|
|
255
|
+
"""A copy of *settings* pointed at a throwaway store.
|
|
256
|
+
|
|
257
|
+
``dataclasses.replace`` rather than mutation: the caller's settings describe
|
|
258
|
+
the project, and quietly repointing them at a temp directory is the sort of
|
|
259
|
+
thing that works until something else reads them.
|
|
260
|
+
"""
|
|
261
|
+
from dataclasses import replace
|
|
262
|
+
|
|
263
|
+
return replace(settings, store_path=store_path, records_dir=records_dir)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _record_rel(manifest_path: str) -> Path:
|
|
267
|
+
"""``output/coverage/raw/<run>/<scenario>/<file>`` → ``raw/<run>/<scenario>/<file>``.
|
|
268
|
+
|
|
269
|
+
``merge_run`` expects the ``raw/<run_id>/`` layout under its ``records_dir``,
|
|
270
|
+
so the tail from ``raw/`` onwards is what has to be reproduced. Falls back to
|
|
271
|
+
the basename if the path is not that shape, which keeps a malformed manifest
|
|
272
|
+
from escaping the temp directory.
|
|
273
|
+
"""
|
|
274
|
+
parts = Path(manifest_path).parts
|
|
275
|
+
if "raw" in parts:
|
|
276
|
+
return Path(*parts[parts.index("raw") :])
|
|
277
|
+
return Path(Path(manifest_path).name)
|
steppy/report/model.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Bundle manifest + event timeline + coverage → :class:`ReportModel`.
|
|
2
|
+
|
|
3
|
+
**This module must never import jinja2.** It is the half of the reporter a control
|
|
4
|
+
plane can consume — ``from steppy.report.model import build_model`` — and dragging
|
|
5
|
+
a renderer along would defeat the point of the model/render split. That is the
|
|
6
|
+
concrete form of "one renderer, not two": the *model* is shared, the presentation
|
|
7
|
+
is not. (It also may not import :mod:`steppy.guide`, which builds a module-level
|
|
8
|
+
``MarkdownIt``; a guard test forbids ``markdown_it`` on the default import path.)
|
|
9
|
+
|
|
10
|
+
The build is a pure function of its inputs, with no clock and no filesystem reads.
|
|
11
|
+
Everything it needs has already been fetched: the manifest carries the file
|
|
12
|
+
inventory *with* attribution (``bundle/classify.py`` did that work at create
|
|
13
|
+
time, so nothing here parses a path), the timeline comes from the event JSONL, and
|
|
14
|
+
coverage arrives resolved. Same input, same bytes out — which is what lets a test
|
|
15
|
+
assert on the model and CI diff two reports.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
from typing import Iterable, Optional
|
|
22
|
+
|
|
23
|
+
from ..contracts.bundle import BundleFile, BundleManifest, BundleTest
|
|
24
|
+
from ..contracts.report import (
|
|
25
|
+
REPORT_SCHEMA_VERSION,
|
|
26
|
+
ReportArtifactGroup,
|
|
27
|
+
ReportArtifacts,
|
|
28
|
+
ReportCapabilities,
|
|
29
|
+
ReportCoverage,
|
|
30
|
+
ReportFailureCause,
|
|
31
|
+
ReportFailureOccurrence,
|
|
32
|
+
ReportModel,
|
|
33
|
+
ReportScenario,
|
|
34
|
+
ReportSourceInfo,
|
|
35
|
+
ReportStep,
|
|
36
|
+
ReportTotals,
|
|
37
|
+
ReportVariant,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: Worst-first. A run's verdict, and a scenario's, is the worst thing that
|
|
41
|
+
#: happened to it — a flow that fails only in Dutch on mobile is a failing flow.
|
|
42
|
+
#: Unknown outcomes sort *after* ``failed`` and before ``passed``: something we
|
|
43
|
+
#: cannot classify is not a pass, and claiming it is a failure would be a guess.
|
|
44
|
+
_OUTCOME_RANK = {
|
|
45
|
+
"worker_died": 0,
|
|
46
|
+
"error": 1,
|
|
47
|
+
"failed": 2,
|
|
48
|
+
"interrupted": 3,
|
|
49
|
+
"unknown": 4,
|
|
50
|
+
"skipped": 5,
|
|
51
|
+
"passed": 6,
|
|
52
|
+
}
|
|
53
|
+
_UNRANKED = 4
|
|
54
|
+
|
|
55
|
+
#: Outcomes that do not fail a gate. ``skipped`` is deliberate scope, and the
|
|
56
|
+
#: coordinator already treats a pass-on-retry as non-fatal.
|
|
57
|
+
_PASSING = frozenset({"passed", "skipped"})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def worse(a: str, b: str) -> str:
|
|
61
|
+
"""The worse of two outcomes, by :data:`_OUTCOME_RANK`."""
|
|
62
|
+
return a if _rank(a) <= _rank(b) else b
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rank(outcome: str) -> int:
|
|
66
|
+
return _OUTCOME_RANK.get(outcome, _UNRANKED)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _decode_tail(test: BundleTest) -> str:
|
|
70
|
+
if not test.fail_tail_b64:
|
|
71
|
+
return ""
|
|
72
|
+
try:
|
|
73
|
+
return base64.b64decode(test.fail_tail_b64).decode("utf-8", errors="replace")
|
|
74
|
+
except (ValueError, TypeError):
|
|
75
|
+
# A corrupt tail is not worth losing the row over — the outcome, the step
|
|
76
|
+
# and the screenshot all still say something useful.
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Row:
|
|
81
|
+
"""Duck-type for ``_parallel.fingerprint.group_failures``.
|
|
82
|
+
|
|
83
|
+
That function needs only ``.slug`` and ``.fail_tails``, which is why the
|
|
84
|
+
parallel renderer's grouping works here unchanged rather than being
|
|
85
|
+
reimplemented — the console summary and the HTML report collapse identical
|
|
86
|
+
failures identically, by construction.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
__slots__ = ("slug", "fail_tails")
|
|
90
|
+
|
|
91
|
+
def __init__(self, slug: str, fail_tails: list[tuple[str, str]]):
|
|
92
|
+
self.slug = slug
|
|
93
|
+
self.fail_tails = fail_tails
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def group_manifest_failures(tests: Iterable[BundleTest]) -> tuple[list[ReportFailureCause], dict[str, str]]:
|
|
97
|
+
"""``(causes, {nodeid: fingerprint})`` for every failing test.
|
|
98
|
+
|
|
99
|
+
Returns the per-nodeid map as well so each variant can link to its cause
|
|
100
|
+
without the templates re-deriving anything.
|
|
101
|
+
"""
|
|
102
|
+
from .._parallel.fingerprint import fingerprint, group_failures
|
|
103
|
+
|
|
104
|
+
by_slug: dict[str, list[tuple[str, str]]] = {}
|
|
105
|
+
context: dict[str, BundleTest] = {}
|
|
106
|
+
prints: dict[str, str] = {}
|
|
107
|
+
for test in tests:
|
|
108
|
+
if test.outcome in _PASSING:
|
|
109
|
+
continue
|
|
110
|
+
tail = _decode_tail(test)
|
|
111
|
+
by_slug.setdefault(test.scenario, []).append((test.nodeid, tail))
|
|
112
|
+
context[test.nodeid] = test
|
|
113
|
+
prints[test.nodeid] = fingerprint(tail)
|
|
114
|
+
|
|
115
|
+
rows = [_Row(slug, tails) for slug, tails in by_slug.items()]
|
|
116
|
+
causes = []
|
|
117
|
+
for group in group_failures(rows):
|
|
118
|
+
occurrences = []
|
|
119
|
+
exemplar = ""
|
|
120
|
+
for _slug, nodeid, tail in group.occurrences:
|
|
121
|
+
test = context.get(nodeid)
|
|
122
|
+
occurrences.append(
|
|
123
|
+
ReportFailureOccurrence(
|
|
124
|
+
scenario=test.scenario if test else _slug,
|
|
125
|
+
nodeid=nodeid,
|
|
126
|
+
locale=test.locale if test else None,
|
|
127
|
+
layout=test.layout if test else None,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
if not exemplar:
|
|
131
|
+
exemplar = tail
|
|
132
|
+
causes.append(
|
|
133
|
+
ReportFailureCause(
|
|
134
|
+
fingerprint=group.fingerprint,
|
|
135
|
+
headline=group.headline,
|
|
136
|
+
count=group.count,
|
|
137
|
+
scenarios=group.slugs,
|
|
138
|
+
occurrences=occurrences,
|
|
139
|
+
exemplar_tail=exemplar,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return causes, prints
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def scenario_href(slug: str) -> str:
|
|
146
|
+
"""Site-relative URL of a scenario page.
|
|
147
|
+
|
|
148
|
+
One definition, used for the link *and* for the filename the renderer writes —
|
|
149
|
+
two would drift, and they would drift silently into a 404. The slug is
|
|
150
|
+
sanitised for the same reason asset paths are: it may have arrived in a
|
|
151
|
+
downloaded manifest, and here it decides a path on disk.
|
|
152
|
+
"""
|
|
153
|
+
from .assets import sanitise
|
|
154
|
+
|
|
155
|
+
return f"scenarios/{sanitise(slug)}.html"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_model(
|
|
159
|
+
*,
|
|
160
|
+
manifest: BundleManifest,
|
|
161
|
+
source: ReportSourceInfo,
|
|
162
|
+
attempts: Optional[dict] = None,
|
|
163
|
+
coverage: Optional[ReportCoverage] = None,
|
|
164
|
+
assets: Optional[dict] = None,
|
|
165
|
+
screenshots_policy: str = "none",
|
|
166
|
+
steppy_version: str = "",
|
|
167
|
+
extra_warnings: Optional[list[str]] = None,
|
|
168
|
+
) -> ReportModel:
|
|
169
|
+
"""Assemble the report model.
|
|
170
|
+
|
|
171
|
+
``attempts`` is ``{nodeid: [TestAttempt]}`` from
|
|
172
|
+
``bundle.results.steps_from_records``; ``assets`` maps a manifest path to the
|
|
173
|
+
site-relative URL it was copied to (absent ⇒ not copied, which is a policy
|
|
174
|
+
outcome rather than an error).
|
|
175
|
+
"""
|
|
176
|
+
attempts = attempts or {}
|
|
177
|
+
assets = assets or {}
|
|
178
|
+
coverage = coverage or ReportCoverage()
|
|
179
|
+
|
|
180
|
+
tests = list(manifest.tests)
|
|
181
|
+
causes, prints = group_manifest_failures(tests)
|
|
182
|
+
|
|
183
|
+
# Scenarios come from the tests AND the files, so a run made without
|
|
184
|
+
# --bundle — no test rows at all — still lists what it captured instead of
|
|
185
|
+
# rendering an empty page.
|
|
186
|
+
slugs: list[str] = []
|
|
187
|
+
seen: set[str] = set()
|
|
188
|
+
for name in [t.scenario for t in tests] + [f.scenario for f in manifest.files if f.scenario]:
|
|
189
|
+
if name and name not in seen:
|
|
190
|
+
seen.add(name)
|
|
191
|
+
slugs.append(name)
|
|
192
|
+
slugs.sort()
|
|
193
|
+
|
|
194
|
+
by_scenario: dict[str, list[BundleTest]] = {}
|
|
195
|
+
for test in tests:
|
|
196
|
+
by_scenario.setdefault(test.scenario, []).append(test)
|
|
197
|
+
|
|
198
|
+
failure_shots = _index_failure_screenshots(manifest, assets)
|
|
199
|
+
failure_logs = _index_by_scenario(manifest, "failure_log")
|
|
200
|
+
traces = _index_paths(manifest, "trace")
|
|
201
|
+
videos = _index_paths(manifest, "video")
|
|
202
|
+
|
|
203
|
+
scenarios: list[ReportScenario] = []
|
|
204
|
+
any_steps = False
|
|
205
|
+
any_attempts = False
|
|
206
|
+
outcomes: dict[str, int] = {}
|
|
207
|
+
duration_sum = 0
|
|
208
|
+
verdict = "passed"
|
|
209
|
+
|
|
210
|
+
for slug in slugs:
|
|
211
|
+
rows = by_scenario.get(slug, [])
|
|
212
|
+
variants: list[ReportVariant] = []
|
|
213
|
+
scenario_outcome = "passed" if rows else "unknown"
|
|
214
|
+
scenario_counts: dict[str, int] = {}
|
|
215
|
+
scenario_duration = 0
|
|
216
|
+
|
|
217
|
+
for test in sorted(rows, key=lambda t: (t.locale or "", t.layout or "", t.nodeid)):
|
|
218
|
+
runs = attempts.get(test.nodeid) or []
|
|
219
|
+
if len(runs) > 1:
|
|
220
|
+
any_attempts = True
|
|
221
|
+
last = runs[-1] if runs else None
|
|
222
|
+
steps = _steps_for(last, test, assets, manifest)
|
|
223
|
+
if steps:
|
|
224
|
+
any_steps = True
|
|
225
|
+
|
|
226
|
+
variants.append(
|
|
227
|
+
ReportVariant(
|
|
228
|
+
nodeid=test.nodeid,
|
|
229
|
+
locale=test.locale,
|
|
230
|
+
layout=test.layout,
|
|
231
|
+
outcome=test.outcome,
|
|
232
|
+
duration_ms=test.duration_ms,
|
|
233
|
+
attempts=max(len(runs), 1),
|
|
234
|
+
attempt_outcomes=[a.outcome or "unknown" for a in runs],
|
|
235
|
+
failed_step=test.failed_step,
|
|
236
|
+
failed_step_title=_step_title(steps, test.failed_step),
|
|
237
|
+
fingerprint=prints.get(test.nodeid),
|
|
238
|
+
fail_tail=_decode_tail(test),
|
|
239
|
+
steps=steps,
|
|
240
|
+
failure_screenshot=failure_shots.get(test.nodeid),
|
|
241
|
+
trace_path=traces.get(test.nodeid),
|
|
242
|
+
video_path=videos.get(test.nodeid),
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
outcomes[test.outcome] = outcomes.get(test.outcome, 0) + 1
|
|
246
|
+
scenario_counts[test.outcome] = scenario_counts.get(test.outcome, 0) + 1
|
|
247
|
+
scenario_outcome = worse(scenario_outcome, test.outcome)
|
|
248
|
+
verdict = worse(verdict, test.outcome)
|
|
249
|
+
duration_sum += test.duration_ms or 0
|
|
250
|
+
scenario_duration += test.duration_ms or 0
|
|
251
|
+
|
|
252
|
+
scenarios.append(
|
|
253
|
+
ReportScenario(
|
|
254
|
+
slug=slug,
|
|
255
|
+
outcome=scenario_outcome,
|
|
256
|
+
outcomes=scenario_counts,
|
|
257
|
+
duration_ms_sum=scenario_duration,
|
|
258
|
+
href=scenario_href(slug),
|
|
259
|
+
variants=variants,
|
|
260
|
+
failure_log=failure_logs.get(slug),
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
not_passed = sum(1 for s in scenarios if s.outcome not in _PASSING)
|
|
265
|
+
if not tests:
|
|
266
|
+
# No outcomes were recorded at all — a run without ``--bundle``, or a
|
|
267
|
+
# capture-only guide run. "passed" would be a claim we have no evidence
|
|
268
|
+
# for, which is the exact failure mode this whole model tries to avoid.
|
|
269
|
+
# ``exit_code`` stays 0 because nothing *failed*: the run itself already
|
|
270
|
+
# reported its own verdict, and a report is not the gate.
|
|
271
|
+
verdict = "unknown"
|
|
272
|
+
exit_code = 0
|
|
273
|
+
else:
|
|
274
|
+
exit_code = 0 if verdict in _PASSING else 1
|
|
275
|
+
totals = ReportTotals(
|
|
276
|
+
tests=len(tests),
|
|
277
|
+
scenarios=len(scenarios),
|
|
278
|
+
scenarios_not_passed=not_passed,
|
|
279
|
+
outcomes=dict(sorted(outcomes.items())),
|
|
280
|
+
duration_ms_sum=duration_sum,
|
|
281
|
+
verdict=verdict,
|
|
282
|
+
exit_code=exit_code,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
warnings = list(manifest.warnings) + list(extra_warnings or [])
|
|
286
|
+
return ReportModel(
|
|
287
|
+
steppy_report=REPORT_SCHEMA_VERSION,
|
|
288
|
+
generated_by=steppy_version,
|
|
289
|
+
source=source,
|
|
290
|
+
run=manifest.run,
|
|
291
|
+
totals=totals,
|
|
292
|
+
dimensions=_observed_dimensions(tests),
|
|
293
|
+
capabilities=ReportCapabilities(
|
|
294
|
+
step_timeline=any_steps,
|
|
295
|
+
attempts=any_attempts,
|
|
296
|
+
coverage=coverage.tier,
|
|
297
|
+
screenshots=screenshots_policy,
|
|
298
|
+
strength_reliable=coverage.tier != "records",
|
|
299
|
+
),
|
|
300
|
+
scenarios=scenarios,
|
|
301
|
+
failure_causes=causes,
|
|
302
|
+
coverage=coverage,
|
|
303
|
+
artifacts=_artifacts(manifest, assets),
|
|
304
|
+
warnings=warnings,
|
|
305
|
+
truncated=manifest.truncated,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _observed_dimensions(tests: list[BundleTest]) -> dict[str, list[str]]:
|
|
310
|
+
"""Locales and layouts that actually produced a row.
|
|
311
|
+
|
|
312
|
+
Not ``run.spec``: a scenario whose frontmatter ``layouts:`` is disjoint from
|
|
313
|
+
the run's is silently deselected, so a matrix built from the *requested* set
|
|
314
|
+
grows a column of empty cells that reads like a failure.
|
|
315
|
+
"""
|
|
316
|
+
locales = sorted({t.locale for t in tests if t.locale})
|
|
317
|
+
layouts = sorted({t.layout for t in tests if t.layout})
|
|
318
|
+
return {"locales": locales, "layouts": layouts}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _steps_for(attempt, test: BundleTest, assets: dict, manifest: BundleManifest) -> list[ReportStep]:
|
|
322
|
+
"""The timeline for one variant, with each step's captures attached."""
|
|
323
|
+
if attempt is None:
|
|
324
|
+
return []
|
|
325
|
+
shots = _index_step_screenshots(manifest, assets, test.scenario, test.locale, test.layout)
|
|
326
|
+
steps = []
|
|
327
|
+
for row in attempt.offsets():
|
|
328
|
+
steps.append(ReportStep(**row, screenshots=shots.get(row["step"], [])))
|
|
329
|
+
return steps
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _step_title(steps: list[ReportStep], failed_step: Optional[int]) -> str:
|
|
333
|
+
if failed_step is None:
|
|
334
|
+
return ""
|
|
335
|
+
for step in steps:
|
|
336
|
+
if step.step == failed_step:
|
|
337
|
+
return step.title
|
|
338
|
+
return ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _index_step_screenshots(
|
|
342
|
+
manifest: BundleManifest, assets: dict, scenario: Optional[str], locale, layout
|
|
343
|
+
) -> dict[int, list[str]]:
|
|
344
|
+
"""``{step: [url, …]}`` for one variant, in frame order.
|
|
345
|
+
|
|
346
|
+
Reads ``manifest.files[]`` attribution rather than parsing paths — that is what
|
|
347
|
+
``bundle/classify.py`` recorded it for.
|
|
348
|
+
"""
|
|
349
|
+
out: dict[int, list[tuple[str, str]]] = {}
|
|
350
|
+
for entry in manifest.files:
|
|
351
|
+
if entry.kind != "screenshot" or entry.step is None:
|
|
352
|
+
continue
|
|
353
|
+
if entry.scenario != scenario or entry.locale != locale or entry.layout != layout:
|
|
354
|
+
continue
|
|
355
|
+
url = assets.get(entry.path)
|
|
356
|
+
if url:
|
|
357
|
+
out.setdefault(entry.step, []).append((entry.frame or "", url))
|
|
358
|
+
return {step: [url for _frame, url in sorted(frames)] for step, frames in out.items()}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _index_failure_screenshots(manifest: BundleManifest, assets: dict) -> dict[str, str]:
|
|
362
|
+
"""``{nodeid: url}``, joined through the manifest's own attribution.
|
|
363
|
+
|
|
364
|
+
``sanitise_nodeid`` is lossy and must only ever be used forwards, so nothing
|
|
365
|
+
here tries to reverse a ``_failures/`` filename: ``classify`` already resolved
|
|
366
|
+
each one to a (scenario, locale, layout) at create time.
|
|
367
|
+
"""
|
|
368
|
+
by_key: dict[tuple, str] = {}
|
|
369
|
+
for entry in manifest.files:
|
|
370
|
+
if entry.kind != "failure_screenshot":
|
|
371
|
+
continue
|
|
372
|
+
url = assets.get(entry.path)
|
|
373
|
+
if url:
|
|
374
|
+
by_key.setdefault((entry.scenario, entry.locale, entry.layout), url)
|
|
375
|
+
return {
|
|
376
|
+
test.nodeid: url
|
|
377
|
+
for test in manifest.tests
|
|
378
|
+
if (url := by_key.get((test.scenario, test.locale, test.layout))) is not None
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _index_by_scenario(manifest: BundleManifest, kind: str) -> dict[str, str]:
|
|
383
|
+
out: dict[str, str] = {}
|
|
384
|
+
for entry in manifest.files:
|
|
385
|
+
if entry.kind == kind and entry.scenario:
|
|
386
|
+
out.setdefault(entry.scenario, entry.path)
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _index_paths(manifest: BundleManifest, kind: str) -> dict[str, str]:
|
|
391
|
+
"""``{nodeid: in-bundle path}`` for artifacts that are named, not copied."""
|
|
392
|
+
by_key: dict[tuple, str] = {}
|
|
393
|
+
for entry in manifest.files:
|
|
394
|
+
if entry.kind == kind:
|
|
395
|
+
by_key.setdefault((entry.scenario, entry.locale, entry.layout), entry.path)
|
|
396
|
+
return {
|
|
397
|
+
test.nodeid: path
|
|
398
|
+
for test in manifest.tests
|
|
399
|
+
if (path := by_key.get((test.scenario, test.locale, test.layout))) is not None
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _artifacts(manifest: BundleManifest, assets: dict) -> ReportArtifacts:
|
|
404
|
+
by_kind: dict[str, ReportArtifactGroup] = {}
|
|
405
|
+
total_bytes = 0
|
|
406
|
+
for entry in manifest.files:
|
|
407
|
+
group = by_kind.setdefault(entry.kind, ReportArtifactGroup())
|
|
408
|
+
group.count += 1
|
|
409
|
+
group.bytes += entry.size
|
|
410
|
+
total_bytes += entry.size
|
|
411
|
+
copied = {url for url in assets.values() if url}
|
|
412
|
+
return ReportArtifacts(
|
|
413
|
+
files=len(manifest.files),
|
|
414
|
+
bytes=total_bytes,
|
|
415
|
+
deduped=max(len(assets) - len(copied), 0),
|
|
416
|
+
by_kind=dict(sorted(by_kind.items())),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def files_of_kind(manifest: BundleManifest, *kinds: str) -> list[BundleFile]:
|
|
421
|
+
"""Manifest rows of any of *kinds*, in manifest order (sorted by path)."""
|
|
422
|
+
wanted = set(kinds)
|
|
423
|
+
return [f for f in manifest.files if f.kind in wanted]
|