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,334 @@
1
+ """Manifest assembly: walk, classify, hash, budget.
2
+
3
+ Two passes over the tree. Pass one walks, stats, classifies and hashes; pass two
4
+ streams the bytes into the archive. That is two reads of every byte, and on a
5
+ large capture tree it is a real cost — but the alternative is buffering the tar
6
+ to a temp file so the manifest can be prepended, which doubles peak disk on
7
+ exactly the runs where disk is already the constraint. Sequential reads are the
8
+ cheaper half of that trade.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from .. import __version__
21
+ from ..contracts.bundle import (
22
+ BUNDLE_SCHEMA_VERSION,
23
+ BundleCoverage,
24
+ BundleFile,
25
+ BundleGit,
26
+ BundleManifest,
27
+ BundleRun,
28
+ BundleSpec,
29
+ BundleTest,
30
+ )
31
+ from . import classify as _classify
32
+ from .hashing import sha256_file
33
+
34
+ #: Default home for written bundles, and therefore excluded from the walk.
35
+ BUNDLES_SUBDIR = "bundles"
36
+
37
+ #: Kinds a user can exclude by flag. Excluding these does NOT set ``truncated``
38
+ #: — that flag means "we dropped something that belonged here", and a declared
39
+ #: scope is not a loss.
40
+ OPTIONAL_KINDS = {
41
+ "guides": {
42
+ _classify.KIND_GUIDE_HTML,
43
+ _classify.KIND_GUIDE_MD,
44
+ _classify.KIND_GUIDE_ASSET,
45
+ _classify.KIND_INTERACTIVE_MANIFEST,
46
+ },
47
+ "traces": {_classify.KIND_TRACE},
48
+ "videos": {_classify.KIND_VIDEO},
49
+ "coverage": {
50
+ _classify.KIND_COVERAGE_RECORD,
51
+ _classify.KIND_COVERAGE_SOURCEMAP,
52
+ _classify.KIND_COVERAGE_GAPS,
53
+ },
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class Candidate:
59
+ """A file that passed classification, with its content facts filled in."""
60
+
61
+ entry: BundleFile
62
+ source: Path
63
+
64
+
65
+ @dataclass
66
+ class Assembly:
67
+ manifest: BundleManifest
68
+ candidates: list[Candidate]
69
+ junit_xml: Optional[bytes] = None
70
+ events: list[tuple[str, Path]] = field(default_factory=list)
71
+ warnings: list[str] = field(default_factory=list)
72
+
73
+
74
+ def _git(project_root: Path, *args: str) -> Optional[str]:
75
+ """Run a read-only git command, or ``None`` if git/the repo is unavailable."""
76
+ try:
77
+ out = subprocess.run(
78
+ ["git", *args],
79
+ cwd=project_root,
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=5,
83
+ )
84
+ except (OSError, subprocess.SubprocessError):
85
+ return None
86
+ value = out.stdout.strip()
87
+ return value if out.returncode == 0 and value else None
88
+
89
+
90
+ def detect_git(
91
+ project_root: Path,
92
+ *,
93
+ sha: Optional[str] = None,
94
+ branch: Optional[str] = None,
95
+ pr: Optional[int] = None,
96
+ ) -> BundleGit:
97
+ """Resolve git provenance: explicit flags → CI env → local git → nothing.
98
+
99
+ CI variables come before ``git rev-parse`` because a CI checkout is often
100
+ detached at a merge commit that does not describe the change under test.
101
+ """
102
+ sha = sha or os.environ.get("GITHUB_SHA") or _git(project_root, "rev-parse", "HEAD")
103
+ branch = branch or os.environ.get("GITHUB_REF_NAME") or _git(project_root, "rev-parse", "--abbrev-ref", "HEAD")
104
+ if pr is None:
105
+ ref = os.environ.get("GITHUB_REF", "")
106
+ # refs/pull/42/merge
107
+ if ref.startswith("refs/pull/"):
108
+ candidate = ref.split("/")[2] if len(ref.split("/")) > 2 else ""
109
+ if candidate.isdigit():
110
+ pr = int(candidate)
111
+ if branch == "HEAD":
112
+ branch = None
113
+ return BundleGit(sha=sha, branch=branch, pr=pr)
114
+
115
+
116
+ def _spec_from_sidecar(sidecar: Optional[dict]) -> BundleSpec:
117
+ if not sidecar:
118
+ return BundleSpec()
119
+ raw = sidecar.get("spec")
120
+ if not isinstance(raw, dict):
121
+ return BundleSpec()
122
+ return BundleSpec.model_validate(raw)
123
+
124
+
125
+ def walk_candidates(
126
+ output_dir: Path,
127
+ ctx: _classify.ClassifyContext,
128
+ *,
129
+ exclude_kinds: frozenset[str] = frozenset(),
130
+ skip_rel: frozenset[str] = frozenset(),
131
+ ) -> tuple[list[Candidate], list[str]]:
132
+ """Classify and hash everything under ``output_dir``.
133
+
134
+ Walks the whole tree rather than only the roots we know about: an
135
+ unclassified file becomes ``kind="other"`` and still ships. Dropping a file
136
+ silently is a far worse failure than carrying one nobody labelled, and the
137
+ contract explicitly allows unknown kinds.
138
+ """
139
+ candidates: list[Candidate] = []
140
+ warnings: list[str] = []
141
+
142
+ for path in sorted(output_dir.rglob("*")):
143
+ if path.is_dir():
144
+ continue
145
+ if path.is_symlink():
146
+ # A symlinked capture tree is a path-traversal vector for whatever
147
+ # unpacks this. Never follow one; say so rather than dropping it
148
+ # quietly.
149
+ warnings.append(f"skipped symlink {path.relative_to(output_dir).as_posix()}")
150
+ continue
151
+ if not path.is_file():
152
+ continue
153
+
154
+ rel = path.relative_to(output_dir)
155
+ rel_posix = rel.as_posix()
156
+ if any(rel_posix == s or rel_posix.startswith(s + "/") for s in skip_rel):
157
+ continue
158
+
159
+ entry = _classify.classify(rel, ctx)
160
+ if entry is None:
161
+ continue
162
+ if entry.kind in exclude_kinds:
163
+ continue
164
+
165
+ # A coverage record describes itself; trust that over the filename.
166
+ if entry.kind == _classify.KIND_COVERAGE_RECORD:
167
+ attribution = _classify.read_coverage_record_attribution(path)
168
+ if attribution:
169
+ scenario, locale, layout = attribution
170
+ entry = entry.model_copy(update={"scenario": scenario, "locale": locale, "layout": layout})
171
+
172
+ try:
173
+ size = path.stat().st_size
174
+ digest = sha256_file(path)
175
+ except OSError as e:
176
+ warnings.append(f"skipped unreadable {rel_posix}: {e}")
177
+ continue
178
+
179
+ candidates.append(
180
+ Candidate(
181
+ entry=entry.model_copy(update={"path": f"output/{rel_posix}", "sha256": digest, "size": size}),
182
+ source=path,
183
+ )
184
+ )
185
+
186
+ return candidates, warnings
187
+
188
+
189
+ def apply_size_budget(candidates: list[Candidate], max_size: int) -> tuple[list[Candidate], bool, list[str]]:
190
+ """Drop the cheapest-to-lose kinds until the payload fits ``max_size``.
191
+
192
+ Returns ``(kept, truncated, warnings)``. ``max_size <= 0`` disables the
193
+ budget. Every drop is reported — a bundle that silently omits data reads to
194
+ its consumer as "this never happened", which is exactly the wrong
195
+ conclusion.
196
+ """
197
+ if max_size <= 0:
198
+ return candidates, False, []
199
+
200
+ total = sum(c.entry.size for c in candidates)
201
+ if total <= max_size:
202
+ return candidates, False, []
203
+
204
+ kept = list(candidates)
205
+ warnings: list[str] = []
206
+ for kind in _classify.DROP_ORDER:
207
+ if total <= max_size:
208
+ break
209
+ victims = [c for c in kept if c.entry.kind == kind]
210
+ if not victims:
211
+ continue
212
+ freed = sum(c.entry.size for c in victims)
213
+ kept = [c for c in kept if c.entry.kind != kind]
214
+ total -= freed
215
+ warnings.append(f"size budget: dropped {len(victims)} {kind} file(s) ({freed / 1_048_576:.1f} MiB)")
216
+
217
+ if total > max_size:
218
+ warnings.append(
219
+ f"size budget exceeded even after dropping every optional artifact "
220
+ f"({total / 1_048_576:.1f} MiB > {max_size / 1_048_576:.1f} MiB); "
221
+ "screenshots and their sidecars are never dropped — raise --max-size"
222
+ )
223
+ return kept, True, warnings
224
+
225
+
226
+ def build(
227
+ *,
228
+ project_root: Path,
229
+ output_dir: Path,
230
+ run_id: str,
231
+ tests: list[BundleTest],
232
+ locales: frozenset[str],
233
+ layouts: frozenset[str],
234
+ coverage_dirname: str = "coverage",
235
+ screenshots_dirname: str = "screenshots",
236
+ guides_dirname: str = "guides",
237
+ reports_dirname: str = "reports",
238
+ sidecar: Optional[dict] = None,
239
+ executor: str = "local",
240
+ git: Optional[BundleGit] = None,
241
+ exclude_kinds: frozenset[str] = frozenset(),
242
+ max_size: int = 0,
243
+ junit_xml: Optional[bytes] = None,
244
+ events: Optional[list[tuple[str, Path]]] = None,
245
+ extra_warnings: Optional[list[str]] = None,
246
+ ) -> Assembly:
247
+ """Assemble the manifest and the list of files to archive."""
248
+ ctx = _classify.ClassifyContext(
249
+ run_id=run_id,
250
+ locales=locales,
251
+ layouts=layouts,
252
+ nodeid_index=_classify.nodeid_index(tests),
253
+ screenshots_dirname=screenshots_dirname,
254
+ coverage_dirname=coverage_dirname,
255
+ guides_dirname=guides_dirname,
256
+ reports_dirname=reports_dirname,
257
+ )
258
+
259
+ # The two generated members are archived at the root, not under output/, so
260
+ # their source directories are skipped in the walk.
261
+ from ..runmeta import EVENTS_SUBDIR, JUNIT_SUBDIR, REPORT_SUBDIR, RUN_SIDECAR_NAME
262
+
263
+ candidates, warnings = walk_candidates(
264
+ output_dir,
265
+ ctx,
266
+ exclude_kinds=exclude_kinds,
267
+ # ``bundles`` is where we write by default; without skipping it, each
268
+ # run would archive its predecessor and the tree would grow
269
+ # quadratically. ``report`` is the same argument once removed: the
270
+ # rendered site *contains copies of* the screenshots this bundle already
271
+ # carries, so archiving it would roughly double the payload for nothing.
272
+ skip_rel=frozenset({EVENTS_SUBDIR, JUNIT_SUBDIR, RUN_SIDECAR_NAME, BUNDLES_SUBDIR, REPORT_SUBDIR}),
273
+ )
274
+ warnings.extend(extra_warnings or [])
275
+
276
+ candidates, truncated, budget_warnings = apply_size_budget(candidates, max_size)
277
+ warnings.extend(budget_warnings)
278
+
279
+ files = [c.entry for c in candidates]
280
+
281
+ # The generated members are content-addressed too, so a consumer's dedup
282
+ # covers every byte in the archive. manifest.json is the one exception —
283
+ # it cannot contain its own digest.
284
+ from .hashing import sha256_bytes
285
+
286
+ if junit_xml is not None:
287
+ files.append(
288
+ BundleFile(path="junit.xml", kind=_classify.KIND_JUNIT, sha256=sha256_bytes(junit_xml), size=len(junit_xml))
289
+ )
290
+ for arcname, source in events or []:
291
+ try:
292
+ files.append(
293
+ BundleFile(
294
+ path=arcname,
295
+ kind=_classify.KIND_EVENTS,
296
+ sha256=sha256_file(source),
297
+ size=source.stat().st_size,
298
+ )
299
+ )
300
+ except OSError as e:
301
+ warnings.append(f"skipped unreadable {arcname}: {e}")
302
+
303
+ files.sort(key=lambda f: f.path)
304
+
305
+ coverage_records = _classify.count_coverage_records(output_dir / coverage_dirname, run_id)
306
+ # ``run.created_at`` describes the RUN, not the bundling of it — so it comes
307
+ # from the sidecar the run wrote, and only falls back to now when there is
308
+ # none. That is both more truthful and what makes an archive reproducible:
309
+ # re-bundling an unchanged tree must produce identical bytes, or the
310
+ # content-addressed upload protocol re-sends everything every time.
311
+ created_at = (sidecar or {}).get("created_at") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
312
+ manifest = BundleManifest(
313
+ steppy_bundle=BUNDLE_SCHEMA_VERSION,
314
+ run=BundleRun(
315
+ run_id=run_id,
316
+ created_at=created_at,
317
+ steppy_version=__version__,
318
+ executor=executor,
319
+ git=git or BundleGit(),
320
+ spec=_spec_from_sidecar(sidecar),
321
+ ),
322
+ tests=tests,
323
+ files=files,
324
+ coverage=BundleCoverage(run_id=run_id, records=coverage_records) if coverage_records else None,
325
+ truncated=truncated,
326
+ warnings=warnings,
327
+ )
328
+ return Assembly(
329
+ manifest=manifest,
330
+ candidates=candidates,
331
+ junit_xml=junit_xml,
332
+ events=list(events or []),
333
+ warnings=warnings,
334
+ )