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
|
@@ -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"
|