steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,505 @@
1
+ """In-app tours data contract v1 — the producer-side self-check.
2
+
3
+ Two files make a tour set: ``tours.manifest.json`` and one
4
+ ``<tour-id>.<locale>.json`` per tour and locale. The overlay
5
+ (``packages/player/src/tour/validate.ts``) is the security boundary and
6
+ re-validates everything it loads; this module is the compiler's guarantee that
7
+ it never emits anything the overlay would reject.
8
+
9
+ **A documented exception to this package's rule.** The other contracts are
10
+ ``extra="allow"`` because a newer producer's field must survive an older
11
+ consumer. These models are ``extra="forbid"`` and ``strict``: they check what
12
+ *this* producer writes, so an unexpected key is a compiler bug. The published
13
+ JSON Schemas stay open (no ``additionalProperties: false``), and the player
14
+ ignores unknown keys within a schema major — that is where forward
15
+ compatibility lives. Unknown enum values and unknown majors are rejected on
16
+ both sides: a tour file is data, never code (spec fixed decision 9).
17
+
18
+ Serialization is canonical — sorted keys, 2-space indent, ``ensure_ascii=False``,
19
+ LF, trailing newline, no timestamp — so the same scenarios always produce the
20
+ same bytes, and ``integrity`` (SHA-384 SRI over those exact bytes) can lock a
21
+ remote copy to the bundled one.
22
+
23
+ Not re-exported from :mod:`steppy.contracts`; import it directly. Lazily
24
+ imported only.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import hashlib
31
+ import json
32
+ import re
33
+ from collections.abc import Mapping
34
+ from typing import Any, Literal, Optional
35
+ from urllib.parse import urlsplit
36
+
37
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
38
+
39
+ from ..buildid import BUILD_ID_PATTERN, is_build_id
40
+ from ..locales import validate_locale_tag
41
+ from ..naming import TOUR_FILE_KIND, TOURS_MANIFEST_KIND
42
+ from ..tour_spec import (
43
+ ACCEPTED_TOUR_SCHEMA_MAJORS,
44
+ ADVANCE_KINDS,
45
+ LAYOUT_NAME_PATTERN,
46
+ LOCALE_PATTERN,
47
+ MAX_LAYOUTS,
48
+ MAX_LOCALES,
49
+ MAX_ROUTE_CHARS,
50
+ MAX_STEPS,
51
+ MAX_TEXT_CHARS,
52
+ MAX_TITLE_CHARS,
53
+ MAX_TOURS,
54
+ MAX_URL_CHARS,
55
+ PLACEMENTS,
56
+ ROLE_PATTERN,
57
+ ROUTE_PATTERN,
58
+ SRI_PATTERN,
59
+ STEP_ID_PATTERN,
60
+ TARGETED_ADVANCE_KINDS,
61
+ TEST_ID_ATTRIBUTE_PATTERN,
62
+ TEST_ID_PATTERN,
63
+ TOUR_ID_PATTERN,
64
+ TOUR_SCHEMA_VERSION,
65
+ is_layout_name,
66
+ is_media_query,
67
+ is_route,
68
+ is_tour_id,
69
+ tour_file_name,
70
+ )
71
+
72
+ MANIFEST_SCHEMA_TITLE = "Steppy in-app tours manifest (tours.manifest.json)"
73
+ TOUR_SCHEMA_TITLE = "Steppy in-app tour file (<tour-id>.<locale>.json)"
74
+
75
+ AdvanceKind = Literal["click", "input", "change", "visible", "manual"]
76
+ Placement = Literal["top", "bottom", "left", "right", "auto"]
77
+ assert ADVANCE_KINDS == AdvanceKind.__args__ # type: ignore[attr-defined]
78
+ assert PLACEMENTS == Placement.__args__ # type: ignore[attr-defined]
79
+
80
+
81
+ class TourSchemaUnsupported(ValueError):
82
+ """The payload's ``schema`` major is not one this version understands."""
83
+
84
+
85
+ class TourDataInvalid(ValueError):
86
+ """The payload violates the v1 contract (the player's ``invalid-data``)."""
87
+
88
+
89
+ def _full(pattern: str, value: str) -> bool:
90
+ return re.fullmatch(pattern, value) is not None
91
+
92
+
93
+ def _check_text(value: Optional[str], limit: int, what: str) -> Optional[str]:
94
+ if value is None:
95
+ return value
96
+ if not value.strip():
97
+ raise ValueError(f"{what} must not be empty")
98
+ if len(value) > limit:
99
+ raise ValueError(f"{what} is longer than {limit} characters")
100
+ return value
101
+
102
+
103
+ def _check_locale(value: str) -> str:
104
+ if not _full(LOCALE_PATTERN, value):
105
+ raise ValueError(f"locale {value!r} does not match {LOCALE_PATTERN}")
106
+ validate_locale_tag(value)
107
+ return value
108
+
109
+
110
+ def _origin_of(url: str) -> Optional[str]:
111
+ parts = urlsplit(url)
112
+ if parts.scheme not in ("http", "https") or not parts.hostname:
113
+ return None
114
+ host = parts.hostname
115
+ port = parts.port
116
+ default = {"http": 80, "https": 443}[parts.scheme]
117
+ return f"{parts.scheme}://{host}" + (f":{port}" if port and port != default else "")
118
+
119
+
120
+ class _Strict(BaseModel):
121
+ model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
122
+
123
+
124
+ class TourEntry(_Strict):
125
+ route: str = Field(pattern=ROUTE_PATTERN, max_length=MAX_ROUTE_CHARS)
126
+
127
+ @field_validator("route")
128
+ @classmethod
129
+ def _route(cls, v: str) -> str:
130
+ if not is_route(v):
131
+ raise ValueError(f"route {v!r} is not a same-origin, base-relative route pattern")
132
+ return v
133
+
134
+
135
+ class TourAdvance(_Strict):
136
+ on: AdvanceKind
137
+
138
+
139
+ class TourStep(_Strict):
140
+ id: str = Field(pattern=STEP_ID_PATTERN)
141
+ target: Optional[str] = Field(..., pattern=TEST_ID_PATTERN)
142
+ advance: TourAdvance
143
+ waitFor: Optional[str] = Field(None, pattern=TEST_ID_PATTERN)
144
+ title: Optional[str] = None
145
+ text: Optional[str] = None
146
+ placement: Optional[Placement] = None
147
+ layouts: Optional[list[str]] = None
148
+
149
+ @field_validator("id")
150
+ @classmethod
151
+ def _id(cls, v: str) -> str:
152
+ if not _full(STEP_ID_PATTERN, v):
153
+ raise ValueError(f"step id {v!r} does not match {STEP_ID_PATTERN}")
154
+ return v
155
+
156
+ @field_validator("target", "waitFor")
157
+ @classmethod
158
+ def _test_id(cls, v: Optional[str]) -> Optional[str]:
159
+ if v is not None and not _full(TEST_ID_PATTERN, v):
160
+ raise ValueError(f"{v!r} is not a test-id value (never a selector): {TEST_ID_PATTERN}")
161
+ return v
162
+
163
+ @field_validator("title")
164
+ @classmethod
165
+ def _title(cls, v: Optional[str]) -> Optional[str]:
166
+ return _check_text(v, MAX_TITLE_CHARS, "step title")
167
+
168
+ @field_validator("text")
169
+ @classmethod
170
+ def _text(cls, v: Optional[str]) -> Optional[str]:
171
+ return _check_text(v, MAX_TEXT_CHARS, "step text")
172
+
173
+ @field_validator("layouts")
174
+ @classmethod
175
+ def _layouts(cls, v: Optional[list[str]]) -> Optional[list[str]]:
176
+ if v is None:
177
+ return v
178
+ if not 1 <= len(v) <= MAX_LAYOUTS or len(set(v)) != len(v):
179
+ raise ValueError(f"step layouts must be 1..{MAX_LAYOUTS} distinct names")
180
+ for name in v:
181
+ if not is_layout_name(name):
182
+ raise ValueError(f"layout name {name!r} does not match {LAYOUT_NAME_PATTERN}")
183
+ return v
184
+
185
+ @model_validator(mode="after")
186
+ def _target_for_kind(self) -> "TourStep":
187
+ if self.target is None and self.advance.on in TARGETED_ADVANCE_KINDS:
188
+ raise ValueError(f"step {self.id!r}: advance.on={self.advance.on!r} needs a target")
189
+ return self
190
+
191
+
192
+ class TourFile(_Strict):
193
+ schema_: int = Field(alias="schema")
194
+ kind: Literal[TOUR_FILE_KIND] # type: ignore[valid-type]
195
+ id: str = Field(pattern=TOUR_ID_PATTERN)
196
+ buildId: str = Field(pattern=BUILD_ID_PATTERN)
197
+ locale: str = Field(pattern=LOCALE_PATTERN)
198
+ title: str
199
+ guideUrl: Optional[str] = None
200
+ entry: Optional[TourEntry] = Field(...)
201
+ steps: list[TourStep]
202
+
203
+ @field_validator("id")
204
+ @classmethod
205
+ def _tid(cls, v: str) -> str:
206
+ if not is_tour_id(v):
207
+ raise ValueError(f"tour id {v!r} is invalid")
208
+ return v
209
+
210
+ @field_validator("buildId")
211
+ @classmethod
212
+ def _bid(cls, v: str) -> str:
213
+ if not is_build_id(v):
214
+ raise ValueError(f"buildId {v!r} is invalid")
215
+ return v
216
+
217
+ @field_validator("locale")
218
+ @classmethod
219
+ def _loc(cls, v: str) -> str:
220
+ return _check_locale(v)
221
+
222
+ @field_validator("title")
223
+ @classmethod
224
+ def _title(cls, v: str) -> str:
225
+ return _check_text(v, MAX_TITLE_CHARS, "tour title") # type: ignore[return-value]
226
+
227
+ @field_validator("guideUrl")
228
+ @classmethod
229
+ def _guide_url(cls, v: Optional[str]) -> Optional[str]:
230
+ if v is None:
231
+ return v
232
+ if len(v) > MAX_URL_CHARS or _origin_of(v) is None or urlsplit(v).username or urlsplit(v).password:
233
+ raise ValueError(f"guideUrl {v!r} must be an absolute http(s) URL without credentials")
234
+ return v
235
+
236
+ @field_validator("steps")
237
+ @classmethod
238
+ def _steps(cls, v: list[TourStep]) -> list[TourStep]:
239
+ if not 1 <= len(v) <= MAX_STEPS:
240
+ raise ValueError(f"a tour has 1..{MAX_STEPS} steps")
241
+ ids = [s.id for s in v]
242
+ if len(set(ids)) != len(ids):
243
+ raise ValueError("step ids must be unique within a tour")
244
+ return v
245
+
246
+ @model_validator(mode="after")
247
+ def _schema(self) -> "TourFile":
248
+ if self.schema_ != TOUR_SCHEMA_VERSION:
249
+ raise ValueError(f"schema must be {TOUR_SCHEMA_VERSION}")
250
+ return self
251
+
252
+
253
+ class ManifestTour(_Strict):
254
+ id: str = Field(pattern=TOUR_ID_PATTERN)
255
+ role: str = Field(pattern=ROLE_PATTERN)
256
+ title: dict[str, str]
257
+ entry: Optional[TourEntry] = Field(...)
258
+ steps: int
259
+ files: dict[str, str]
260
+ integrity: dict[str, str]
261
+
262
+ @field_validator("id")
263
+ @classmethod
264
+ def _tid(cls, v: str) -> str:
265
+ if not is_tour_id(v):
266
+ raise ValueError(f"tour id {v!r} is invalid")
267
+ return v
268
+
269
+ @field_validator("role")
270
+ @classmethod
271
+ def _role(cls, v: str) -> str:
272
+ if not _full(ROLE_PATTERN, v):
273
+ raise ValueError(f"role {v!r} does not match {ROLE_PATTERN}")
274
+ return v
275
+
276
+ @field_validator("title")
277
+ @classmethod
278
+ def _title(cls, v: dict[str, str]) -> dict[str, str]:
279
+ for text in v.values():
280
+ _check_text(text, MAX_TITLE_CHARS, "tour title")
281
+ return v
282
+
283
+ @field_validator("steps")
284
+ @classmethod
285
+ def _steps(cls, v: int) -> int:
286
+ if not 1 <= v <= MAX_STEPS:
287
+ raise ValueError(f"steps must be 1..{MAX_STEPS}")
288
+ return v
289
+
290
+ @field_validator("integrity")
291
+ @classmethod
292
+ def _sri(cls, v: dict[str, str]) -> dict[str, str]:
293
+ for value in v.values():
294
+ if not _full(SRI_PATTERN, value):
295
+ raise ValueError(f"integrity {value!r} is not a sha384 SRI value")
296
+ return v
297
+
298
+ @model_validator(mode="after")
299
+ def _keys(self) -> "ManifestTour":
300
+ if not self.files:
301
+ raise ValueError(f"tour {self.id!r} lists no files")
302
+ if set(self.files) != set(self.title) or set(self.files) != set(self.integrity):
303
+ raise ValueError(f"tour {self.id!r}: files, title and integrity must cover the same locales")
304
+ for locale, name in self.files.items():
305
+ if name != tour_file_name(self.id, locale):
306
+ raise ValueError(f"tour {self.id!r}: file for {locale!r} must be {tour_file_name(self.id, locale)!r}")
307
+ return self
308
+
309
+
310
+ class ToursManifest(_Strict):
311
+ schema_: int = Field(alias="schema")
312
+ kind: Literal[TOURS_MANIFEST_KIND] # type: ignore[valid-type]
313
+ buildId: str = Field(pattern=BUILD_ID_PATTERN)
314
+ defaultLocale: str = Field(pattern=LOCALE_PATTERN)
315
+ locales: list[str]
316
+ testIdAttribute: str = Field(pattern=TEST_ID_ATTRIBUTE_PATTERN)
317
+ guideOrigin: Optional[str] = None
318
+ layouts: Optional[dict[str, str]] = None
319
+ tours: list[ManifestTour]
320
+
321
+ @field_validator("buildId")
322
+ @classmethod
323
+ def _bid(cls, v: str) -> str:
324
+ if not is_build_id(v):
325
+ raise ValueError(f"buildId {v!r} is invalid")
326
+ return v
327
+
328
+ @field_validator("locales")
329
+ @classmethod
330
+ def _locales(cls, v: list[str]) -> list[str]:
331
+ # `fr-BE`, `fr-be` and `fr_BE` name one language and, on a case-insensitive
332
+ # filesystem, one file: the second would overwrite the first and break its hash.
333
+ if not 1 <= len(v) <= MAX_LOCALES or len({t.casefold().replace("_", "-") for t in v}) != len(v):
334
+ raise ValueError(f"locales must be 1..{MAX_LOCALES} distinct tags (case and -/_ do not distinguish)")
335
+ for tag in v:
336
+ _check_locale(tag)
337
+ return v
338
+
339
+ @field_validator("testIdAttribute")
340
+ @classmethod
341
+ def _attr(cls, v: str) -> str:
342
+ if not _full(TEST_ID_ATTRIBUTE_PATTERN, v):
343
+ raise ValueError(f"testIdAttribute {v!r} does not match {TEST_ID_ATTRIBUTE_PATTERN}")
344
+ return v
345
+
346
+ @field_validator("guideOrigin")
347
+ @classmethod
348
+ def _origin(cls, v: Optional[str]) -> Optional[str]:
349
+ if v is not None and _origin_of(v) != v:
350
+ raise ValueError(f"guideOrigin {v!r} must be an origin only (scheme://host[:port])")
351
+ return v
352
+
353
+ @field_validator("layouts")
354
+ @classmethod
355
+ def _layouts(cls, v: Optional[dict[str, str]]) -> Optional[dict[str, str]]:
356
+ if v is None:
357
+ return v
358
+ if not 1 <= len(v) <= MAX_LAYOUTS:
359
+ raise ValueError(f"layouts must map 1..{MAX_LAYOUTS} names")
360
+ for name, query in v.items():
361
+ if not is_layout_name(name) or not is_media_query(query):
362
+ raise ValueError(f"layout {name!r}: {query!r} is not a valid name / media query")
363
+ return v
364
+
365
+ @field_validator("tours")
366
+ @classmethod
367
+ def _tours(cls, v: list[ManifestTour]) -> list[ManifestTour]:
368
+ if len(v) > MAX_TOURS:
369
+ raise ValueError(f"at most {MAX_TOURS} tours")
370
+ ids = [t.id for t in v]
371
+ if ids != sorted(ids):
372
+ raise ValueError("tours must be sorted by id")
373
+ if len({i.casefold() for i in ids}) != len(ids):
374
+ raise ValueError("tour ids must be unique (case-insensitively)")
375
+ return v
376
+
377
+ @model_validator(mode="after")
378
+ def _cross(self) -> "ToursManifest":
379
+ if self.schema_ != TOUR_SCHEMA_VERSION:
380
+ raise ValueError(f"schema must be {TOUR_SCHEMA_VERSION}")
381
+ if self.defaultLocale not in self.locales:
382
+ raise ValueError("defaultLocale must be one of locales")
383
+ for tour in self.tours:
384
+ if not set(tour.files) <= set(self.locales):
385
+ raise ValueError(f"tour {tour.id!r} has files for locales outside 'locales'")
386
+ if self.defaultLocale not in tour.files:
387
+ raise ValueError(f"tour {tour.id!r} has no file for the default locale {self.defaultLocale!r}")
388
+ return self
389
+
390
+
391
+ # ── Serialization ─────────────────────────────────────────────────────────────
392
+
393
+
394
+ def dumps_canonical(obj: Any) -> bytes:
395
+ """The one serializer for tour data: deterministic UTF-8 JSON with LF endings."""
396
+ return (json.dumps(obj, sort_keys=True, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
397
+
398
+
399
+ def sri_sha384(data: bytes) -> str:
400
+ """Subresource-Integrity value over ``data``, as the Fetch API checks it."""
401
+ return "sha384-" + base64.b64encode(hashlib.sha384(data).digest()).decode("ascii")
402
+
403
+
404
+ def _schema_major(payload: Any) -> None:
405
+ if not isinstance(payload, dict):
406
+ raise TourDataInvalid("payload must be a JSON object")
407
+ major = payload.get("schema")
408
+ if isinstance(major, bool) or not isinstance(major, int):
409
+ raise TourDataInvalid("missing integer 'schema'")
410
+ if major not in ACCEPTED_TOUR_SCHEMA_MAJORS:
411
+ raise TourSchemaUnsupported(
412
+ f"tour schema {major} is not supported (supports {list(ACCEPTED_TOUR_SCHEMA_MAJORS)})"
413
+ )
414
+
415
+
416
+ def _validate(model: type[BaseModel], payload: Any) -> Any:
417
+ _schema_major(payload)
418
+ try:
419
+ return model.model_validate(payload)
420
+ except ValidationError as exc:
421
+ first = exc.errors()[0]
422
+ where = ".".join(str(p) for p in first["loc"])
423
+ raise TourDataInvalid(f"{where}: {first['msg']}") from exc
424
+
425
+
426
+ def parse_tours_manifest(payload: Any) -> ToursManifest:
427
+ return _validate(ToursManifest, payload)
428
+
429
+
430
+ def parse_tour_file(payload: Any) -> TourFile:
431
+ return _validate(TourFile, payload)
432
+
433
+
434
+ def check_consistency(manifest: ToursManifest, files: Mapping[str, bytes]) -> list[str]:
435
+ """Cross-file rules a single-file schema cannot express; returns the problems found."""
436
+ problems: list[str] = []
437
+ for tour in manifest.tours:
438
+ for locale, name in tour.files.items():
439
+ data = files.get(name)
440
+ if data is None:
441
+ problems.append(f"{name}: listed in the manifest but missing")
442
+ continue
443
+ if sri_sha384(data) != tour.integrity[locale]:
444
+ problems.append(f"{name}: integrity does not match its bytes")
445
+ try:
446
+ tf = parse_tour_file(json.loads(data.decode("utf-8")))
447
+ except (ValueError, UnicodeDecodeError) as exc:
448
+ problems.append(f"{name}: {exc}")
449
+ continue
450
+ if tf.buildId != manifest.buildId:
451
+ problems.append(f"{name}: buildId {tf.buildId!r} differs from the manifest's {manifest.buildId!r}")
452
+ if tf.id != tour.id or tf.locale != locale:
453
+ problems.append(f"{name}: id/locale do not match the manifest entry")
454
+ if tf.title != tour.title[locale]:
455
+ problems.append(f"{name}: title differs from the manifest entry")
456
+ if (tf.entry.route if tf.entry else None) != (tour.entry.route if tour.entry else None):
457
+ problems.append(f"{name}: entry differs from the manifest entry")
458
+ if len(tf.steps) != tour.steps:
459
+ problems.append(f"{name}: {len(tf.steps)} steps, manifest says {tour.steps}")
460
+ known = set(manifest.layouts or {})
461
+ for step in tf.steps:
462
+ if step.layouts and not set(step.layouts) <= known:
463
+ problems.append(f"{name}: step {step.id!r} uses layouts the manifest does not map")
464
+ if tf.guideUrl is not None and (
465
+ manifest.guideOrigin is None or _origin_of(tf.guideUrl) != manifest.guideOrigin
466
+ ):
467
+ problems.append(f"{name}: guideUrl is not on the manifest's guideOrigin")
468
+ return problems
469
+
470
+
471
+ # ── Published JSON Schemas ────────────────────────────────────────────────────
472
+
473
+
474
+ def _open(node: Any) -> Any:
475
+ """Drop ``additionalProperties: false``: consumers must ignore unknown keys."""
476
+ if isinstance(node, dict):
477
+ return {k: _open(v) for k, v in node.items() if not (k == "additionalProperties" and v is False)}
478
+ if isinstance(node, list):
479
+ return [_open(v) for v in node]
480
+ return node
481
+
482
+
483
+ def _published(model: type[BaseModel], title: str) -> dict:
484
+ from ..config_schema import _canonicalise
485
+
486
+ schema = _open(_canonicalise(model.model_json_schema(by_alias=True)))
487
+ schema["title"] = title
488
+ schema["x-steppy-tour-schema"] = TOUR_SCHEMA_VERSION
489
+ return schema
490
+
491
+
492
+ def generate_manifest_schema() -> dict:
493
+ return _published(ToursManifest, MANIFEST_SCHEMA_TITLE)
494
+
495
+
496
+ def generate_tour_schema() -> dict:
497
+ return _published(TourFile, TOUR_SCHEMA_TITLE)
498
+
499
+
500
+ def manifest_schema_json() -> str:
501
+ return json.dumps(generate_manifest_schema(), indent=2, sort_keys=True) + "\n"
502
+
503
+
504
+ def tour_schema_json() -> str:
505
+ return json.dumps(generate_tour_schema(), indent=2, sort_keys=True) + "\n"