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/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
@@ -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)