steppy-dev 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/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]")
|