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/locales.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""BCP-47 language tags — the grammar behind ``lang`` and ``hreflang``.
|
|
2
|
+
|
|
3
|
+
A build's ``locales`` list lives a double life, and this module exists to keep
|
|
4
|
+
the two halves from being confused for one another.
|
|
5
|
+
|
|
6
|
+
The **authored** tag — exactly as written in ``steppy.guide.config.ts`` — is an
|
|
7
|
+
identity in three systems Steppy does not own:
|
|
8
|
+
|
|
9
|
+
* the capture directories on disk, whose names were written by a *different*
|
|
10
|
+
config file (``config.yaml``'s ``locales``, or ``steppy run --locales``) on
|
|
11
|
+
a different machine, and which may already be committed under an assets
|
|
12
|
+
tree;
|
|
13
|
+
* the host application's i18n language code — the Angular target emits
|
|
14
|
+
``CONTENT['<locale>']`` and the generated component indexes it with
|
|
15
|
+
ngx-translate's ``currentLang``, and real projects run codes like ``fr_BE``;
|
|
16
|
+
* the keys of every ``LocalizedText`` map and every scenario's ``guide-text:``
|
|
17
|
+
block, written by hand.
|
|
18
|
+
|
|
19
|
+
The **normalized** tag serves exactly one consumer: the W3C/BCP-47 readers of
|
|
20
|
+
``<html lang>`` and ``hreflang``. So the authored form is never rewritten — not
|
|
21
|
+
the filenames, not the directory segments, not the lookup keys — and the
|
|
22
|
+
normalized form is only ever bound to a name that says what it is
|
|
23
|
+
(``html_lang``, ``hreflang``, ``og_locale``). Normalizing the authored form
|
|
24
|
+
"for consistency" would silently repoint every screenshot lookup and every
|
|
25
|
+
host-app translation key at a directory and a language that do not exist.
|
|
26
|
+
|
|
27
|
+
What this module *does* do at config load is refuse a tag that cannot be given a
|
|
28
|
+
correct ``hreflang`` at all. That matters more than it sounds: one malformed
|
|
29
|
+
``hreflang`` makes a search engine discard the **whole** alternates cluster,
|
|
30
|
+
silently, so a project mis-spelling a single locale loses the annotation for all
|
|
31
|
+
of them.
|
|
32
|
+
|
|
33
|
+
Stdlib-only leaf: it imports nothing from ``steppy``, so ``build_models.py`` can
|
|
34
|
+
import it on the eager path (like ``assertions`` and ``exclusive``) without
|
|
35
|
+
tripping the lazy-import guards.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import re
|
|
41
|
+
|
|
42
|
+
# ── Errors ───────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LocaleSyntaxError(ValueError):
|
|
46
|
+
"""Raised when a configured locale is not a usable BCP-47 language tag."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Grammar ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
# A tag becomes a filename, so an unbounded `*variant` is an unbounded path
|
|
52
|
+
# component. Three variants is already more than any registered tag uses.
|
|
53
|
+
MAX_LOCALE_LEN = 64
|
|
54
|
+
MAX_VARIANTS = 3
|
|
55
|
+
|
|
56
|
+
# RFC 5646 langtag, minus extlang/extension/privateuse (all rejected below with a
|
|
57
|
+
# message naming the modern spelling).
|
|
58
|
+
#
|
|
59
|
+
# The 2-3 letter bound on the language subtag is a correctness control, not a
|
|
60
|
+
# style rule. The tag is written verbatim as `<slug>/<locale>.html`, in the same
|
|
61
|
+
# directory as `interactive.html` and `video.html`, and `_write_file` overwrites
|
|
62
|
+
# without complaining -- so a locale named `video` or `interactive` silently
|
|
63
|
+
# clobbers a guide page, or is clobbered by one, depending on emission order. The
|
|
64
|
+
# same bound keeps a locale away from `index.html` and from `x-default`, the
|
|
65
|
+
# sentinel `_hreflang_alternates` appends. Every registered language subtag is
|
|
66
|
+
# two or three letters (ISO 639-1/2/3/5); RFC 5646's 4-letter and 5-8-letter
|
|
67
|
+
# productions are reserved and nothing occupies them, so the bound rejects
|
|
68
|
+
# nothing real and makes all four collisions unrepresentable.
|
|
69
|
+
_TAG_RE = re.compile(
|
|
70
|
+
r"^(?P<lang>[A-Za-z]{2,3})"
|
|
71
|
+
r"(?:-(?P<script>[A-Za-z]{4}))?"
|
|
72
|
+
r"(?:-(?P<region>[A-Za-z]{2}|[0-9]{3}))?"
|
|
73
|
+
rf"(?P<variants>(?:-(?:[A-Za-z0-9]{{5,8}}|[0-9][A-Za-z0-9]{{3}})){{0,{MAX_VARIANTS}}})$"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_EXTLANG_RE = re.compile(r"^[A-Za-z]{3}$")
|
|
77
|
+
|
|
78
|
+
# Reserved on Windows for the basename *before* the extension, so `con.html` is
|
|
79
|
+
# un-creatable just as `con` is -- and `con` is a real language, Cofan
|
|
80
|
+
# (ISO 639-3). The check runs against the whole normalized tag rather than its
|
|
81
|
+
# language subtag, because `con-EC.html` is a perfectly good filename and is the
|
|
82
|
+
# workaround the error message offers. Same guard, same reasoning, as
|
|
83
|
+
# `exclusive._WINDOWS_RESERVED`.
|
|
84
|
+
_WINDOWS_RESERVED = frozenset(
|
|
85
|
+
{"con", "prn", "aux", "nul"} | {f"com{i}" for i in range(1, 10)} | {f"lpt{i}" for i in range(1, 10)}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Grandfathered tags, irregular and regular alike. Every one is deprecated in the
|
|
89
|
+
# IANA registry; those with a registered Preferred-Value map to it, the rest have
|
|
90
|
+
# no modern spelling and are simply refused.
|
|
91
|
+
_DEPRECATED: dict[str, str] = {
|
|
92
|
+
"art-lojban": "jbo",
|
|
93
|
+
"cel-gaulish": "",
|
|
94
|
+
"en-gb-oed": "en-GB-oxendict",
|
|
95
|
+
"i-ami": "ami",
|
|
96
|
+
"i-bnn": "bnn",
|
|
97
|
+
"i-default": "",
|
|
98
|
+
"i-enochian": "",
|
|
99
|
+
"i-hak": "hak",
|
|
100
|
+
"i-klingon": "tlh",
|
|
101
|
+
"i-lux": "lb",
|
|
102
|
+
"i-mingo": "",
|
|
103
|
+
"i-navajo": "nv",
|
|
104
|
+
"i-pwn": "pwn",
|
|
105
|
+
"i-tao": "tao",
|
|
106
|
+
"i-tay": "tay",
|
|
107
|
+
"i-tsu": "tsu",
|
|
108
|
+
"no-bok": "nb",
|
|
109
|
+
"no-nyn": "nn",
|
|
110
|
+
"sgn-be-fr": "sfb",
|
|
111
|
+
"sgn-be-nl": "vgt",
|
|
112
|
+
"sgn-ch-de": "sgg",
|
|
113
|
+
"zh-guoyu": "cmn",
|
|
114
|
+
"zh-hakka": "hak",
|
|
115
|
+
"zh-min": "",
|
|
116
|
+
"zh-min-nan": "nan",
|
|
117
|
+
"zh-xiang": "hsn",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_SHAPE_HELP = (
|
|
121
|
+
"Use a BCP-47 language tag: a 2- or 3-letter language, optionally followed by a "
|
|
122
|
+
"4-letter script, a 2-letter or 3-digit region, and variant subtags: 'fr', 'fr-BE', "
|
|
123
|
+
"'pt-BR', 'zh-Hant', 'es-419', 'de-CH-1901'. Separators may be '-' or '_' and case "
|
|
124
|
+
"is free."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
_PRIVATE_USE_HELP = (
|
|
128
|
+
"private-use ('x-') subtags are not supported. They carry no meaning for hreflang or "
|
|
129
|
+
"<html lang>, and 'x-default' is already the hreflang fallback this build emits for "
|
|
130
|
+
"the default locale."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
_LANGUAGE_BOUND_HELP = (
|
|
134
|
+
"must be 2 or 3 letters. Every registered language subtag is, and the bound is what "
|
|
135
|
+
"keeps a locale from colliding with the 'index.html', 'video.html' and "
|
|
136
|
+
"'interactive.html' pages it is written beside."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Normalisation ────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def normalize_locale(value: object) -> str:
|
|
144
|
+
"""Validate one authored locale tag and return its canonical BCP-47 form.
|
|
145
|
+
|
|
146
|
+
``fr_BE`` -> ``fr-BE``, ``FR`` -> ``fr``, ``zh-hant-cn`` -> ``zh-Hant-CN``.
|
|
147
|
+
The result is what belongs in ``<html lang>`` and ``hreflang``; the caller
|
|
148
|
+
keeps the authored spelling for filenames and lookup keys (see the module
|
|
149
|
+
docstring).
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
LocaleSyntaxError: with an actionable message for every rejection,
|
|
153
|
+
naming the modern spelling wherever one exists.
|
|
154
|
+
"""
|
|
155
|
+
if not isinstance(value, str):
|
|
156
|
+
raise LocaleSyntaxError(f"locales entries must be strings, got {type(value).__name__}")
|
|
157
|
+
|
|
158
|
+
tag = value.strip()
|
|
159
|
+
if not tag:
|
|
160
|
+
raise LocaleSyntaxError("a locale must be a non-empty language tag. " + _SHAPE_HELP)
|
|
161
|
+
if len(tag) > MAX_LOCALE_LEN:
|
|
162
|
+
raise LocaleSyntaxError(
|
|
163
|
+
f"locale {tag!r} is too long ({len(tag)} characters, limit {MAX_LOCALE_LEN}). "
|
|
164
|
+
"The tag is written verbatim into page filenames and screenshot directories."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
folded = tag.replace("_", "-")
|
|
168
|
+
subtags = folded.split("-")
|
|
169
|
+
if any(not part for part in subtags):
|
|
170
|
+
raise LocaleSyntaxError(f"locale {tag!r} has an empty subtag. " + _SHAPE_HELP)
|
|
171
|
+
|
|
172
|
+
lowered = folded.lower()
|
|
173
|
+
if lowered in _DEPRECATED:
|
|
174
|
+
preferred = _DEPRECATED[lowered]
|
|
175
|
+
if preferred:
|
|
176
|
+
raise LocaleSyntaxError(f"locale {tag!r} is a deprecated tag. Use {preferred!r} instead.")
|
|
177
|
+
raise LocaleSyntaxError(
|
|
178
|
+
f"locale {tag!r} is a deprecated tag with no modern replacement. "
|
|
179
|
+
"Pick a current ISO 639 language code instead."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
for index, part in enumerate(subtags):
|
|
183
|
+
if len(part) != 1:
|
|
184
|
+
continue
|
|
185
|
+
if part.lower() == "x":
|
|
186
|
+
raise LocaleSyntaxError(f"locale {tag!r}: " + _PRIVATE_USE_HELP)
|
|
187
|
+
if index:
|
|
188
|
+
raise LocaleSyntaxError(
|
|
189
|
+
f"locale {tag!r}: extension subtags such as '-{part.lower()}-' are not supported. "
|
|
190
|
+
"They carry no meaning for hreflang or <html lang>."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
match = _TAG_RE.match(folded)
|
|
194
|
+
if match is None:
|
|
195
|
+
# An extlang form (`zh-yue`, `ar-arz`) is the one near-miss worth naming:
|
|
196
|
+
# RFC 5646 asks for the primary subtag, so the fix is to drop the prefix.
|
|
197
|
+
if len(subtags) == 2 and _EXTLANG_RE.match(subtags[1]):
|
|
198
|
+
raise LocaleSyntaxError(
|
|
199
|
+
f"locale {tag!r} looks like an extended language subtag. Use the language "
|
|
200
|
+
f"itself, {subtags[1].lower()!r}, instead."
|
|
201
|
+
)
|
|
202
|
+
if len(subtags[0]) not in (2, 3) or not subtags[0].isalpha():
|
|
203
|
+
raise LocaleSyntaxError(
|
|
204
|
+
f"invalid locale {tag!r}: the language subtag {subtags[0]!r} " + _LANGUAGE_BOUND_HELP
|
|
205
|
+
)
|
|
206
|
+
raise LocaleSyntaxError(f"invalid locale {tag!r}. " + _SHAPE_HELP)
|
|
207
|
+
|
|
208
|
+
variants = [part for part in match.group("variants").split("-") if part]
|
|
209
|
+
seen: set[str] = set()
|
|
210
|
+
for variant in variants:
|
|
211
|
+
key = variant.lower()
|
|
212
|
+
if key in seen:
|
|
213
|
+
raise LocaleSyntaxError(f"invalid locale {tag!r}: the variant subtag {variant!r} appears more than once.")
|
|
214
|
+
seen.add(key)
|
|
215
|
+
|
|
216
|
+
parts = [match.group("lang").lower()]
|
|
217
|
+
if match.group("script"):
|
|
218
|
+
parts.append(match.group("script").title())
|
|
219
|
+
region = match.group("region")
|
|
220
|
+
if region:
|
|
221
|
+
parts.append(region.upper() if region.isalpha() else region)
|
|
222
|
+
parts.extend(variant.lower() for variant in variants)
|
|
223
|
+
normalized = "-".join(parts)
|
|
224
|
+
|
|
225
|
+
if normalized.lower() in _WINDOWS_RESERVED:
|
|
226
|
+
raise LocaleSyntaxError(
|
|
227
|
+
f"locale {tag!r} normalises to {normalized!r}, a reserved device name on Windows: the "
|
|
228
|
+
f"build writes '<slug>/{normalized}.html', and that filename cannot be created there. "
|
|
229
|
+
f"Use a region-qualified tag such as '{normalized}-EC' instead."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return normalized
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def validate_locale_tag(value: object) -> None:
|
|
236
|
+
"""Raise ``ValueError`` if ``value`` is not a usable BCP-47 tag.
|
|
237
|
+
|
|
238
|
+
Validates only — the authored spelling is never rewritten (see the module
|
|
239
|
+
docstring for why). Lives here rather than in either model module because
|
|
240
|
+
both sides of the repo validate locales now: ``models.SteppyConfig`` for the
|
|
241
|
+
capture side (``config.yaml``) and ``build_models.BuildConfig`` for the build
|
|
242
|
+
side. Re-raised as a plain ``ValueError`` so pydantic reports it as a field
|
|
243
|
+
error rather than an unhandled exception.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
normalize_locale(value)
|
|
247
|
+
except LocaleSyntaxError as exc:
|
|
248
|
+
raise ValueError(str(exc)) from exc
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def base_language(tag: str) -> str:
|
|
252
|
+
"""The bare language subtag of ``tag``, lowercased (``fr-BE`` -> ``fr``).
|
|
253
|
+
|
|
254
|
+
Deliberately lenient — it never validates and never raises. Its callers are
|
|
255
|
+
content-resolution fallbacks (``pick_locale``, ``GuideText.get``), where
|
|
256
|
+
turning a missing translation into an exception would be strictly worse than
|
|
257
|
+
falling through to the default locale.
|
|
258
|
+
|
|
259
|
+
**Byte-mirrored** as ``_base_language`` in ``templates/conftest.py.j2``: the
|
|
260
|
+
generated test suite is standalone and cannot import steppy, but it needs the
|
|
261
|
+
same rule to pick an i18n value for a regional locale. Guard:
|
|
262
|
+
``tests/test_conftest_locale_mirror.py``. Apply any change to both.
|
|
263
|
+
"""
|
|
264
|
+
return tag.replace("_", "-").split("-", 1)[0].lower()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def og_locale(tag: str) -> str | None:
|
|
268
|
+
"""Open Graph ``og:locale`` for a normalized tag, or ``None`` to omit it.
|
|
269
|
+
|
|
270
|
+
The Open Graph protocol defines the value as ``language_TERRITORY`` and
|
|
271
|
+
Facebook validates it against a finite list, every entry of which is a
|
|
272
|
+
2-letter language plus a 2-letter country. So a script or a numeric region
|
|
273
|
+
has no valid spelling (``zh-Hant`` is not ``zh_Hant``), and neither does a
|
|
274
|
+
region-less tag — ``fr`` was never a valid ``og:locale``, and inventing
|
|
275
|
+
``fr_FR`` for it would be a guess this project cannot make (Steppy's own
|
|
276
|
+
demo is Belgian). Omitting and emitting an invalid value produce the same
|
|
277
|
+
consumer behaviour, and omitting does not lie.
|
|
278
|
+
"""
|
|
279
|
+
parts = tag.split("-")
|
|
280
|
+
if len(parts) != 2 or len(parts[1]) != 2 or not parts[1].isalpha():
|
|
281
|
+
return None
|
|
282
|
+
return f"{parts[0].lower()}_{parts[1].upper()}"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""``steppy migrate custom`` — rewrite mechanical ``custom`` blocks as steps.
|
|
2
|
+
|
|
3
|
+
New actions do not shrink the custom blocks a project already has; somebody
|
|
4
|
+
still has to rewrite them, and one measured project holds 4 967. This package is
|
|
5
|
+
that rewriter.
|
|
6
|
+
|
|
7
|
+
It is deliberately conservative. A block is converted only when **every**
|
|
8
|
+
statement in it maps to a rule, and only when the newly generated Python is
|
|
9
|
+
equivalent to what the ``custom`` body already emitted — see
|
|
10
|
+
:mod:`steppy.migrate.verify`. Anything else is reported, never touched. The
|
|
11
|
+
residue is the input to the next round of commands.
|
|
12
|
+
|
|
13
|
+
Lazy-imported: nothing here is reachable from ``import steppy.cli``
|
|
14
|
+
(``tests/test_no_migrate_import_on_default_path.py``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .apply import Outcome, apply_plan, steps_to_markdown
|
|
18
|
+
from .plan import MigrationPlan, StepPlan, plan_file, plan_scenarios
|
|
19
|
+
from .rules import RULES, parse_block, statements
|
|
20
|
+
from .verify import VerificationResult, verify_rewrite
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"RULES",
|
|
24
|
+
"MigrationPlan",
|
|
25
|
+
"Outcome",
|
|
26
|
+
"StepPlan",
|
|
27
|
+
"VerificationResult",
|
|
28
|
+
"apply_plan",
|
|
29
|
+
"parse_block",
|
|
30
|
+
"plan_file",
|
|
31
|
+
"plan_scenarios",
|
|
32
|
+
"statements",
|
|
33
|
+
"steps_to_markdown",
|
|
34
|
+
"verify_rewrite",
|
|
35
|
+
]
|
steppy/migrate/apply.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Render proposed steps, verify them, and write the rewritten scenario.
|
|
2
|
+
|
|
3
|
+
The render goes through the *real* generator, not a copy of it: a codemod
|
|
4
|
+
verified against a second implementation of codegen proves only that the two
|
|
5
|
+
copies agree.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .plan import _NESTED, MigrationPlan, StepPlan
|
|
16
|
+
from .verify import VerificationResult, verify_rewrite
|
|
17
|
+
|
|
18
|
+
__all__ = ["Outcome", "apply_plan", "render_steps", "steps_to_markdown"]
|
|
19
|
+
|
|
20
|
+
#: Order the keys are written in, so a rewritten step reads like a hand-written
|
|
21
|
+
#: one. Anything not listed follows, in the order the author had it.
|
|
22
|
+
_KEY_ORDER = [
|
|
23
|
+
"role",
|
|
24
|
+
"action",
|
|
25
|
+
"target",
|
|
26
|
+
"fn",
|
|
27
|
+
"selector",
|
|
28
|
+
"control",
|
|
29
|
+
"within",
|
|
30
|
+
"match",
|
|
31
|
+
"value",
|
|
32
|
+
"args",
|
|
33
|
+
"kwargs",
|
|
34
|
+
"state",
|
|
35
|
+
"detect",
|
|
36
|
+
"option",
|
|
37
|
+
"expect",
|
|
38
|
+
"duration",
|
|
39
|
+
"timeout",
|
|
40
|
+
"force",
|
|
41
|
+
"reload",
|
|
42
|
+
"wait",
|
|
43
|
+
"store",
|
|
44
|
+
"page",
|
|
45
|
+
"wait_after",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Outcome:
|
|
51
|
+
step: StepPlan
|
|
52
|
+
verification: Optional[VerificationResult] = None
|
|
53
|
+
markdown: str = ""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def applied(self) -> bool:
|
|
57
|
+
return self.verification is not None and self.verification.ok
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _quoted_element(value) -> str:
|
|
61
|
+
"""A list element, always quoted when it is a string."""
|
|
62
|
+
if isinstance(value, bool):
|
|
63
|
+
return "true" if value else "false"
|
|
64
|
+
if isinstance(value, (int, float)):
|
|
65
|
+
return str(value)
|
|
66
|
+
return "'" + str(value).replace("'", "''") + "'"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _yaml_value(value) -> str:
|
|
70
|
+
if isinstance(value, bool):
|
|
71
|
+
return "true" if value else "false"
|
|
72
|
+
if isinstance(value, (int, float)):
|
|
73
|
+
return str(value)
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
# Quote every string element. A bare `/settings?subscription=success` is
|
|
76
|
+
# not valid flow-sequence content, and the failure is a parse error in
|
|
77
|
+
# the rewritten scenario rather than anything visible here.
|
|
78
|
+
return "[" + ", ".join(_quoted_element(v) for v in value) + "]"
|
|
79
|
+
text = str(value)
|
|
80
|
+
# Quote anything YAML would otherwise reinterpret, and anything holding the
|
|
81
|
+
# separator characters a `- key: value` line is parsed with.
|
|
82
|
+
if text == "" or text[0] in "[{'\"|>&*!%@`#-?," or ": " in text or text.strip() != text:
|
|
83
|
+
return "'" + text.replace("'", "''") + "'"
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def steps_to_markdown(steps: list[dict], heading: str, role: Optional[str], extra: list[tuple[str, str]]) -> str:
|
|
88
|
+
"""The scenario Markdown for one rewritten block.
|
|
89
|
+
|
|
90
|
+
A block becoming several steps gets one heading per step, numbered
|
|
91
|
+
``N.1``/``N.2`` so the surrounding numbering is left alone — renumbering a
|
|
92
|
+
whole file would bury the real change in a diff nobody can read.
|
|
93
|
+
"""
|
|
94
|
+
lines: list[str] = []
|
|
95
|
+
multiple = len(steps) > 1
|
|
96
|
+
for index, step in enumerate(steps, start=1):
|
|
97
|
+
title = heading if not multiple else f"{heading} ({index}/{len(steps)})"
|
|
98
|
+
lines.append(title)
|
|
99
|
+
if role:
|
|
100
|
+
lines.append(f"- role: {role}")
|
|
101
|
+
for key in _KEY_ORDER:
|
|
102
|
+
if key == "role" or key not in step:
|
|
103
|
+
continue
|
|
104
|
+
if key == "expect" and isinstance(step[key], list):
|
|
105
|
+
# The list form, one entry per assertion, each optionally
|
|
106
|
+
# carrying the failure message the author wrote by hand.
|
|
107
|
+
lines.append(f"- {key}:")
|
|
108
|
+
for entry in step[key]:
|
|
109
|
+
if isinstance(entry, dict):
|
|
110
|
+
lines.append(f" - check: {_yaml_value(entry['check'])}")
|
|
111
|
+
if entry.get("message"):
|
|
112
|
+
lines.append(f" message: {_yaml_value(entry['message'])}")
|
|
113
|
+
else:
|
|
114
|
+
lines.append(f" - {_yaml_value(entry)}")
|
|
115
|
+
continue
|
|
116
|
+
if isinstance(step[key], dict):
|
|
117
|
+
lines.append(f"- {key}:")
|
|
118
|
+
for sub, sub_value in step[key].items():
|
|
119
|
+
lines.append(f" {sub}: {_yaml_value(sub_value)}")
|
|
120
|
+
continue
|
|
121
|
+
lines.append(f"- {key}: {_yaml_value(step[key])}")
|
|
122
|
+
for key, value in step.items():
|
|
123
|
+
if key in _KEY_ORDER or key == "role":
|
|
124
|
+
continue
|
|
125
|
+
lines.append(f"- {key}: {_yaml_value(value)}")
|
|
126
|
+
# `note:` / `screenshot:` / `guide-text:` ride on the FIRST step only.
|
|
127
|
+
# Duplicating a note onto every fragment would say the same thing three
|
|
128
|
+
# times; duplicating a screenshot would capture the same frame three
|
|
129
|
+
# times.
|
|
130
|
+
if index == 1:
|
|
131
|
+
for key, value in extra:
|
|
132
|
+
if value.startswith(_NESTED):
|
|
133
|
+
lines.append(f"- {key}:")
|
|
134
|
+
lines.extend(value[len(_NESTED) :].splitlines())
|
|
135
|
+
elif "\n" in value:
|
|
136
|
+
lines.append(f"- {key}: |")
|
|
137
|
+
lines.extend(" " + line for line in value.splitlines())
|
|
138
|
+
else:
|
|
139
|
+
lines.append(f"- {key}: {value}")
|
|
140
|
+
lines.append("")
|
|
141
|
+
return "\n".join(lines)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def render_steps(generator, markdown_body: str, tmp_root: Path) -> str:
|
|
145
|
+
"""Generated Python for a one-step scenario holding ``markdown_body``."""
|
|
146
|
+
from ..parser import parse_scenario_file
|
|
147
|
+
|
|
148
|
+
path = tmp_root / "probe.md"
|
|
149
|
+
path.write_text("---\ntitle: probe\nauth: false\n---\n\n" + markdown_body, encoding="utf-8")
|
|
150
|
+
return generator.generate_test_file(parse_scenario_file(path))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def apply_plan(plan: MigrationPlan, generator, write: bool) -> list[Outcome]:
|
|
154
|
+
"""Verify every convertible block, and rewrite the file if asked.
|
|
155
|
+
|
|
156
|
+
Verification renders both sides through ``generator`` — the caller's real
|
|
157
|
+
``SteppyGenerator``, configured from the project's own ``config.yaml``, so
|
|
158
|
+
the comparison uses the same widget selectors the project runs with.
|
|
159
|
+
"""
|
|
160
|
+
outcomes: list[Outcome] = []
|
|
161
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
162
|
+
tmp_root = Path(tmp)
|
|
163
|
+
for entry in plan.steps:
|
|
164
|
+
outcome = Outcome(step=entry)
|
|
165
|
+
outcomes.append(outcome)
|
|
166
|
+
if not entry.convertible:
|
|
167
|
+
continue
|
|
168
|
+
markdown = steps_to_markdown(entry.steps, entry.heading, entry.role, entry.keys)
|
|
169
|
+
try:
|
|
170
|
+
generated = render_steps(generator, markdown, tmp_root)
|
|
171
|
+
except Exception as exc: # a proposed step the models reject
|
|
172
|
+
outcome.verification = VerificationResult(False, f"proposed step is invalid: {exc}")
|
|
173
|
+
continue
|
|
174
|
+
outcome.verification = verify_rewrite(entry.code, generated, entry.steps)
|
|
175
|
+
outcome.markdown = markdown
|
|
176
|
+
|
|
177
|
+
if write and any(o.applied for o in outcomes):
|
|
178
|
+
_rewrite_file(plan, outcomes)
|
|
179
|
+
return outcomes
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _rewrite_file(plan: MigrationPlan, outcomes: list[Outcome]) -> None:
|
|
183
|
+
"""Replace each verified block in place, leaving everything else byte-identical."""
|
|
184
|
+
text = plan.path.read_text(encoding="utf-8")
|
|
185
|
+
lines = text.splitlines(keepends=True)
|
|
186
|
+
# Bottom-up, so earlier line numbers stay valid as blocks are replaced.
|
|
187
|
+
for outcome in sorted((o for o in outcomes if o.applied), key=lambda o: o.step.line, reverse=True):
|
|
188
|
+
start = outcome.step.line - 1
|
|
189
|
+
end = start + 1
|
|
190
|
+
while end < len(lines) and not lines[end].startswith("### "):
|
|
191
|
+
end += 1
|
|
192
|
+
replacement = outcome.markdown.rstrip("\n") + "\n\n"
|
|
193
|
+
lines[start:end] = [replacement]
|
|
194
|
+
plan.path.write_text("".join(lines), encoding="utf-8")
|