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,291 @@
1
+ """Pytest plugin: emit ``STEPPY-EVT::*`` events for the parallel coordinator.
2
+
3
+ Loaded when EITHER of two independent gates is set — the generated
4
+ ``conftest.py`` opts in behind the same condition, so a plain single-worker run
5
+ loads nothing and its pytest output stays byte-identical to the historic
6
+ behaviour:
7
+
8
+ STEPPY_EMIT_EVENTS=1 stdout lines, parsed live by the coordinator's
9
+ EventBus (``steppy run --workers N``)
10
+ STEPPY_EVENTS_DIR=<dir> one JSONL line per event, appended to
11
+ ``<dir>/<scenario>__<locale>-<layout>.jsonl``
12
+ (``steppy run --bundle``)
13
+
14
+ The two sinks are orthogonal: the coordinator wants a live stream it can render,
15
+ a results bundle wants a durable per-test record. A parallel run under
16
+ ``--bundle`` sets both.
17
+
18
+ Each stdout event is one line prefixed ``STEPPY-EVT::`` for machine parsing.
19
+ Every emission uses ``flush=True`` so the coordinator sees events in real time
20
+ even when stdout is the container's log driver (no TTY, block-buffered
21
+ otherwise).
22
+
23
+ Event grammar (one per line):
24
+
25
+ STEPPY-EVT::session-start::<test_count>
26
+ STEPPY-EVT::test-start::<nodeid>::<unix_ts>
27
+ STEPPY-EVT::test-end::<nodeid>::<outcome>::<duration_ms>
28
+ STEPPY-EVT::test-fail-tail::<nodeid>::<b64(last_15_lines_of_longrepr)>
29
+ STEPPY-EVT::session-end::<unix_ts>
30
+
31
+ ``outcome`` is one of ``passed|failed|skipped|error``. ``error`` covers
32
+ setup/teardown failures (pytest's own ``E`` status).
33
+
34
+ The JSONL sink encodes the SAME vocabulary — same ``kind`` names, same
35
+ ``outcome`` values — so the two are two encodings of one grammar rather than two
36
+ grammars. Four kinds never appear in it (``scenario-start``, ``scenario-end``,
37
+ ``worker-done``, ``warning``): a single pytest process cannot know them; they
38
+ belong to the worker and the coordinator.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import base64
44
+ import json
45
+ import os
46
+ import sys
47
+ import time
48
+
49
+
50
+ _EVT_PREFIX = "STEPPY-EVT::"
51
+ _FAIL_TAIL_LINES = 15
52
+
53
+ #: Schema version of one JSONL line. Additive fields are always allowed and
54
+ #: readers default what is absent — the same forward-compat rule the screenshot
55
+ #: sidecars follow. Bump only on a breaking change.
56
+ _JSONL_SCHEMA_VERSION = 1
57
+
58
+ # Tracks which nodeids have already had a terminal event emitted so we
59
+ # don't double-count when teardown also reports. Reset per pytest session
60
+ # (module-level state is fine because the worker spawns one pytest
61
+ # subprocess per scenario).
62
+ _emitted_end: set[str] = set()
63
+
64
+ # Set by pytest_configure — the pytest CaptureManager plugin. We use it to
65
+ # suspend fd-level capture around every emit so the ``STEPPY-EVT::*`` lines
66
+ # actually reach the container's stdout (and thus the coordinator's log
67
+ # tailer). Without this, pytest's default ``--capture=fd`` swallows our
68
+ # output during the ``call`` phase of each test, so ``test-end`` events
69
+ # never reach the EventBus and the live "tests done · failed" counters
70
+ # stay pinned at 0 — exactly the bug the parallel UI showed.
71
+ #
72
+ # The JSONL sink needs none of this: it writes to a file, not to stdout.
73
+ _capman = None
74
+
75
+ # nodeid -> (scenario, locale, layout), filled by ``pytest_runtest_setup``.
76
+ _ctx: dict = {}
77
+
78
+ # JSONL paths this process has already opened, so the first write truncates and
79
+ # the rest append. A re-run of one scenario (the coordinator's retry pass)
80
+ # therefore REPLACES its file rather than stacking a second session onto the
81
+ # first — mirroring how ``output/failures/`` is reset per run, and how the
82
+ # coverage merge deletes a scenario's rows before re-inserting them.
83
+ _opened: set = set()
84
+
85
+ _events_dir = None
86
+
87
+
88
+ def pytest_configure(config) -> None:
89
+ """Capture pytest's CaptureManager for use in ``_emit``, and resolve the
90
+ JSONL sink directory once."""
91
+ global _capman, _events_dir
92
+ _capman = config.pluginmanager.getplugin("capturemanager")
93
+ raw = (os.environ.get("STEPPY_EVENTS_DIR") or "").strip()
94
+ _events_dir = raw or None
95
+
96
+
97
+ def _emit(*parts: str) -> None:
98
+ """Write one ``STEPPY-EVT::*`` line, bypassing pytest's stdout capture
99
+ so the coordinator's log tailer actually sees it.
100
+
101
+ Falls back to a plain ``print`` if the capture manager isn't available
102
+ (very early hooks, or pytest configured without capture)."""
103
+ if os.environ.get("STEPPY_EMIT_EVENTS") != "1":
104
+ return
105
+ line = _EVT_PREFIX + "::".join(parts)
106
+ if _capman is not None:
107
+ try:
108
+ _capman.suspend_global_capture(in_=False)
109
+ try:
110
+ sys.stdout.write(line + "\n")
111
+ sys.stdout.flush()
112
+ finally:
113
+ _capman.resume_global_capture()
114
+ return
115
+ except Exception:
116
+ # Fall through to plain print — better degraded than silent.
117
+ pass
118
+ print(line, flush=True)
119
+
120
+
121
+ def _context(nodeid: str):
122
+ """``(scenario, locale, layout)`` for a nodeid — ``(None, None, None)`` when
123
+ the setup hook never saw it (e.g. a collection error)."""
124
+ return _ctx.get(nodeid, (None, None, None))
125
+
126
+
127
+ def _jsonl_path(scenario, locale, layout) -> str:
128
+ """``<events_dir>/<scenario>__<locale>-<layout>.jsonl``.
129
+
130
+ ``default`` substitutes for an unset dimension — deliberately the SAME
131
+ convention as the two other writers that face the same problem: the pytest
132
+ param id (``_locale_layout_id`` in conftest.py) and the coverage record
133
+ suffix. Three writers, one convention.
134
+ """
135
+ stem = "{}__{}-{}".format(scenario or "unknown", locale or "default", layout or "default")
136
+ return os.path.join(_events_dir, stem + ".jsonl")
137
+
138
+
139
+ def _record(kind: str, nodeid=None, **fields) -> None:
140
+ """Append one JSONL line. Never raises — a broken sink must not fail a run.
141
+
142
+ Silence is deliberate rather than lazy: this runs inside every test of a
143
+ long suite, and a per-line warning on a full disk would bury the pytest
144
+ output it is meant to describe. ``steppy bundle create`` is where the
145
+ absence becomes visible, and it says so loudly.
146
+ """
147
+ if not _events_dir:
148
+ return
149
+ try:
150
+ scenario, locale, layout = _context(nodeid) if nodeid else (None, None, None)
151
+ payload = {
152
+ "v": _JSONL_SCHEMA_VERSION,
153
+ "kind": kind,
154
+ "ts": round(time.time(), 3),
155
+ "scenario": scenario,
156
+ "locale": locale,
157
+ "layout": layout,
158
+ }
159
+ if nodeid is not None:
160
+ payload["nodeid"] = nodeid
161
+ worker = os.environ.get("STEPPY_WORKER_ID")
162
+ if worker:
163
+ payload["worker"] = worker
164
+ payload.update(fields)
165
+
166
+ path = _jsonl_path(scenario, locale, layout)
167
+ os.makedirs(os.path.dirname(path), exist_ok=True)
168
+ mode = "a" if path in _opened else "w"
169
+ _opened.add(path)
170
+ with open(path, mode, encoding="utf-8", newline="\n") as fh:
171
+ fh.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n")
172
+ except Exception:
173
+ pass
174
+
175
+
176
+ def _b64(text: str) -> str:
177
+ return base64.b64encode(text.encode("utf-8", errors="replace")).decode("ascii")
178
+
179
+
180
+ def _tail(text: str, n: int = _FAIL_TAIL_LINES) -> str:
181
+ if not text:
182
+ return ""
183
+ return "\n".join(text.splitlines()[-n:])
184
+
185
+
186
+ def _emit_end(nodeid: str, outcome: str, report) -> None:
187
+ duration_ms = int((getattr(report, "duration", 0.0) or 0.0) * 1000)
188
+ _emit("test-end", nodeid, outcome, str(duration_ms))
189
+ _record("test-end", nodeid, outcome=outcome, duration_ms=duration_ms)
190
+ if outcome in ("failed", "error") and getattr(report, "longrepr", None) is not None:
191
+ tail_b64 = _b64(_tail(str(report.longrepr)))
192
+ _emit("test-fail-tail", nodeid, tail_b64)
193
+ _record("test-fail-tail", nodeid, tail_b64=tail_b64)
194
+
195
+
196
+ def pytest_collection_finish(session) -> None:
197
+ # Not mirrored into the JSONL sink: its files are keyed by
198
+ # ``(scenario, locale, layout)`` and a session spans several of them, so a
199
+ # session marker has no single home. The bundle needs per-test rows, and
200
+ # ``session-start``'s count is a live-progress concern.
201
+ try:
202
+ count = len(session.items)
203
+ except Exception:
204
+ count = 0
205
+ _emit("session-start", str(count))
206
+
207
+
208
+ def pytest_runtest_protocol(item, nextitem):
209
+ """Record ``(scenario, locale, layout)`` for this test, from the item itself.
210
+ Always returns ``None`` so pytest runs its own protocol as usual.
211
+
212
+ This exists so the JSONL sink never has to parse a nodeid, which would be
213
+ wrong rather than merely awkward: a locale like ``fr-BE`` contains the same
214
+ separator the id joins on. ``callspec.params`` carries the ``(locale,
215
+ layout)`` tuple verbatim — once per role page the test requests, all equal,
216
+ because ``pytest_generate_tests`` binds them in lockstep — and
217
+ ``module.SCENARIO`` is the slug the generator baked in. Both exact.
218
+
219
+ Hook choice is load-bearing, twice over. It must be ``runtest_protocol``
220
+ rather than ``runtest_setup``, because ``pytest_runtest_logstart`` — which
221
+ writes the ``test-start`` line — fires BEFORE setup, and would otherwise
222
+ file that line under an unknown scenario. And it must not be a name the
223
+ generated conftest also defines, since the conftest pulls these hook
224
+ functions into its own namespace BY NAME and would silently shadow itself
225
+ (it owns ``pytest_generate_tests``, ``pytest_collection_modifyitems`` and
226
+ ``pytest_runtest_makereport``). Guard: ``tests/test_bundle_events_sink.py``.
227
+ """
228
+ if not _events_dir:
229
+ return None
230
+ scenario = locale = layout = None
231
+ try:
232
+ scenario = getattr(item.module, "SCENARIO", None)
233
+ except Exception:
234
+ pass
235
+ try:
236
+ callspec = getattr(item, "callspec", None)
237
+ if callspec is not None:
238
+ for value in callspec.params.values():
239
+ if isinstance(value, tuple) and len(value) == 2:
240
+ locale, layout = value
241
+ break
242
+ except Exception:
243
+ pass
244
+ _ctx[item.nodeid] = (scenario, locale, layout)
245
+ return None
246
+
247
+
248
+ def pytest_runtest_logstart(nodeid, location) -> None:
249
+ _emit("test-start", nodeid, f"{time.time():.3f}")
250
+ _record("test-start", nodeid)
251
+
252
+
253
+ def pytest_runtest_logreport(report) -> None:
254
+ nodeid = report.nodeid
255
+
256
+ # If we already emitted a terminal event for this nodeid, only surface
257
+ # an additional fail-tail when teardown itself blew up — useful for
258
+ # debugging fixture cleanup errors. We don't upgrade the outcome.
259
+ if nodeid in _emitted_end:
260
+ if report.when == "teardown" and report.failed and report.longrepr is not None:
261
+ tail_b64 = _b64(_tail(str(report.longrepr)))
262
+ _emit("test-fail-tail", nodeid, tail_b64)
263
+ _record("test-fail-tail", nodeid, tail_b64=tail_b64, phase="teardown")
264
+ return
265
+
266
+ if report.when == "setup":
267
+ if report.failed:
268
+ _emit_end(nodeid, "error", report)
269
+ _emitted_end.add(nodeid)
270
+ elif report.skipped:
271
+ _emit_end(nodeid, "skipped", report)
272
+ _emitted_end.add(nodeid)
273
+ # passed setup → wait for the call phase
274
+ return
275
+
276
+ if report.when == "call":
277
+ # outcome ∈ {passed, failed, skipped}
278
+ _emit_end(nodeid, report.outcome, report)
279
+ _emitted_end.add(nodeid)
280
+ return
281
+
282
+ # teardown phase, terminal not yet emitted (rare: setup passed, no call
283
+ # report was produced). Surface teardown failure as error.
284
+ if report.when == "teardown" and report.failed:
285
+ _emit_end(nodeid, "error", report)
286
+ _emitted_end.add(nodeid)
287
+
288
+
289
+ def pytest_sessionfinish(session, exitstatus) -> None:
290
+ # Not mirrored into the JSONL sink — see ``pytest_collection_finish``.
291
+ _emit("session-end", f"{time.time():.3f}")