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/config.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Configuration loader for Steppy."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from .models import SteppyConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(Exception):
|
|
16
|
+
"""Raised when configuration is invalid."""
|
|
17
|
+
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ${VAR} or ${VAR:-default}. Only ASCII env-var names; the optional ``:-default``
|
|
22
|
+
# supplies a fallback used when the variable is unset.
|
|
23
|
+
_ENV_REF = re.compile(r"\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
27
|
+
"""Recursively merge override into base. Override values win.
|
|
28
|
+
|
|
29
|
+
NOTE: intentionally mirrored by ``_deep_merge`` in the rendered
|
|
30
|
+
``templates/conftest.py.j2`` (and its bundled copy) — that copy is
|
|
31
|
+
standalone runtime code in each generated project and cannot import from
|
|
32
|
+
here. Keep the two in sync if the merge semantics ever change.
|
|
33
|
+
"""
|
|
34
|
+
result = base.copy()
|
|
35
|
+
for key, value in override.items():
|
|
36
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
37
|
+
result[key] = _deep_merge(result[key], value)
|
|
38
|
+
else:
|
|
39
|
+
result[key] = value
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _interpolate_env(obj: Any, _warnings: Optional[list] = None) -> Any:
|
|
44
|
+
"""Recursively substitute ``${VAR}`` / ``${VAR:-default}`` in every string leaf.
|
|
45
|
+
|
|
46
|
+
Resolution per reference: use the env var if set; else the ``:-default`` if
|
|
47
|
+
that form was used; else leave the literal ``${...}`` in place and record a
|
|
48
|
+
warning. Keeps secrets out of committed config — put ``${STEPPY_PW}`` in
|
|
49
|
+
``config.yaml`` and the real value in the environment.
|
|
50
|
+
"""
|
|
51
|
+
if isinstance(obj, dict):
|
|
52
|
+
return {k: _interpolate_env(v, _warnings) for k, v in obj.items()}
|
|
53
|
+
if isinstance(obj, list):
|
|
54
|
+
return [_interpolate_env(v, _warnings) for v in obj]
|
|
55
|
+
if isinstance(obj, str):
|
|
56
|
+
|
|
57
|
+
def _sub(m: "re.Match") -> str:
|
|
58
|
+
name, default = m.group(1), m.group(2)
|
|
59
|
+
if name in os.environ:
|
|
60
|
+
return os.environ[name]
|
|
61
|
+
if default is not None:
|
|
62
|
+
return default
|
|
63
|
+
if _warnings is not None:
|
|
64
|
+
_warnings.append(
|
|
65
|
+
f"environment variable ${{{name}}} referenced in config is not "
|
|
66
|
+
f"set; leaving the literal '{m.group(0)}' in place"
|
|
67
|
+
)
|
|
68
|
+
return m.group(0)
|
|
69
|
+
|
|
70
|
+
return _ENV_REF.sub(_sub, obj)
|
|
71
|
+
return obj
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_credential_hygiene(raw_config: dict, _warnings: list) -> None:
|
|
75
|
+
"""Warn when credentials are committed as inline plaintext in config.yaml.
|
|
76
|
+
|
|
77
|
+
Runs on the base (pre-interpolation) config: a username/password that is a
|
|
78
|
+
non-empty literal (not a ``${VAR}`` reference) means a real secret is sitting
|
|
79
|
+
in a file that is typically committed. The gitignored ``credentials.yaml``
|
|
80
|
+
overlay or ``${ENV_VAR}`` references are the safe alternatives.
|
|
81
|
+
"""
|
|
82
|
+
apps = raw_config.get("apps")
|
|
83
|
+
if not isinstance(apps, dict):
|
|
84
|
+
return
|
|
85
|
+
for role, app in apps.items():
|
|
86
|
+
if not isinstance(app, dict):
|
|
87
|
+
continue
|
|
88
|
+
creds = app.get("auth", {}).get("credentials") if isinstance(app.get("auth"), dict) else None
|
|
89
|
+
if not isinstance(creds, dict):
|
|
90
|
+
continue
|
|
91
|
+
for field in ("username", "password", "totp_secret"):
|
|
92
|
+
value = creds.get(field)
|
|
93
|
+
if isinstance(value, str) and value and not _ENV_REF.search(value):
|
|
94
|
+
_warnings.append(
|
|
95
|
+
f"app '{role}' has a plaintext {field} in config.yaml. Prefer a "
|
|
96
|
+
f"${{ENV_VAR}} reference or the gitignored credentials.yaml overlay "
|
|
97
|
+
f"(see SECURITY.md)."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
_check_tts_key_hygiene(raw_config, _warnings)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_tts_key_hygiene(raw_config: dict, _warnings: list) -> None:
|
|
104
|
+
"""Same rule for the video narration provider's API key.
|
|
105
|
+
|
|
106
|
+
A cloud TTS key is a billable credential — a committed one is someone else's
|
|
107
|
+
invoice, not just an access risk.
|
|
108
|
+
"""
|
|
109
|
+
video = raw_config.get("video")
|
|
110
|
+
tts = video.get("tts") if isinstance(video, dict) else None
|
|
111
|
+
if not isinstance(tts, dict):
|
|
112
|
+
return
|
|
113
|
+
value = tts.get("api_key")
|
|
114
|
+
if isinstance(value, str) and value and not _ENV_REF.search(value):
|
|
115
|
+
_warnings.append(
|
|
116
|
+
"video.tts.api_key is a plaintext value in config.yaml. Prefer a "
|
|
117
|
+
"${ENV_VAR} reference or the gitignored credentials.yaml overlay "
|
|
118
|
+
"(see SECURITY.md) — a cloud TTS key is billable."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _check_deprecated_output_keys(raw_config: dict, _warnings: list) -> None:
|
|
123
|
+
"""Warn on ``output.reports_dir``, which is accepted but does nothing.
|
|
124
|
+
|
|
125
|
+
Kept parseable rather than removed so an existing ``config.yaml`` still
|
|
126
|
+
loads (``SteppyConfig`` forbids extras), but a key that silently does
|
|
127
|
+
nothing is precisely the failure FLAWS §4.6 was about — so it says so once
|
|
128
|
+
per load. ``steppy update --check`` reports the same thing, CI-ably.
|
|
129
|
+
"""
|
|
130
|
+
output = raw_config.get("output")
|
|
131
|
+
if isinstance(output, dict) and output.get("reports_dir") is not None:
|
|
132
|
+
_warnings.append(
|
|
133
|
+
"output.reports_dir is deprecated and ignored — nothing writes output/reports. "
|
|
134
|
+
"Playwright traces and videos go to output/traces and output/videos; JUnit and HTML "
|
|
135
|
+
"reports take their paths from report.junit_xml / report.html. Remove the key."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _emit_warnings(warnings: list) -> None:
|
|
140
|
+
"""Print collected config warnings to stderr (config.py has no console)."""
|
|
141
|
+
for w in warnings:
|
|
142
|
+
print(f"WARNING: {w}", file=sys.stderr)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _load_credentials(config_path: Path) -> Optional[dict]:
|
|
146
|
+
"""Load credentials.yaml from the same directory as config_path, if it exists."""
|
|
147
|
+
credentials_path = config_path.parent / "credentials.yaml"
|
|
148
|
+
if not credentials_path.exists():
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
153
|
+
data = yaml.safe_load(f)
|
|
154
|
+
except yaml.YAMLError as e:
|
|
155
|
+
raise ConfigError(f"Failed to parse YAML in {credentials_path}:\n {e}")
|
|
156
|
+
except Exception as e:
|
|
157
|
+
raise ConfigError(f"Failed to read credentials file: {e}")
|
|
158
|
+
|
|
159
|
+
return data if data else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_config(config_path: Optional[Path] = None) -> SteppyConfig:
|
|
163
|
+
"""
|
|
164
|
+
Load and validate the Steppy configuration from config.yaml.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
config_path: Path to config.yaml. If None, looks for config.yaml in current directory.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Validated SteppyConfig instance.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ConfigError: If config file doesn't exist or is invalid.
|
|
174
|
+
"""
|
|
175
|
+
if config_path is None:
|
|
176
|
+
config_path = Path.cwd() / "config.yaml"
|
|
177
|
+
|
|
178
|
+
if not config_path.exists():
|
|
179
|
+
error_msg = f"Configuration file not found: {config_path}\n"
|
|
180
|
+
error_msg += "\nHint: Create a config.yaml file with your application settings."
|
|
181
|
+
error_msg += "\n See STEPPY-REFERENCE.md or the README configuration section."
|
|
182
|
+
raise ConfigError(error_msg)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
186
|
+
raw_config = yaml.safe_load(f)
|
|
187
|
+
except yaml.YAMLError as e:
|
|
188
|
+
error_msg = f"Failed to parse YAML in {config_path}:\n"
|
|
189
|
+
error_msg += f" {e}\n"
|
|
190
|
+
error_msg += "\nHint: Check for syntax errors (indentation, quotes, colons)"
|
|
191
|
+
raise ConfigError(error_msg)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
raise ConfigError(f"Failed to read config file: {e}")
|
|
194
|
+
|
|
195
|
+
if raw_config is None:
|
|
196
|
+
error_msg = f"Configuration file is empty: {config_path}\n"
|
|
197
|
+
error_msg += "\nHint: Add your application configuration to config.yaml"
|
|
198
|
+
raise ConfigError(error_msg)
|
|
199
|
+
|
|
200
|
+
warnings: list = []
|
|
201
|
+
# Flag plaintext secrets in the committed config *before* they get overlaid
|
|
202
|
+
# or interpolated (so we only warn about what's literally in config.yaml).
|
|
203
|
+
_check_credential_hygiene(raw_config, warnings)
|
|
204
|
+
_check_deprecated_output_keys(raw_config, warnings)
|
|
205
|
+
|
|
206
|
+
# Merge credentials from separate file if it exists
|
|
207
|
+
credentials = _load_credentials(config_path)
|
|
208
|
+
if credentials:
|
|
209
|
+
raw_config = _deep_merge(raw_config, credentials)
|
|
210
|
+
|
|
211
|
+
# Resolve ${VAR} / ${VAR:-default} references against the environment.
|
|
212
|
+
raw_config = _interpolate_env(raw_config, warnings)
|
|
213
|
+
|
|
214
|
+
_emit_warnings(warnings)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
config = SteppyConfig(**raw_config)
|
|
218
|
+
except ValidationError as e:
|
|
219
|
+
error_msg = f"Configuration validation failed in {config_path}:\n\n"
|
|
220
|
+
|
|
221
|
+
# Extract and format validation errors more clearly
|
|
222
|
+
for error in e.errors():
|
|
223
|
+
field = " -> ".join(str(x) for x in error["loc"])
|
|
224
|
+
msg = error["msg"]
|
|
225
|
+
error_msg += f" Field '{field}': {msg}\n"
|
|
226
|
+
|
|
227
|
+
error_msg += "\nHint: Check that all required fields are present and have correct types."
|
|
228
|
+
error_msg += "\n See STEPPY-REFERENCE.md or the README configuration section."
|
|
229
|
+
raise ConfigError(error_msg)
|
|
230
|
+
|
|
231
|
+
return config
|
steppy/config_schema.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""JSON Schema generation for ``config.yaml`` (FLAWS §7.2).
|
|
2
|
+
|
|
3
|
+
``SteppyConfig`` uses Pydantic ``extra = "forbid"`` everywhere, so any typo in
|
|
4
|
+
``config.yaml`` is a hard failure with no autocomplete. Pydantic hands us the
|
|
5
|
+
antidote for free: dump ``SteppyConfig.model_json_schema()`` to a committed
|
|
6
|
+
``steppy-config.schema.json`` and reference it from the top of ``config.yaml``
|
|
7
|
+
with a ``# yaml-language-server: $schema=...`` line — the VS Code YAML extension
|
|
8
|
+
then provides autocomplete + inline typo squiggles, flipping ``extra="forbid"``
|
|
9
|
+
from hostile to helpful.
|
|
10
|
+
|
|
11
|
+
This module is the single source of truth for that schema, consumed by both
|
|
12
|
+
``steppy init`` (which writes the schema into a fresh project) and the drift
|
|
13
|
+
guard test (``tests/test_config_schema.py``), so the committed file can never
|
|
14
|
+
silently fall out of sync with the model.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
from .models import SteppyConfig
|
|
22
|
+
|
|
23
|
+
# Bumped only when we intentionally change the emitted schema shape (e.g. adopt
|
|
24
|
+
# a newer JSON Schema dialect). Consumers should not care about this value; it
|
|
25
|
+
# exists so a regeneration is an explicit, reviewable diff.
|
|
26
|
+
SCHEMA_TITLE = "Steppy configuration (config.yaml)"
|
|
27
|
+
|
|
28
|
+
#: Numeric constraint keywords whose value pydantic may emit as ``0`` or ``0.0``
|
|
29
|
+
#: depending on its version. JSON Schema treats the two as the same number.
|
|
30
|
+
_NUMERIC_KEYWORDS = frozenset({"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _canonicalise(node):
|
|
34
|
+
"""Strip the parts of pydantic's output that vary by pydantic version.
|
|
35
|
+
|
|
36
|
+
The committed schema is an *artifact*, and the guard test asserts it matches
|
|
37
|
+
the models byte for byte — which only means anything if the bytes are a
|
|
38
|
+
function of the models. They were not: ``pydantic>=2.0.0`` is unbounded, and
|
|
39
|
+
two things it emits differently across 2.x releases leaked straight into the
|
|
40
|
+
file, so the guard failed on a machine whose pydantic simply differed from
|
|
41
|
+
the one that last regenerated it. Neither difference carries meaning:
|
|
42
|
+
|
|
43
|
+
* ``additionalProperties: true`` on an open ``dict`` field — ``true`` is the
|
|
44
|
+
JSON Schema default, so emitting it and omitting it say the same thing.
|
|
45
|
+
* ``exclusiveMinimum: 0.0`` vs ``0`` — the same number, differently typed.
|
|
46
|
+
|
|
47
|
+
Normalising both here makes the artifact reproducible, which is the property
|
|
48
|
+
a committed generated file needs. Add a rule when a future pydantic
|
|
49
|
+
introduces another purely-cosmetic difference; do NOT reach for it to paper
|
|
50
|
+
over a real schema change.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(node, dict):
|
|
53
|
+
out = {}
|
|
54
|
+
for key, value in node.items():
|
|
55
|
+
if key == "additionalProperties" and value is True:
|
|
56
|
+
continue
|
|
57
|
+
if key in _NUMERIC_KEYWORDS and isinstance(value, float) and value.is_integer():
|
|
58
|
+
out[key] = int(value)
|
|
59
|
+
continue
|
|
60
|
+
out[key] = _canonicalise(value)
|
|
61
|
+
return out
|
|
62
|
+
if isinstance(node, list):
|
|
63
|
+
return [_canonicalise(item) for item in node]
|
|
64
|
+
return node
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def generate_config_schema() -> dict:
|
|
68
|
+
"""Return the JSON Schema for ``config.yaml`` as a plain dict.
|
|
69
|
+
|
|
70
|
+
Deterministic: same model in ⇒ same bytes out (see :func:`schema_json`), so
|
|
71
|
+
the drift guard can assert byte-equality against the committed file.
|
|
72
|
+
"""
|
|
73
|
+
schema = _canonicalise(SteppyConfig.model_json_schema())
|
|
74
|
+
# A stable, self-describing title so the generated file reads well and the
|
|
75
|
+
# editor shows something meaningful on hover at the root.
|
|
76
|
+
schema["title"] = SCHEMA_TITLE
|
|
77
|
+
return schema
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def schema_json() -> str:
|
|
81
|
+
"""Return the canonical serialized form written to disk / compared in tests.
|
|
82
|
+
|
|
83
|
+
``sort_keys=True`` + 2-space indent + trailing newline, over a
|
|
84
|
+
:func:`_canonicalise`-d schema, so the output depends on the models and not
|
|
85
|
+
on which pydantic 2.x happens to be installed.
|
|
86
|
+
"""
|
|
87
|
+
return json.dumps(generate_config_schema(), indent=2, sort_keys=True) + "\n"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Versioned wire contracts Steppy shares with anything that consumes its runs.
|
|
2
|
+
|
|
3
|
+
These are *not* internal types. They are the published shape of what leaves a
|
|
4
|
+
Steppy run — today the results bundle, later the job API — and a consumer at a
|
|
5
|
+
different version of Steppy has to be able to read them. That inverts one house
|
|
6
|
+
rule and is worth stating plainly: every model here is ``extra="allow"``,
|
|
7
|
+
whereas ``models.py`` is ``extra="forbid"``. The config models protect a
|
|
8
|
+
hand-written file, where a typo must fail loudly. These protect a wire format,
|
|
9
|
+
where the requirement is the reverse — a newer producer's extra field must
|
|
10
|
+
survive an older consumer, not crash it.
|
|
11
|
+
|
|
12
|
+
Compatibility rules, enforced in code rather than by convention:
|
|
13
|
+
|
|
14
|
+
1. **Additive fields are always allowed**, and unknown ones are preserved.
|
|
15
|
+
2. **Unknown enum-ish values are data, not errors** — an artifact ``kind`` this
|
|
16
|
+
version has never heard of is stored opaquely rather than failing ingestion.
|
|
17
|
+
3. **A consumer accepts version N and N−1**, expressed as an explicit tuple so
|
|
18
|
+
a test and an error message can both read it.
|
|
19
|
+
4. **Absent keys default** — inherited from the screenshot sidecars, whose
|
|
20
|
+
``device_scale_factor`` is the worked example (absent means 1).
|
|
21
|
+
|
|
22
|
+
Two contracts live here: ``bundle`` (``manifest.json`` — what a run *produced*)
|
|
23
|
+
and ``report`` (``report.json`` — what a run *means*). Only ``bundle`` is
|
|
24
|
+
re-exported below, because both modules define ``accepts`` and ``schema_json``
|
|
25
|
+
and flattening them would collide; import ``steppy.contracts.report`` directly.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .bundle import (
|
|
29
|
+
ACCEPTED_BUNDLE_VERSIONS,
|
|
30
|
+
BUNDLE_SCHEMA_VERSION,
|
|
31
|
+
BundleCoverage,
|
|
32
|
+
BundleFile,
|
|
33
|
+
BundleGit,
|
|
34
|
+
BundleManifest,
|
|
35
|
+
BundleRun,
|
|
36
|
+
BundleSpec,
|
|
37
|
+
BundleTest,
|
|
38
|
+
accepts,
|
|
39
|
+
parse_manifest,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"ACCEPTED_BUNDLE_VERSIONS",
|
|
44
|
+
"BUNDLE_SCHEMA_VERSION",
|
|
45
|
+
"BundleCoverage",
|
|
46
|
+
"BundleFile",
|
|
47
|
+
"BundleGit",
|
|
48
|
+
"BundleManifest",
|
|
49
|
+
"BundleRun",
|
|
50
|
+
"BundleSpec",
|
|
51
|
+
"BundleTest",
|
|
52
|
+
"accepts",
|
|
53
|
+
"parse_manifest",
|
|
54
|
+
]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Results-bundle manifest — contract v1.
|
|
2
|
+
|
|
3
|
+
A bundle is a ``.tar.zst`` (or ``.tar.gz``) of a subset of a run's ``output/``
|
|
4
|
+
tree **exactly as steppy writes it** — no path rewriting, so an unpacked bundle
|
|
5
|
+
feeds ``steppy guide`` and ``steppy build`` unchanged, and a laptop, a CI runner
|
|
6
|
+
and a remote agent produce identical structures by construction — plus three
|
|
7
|
+
generated members at the archive root::
|
|
8
|
+
|
|
9
|
+
manifest.json this contract
|
|
10
|
+
junit.xml merged pytest --junitxml (belt and braces)
|
|
11
|
+
events/
|
|
12
|
+
<scenario>__<locale>-<layout>.jsonl
|
|
13
|
+
output/
|
|
14
|
+
screenshots/<scenario>/[<locale>/][<layout>/]step-NN[-<frame>].png
|
|
15
|
+
screenshots/.../step-NN[-<frame>].json sidecars: viewport, DSF, bbox
|
|
16
|
+
screenshots/_failures/...
|
|
17
|
+
coverage/raw/<run_id>/<scenario>/<fixture>__<locale-layout>.json
|
|
18
|
+
coverage/raw/<run_id>/_maps/<sha256>.json.gz
|
|
19
|
+
failures/<slug>/pytest.log
|
|
20
|
+
guides/... optional
|
|
21
|
+
|
|
22
|
+
Producer: ``steppy bundle create``. See :mod:`steppy.contracts` for the
|
|
23
|
+
compatibility rules and why these models are ``extra="allow"``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
31
|
+
|
|
32
|
+
#: Bump only on a breaking change. Additive fields never require it.
|
|
33
|
+
BUNDLE_SCHEMA_VERSION = 1
|
|
34
|
+
|
|
35
|
+
#: What a consumer at this version will read. The N and N−1 rule expressed as
|
|
36
|
+
#: data, so the error message and the test read the same tuple.
|
|
37
|
+
ACCEPTED_BUNDLE_VERSIONS: tuple[int, ...] = (1,)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Wire(BaseModel):
|
|
41
|
+
"""Base for every wire model: unknown fields are kept, never rejected."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="allow")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BundleGit(_Wire):
|
|
47
|
+
"""Where the run's code came from. Every field optional — a laptop run
|
|
48
|
+
outside a repository has none of it, and that is not an error."""
|
|
49
|
+
|
|
50
|
+
sha: Optional[str] = None
|
|
51
|
+
branch: Optional[str] = None
|
|
52
|
+
pr: Optional[int] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BundleSpec(_Wire):
|
|
56
|
+
"""What the run was asked to do — the ``steppy run`` flags, resolved.
|
|
57
|
+
|
|
58
|
+
Recorded by the run itself rather than reconstructed by the bundler: they
|
|
59
|
+
are separate processes, and re-deriving this from flags a user retypes
|
|
60
|
+
drifts silently.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
guide: bool = False
|
|
64
|
+
no_guide: bool = False
|
|
65
|
+
no_asserts: bool = False
|
|
66
|
+
screenshots: bool = False
|
|
67
|
+
coverage: bool = False
|
|
68
|
+
locales: list[str] = Field(default_factory=list)
|
|
69
|
+
layouts: list[str] = Field(default_factory=list)
|
|
70
|
+
workers: int = 1
|
|
71
|
+
tag: Optional[Any] = None
|
|
72
|
+
scenario: Optional[str] = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BundleRun(_Wire):
|
|
76
|
+
"""Run identity and provenance."""
|
|
77
|
+
|
|
78
|
+
run_id: str
|
|
79
|
+
created_at: str
|
|
80
|
+
steppy_version: str
|
|
81
|
+
executor: str = "local"
|
|
82
|
+
git: BundleGit = Field(default_factory=BundleGit)
|
|
83
|
+
spec: BundleSpec = Field(default_factory=BundleSpec)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BundleTest(_Wire):
|
|
87
|
+
"""One test result — one (scenario, locale, layout) parametrization.
|
|
88
|
+
|
|
89
|
+
``outcome`` uses the ``STEPPY-EVT`` vocabulary: ``passed|failed|skipped|
|
|
90
|
+
error`` from a worker, plus ``interrupted`` and ``worker_died``, which a
|
|
91
|
+
coordinator synthesises for tests that were still in flight. Deliberately
|
|
92
|
+
a plain ``str`` rather than an enum — rule 2 in :mod:`steppy.contracts`.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
#: Required, not optional: a Steppy test belongs to a scenario by
|
|
96
|
+
#: construction. Producers that lack one on a given path derive it from the
|
|
97
|
+
#: node id's module name (``test_<slug>.py``) rather than emitting null —
|
|
98
|
+
#: an older consumer types this as a plain ``str`` and rejects the whole
|
|
99
|
+
#: manifest otherwise.
|
|
100
|
+
scenario: str
|
|
101
|
+
nodeid: str
|
|
102
|
+
outcome: str
|
|
103
|
+
duration_ms: Optional[int] = None
|
|
104
|
+
locale: Optional[str] = None
|
|
105
|
+
layout: Optional[str] = None
|
|
106
|
+
fail_tail_b64: Optional[str] = None
|
|
107
|
+
#: 1-based index of the step that was in flight when the test died — the
|
|
108
|
+
#: scenario's own step number, so it survives steps skipped by ``only-in:``
|
|
109
|
+
#: or ``layouts:``. Recorded by the env-gated ``STEPPY_STEP_EVENTS`` emitter
|
|
110
|
+
#: that ``steppy run --bundle`` turns on.
|
|
111
|
+
#:
|
|
112
|
+
#: ``None`` is a real answer, not just "unknown": a passing test has no
|
|
113
|
+
#: failed step, and a test that died in fixture teardown closed every step
|
|
114
|
+
#: it ran. A producer that was not recording step events also reports
|
|
115
|
+
#: ``None``, so a consumer that needs to tell the two apart should look for
|
|
116
|
+
#: ``step-start`` records in ``events/``.
|
|
117
|
+
failed_step: Optional[int] = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class BundleFile(_Wire):
|
|
121
|
+
"""One archived file, content-addressed.
|
|
122
|
+
|
|
123
|
+
``sha256`` is what makes uploads cheap: with locales × layouts × device
|
|
124
|
+
scale factors, unchanged screenshots dominate a bundle, so a consumer can
|
|
125
|
+
ask only for digests it does not already hold.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
path: str
|
|
129
|
+
kind: str
|
|
130
|
+
sha256: str
|
|
131
|
+
size: int
|
|
132
|
+
scenario: Optional[str] = None
|
|
133
|
+
locale: Optional[str] = None
|
|
134
|
+
layout: Optional[str] = None
|
|
135
|
+
step: Optional[int] = None
|
|
136
|
+
#: Multi-frame captures (a tall ``fill-form``) emit ``step-NN-a``, ``-b``, …
|
|
137
|
+
frame: Optional[str] = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class BundleCoverage(_Wire):
|
|
141
|
+
"""Pointer to the coverage records carried in this bundle.
|
|
142
|
+
|
|
143
|
+
``run_id`` may legitimately differ from ``run.run_id``: a bundle created
|
|
144
|
+
with an explicit ``--run-id`` names itself after the identity its consumer
|
|
145
|
+
assigned, while the records on disk keep whatever the run recorded them
|
|
146
|
+
under.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
run_id: Optional[str] = None
|
|
150
|
+
records: int = 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class BundleManifest(_Wire):
|
|
154
|
+
"""The contract."""
|
|
155
|
+
|
|
156
|
+
steppy_bundle: int = Field(description="Contract version; see ACCEPTED_BUNDLE_VERSIONS.")
|
|
157
|
+
run: BundleRun
|
|
158
|
+
tests: list[BundleTest] = Field(default_factory=list)
|
|
159
|
+
files: list[BundleFile] = Field(default_factory=list)
|
|
160
|
+
coverage: Optional[BundleCoverage] = None
|
|
161
|
+
#: True **only** when a file that belonged in the bundle was dropped to fit
|
|
162
|
+
#: a size budget. Not set for content the user excluded by flag, and not for
|
|
163
|
+
#: content that was never produced. The distinction is what lets a consumer
|
|
164
|
+
#: tell "step 7 never ran" from "we dropped step 7's screenshot".
|
|
165
|
+
truncated: bool = False
|
|
166
|
+
#: Human-readable notes from the producer (dropped files, degraded sources).
|
|
167
|
+
warnings: list[str] = Field(default_factory=list)
|
|
168
|
+
|
|
169
|
+
def digests(self) -> set[str]:
|
|
170
|
+
return {f.sha256 for f in self.files}
|
|
171
|
+
|
|
172
|
+
def files_by_kind(self, kind: str) -> list[BundleFile]:
|
|
173
|
+
return [f for f in self.files if f.kind == kind]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
SCHEMA_TITLE = "Steppy results bundle manifest"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def generate_bundle_schema() -> dict:
|
|
180
|
+
"""JSON Schema for ``manifest.json``, for consumers that are not Python.
|
|
181
|
+
|
|
182
|
+
Mirrors :mod:`steppy.config_schema`: generated from the model, committed to
|
|
183
|
+
the repo, and byte-compared by a drift guard so the published contract can
|
|
184
|
+
never fall out of sync with what the producer emits.
|
|
185
|
+
"""
|
|
186
|
+
schema = BundleManifest.model_json_schema()
|
|
187
|
+
schema["title"] = SCHEMA_TITLE
|
|
188
|
+
schema["x-steppy-bundle-version"] = BUNDLE_SCHEMA_VERSION
|
|
189
|
+
schema["x-steppy-bundle-accepted"] = list(ACCEPTED_BUNDLE_VERSIONS)
|
|
190
|
+
return schema
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def schema_json() -> str:
|
|
194
|
+
"""The canonical serialized form written to disk and compared in tests."""
|
|
195
|
+
import json
|
|
196
|
+
|
|
197
|
+
return json.dumps(generate_bundle_schema(), indent=2, sort_keys=True) + "\n"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def accepts(version: int) -> bool:
|
|
201
|
+
return version in ACCEPTED_BUNDLE_VERSIONS
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def parse_manifest(payload: dict[str, Any]) -> BundleManifest:
|
|
205
|
+
"""Validate a ``manifest.json`` payload, with actionable version errors."""
|
|
206
|
+
version = payload.get("steppy_bundle")
|
|
207
|
+
if not isinstance(version, int):
|
|
208
|
+
raise ValueError("manifest.json is missing the integer 'steppy_bundle' contract version")
|
|
209
|
+
if not accepts(version):
|
|
210
|
+
supported = ", ".join(f"v{v}" for v in sorted(ACCEPTED_BUNDLE_VERSIONS, reverse=True))
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"bundle contract v{version} is not accepted by this version of steppy (supports {supported}). "
|
|
213
|
+
"Upgrade the steppy that is reading this bundle, or re-create it with the steppy that wrote it."
|
|
214
|
+
)
|
|
215
|
+
return BundleManifest.model_validate(payload)
|