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/report/render.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
""":class:`ReportModel` → a self-contained static site.
|
|
2
|
+
|
|
3
|
+
The renderer, and nothing else. It knows about Jinja and about which files a site
|
|
4
|
+
has; it computes nothing — every number, label and URL was decided in
|
|
5
|
+
:mod:`steppy.report.model` and :mod:`steppy.report.assets`. That split is what
|
|
6
|
+
lets a control plane consume the model without importing a template engine.
|
|
7
|
+
|
|
8
|
+
**Two rules this module has to keep.**
|
|
9
|
+
|
|
10
|
+
Autoescape is **on**. Everything rendered here came out of a bundle, and a failure
|
|
11
|
+
tail is arbitrary page content — a page under test that echoes user input can put
|
|
12
|
+
``<script>`` into an assertion message. Escaping by default means a forgotten
|
|
13
|
+
``| e`` is impossible rather than merely unlikely.
|
|
14
|
+
|
|
15
|
+
It must **not import** :mod:`steppy.guide`. That module builds a module-level
|
|
16
|
+
``MarkdownIt``, and ``markdown_it`` is one of the prefixes
|
|
17
|
+
``tests/test_no_playwright_import_on_default_path.py`` forbids on the default path.
|
|
18
|
+
Nothing here needs markdown: step titles are plain text and tails live in
|
|
19
|
+
``<pre>``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from ..contracts.report import ReportModel, ReportScenario, ReportVariant, report_bytes
|
|
29
|
+
|
|
30
|
+
#: One directory deep, so a scenario page's ``../`` reaches the site root. Every
|
|
31
|
+
#: URL in the site is relative, which is what lets it be published at any prefix
|
|
32
|
+
#: (a Pages project site lives under ``/<repo>/``, not at ``/``).
|
|
33
|
+
SCENARIOS_DIRNAME = "scenarios"
|
|
34
|
+
|
|
35
|
+
REPORT_JSON = "report.json"
|
|
36
|
+
BADGE_JSON = "badge.json"
|
|
37
|
+
CSS_NAME = "report.css"
|
|
38
|
+
|
|
39
|
+
#: Present for the same reason ``steppy build`` emits it: GitHub Pages runs Jekyll
|
|
40
|
+
#: by default, which silently drops files and directories beginning with ``_`` —
|
|
41
|
+
#: and this site has an ``assets/_failures/`` directory.
|
|
42
|
+
NOJEKYLL = ".nojekyll"
|
|
43
|
+
|
|
44
|
+
_ROBOTS = """# A Steppy run report. Not documentation, and not for indexing.
|
|
45
|
+
#
|
|
46
|
+
# The <meta name="robots" content="noindex,nofollow,noarchive"> on every page is
|
|
47
|
+
# the real control: a crawler blocked here never fetches a page and therefore
|
|
48
|
+
# never learns it is noindex, so a link from elsewhere could still be listed.
|
|
49
|
+
# This file is belt and braces, and matches what `steppy build` writes in its
|
|
50
|
+
# private SEO mode so the two sites do not diverge.
|
|
51
|
+
User-agent: *
|
|
52
|
+
Disallow: /
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class RenderResult:
|
|
58
|
+
"""Which files the site has, keyed by site-relative path.
|
|
59
|
+
|
|
60
|
+
Values use the same vocabulary as ``build.BuildAssembler``:
|
|
61
|
+
``planned`` | ``generated`` | ``updated`` | ``unchanged``.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
files: dict[str, str] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
def count(self, status: str) -> int:
|
|
67
|
+
return sum(1 for value in self.files.values() if value == status)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_duration(ms) -> str:
|
|
71
|
+
"""Human duration from milliseconds. ``None`` reads as unknown, not as zero."""
|
|
72
|
+
if ms is None:
|
|
73
|
+
return "—"
|
|
74
|
+
try:
|
|
75
|
+
ms = int(ms)
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return "—"
|
|
78
|
+
if ms < 1000:
|
|
79
|
+
return f"{ms} ms"
|
|
80
|
+
seconds = ms / 1000
|
|
81
|
+
if seconds < 60:
|
|
82
|
+
return f"{seconds:.1f} s"
|
|
83
|
+
minutes, rest = divmod(int(seconds), 60)
|
|
84
|
+
if minutes < 60:
|
|
85
|
+
return f"{minutes}m {rest:02d}s"
|
|
86
|
+
hours, minutes = divmod(minutes, 60)
|
|
87
|
+
return f"{hours}h {minutes:02d}m"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_filesize(size) -> str:
|
|
91
|
+
try:
|
|
92
|
+
size = float(size)
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
return "—"
|
|
95
|
+
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
96
|
+
if size < 1024 or unit == "GiB":
|
|
97
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
98
|
+
size /= 1024
|
|
99
|
+
return f"{size:.1f} GiB" # pragma: no cover - unreachable, the loop returns
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def variant_label(variant: ReportVariant) -> str:
|
|
103
|
+
"""``fr / mobile``, ``fr``, or ``default`` — never an empty heading."""
|
|
104
|
+
parts = [p for p in (variant.locale, variant.layout) if p]
|
|
105
|
+
return " / ".join(parts) if parts else "default"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def matrix_columns(report: ReportModel) -> list[dict]:
|
|
109
|
+
"""The (locale, layout) columns of the scenario table.
|
|
110
|
+
|
|
111
|
+
Built from the *observed* dimensions, so a scenario deselected by a disjoint
|
|
112
|
+
frontmatter ``layouts:`` does not add a column of empty cells that reads like a
|
|
113
|
+
failure. An unparametrised run gets no columns at all and the table falls back
|
|
114
|
+
to a single outcome cell.
|
|
115
|
+
"""
|
|
116
|
+
locales = report.dimensions.get("locales") or []
|
|
117
|
+
layouts = report.dimensions.get("layouts") or []
|
|
118
|
+
if not locales and not layouts:
|
|
119
|
+
return []
|
|
120
|
+
for values in (locales, layouts):
|
|
121
|
+
if not values:
|
|
122
|
+
values.append(None)
|
|
123
|
+
return [
|
|
124
|
+
{"locale": locale, "layout": layout, "label": " / ".join(p for p in (locale, layout) if p)}
|
|
125
|
+
for locale in locales
|
|
126
|
+
for layout in layouts
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def matrix_cell(scenario: ReportScenario, column: dict) -> Optional[str]:
|
|
131
|
+
"""The outcome in one matrix cell, or ``None`` when that pair never ran."""
|
|
132
|
+
for variant in scenario.variants:
|
|
133
|
+
if variant.locale == column["locale"] and variant.layout == column["layout"]:
|
|
134
|
+
return variant.outcome
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def make_env(templates_dir: Optional[Path], project_root: Optional[Path]):
|
|
139
|
+
"""The report's Jinja environment: autoescaping, with the report's own filters."""
|
|
140
|
+
from .._templating import make_template_env
|
|
141
|
+
|
|
142
|
+
env = make_template_env(templates_dir, project_root, autoescape=True)
|
|
143
|
+
env.filters["duration"] = format_duration
|
|
144
|
+
env.filters["filesize"] = format_filesize
|
|
145
|
+
return env
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def render_site(
|
|
149
|
+
report: ReportModel,
|
|
150
|
+
*,
|
|
151
|
+
site_dir: Path,
|
|
152
|
+
title: str,
|
|
153
|
+
templates_dir: Optional[Path] = None,
|
|
154
|
+
project_root: Optional[Path] = None,
|
|
155
|
+
dry_run: bool = False,
|
|
156
|
+
) -> RenderResult:
|
|
157
|
+
"""Write every page, stylesheet and data file of the site."""
|
|
158
|
+
from .model import scenario_href
|
|
159
|
+
|
|
160
|
+
env = make_env(templates_dir, project_root)
|
|
161
|
+
result = RenderResult()
|
|
162
|
+
|
|
163
|
+
def write(rel: str, content: str) -> None:
|
|
164
|
+
_write(site_dir / rel, content, result, dry_run, site_dir=site_dir)
|
|
165
|
+
|
|
166
|
+
columns = matrix_columns(report)
|
|
167
|
+
write(
|
|
168
|
+
"index.html",
|
|
169
|
+
env.get_template("report/index.html.j2").render(
|
|
170
|
+
report=report,
|
|
171
|
+
title=title,
|
|
172
|
+
page_title=title,
|
|
173
|
+
root="",
|
|
174
|
+
matrix_columns=columns,
|
|
175
|
+
matrix_cell=matrix_cell,
|
|
176
|
+
scenario_href=scenario_href,
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
scenario_template = env.get_template("report/scenario.html.j2")
|
|
181
|
+
for scenario in report.scenarios:
|
|
182
|
+
write(
|
|
183
|
+
# ``scenario_href`` decides the link AND the filename — one definition,
|
|
184
|
+
# and it sanitises, because a slug from a downloaded manifest decides a
|
|
185
|
+
# path on disk here.
|
|
186
|
+
scenario.href or scenario_href(scenario.slug),
|
|
187
|
+
scenario_template.render(
|
|
188
|
+
report=report,
|
|
189
|
+
scenario=scenario,
|
|
190
|
+
title=title,
|
|
191
|
+
page_title=f"{scenario.slug} — {title}",
|
|
192
|
+
# One level deep, so ``../`` is the site root.
|
|
193
|
+
root="../",
|
|
194
|
+
variant_label=variant_label,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if report.coverage.tier != "none":
|
|
199
|
+
write(
|
|
200
|
+
"coverage.html",
|
|
201
|
+
env.get_template("report/coverage.html.j2").render(
|
|
202
|
+
coverage=_coverage_context(report),
|
|
203
|
+
bucket=_bucket_getter(report),
|
|
204
|
+
title=title,
|
|
205
|
+
page_title=f"Coverage — {title}",
|
|
206
|
+
root="",
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
write(BADGE_JSON, _badge_json(report))
|
|
210
|
+
|
|
211
|
+
# Through the loader, so a project override at templates/report/report.css wins
|
|
212
|
+
# by the same precedence rule as every other template.
|
|
213
|
+
write(f"assets/{CSS_NAME}", env.get_template("report/report.css").render())
|
|
214
|
+
|
|
215
|
+
write(REPORT_JSON, report_bytes(report).decode("utf-8"))
|
|
216
|
+
write(NOJEKYLL, "")
|
|
217
|
+
write("robots.txt", _ROBOTS)
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _coverage_context(report: ReportModel):
|
|
222
|
+
"""The coverage view plus the one derived flag its template needs.
|
|
223
|
+
|
|
224
|
+
``capabilities_strength`` lives on the model's ``capabilities`` block, not on
|
|
225
|
+
the coverage block, and the template should not have to reach across for it.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
class _Context:
|
|
229
|
+
def __init__(self, coverage, strength):
|
|
230
|
+
self._coverage = coverage
|
|
231
|
+
self.capabilities_strength = strength
|
|
232
|
+
|
|
233
|
+
def __getattr__(self, name):
|
|
234
|
+
return getattr(self._coverage, name)
|
|
235
|
+
|
|
236
|
+
return _Context(report.coverage, report.capabilities.strength_reliable)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _bucket_getter(report: ReportModel):
|
|
240
|
+
"""``bucket(kind, name)`` over the carried gap buckets.
|
|
241
|
+
|
|
242
|
+
Tolerant of an absent bucket on purpose: the buckets come from
|
|
243
|
+
``compute_gaps`` verbatim, and a tier that could not produce one (or a future
|
|
244
|
+
version that renames one) should render an empty section rather than raise
|
|
245
|
+
inside a template.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def bucket(kind: str, name: str) -> list:
|
|
249
|
+
section = report.coverage.buckets.get(kind)
|
|
250
|
+
if not isinstance(section, dict):
|
|
251
|
+
return []
|
|
252
|
+
rows = section.get(name)
|
|
253
|
+
return rows if isinstance(rows, list) else []
|
|
254
|
+
|
|
255
|
+
return bucket
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _badge_json(report: ReportModel) -> str:
|
|
259
|
+
"""The shields endpoint object, kept as its own file at the site root.
|
|
260
|
+
|
|
261
|
+
Separate from ``report.json`` so a README's
|
|
262
|
+
``https://img.shields.io/endpoint?url=…`` never has to change, and so the
|
|
263
|
+
badge stays readable by shields without it parsing a whole report.
|
|
264
|
+
"""
|
|
265
|
+
import json
|
|
266
|
+
|
|
267
|
+
badge = report.coverage.badge
|
|
268
|
+
message = "n/a" if badge.percent is None else f"{badge.percent}%"
|
|
269
|
+
payload = {"schemaVersion": 1, "label": badge.label, "message": message, "color": badge.color}
|
|
270
|
+
return json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _write(path: Path, content: str, result: RenderResult, dry_run: bool, *, site_dir: Path) -> None:
|
|
274
|
+
"""Write *content*, recording what happened under its site-relative key.
|
|
275
|
+
|
|
276
|
+
Same shape and vocabulary as ``build.BuildAssembler._write_file`` — including
|
|
277
|
+
``unchanged`` — so the CLI summary reads identically for both commands. The key
|
|
278
|
+
is site-relative because that is what a reader can act on; an absolute temp
|
|
279
|
+
path in a CI log tells them nothing.
|
|
280
|
+
"""
|
|
281
|
+
key = _site_key(path, site_dir)
|
|
282
|
+
if dry_run:
|
|
283
|
+
result.files[key] = "planned"
|
|
284
|
+
return
|
|
285
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
if path.exists():
|
|
287
|
+
try:
|
|
288
|
+
if path.read_text(encoding="utf-8") == content:
|
|
289
|
+
result.files[key] = "unchanged"
|
|
290
|
+
return
|
|
291
|
+
except (OSError, UnicodeDecodeError):
|
|
292
|
+
pass
|
|
293
|
+
result.files[key] = "updated"
|
|
294
|
+
else:
|
|
295
|
+
result.files[key] = "generated"
|
|
296
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _site_key(path: Path, site_dir: Path) -> str:
|
|
300
|
+
try:
|
|
301
|
+
return path.relative_to(site_dir).as_posix()
|
|
302
|
+
except ValueError: # pragma: no cover - every writer stays inside the site
|
|
303
|
+
return path.as_posix()
|
steppy/report/source.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Three ways to hand the report a run, reduced to one protocol.
|
|
2
|
+
|
|
3
|
+
A run reaches the reporter as a live ``output/`` tree, as a ``.tar.zst`` /
|
|
4
|
+
``.tar.gz`` someone downloaded from CI, or as a directory an earlier ``tar -xf``
|
|
5
|
+
left behind. Downstream code must not care which — so everything here exists to
|
|
6
|
+
produce one :class:`ReportSource` with a validated manifest and a single way to
|
|
7
|
+
ask for bytes.
|
|
8
|
+
|
|
9
|
+
Two decisions carry this module.
|
|
10
|
+
|
|
11
|
+
**The manifest is the whole index.** ``bundle/classify.py`` already stamped
|
|
12
|
+
``scenario``/``locale``/``layout``/``step``/``frame`` onto every file when the
|
|
13
|
+
bundle was created, so the reporter never parses a path. That is the same
|
|
14
|
+
"one grammar, one home" rule the capture layer follows, applied to the manifest.
|
|
15
|
+
|
|
16
|
+
**Archives are read in two passes, and neither pass is wasteful.** Pass 1 is
|
|
17
|
+
``inspect_bundle``, which streams until it meets ``manifest.json`` and stops —
|
|
18
|
+
a few kilobytes, and it makes no assumption about member position. Pass 2 pulls
|
|
19
|
+
exactly what the caller asked for. Two passes rather than one because the zstd
|
|
20
|
+
stream is non-seekable (``tarfile`` ``r|``): there is no rewinding to fetch
|
|
21
|
+
something a single pass decided about too late.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from ..contracts.bundle import BundleManifest, parse_manifest
|
|
32
|
+
|
|
33
|
+
#: Names of the two source kinds that come with their own bytes on disk.
|
|
34
|
+
KIND_TREE = "tree"
|
|
35
|
+
KIND_ARCHIVE = "archive"
|
|
36
|
+
KIND_DIR = "dir"
|
|
37
|
+
|
|
38
|
+
MANIFEST_NAME = "manifest.json"
|
|
39
|
+
|
|
40
|
+
_ARCHIVE_SUFFIXES = (".zst", ".gz")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReportSourceError(Exception):
|
|
44
|
+
"""The input could not be read as a run. Fatal, and always actionable."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ReportSource:
|
|
49
|
+
"""A run's manifest plus one way to fetch the bytes behind it."""
|
|
50
|
+
|
|
51
|
+
manifest: BundleManifest
|
|
52
|
+
kind: str
|
|
53
|
+
origin: Path
|
|
54
|
+
label: str
|
|
55
|
+
warnings: list[str] = field(default_factory=list)
|
|
56
|
+
#: Live-tree mode only: manifest path → local file, from ``plan.members()``.
|
|
57
|
+
_local: dict[str, Path] = field(default_factory=dict, repr=False)
|
|
58
|
+
#: Live-tree mode only: manifest path → generated bytes (junit.xml).
|
|
59
|
+
_inline: dict[str, bytes] = field(default_factory=dict, repr=False)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_live_tree(self) -> bool:
|
|
63
|
+
"""True when the bytes are the project's own ``output/``.
|
|
64
|
+
|
|
65
|
+
The one place this matters is coverage: reading the local
|
|
66
|
+
``.steppy/coverage.db`` for someone *else's* downloaded bundle would
|
|
67
|
+
attribute this machine's coverage map to their run, and produce a
|
|
68
|
+
plausible-looking percentage that means nothing.
|
|
69
|
+
"""
|
|
70
|
+
return self.kind == KIND_TREE
|
|
71
|
+
|
|
72
|
+
def fetch(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
into: Optional[dict[str, Path]] = None,
|
|
76
|
+
texts: Optional[set[str]] = None,
|
|
77
|
+
) -> tuple[dict[str, bytes], list[str]]:
|
|
78
|
+
"""Copy/return the requested members. ``(fetched_texts, warnings)``.
|
|
79
|
+
|
|
80
|
+
``into`` maps a manifest path to the file it should land in; ``texts``
|
|
81
|
+
names manifest paths wanted as bytes. Callers choose every destination
|
|
82
|
+
themselves — a path out of a downloaded manifest is untrusted input and is
|
|
83
|
+
never used to decide where anything is written.
|
|
84
|
+
"""
|
|
85
|
+
wanted_files = dict(into or {})
|
|
86
|
+
wanted_texts = set(texts or ())
|
|
87
|
+
if self.kind == KIND_ARCHIVE:
|
|
88
|
+
from ..bundle.archive import ArchiveError, read_members
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
return read_members(self.origin, into=wanted_files, texts=wanted_texts)
|
|
92
|
+
except ArchiveError as e:
|
|
93
|
+
raise ReportSourceError(str(e)) from e
|
|
94
|
+
return self._fetch_local(wanted_files, wanted_texts)
|
|
95
|
+
|
|
96
|
+
def _fetch_local(self, wanted_files: dict[str, Path], wanted_texts: set[str]) -> tuple[dict[str, bytes], list[str]]:
|
|
97
|
+
import shutil
|
|
98
|
+
|
|
99
|
+
fetched: dict[str, bytes] = {}
|
|
100
|
+
warnings: list[str] = []
|
|
101
|
+
for name in sorted(set(wanted_files) | wanted_texts):
|
|
102
|
+
payload = self._inline.get(name)
|
|
103
|
+
source = self._local.get(name)
|
|
104
|
+
target = wanted_files.get(name)
|
|
105
|
+
if payload is None and (source is None or not source.is_file()):
|
|
106
|
+
warnings.append(f"{name} is listed in the manifest but missing on disk")
|
|
107
|
+
continue
|
|
108
|
+
if name in wanted_texts:
|
|
109
|
+
data = payload if payload is not None else source.read_bytes()
|
|
110
|
+
fetched[name] = data
|
|
111
|
+
if target is not None:
|
|
112
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
target.write_bytes(data)
|
|
114
|
+
continue
|
|
115
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
if payload is not None:
|
|
117
|
+
target.write_bytes(payload)
|
|
118
|
+
else:
|
|
119
|
+
shutil.copy2(source, target)
|
|
120
|
+
return fetched, warnings
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def open_source(
|
|
124
|
+
path: Optional[Path],
|
|
125
|
+
*,
|
|
126
|
+
project_root: Path,
|
|
127
|
+
output_dir: Optional[Path] = None,
|
|
128
|
+
run_id: Optional[str] = None,
|
|
129
|
+
) -> ReportSource:
|
|
130
|
+
"""Resolve *path* to a :class:`ReportSource`.
|
|
131
|
+
|
|
132
|
+
``None`` means the project's live ``output/`` tree — the case a CI job hits
|
|
133
|
+
right after ``steppy run --bundle``, and the only one where a local coverage
|
|
134
|
+
store may be consulted.
|
|
135
|
+
"""
|
|
136
|
+
if path is None:
|
|
137
|
+
return _from_tree(project_root=project_root, output_dir=output_dir, run_id=run_id)
|
|
138
|
+
path = Path(path)
|
|
139
|
+
if path.is_dir():
|
|
140
|
+
return _from_dir(path)
|
|
141
|
+
if not path.exists():
|
|
142
|
+
raise ReportSourceError(f"no such bundle or directory: {path}")
|
|
143
|
+
if path.suffix in _ARCHIVE_SUFFIXES:
|
|
144
|
+
return _from_archive(path)
|
|
145
|
+
raise ReportSourceError(
|
|
146
|
+
f"{path.name} is neither a .tar.zst/.tar.gz bundle nor an unpacked bundle directory. "
|
|
147
|
+
"Pass a bundle, a directory containing manifest.json, or nothing at all to report on ./output/."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _from_tree(*, project_root: Path, output_dir: Optional[Path], run_id: Optional[str]) -> ReportSource:
|
|
152
|
+
from ..bundle import BundleError, plan_bundle
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
plan = plan_bundle(project_root=project_root, output_dir=output_dir, run_id=run_id)
|
|
156
|
+
except BundleError as e:
|
|
157
|
+
raise ReportSourceError(str(e)) from e
|
|
158
|
+
|
|
159
|
+
local: dict[str, Path] = {}
|
|
160
|
+
inline: dict[str, bytes] = {}
|
|
161
|
+
for member in plan.members():
|
|
162
|
+
if member.arcname == MANIFEST_NAME:
|
|
163
|
+
continue
|
|
164
|
+
if member.data is not None:
|
|
165
|
+
inline[member.arcname] = member.data
|
|
166
|
+
elif member.source is not None:
|
|
167
|
+
local[member.arcname] = member.source
|
|
168
|
+
|
|
169
|
+
tree = output_dir or (project_root / "output")
|
|
170
|
+
return ReportSource(
|
|
171
|
+
manifest=plan.manifest,
|
|
172
|
+
kind=KIND_TREE,
|
|
173
|
+
origin=tree,
|
|
174
|
+
# Project-relative, because this string is written into ``report.json`` and
|
|
175
|
+
# rendered into the page footer — and both get published. An absolute path
|
|
176
|
+
# leaks a home directory and a CI runner's layout to anyone who opens the
|
|
177
|
+
# site, which is a small thing that is impossible to take back.
|
|
178
|
+
label=_display_path(tree, project_root),
|
|
179
|
+
warnings=list(plan.manifest.warnings),
|
|
180
|
+
_local=local,
|
|
181
|
+
_inline=inline,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _from_archive(path: Path) -> ReportSource:
|
|
186
|
+
from ..bundle import BundleError, inspect_bundle
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
manifest = inspect_bundle(path)
|
|
190
|
+
except BundleError as e:
|
|
191
|
+
raise ReportSourceError(str(e)) from e
|
|
192
|
+
return ReportSource(
|
|
193
|
+
manifest=manifest,
|
|
194
|
+
kind=KIND_ARCHIVE,
|
|
195
|
+
origin=path,
|
|
196
|
+
label=path.name,
|
|
197
|
+
warnings=list(manifest.warnings),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _display_path(path: Path, root: Path) -> str:
|
|
202
|
+
"""A path safe to publish: relative to *root* when it is under it, else a name.
|
|
203
|
+
|
|
204
|
+
Never the absolute path. Falling back to the basename rather than the full path
|
|
205
|
+
keeps the guarantee even for a tree outside the project (``--output-dir``
|
|
206
|
+
somewhere else), where "relative" is not available.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
return Path(path).resolve().relative_to(Path(root).resolve()).as_posix()
|
|
210
|
+
except (OSError, ValueError):
|
|
211
|
+
return Path(path).name
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _from_dir(path: Path) -> ReportSource:
|
|
215
|
+
manifest_path = path / MANIFEST_NAME
|
|
216
|
+
if not manifest_path.is_file():
|
|
217
|
+
raise ReportSourceError(
|
|
218
|
+
f"{path} contains no {MANIFEST_NAME} — is it an unpacked bundle? "
|
|
219
|
+
"(a bundle unpacks to manifest.json + junit.xml + events/ + output/)"
|
|
220
|
+
)
|
|
221
|
+
try:
|
|
222
|
+
manifest = parse_manifest(json.loads(manifest_path.read_text(encoding="utf-8")))
|
|
223
|
+
except (OSError, ValueError) as e:
|
|
224
|
+
raise ReportSourceError(f"could not read {manifest_path}: {e}") from e
|
|
225
|
+
|
|
226
|
+
# An unpacked bundle holds every member at its manifest path, relative to the
|
|
227
|
+
# directory — which is exactly why the bundle refuses to rewrite paths.
|
|
228
|
+
local = {entry.path: path / entry.path for entry in manifest.files}
|
|
229
|
+
return ReportSource(
|
|
230
|
+
manifest=manifest,
|
|
231
|
+
kind=KIND_DIR,
|
|
232
|
+
origin=path,
|
|
233
|
+
# Basename only, like the archive's — an unpacked bundle sits wherever
|
|
234
|
+
# someone happened to untar it, and that path goes into a published file.
|
|
235
|
+
label=path.name,
|
|
236
|
+
warnings=list(manifest.warnings),
|
|
237
|
+
_local=local,
|
|
238
|
+
)
|