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
steppy/coverage_cli.py ADDED
@@ -0,0 +1,265 @@
1
+ """Coverage-map CLI commands (`steppy coverage ...`).
2
+
3
+ Extracted from cli.py (FLAWS §4.2) — these ~200 lines are a self-contained
4
+ sub-app that the god module no longer needs to carry.
5
+
6
+ Everything under `steppy coverage` lazy-imports steppy.coverage.* — plain
7
+ runs never load it (tests/test_no_coverage_import_on_default_path.py). Keep
8
+ all ``from .coverage.* import`` statements *inside* the command bodies so
9
+ importing this module (which `import steppy.cli` does) stays coverage-free.
10
+ """
11
+
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import typer
16
+ from rich.markup import escape
17
+ from rich.table import Table
18
+
19
+ from ._cli_common import console, _cli_errors, _error
20
+ from .config import load_config
21
+
22
+ coverage_app = typer.Typer(
23
+ name="coverage",
24
+ help="Coverage map: record with `steppy run --coverage`, refresh the declared "
25
+ "surface from OpenAPI/sitemap manifests, report what is NOT yet tested.",
26
+ add_completion=False,
27
+ )
28
+
29
+ triage_app = typer.Typer(
30
+ name="triage",
31
+ help="Persisted per-node triage flags (ui_testable | non_ui | unknown). "
32
+ "Survive merges and manifest refreshes — flag webhooks/cron callbacks "
33
+ "non_ui once and the gap report discounts them forever. These live in "
34
+ ".steppy/coverage.db, which is gitignored: for CI (and the badge), declare "
35
+ "coverage.non_ui patterns in config.yaml instead, and use these commands for "
36
+ "the one-off a pattern should not cover.",
37
+ add_completion=False,
38
+ )
39
+ coverage_app.add_typer(triage_app, name="triage")
40
+
41
+
42
+ def _coverage_settings():
43
+ from .coverage.merge import CoverageSettings
44
+
45
+ return CoverageSettings.from_config(load_config(), Path.cwd())
46
+
47
+
48
+ def _utc_now() -> str:
49
+ import time as _time
50
+
51
+ return _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime())
52
+
53
+
54
+ @coverage_app.command("refresh")
55
+ def coverage_refresh(
56
+ api: Optional[str] = typer.Option(
57
+ None, "--api", help="Refresh only this configured API (default: all APIs + sitemap)."
58
+ ),
59
+ ):
60
+ """Fetch the configured OpenAPI manifest(s) (and optional sitemap) into the
61
+ declared surface, then re-link previously observed endpoints onto newly
62
+ declared templates. Never touches triage flags; nodes are never deleted."""
63
+ with _cli_errors():
64
+ settings = _coverage_settings()
65
+ if not settings.apis and not settings.sitemap:
66
+ console.print(
67
+ "[red]ERROR[/red] No coverage.apis (or coverage.sitemap) configured — "
68
+ "add a `coverage:` block to config.yaml"
69
+ )
70
+ raise typer.Exit(1)
71
+ apis = settings.apis
72
+ if api is not None:
73
+ apis = [a for a in settings.apis if a["name"] == api]
74
+ if not apis:
75
+ names = [a["name"] for a in settings.apis]
76
+ console.print(f"[red]ERROR[/red] Unknown API {api!r}; configured: {names}")
77
+ raise typer.Exit(1)
78
+
79
+ from .coverage.manifest import refresh_declared
80
+ from .coverage.store import open_store
81
+
82
+ conn = open_store(settings.store_path)
83
+ try:
84
+ summary = refresh_declared(
85
+ conn,
86
+ apis,
87
+ settings.sitemap if api is None else None,
88
+ settings.route_param_patterns,
89
+ updated_at=_utc_now(),
90
+ )
91
+ finally:
92
+ conn.close()
93
+ for entry in summary["apis"]:
94
+ console.print(
95
+ f"[green]OK[/green] {entry['source']}: {entry['declared']} declared "
96
+ f"endpoint(s), {entry['removed']} removed"
97
+ )
98
+ if summary["sitemap"]:
99
+ console.print(
100
+ f"[green]OK[/green] sitemap: {summary['sitemap']['declared']} declared "
101
+ f"route(s), {summary['sitemap']['removed']} removed"
102
+ )
103
+ if summary["relinked"]:
104
+ console.print(
105
+ f"[dim]{summary['relinked']} observed contribution(s) re-linked onto declared endpoints[/dim]"
106
+ )
107
+
108
+
109
+ @coverage_app.command("report")
110
+ def coverage_report(
111
+ json_path: Optional[Path] = typer.Option(
112
+ None, "--json", help="Also write the machine-readable report (deterministic JSON) here."
113
+ ),
114
+ badge_path: Optional[Path] = typer.Option(
115
+ None,
116
+ "--badge",
117
+ help="Also write a shields.io endpoint JSON for the e2e-surface percentage here.",
118
+ ),
119
+ badge_surface: str = typer.Option(
120
+ "all",
121
+ "--badge-surface",
122
+ help="Surface the badge measures: all (endpoints + routes), endpoints, or routes.",
123
+ ),
124
+ badge_label: Optional[str] = typer.Option(
125
+ None, "--badge-label", help="Left-hand text on the badge (default: 'e2e surface')."
126
+ ),
127
+ ):
128
+ """Compute and print the gap report: tested, declared-unreached (the key
129
+ bucket), discovered-uncovered, plus unclassified requests and the covered
130
+ module inventory."""
131
+ with _cli_errors():
132
+ settings = _coverage_settings()
133
+ if not Path(settings.store_path).exists():
134
+ console.print(
135
+ f"[red]ERROR[/red] No coverage store at {settings.store_path} — "
136
+ "record a run first: steppy run --coverage"
137
+ )
138
+ raise typer.Exit(1)
139
+
140
+ from .coverage.gaps import SURFACES, compute_gaps
141
+ from .coverage.report import DEFAULT_BADGE_LABEL, badge_to_json, gaps_to_json, print_summary
142
+ from .coverage.store import open_store, read_map
143
+ from .coverage.triage import apply_config_triage, unmatched_patterns
144
+
145
+ if badge_surface not in SURFACES:
146
+ _error(f"--badge-surface must be one of {', '.join(SURFACES)} (got {badge_surface!r})")
147
+ raise typer.Exit(1)
148
+
149
+ conn = open_store(settings.store_path)
150
+ try:
151
+ raw_map = read_map(conn)
152
+ finally:
153
+ conn.close()
154
+
155
+ # The committed ``coverage.non_ui`` declarations are a read-time overlay:
156
+ # applied here, never written to the store, so merge/refresh stay free of
157
+ # triage and a pattern edit takes effect without re-recording anything.
158
+ map_view = apply_config_triage(raw_map, settings.non_ui)
159
+ gaps = compute_gaps(map_view)
160
+
161
+ # A pattern that matches nothing silently inflates the percentage — the
162
+ # exact failure this feature exists to prevent — so it is worth a line.
163
+ stale = unmatched_patterns(map_view, settings.non_ui)
164
+ if stale:
165
+ console.print(
166
+ f"[yellow]WARNING[/yellow] {len(stale)} coverage.non_ui pattern(s) matched nothing: "
167
+ f"{escape(', '.join(stale))}"
168
+ )
169
+ console.print("[dim]A renamed or retired endpoint leaves a stale rule that inflates the percentage.[/dim]")
170
+
171
+ print_summary(gaps, console)
172
+ if json_path is not None:
173
+ json_path.parent.mkdir(parents=True, exist_ok=True)
174
+ json_path.write_text(gaps_to_json(gaps), encoding="utf-8")
175
+ console.print(f"[dim]JSON report written to {json_path}[/dim]")
176
+ if badge_path is not None:
177
+ badge_path.parent.mkdir(parents=True, exist_ok=True)
178
+ badge_path.write_text(
179
+ badge_to_json(gaps, surface=badge_surface, label=badge_label or DEFAULT_BADGE_LABEL),
180
+ encoding="utf-8",
181
+ )
182
+ console.print(f"[dim]Badge endpoint written to {badge_path}[/dim]")
183
+
184
+
185
+ @coverage_app.command("merge")
186
+ def coverage_merge(
187
+ run_id: Optional[str] = typer.Option(
188
+ None, "--run-id", help="Run directory to merge (default: latest under <records_dir>/raw)."
189
+ ),
190
+ ):
191
+ """Merge raw run records into the map. Rescue path — `steppy run --coverage`
192
+ merges automatically; use this after a crashed/interrupted run. Idempotent:
193
+ re-merging a run replaces each scenario's contribution, it never doubles."""
194
+ with _cli_errors():
195
+ settings = _coverage_settings()
196
+ from .coverage.merge import merge_run
197
+
198
+ summary = merge_run(settings, run_id)
199
+ console.print(
200
+ f"[green]OK[/green] Merged run {summary['run_id']}: "
201
+ f"{summary['totals']['scenarios']} scenario(s), "
202
+ f"{summary['totals']['nodes']} map node(s) → {settings.store_path}"
203
+ )
204
+ for warning in summary.get("warnings", []):
205
+ console.print(f"[yellow]WARNING[/yellow] {warning}")
206
+
207
+
208
+ @triage_app.command("set")
209
+ def coverage_triage_set(
210
+ path: str = typer.Argument(
211
+ ..., help="Node key: endpoint path template (e.g. '/webhooks/stripe') or route pattern."
212
+ ),
213
+ flag: str = typer.Option(..., "--flag", help="ui_testable | non_ui | unknown"),
214
+ method: str = typer.Option("", "--method", help="HTTP method (endpoints only)."),
215
+ api: str = typer.Option("", "--api", help="API name from coverage.apis (endpoints only)."),
216
+ kind: str = typer.Option("endpoint", "--kind", help="endpoint | route | module"),
217
+ note: str = typer.Option("", "--note", help="Why, e.g. 'Payment provider callback — not UI-reachable'."),
218
+ ):
219
+ """Persist a triage flag on a node (created if not yet in the map). The
220
+ flag survives every merge and refresh and is never reset."""
221
+ with _cli_errors():
222
+ settings = _coverage_settings()
223
+ from .coverage.store import open_store, set_triage
224
+
225
+ conn = open_store(settings.store_path)
226
+ try:
227
+ set_triage(
228
+ conn,
229
+ kind=kind,
230
+ key=path,
231
+ api=api,
232
+ method=method.upper(),
233
+ flag=flag,
234
+ note=note,
235
+ updated_at=_utc_now(),
236
+ )
237
+ finally:
238
+ conn.close()
239
+ console.print(f"[green]OK[/green] {kind} {method.upper()} {path} → {flag}")
240
+
241
+
242
+ @triage_app.command("list")
243
+ def coverage_triage_list(
244
+ flag: Optional[str] = typer.Option(None, "--flag", help="Filter by flag value."),
245
+ ):
246
+ """List persisted triage flags."""
247
+ with _cli_errors():
248
+ settings = _coverage_settings()
249
+ from .coverage.store import list_triage, open_store
250
+
251
+ if not Path(settings.store_path).exists():
252
+ console.print("[dim]No coverage store yet — nothing triaged[/dim]")
253
+ return
254
+ conn = open_store(settings.store_path)
255
+ try:
256
+ rows = list_triage(conn, flag)
257
+ finally:
258
+ conn.close()
259
+ if not rows:
260
+ console.print("[dim]No triage flags set[/dim]")
261
+ return
262
+ table = Table("kind", "api", "method", "key", "flag", "note")
263
+ for row in rows:
264
+ table.add_row(row["kind"], row["api"], row["method"], row["key"], row["flag"], row["note"])
265
+ console.print(table)
steppy/durations.py ADDED
@@ -0,0 +1,48 @@
1
+ """Duration parsing, shared by the model validator and codegen.
2
+
3
+ ``timeout:`` is written by a scenario author and consumed by the generator, so
4
+ the grammar has to be the same in both places or ``steppy validate`` accepts a
5
+ value the generator then mangles — the shape of bug ``steppy.assertions`` was
6
+ extracted to close. Stdlib only: ``models.py`` is on the eager CLI path.
7
+
8
+ The grammar matches the ``wait`` action's ``duration:`` exactly, including its
9
+ bare-number-means-milliseconds rule, so an author never has to remember two.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Union
16
+
17
+ __all__ = ["DurationSyntaxError", "parse_ms"]
18
+
19
+
20
+ class DurationSyntaxError(ValueError):
21
+ """Raised when a duration string is not ``<number>[ms|s]``."""
22
+
23
+
24
+ _DURATION = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(ms|s)?\s*$")
25
+
26
+
27
+ def parse_ms(value: Union[str, int, float]) -> int:
28
+ """Return ``value`` in whole milliseconds.
29
+
30
+ ``"25s"`` -> 25000, ``"500ms"`` -> 500, ``25000`` -> 25000. A bare number is
31
+ milliseconds — the same rule ``generator._format_wait_duration`` applies to
32
+ ``wait``'s ``duration:``.
33
+ """
34
+ if isinstance(value, bool): # bool is an int subclass; a timeout of True is a typo
35
+ raise DurationSyntaxError(f"duration must be a number or a string, got {value!r}")
36
+ if isinstance(value, (int, float)):
37
+ ms = float(value)
38
+ else:
39
+ match = _DURATION.match(str(value))
40
+ if not match:
41
+ raise DurationSyntaxError(
42
+ f'duration {value!r} is not understood. Use e.g. "25s", "500ms", or a bare number of milliseconds'
43
+ )
44
+ number, unit = float(match.group(1)), match.group(2)
45
+ ms = number * 1000 if unit == "s" else number
46
+ if ms <= 0:
47
+ raise DurationSyntaxError(f"duration must be positive, got {value!r}")
48
+ return int(round(ms))
steppy/exclusive.py ADDED
@@ -0,0 +1,178 @@
1
+ """Exclusion groups — scenarios that must never run concurrently.
2
+
3
+ A scenario opts in with a frontmatter ``exclusive: <name>``. Two scenarios naming
4
+ the same group never run at the same time under ``steppy run --workers N``, while
5
+ different groups still run concurrently and ungrouped scenarios are untouched.
6
+
7
+ This module is the one home for two things:
8
+
9
+ * the **group-name grammar**, because the name becomes a filename component
10
+ (``claims/<task_id>.claim`` is built by string concatenation), and
11
+ * the **partition**, which collapses each group into a single queue task.
12
+
13
+ Mutual exclusion is therefore a property of how the queue was *seeded* — decided
14
+ once, single-threaded, on the host, before any container exists — rather than a
15
+ run-time protocol. There is no lock, so nothing leaks when a worker is killed, no
16
+ lease to renew, no timeout to tune, and no worker ever waits on another. The only
17
+ run-time primitive involved is the O_EXCL claim the queue already performs.
18
+
19
+ Stdlib-only leaf: it imports nothing from ``steppy``, so ``models.py`` can import
20
+ it on the eager CLI path (like ``assertions`` and ``durations``) without tripping
21
+ the no-parallel-import guard, while ``_parallel/queue.py`` and ``coordinator.py``
22
+ import the same module lazily.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from dataclasses import dataclass
29
+ from typing import Optional
30
+
31
+
32
+ class ExclusiveGroupSyntaxError(ValueError):
33
+ """Raised when a scenario's ``exclusive:`` value is not a usable group name."""
34
+
35
+
36
+ # The group name becomes a path component under the run's coordination directory,
37
+ # so this pattern is a security control and not a style rule: it rejects ``..``,
38
+ # both separators, leading dots, and anything else that could escape the run's
39
+ # tmp dir with the CI runner's privileges. Lowercase-only is equally load-bearing
40
+ # — the coordination directory is created on the HOST, and NTFS and APFS are
41
+ # case-insensitive by default, so `Payments` and `payments` would collapse into
42
+ # one claim file and one of the two batches would silently never run.
43
+ GROUP_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._+-]{0,63}$")
44
+ MAX_GROUP_NAME_LEN = 64
45
+
46
+ # Reserved on Windows for the basename *before* the extension, so `con.claim` is
47
+ # un-creatable just as `con` is. Cheap to reject here; baffling to diagnose when
48
+ # it surfaces as a Docker error on one developer's machine and nowhere in CI.
49
+ _WINDOWS_RESERVED = frozenset(
50
+ {"con", "prn", "aux", "nul"} | {f"com{i}" for i in range(1, 10)} | {f"lpt{i}" for i in range(1, 10)}
51
+ )
52
+
53
+ # Naming the combination of two resources does NOT exclude a scenario against one
54
+ # named after a single resource: 'stripe-and-mailbox' and 'stripe' are two group
55
+ # names, so they are two tasks and they run concurrently. The only safe reading of
56
+ # a one-name-per-scenario model is transitive, so say so rather than suggesting a
57
+ # fusion that produces exactly the bug the field exists to prevent.
58
+ _CLOSURE_RULE = (
59
+ "a scenario belongs to at most one exclusion group. If a flow contends on two "
60
+ "shared resources, every scenario touching EITHER resource must use the SAME "
61
+ "group name — naming the combination (e.g. 'stripe-and-mailbox') does not "
62
+ "exclude it against a scenario named 'stripe', so the two would still run "
63
+ "concurrently"
64
+ )
65
+
66
+
67
+ def normalize_group_name(value: object) -> str:
68
+ """Validate one ``exclusive:`` frontmatter value and return the group name.
69
+
70
+ Raises :class:`ExclusiveGroupSyntaxError` with an actionable message for every
71
+ rejection. A list or dict is refused outright rather than being fused, because
72
+ the fusion an author would reach for is unsound — see ``_CLOSURE_RULE``.
73
+ """
74
+ if isinstance(value, (list, tuple, set, dict)):
75
+ raise ExclusiveGroupSyntaxError(f"'exclusive' takes a single group name, not a list: {_CLOSURE_RULE}")
76
+ if not isinstance(value, str):
77
+ raise ExclusiveGroupSyntaxError(f"'exclusive' must be a string group name, got {type(value).__name__}")
78
+
79
+ name = value.strip()
80
+ if not name:
81
+ raise ExclusiveGroupSyntaxError("'exclusive' must be a non-empty group name (or omitted)")
82
+ if len(name) > MAX_GROUP_NAME_LEN:
83
+ raise ExclusiveGroupSyntaxError(
84
+ f"exclusion group name is too long ({len(name)} > {MAX_GROUP_NAME_LEN}): {name!r}"
85
+ )
86
+ if not GROUP_NAME_RE.match(name):
87
+ raise ExclusiveGroupSyntaxError(
88
+ f"invalid exclusion group name {name!r}. Use lowercase letters, digits, "
89
+ "'.', '_', '+' or '-', starting with a letter or digit. The name becomes "
90
+ "a filename in the run's coordination directory, and uppercase is rejected "
91
+ "because Windows and macOS filesystems would silently merge it with a "
92
+ "lowercase twin."
93
+ )
94
+ if name in _WINDOWS_RESERVED:
95
+ raise ExclusiveGroupSyntaxError(
96
+ f"exclusion group name {name!r} is a reserved device name on Windows and "
97
+ "cannot be used as a filename there. Pick another name."
98
+ )
99
+ return name
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Task:
104
+ """One unit of work in the queue.
105
+
106
+ ``task_id`` is the key used for ``queue/NNNN-<task_id>.task`` and
107
+ ``claims/<task_id>.claim``: the bare slug for an ungrouped scenario, the group
108
+ name for a batch. ``slugs`` is the execution order — always exactly one entry
109
+ when ``group`` is None, which is what makes an ungrouped run byte-identical to
110
+ the pre-feature queue.
111
+ """
112
+
113
+ task_id: str
114
+ slugs: tuple[str, ...]
115
+ group: Optional[str] = None
116
+
117
+ @property
118
+ def is_batch(self) -> bool:
119
+ return self.group is not None
120
+
121
+
122
+ def partition(ordered_slugs: list[str], group_of: dict[str, Optional[str]]) -> list[Task]:
123
+ """Collapse each exclusion group into one :class:`Task`, preserving order.
124
+
125
+ An ungrouped slug becomes its own task; a grouped slug is appended to its
126
+ group's task, which occupies the position of the group's **first** member.
127
+ Members keep selection order inside the batch — deterministic and debuggable;
128
+ ordering them by duration would buy nothing, since a batch's total is fixed.
129
+
130
+ With no groups this returns one singleton task per slug in the same order,
131
+ which is what keeps the seeded queue directory byte-identical.
132
+
133
+ Grouped and ungrouped entries are keyed separately, so a group named after an
134
+ *ungrouped* scenario produces two tasks that share an id rather than one task
135
+ that quietly swallows the loose scenario. That distinction is load-bearing:
136
+ merging them would leave a task whose ``group`` is None but whose ``slugs``
137
+ holds two entries, which ``seed`` writes with no payload — and the second
138
+ scenario would then never run, and be reported nowhere.
139
+ :func:`duplicate_task_ids` is what turns the collision into an error.
140
+ """
141
+ order: list[tuple[bool, str]] = []
142
+ members: dict[tuple[bool, str], list[str]] = {}
143
+
144
+ for slug in ordered_slugs:
145
+ group = group_of.get(slug)
146
+ key = (True, group) if group else (False, slug)
147
+ if key not in members:
148
+ members[key] = []
149
+ order.append(key)
150
+ members[key].append(slug)
151
+
152
+ return [
153
+ Task(task_id=name, slugs=tuple(members[(grouped, name)]), group=name if grouped else None)
154
+ for grouped, name in order
155
+ ]
156
+
157
+
158
+ def duplicate_task_ids(tasks: list[Task]) -> list[tuple[str, str]]:
159
+ """Task ids that collide once case is folded, as ``(first, duplicate)`` pairs.
160
+
161
+ Exact-string uniqueness is not enough: the coordination directory lives on the
162
+ host, and on a case-insensitive filesystem two ids differing only in case share
163
+ one claim file — the second worker's O_EXCL fails, it concludes the queue is
164
+ drained, and a whole task silently never runs while the run still exits 0.
165
+
166
+ Checking the resulting *id set* rather than "group name versus slug" is also
167
+ what keeps ``exclusive: checkout`` legal on a scenario slugged ``checkout``,
168
+ which is the most natural name there is.
169
+ """
170
+ seen: dict[str, str] = {}
171
+ dupes: list[tuple[str, str]] = []
172
+ for task in tasks:
173
+ key = task.task_id.casefold()
174
+ if key in seen:
175
+ dupes.append((seen[key], task.task_id))
176
+ else:
177
+ seen[key] = task.task_id
178
+ return dupes
steppy/flaky_cli.py ADDED
@@ -0,0 +1,169 @@
1
+ """``steppy flaky`` — rank scenarios by how often they cost you attention.
2
+
3
+ Reads the append-only history that parallel runs leave at
4
+ ``.steppy/history.jsonl`` (see :mod:`steppy.history`) and answers the question a
5
+ single run's summary cannot: *which* of my three hundred scenarios are the
6
+ unreliable ones? That ranking is the whole point — a list of five slugs is
7
+ actionable in a way that twenty separate red runs never were.
8
+
9
+ A leaf module, like ``coverage_cli``: it imports ``_cli_common`` and
10
+ ``history`` (stdlib-only) and nothing from ``cli`` / ``coordinator`` /
11
+ ``_parallel``, so ``import steppy.cli`` stays free of parallel-mode code
12
+ (``tests/test_no_parallel_import_on_default_path.py``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json as _json
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import typer
22
+ from rich.table import Table
23
+
24
+ from ._cli_common import _cli_errors, console
25
+
26
+
27
+ DEFAULT_WINDOW = 20
28
+
29
+
30
+ def _rate_style(rate: float) -> str:
31
+ if rate >= 0.5:
32
+ return "bold red"
33
+ if rate >= 0.2:
34
+ return "yellow"
35
+ return "dim"
36
+
37
+
38
+ def register_flaky(app: typer.Typer) -> None:
39
+ """Attach the ``flaky`` command to the root Typer app."""
40
+
41
+ @app.command("flaky")
42
+ def flaky(
43
+ last: int = typer.Option(
44
+ DEFAULT_WINDOW,
45
+ "--last",
46
+ "-n",
47
+ min=1,
48
+ help=f"Consider only the N most recent runs (default: {DEFAULT_WINDOW}).",
49
+ ),
50
+ min_rate: float = typer.Option(
51
+ 0.0,
52
+ "--min-rate",
53
+ min=0.0,
54
+ max=1.0,
55
+ help="Only show scenarios whose flake rate is at least this (0.0-1.0). "
56
+ "e.g. --min-rate 0.2 hides anything that misbehaved in under a fifth of runs.",
57
+ ),
58
+ limit: int = typer.Option(20, "--limit", min=1, help="Maximum rows to print."),
59
+ as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON instead of a table."),
60
+ prune_to: Optional[int] = typer.Option(
61
+ None,
62
+ "--prune",
63
+ min=1,
64
+ help="Rewrite the history file keeping only the newest N runs, then exit.",
65
+ ),
66
+ ):
67
+ """Rank scenarios by flakiness across recent runs.
68
+
69
+ History is recorded by parallel runs (`steppy run --workers N`); the
70
+ single-worker path produces no per-scenario records, so runs made with
71
+ `--workers 1` do not appear here.
72
+
73
+ The flake rate counts every run where a scenario did not pass first
74
+ try — outright failures included. A scenario failing 7 of 10 runs is not
75
+ healthier than one flaking 7 of 10; both cost the same attention.
76
+ """
77
+ with _cli_errors():
78
+ from .history import flake_stats, history_path, load, prune
79
+
80
+ project_root = Path.cwd()
81
+
82
+ if prune_to is not None:
83
+ dropped = prune(project_root, keep_runs=prune_to)
84
+ console.print(f"[green]OK[/green] Pruned {dropped} record(s); kept the newest {prune_to} run(s).")
85
+ raise typer.Exit(0)
86
+
87
+ path = history_path(project_root)
88
+ records = load(project_root, last_runs=last)
89
+ if not records:
90
+ if as_json:
91
+ console.print_json(data={"runs": 0, "scenarios": []})
92
+ raise typer.Exit(0)
93
+ console.print(f"[yellow]No run history yet[/yellow] [dim]({path})[/dim]")
94
+ console.print(
95
+ "\nHistory is written by parallel runs. Record some with:\n [bold]steppy run --workers 4[/bold]"
96
+ )
97
+ raise typer.Exit(0)
98
+
99
+ run_count = len({r["run_id"] for r in records})
100
+ stats = [s for s in flake_stats(records) if s.flake_rate >= min_rate]
101
+
102
+ if as_json:
103
+ console.print_json(
104
+ _json.dumps(
105
+ {
106
+ "runs": run_count,
107
+ "window": last,
108
+ "scenarios": [
109
+ {
110
+ "slug": s.slug,
111
+ "runs": s.runs,
112
+ "failures": s.failures,
113
+ "flakes": s.flakes,
114
+ "flake_rate": round(s.flake_rate, 4),
115
+ "median_s": s.median_s,
116
+ "p95_s": s.p95_s,
117
+ "last_outcome": s.last_outcome,
118
+ "fingerprints": s.fingerprints,
119
+ }
120
+ for s in stats[:limit]
121
+ ],
122
+ }
123
+ )
124
+ )
125
+ raise typer.Exit(0)
126
+
127
+ if not stats:
128
+ console.print(
129
+ f"[green]✓[/green] No scenario exceeded a {min_rate:.0%} flake rate "
130
+ f"across the last {run_count} run(s)."
131
+ )
132
+ raise typer.Exit(0)
133
+
134
+ table = Table(title=f"Flakiest scenarios — last {run_count} run(s)", title_justify="left")
135
+ table.add_column("scenario", overflow="fold")
136
+ table.add_column("runs", justify="right")
137
+ table.add_column("fail", justify="right")
138
+ table.add_column("flaky", justify="right")
139
+ table.add_column("rate", justify="right")
140
+ table.add_column("median", justify="right")
141
+ table.add_column("p95", justify="right")
142
+ table.add_column("last")
143
+
144
+ for s in stats[:limit]:
145
+ table.add_row(
146
+ s.slug,
147
+ str(s.runs),
148
+ f"[red]{s.failures}[/red]" if s.failures else "0",
149
+ f"[yellow]{s.flakes}[/yellow]" if s.flakes else "0",
150
+ f"[{_rate_style(s.flake_rate)}]{s.flake_rate:.0%}[/{_rate_style(s.flake_rate)}]",
151
+ f"{s.median_s:.0f}s" if s.median_s is not None else "—",
152
+ f"{s.p95_s:.0f}s" if s.p95_s is not None else "—",
153
+ s.last_outcome,
154
+ )
155
+ console.print(table)
156
+
157
+ hidden = len(stats) - min(limit, len(stats))
158
+ if hidden > 0:
159
+ console.print(f"[dim]… and {hidden} more (raise --limit to see them)[/dim]")
160
+
161
+ # A scenario that always fails the same way is a bug, not a flake —
162
+ # worth saying out loud, because the two need opposite responses.
163
+ consistent = [s for s in stats[:limit] if len(s.fingerprints) == 1 and s.failures]
164
+ if consistent:
165
+ console.print(
166
+ f"\n[dim]{len(consistent)} scenario(s) always fail the same way "
167
+ f"(single fingerprint) — likely a real bug, not contention.[/dim]"
168
+ )
169
+ console.print(f"[dim]source: {path}[/dim]")