steppy-dev 0.3.0__py3-none-any.whl

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