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/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
+ ]
@@ -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")