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/report_cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """``steppy report`` — render one run to a static site (leaf CLI module).
2
+
3
+ Module scope stays typer + stdlib + ``_cli_common``. Every ``steppy.report`` (and
4
+ ``steppy.bundle``) import lives inside the command body, because ``cli.py`` imports
5
+ *this* module eagerly to register the command — so a lazy import here is the only
6
+ thing keeping the site renderer, the tar reader and the ``output/`` walker off the
7
+ path a plain ``steppy run`` takes. Guard:
8
+ ``tests/test_no_report_import_on_default_path.py``.
9
+
10
+ Naming note, since "report" is crowded: ``config.yaml``'s ``report:`` block is
11
+ about pytest artifacts (JUnit, HTML, traces, videos, reruns), the
12
+ ``steppy-dev[report]`` extra is pytest-html, and ``steppy coverage report`` prints gap
13
+ buckets. This command is the *run report site* — the thing you publish.
14
+ """
15
+
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ import typer
20
+ from rich.markup import escape
21
+
22
+ from ._cli_common import console, _cli_errors, _error
23
+
24
+
25
+ def register_report(app: typer.Typer) -> None:
26
+ """Attach the ``report`` command to the root Typer app."""
27
+
28
+ @app.command("report")
29
+ def report(
30
+ bundle: Optional[Path] = typer.Argument(
31
+ None,
32
+ help="A .tar.zst/.tar.gz bundle, or an unpacked bundle directory. Omit to report on ./output/.",
33
+ ),
34
+ output: Optional[Path] = typer.Option(
35
+ None,
36
+ "--output",
37
+ "-o",
38
+ help="Where to write the site (default: output/report).",
39
+ ),
40
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Project root (default: .)."),
41
+ output_dir: Optional[Path] = typer.Option(
42
+ None, "--output-dir", help="The run's output/ tree to read (default: <project-root>/output)."
43
+ ),
44
+ run_id: Optional[str] = typer.Option(
45
+ None, "--run-id", help="Which run to report on when output/ holds more than one."
46
+ ),
47
+ screenshots: str = typer.Option(
48
+ "failed",
49
+ "--screenshots",
50
+ help="Which captures to copy into the site: failed (default), all, or none.",
51
+ ),
52
+ badge_surface: str = typer.Option(
53
+ "all",
54
+ "--badge-surface",
55
+ help="Surface the coverage badge measures: all (endpoints + routes), endpoints, or routes.",
56
+ ),
57
+ badge_label: Optional[str] = typer.Option(
58
+ None, "--badge-label", help="Left-hand text on the badge (default: 'e2e surface')."
59
+ ),
60
+ title: Optional[str] = typer.Option(None, "--title", help="Heading for the report pages."),
61
+ templates_dir: Optional[Path] = typer.Option(
62
+ None, "--templates-dir", help="Override the template search root."
63
+ ),
64
+ force: bool = typer.Option(
65
+ False, "--force", "-f", help="Replace the contents of a non-empty output directory."
66
+ ),
67
+ dry_run: bool = typer.Option(False, "--dry-run", help="Report what would be written, write nothing."),
68
+ as_json: bool = typer.Option(False, "--json", help="Print report.json to stdout instead of a summary."),
69
+ open_browser: bool = typer.Option(False, "--open", help="Open the rendered report in a browser."),
70
+ ) -> None:
71
+ """Render one run's results to a self-contained static site.
72
+
73
+ Reads a results bundle — or the live ``output/`` tree of a run that used
74
+ ``steppy run --bundle`` — and writes ``index.html``, a page per scenario,
75
+ the coverage gap report, a machine-readable ``report.json`` and a
76
+ shields.io ``badge.json``. Everything is relative and self-contained, so it
77
+ works from a file:// path, a CI artifact, or GitHub/GitLab Pages.
78
+
79
+ **The exit code describes rendering, not the run.** A report of a failing
80
+ run still exits 0 — it rendered fine. Gate your build on ``steppy run``
81
+ itself (or read ``totals.exit_code`` out of ``report.json``); a report step
82
+ that goes red for a red run is a step people delete.
83
+ """
84
+ with _cli_errors():
85
+ from .report.build import ReportError, build_report
86
+
87
+ try:
88
+ result = build_report(
89
+ project_root=project_root,
90
+ bundle=bundle,
91
+ site_dir=output,
92
+ output_dir=output_dir,
93
+ run_id=run_id,
94
+ screenshots=screenshots,
95
+ badge_surface=badge_surface,
96
+ badge_label=badge_label,
97
+ title=title,
98
+ templates_dir=templates_dir,
99
+ force=force,
100
+ dry_run=dry_run,
101
+ )
102
+ except ReportError as e:
103
+ _error(str(e))
104
+ raise typer.Exit(1)
105
+ except ValueError as e:
106
+ # The policy/surface validators, which are worth a clean message
107
+ # rather than a traceback.
108
+ _error(str(e))
109
+ raise typer.Exit(1)
110
+
111
+ if as_json:
112
+ from .contracts.report import report_bytes
113
+
114
+ print(report_bytes(result.model).decode("utf-8"), end="")
115
+ raise typer.Exit(0)
116
+
117
+ _print_summary(result, dry_run=dry_run)
118
+
119
+ if open_browser and not dry_run:
120
+ import webbrowser
121
+
122
+ webbrowser.open((result.site_dir / "index.html").resolve().as_uri())
123
+
124
+
125
+ def _print_summary(result, *, dry_run: bool) -> None:
126
+ """The post-render summary. Mirrors ``steppy build``'s counts vocabulary."""
127
+ totals = result.model.totals
128
+ verb = "Would render" if dry_run else "Rendered"
129
+ colour = "green" if totals.verdict in ("passed", "skipped") else "red"
130
+ console.print(
131
+ f"[{colour}]{totals.verdict.upper()}[/{colour}] {totals.scenarios} scenario(s), {totals.tests} test(s)"
132
+ )
133
+ if totals.outcomes:
134
+ console.print("[dim]" + " ".join(f"{count} {name}" for name, count in totals.outcomes.items()) + "[/dim]")
135
+
136
+ causes = result.model.failure_causes
137
+ if causes:
138
+ console.print(f"\n[bold]Failure causes[/bold] ({len(causes)} distinct)")
139
+ for cause in causes[:5]:
140
+ # escape(), because a headline routinely contains a CSS selector and
141
+ # Rich would read `[data-testid="x"]` as markup and swallow it.
142
+ console.print(f" [red]×{cause.count}[/red] {escape(cause.headline)} [dim]({cause.fingerprint})[/dim]")
143
+ if len(causes) > 5:
144
+ console.print(f" [dim]… and {len(causes) - 5} more[/dim]")
145
+
146
+ coverage = result.model.coverage
147
+ if coverage.tier != "none":
148
+ pct = coverage.badge.percent
149
+ measured = f"{pct}%" if pct is not None else "n/a"
150
+ console.print(
151
+ f"\n[bold]E2E surface[/bold] {measured} "
152
+ f"[dim]({coverage.badge.tested}/{coverage.badge.declared}, source: {coverage.tier})[/dim]"
153
+ )
154
+
155
+ console.print(f"\n{verb} {len(result.files)} file(s) → {result.site_dir}")
156
+ if result.copied:
157
+ console.print(f"[dim]{result.copied} screenshot(s) copied, {_human(result.copied_bytes)}[/dim]")
158
+ if not dry_run:
159
+ console.print(f"[dim]Open {result.site_dir / 'index.html'}[/dim]")
160
+
161
+ for warning in result.warnings:
162
+ console.print(f"[yellow]WARNING[/yellow] {escape(warning)}")
163
+ for warning in result.model.coverage.warnings:
164
+ console.print(f"[yellow]WARNING[/yellow] {escape(warning)}")
165
+
166
+
167
+ def _human(size: int) -> str:
168
+ value = float(size)
169
+ for unit in ("B", "KiB", "MiB", "GiB"):
170
+ if value < 1024 or unit == "GiB":
171
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
172
+ value /= 1024
173
+ return f"{value:.1f} GiB" # pragma: no cover - the loop returns first
steppy/runmeta.py ADDED
@@ -0,0 +1,114 @@
1
+ """Run identity and the ``--bundle`` artifact layout.
2
+
3
+ A **leaf**: stdlib plus ``steppy.__version__`` only. Three modules need to agree
4
+ on what is written here and where — ``cli`` (single-worker runs),
5
+ ``coordinator`` (parallel runs) and ``bundle`` (the reader) — and two of those
6
+ are lazy-imported from the third, so a shared home avoids an import cycle as
7
+ well as three copies of the same path strings.
8
+
9
+ Two things live here:
10
+
11
+ **Run identity.** :func:`new_run_id` is the project's single id generator.
12
+ Sortability is load-bearing rather than cosmetic: both
13
+ ``coverage.merge.latest_run_id`` and ``steppy bundle create`` resolve "the
14
+ newest recorded run" by taking the lexicographically last directory name, so a
15
+ random id would make them pick arbitrarily. Historically the parallel
16
+ coordinator minted a bare ``uuid4().hex[:8]`` for exactly this variable, which
17
+ is why that resolution was only ever correct on the single-worker path.
18
+
19
+ **The sidecar.** ``steppy bundle create`` runs as a separate process, minutes
20
+ or a CI step after the run itself, and cannot otherwise know what the run was
21
+ asked to do. Re-deriving the spec from flags the user retypes is guaranteed to
22
+ drift, and drift here means a results bundle that misreports its own
23
+ dimensions. So a ``--bundle`` run records them.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import time
30
+ import uuid
31
+ from pathlib import Path
32
+ from typing import Any, Optional
33
+
34
+ from . import __version__
35
+
36
+ #: Per-run directories under ``output/``, mirroring
37
+ #: ``output/coverage/raw/<run_id>/``. Keying on the run means a previous run's
38
+ #: files can never be mistaken for this one's, so nothing has to be reset
39
+ #: between runs — the failure mode that would otherwise ship yesterday's
40
+ #: results inside today's bundle.
41
+ EVENTS_SUBDIR = "events"
42
+ JUNIT_SUBDIR = "junit"
43
+
44
+ #: Where ``steppy report`` writes its static site by default. Lives here rather
45
+ #: than in ``report/`` because the *bundler* needs it too: without skipping this
46
+ #: directory, ``steppy bundle create`` would archive the whole rendered site —
47
+ #: every screenshot it copied included — and roughly double the bundle's payload.
48
+ #: (``classify`` maps ``output/reports``, plural, to a pytest-report kind; this
49
+ #: singular directory would otherwise fall through to ``other`` and ship.)
50
+ REPORT_SUBDIR = "report"
51
+
52
+ #: Leading dot so it can never collide with a scenario slug. Under ``output/``
53
+ #: because that is the only writable mount inside a worker container.
54
+ RUN_SIDECAR_NAME = ".steppy-run.json"
55
+
56
+ #: Schema version of the sidecar. Additive fields are always allowed and
57
+ #: readers default what is absent — the same forward-compat rule the screenshot
58
+ #: sidecars follow.
59
+ RUN_SIDECAR_VERSION = 1
60
+
61
+
62
+ def new_run_id() -> str:
63
+ """Sortable unique id for one run: ``20260806T101500Z-a1b2c3``."""
64
+ return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "-" + uuid.uuid4().hex[:6]
65
+
66
+
67
+ def bundle_dirs(project_root: Path, run_id: str) -> tuple[Path, Path]:
68
+ """``(events_dir, junit_dir)`` for a ``--bundle`` run."""
69
+ output = Path(project_root) / "output"
70
+ return output / EVENTS_SUBDIR / run_id, output / JUNIT_SUBDIR / run_id
71
+
72
+
73
+ def sidecar_path(project_root: Path) -> Path:
74
+ return Path(project_root) / "output" / RUN_SIDECAR_NAME
75
+
76
+
77
+ def write_run_sidecar(
78
+ project_root: Path,
79
+ *,
80
+ run_id: str,
81
+ spec: dict,
82
+ executor: str = "local",
83
+ slugs: Optional[list[str]] = None,
84
+ ) -> Optional[Path]:
85
+ """Write ``output/.steppy-run.json``. Best-effort — returns ``None`` and
86
+ never raises, because failing a whole test run over a metadata file would
87
+ be a poor trade. ``steppy bundle create`` says so loudly when it is absent.
88
+ """
89
+ payload: dict[str, Any] = {
90
+ "steppy_run": RUN_SIDECAR_VERSION,
91
+ "run_id": run_id,
92
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
93
+ "steppy_version": __version__,
94
+ "executor": executor,
95
+ "spec": spec,
96
+ }
97
+ if slugs is not None:
98
+ payload["slugs"] = list(slugs)
99
+ try:
100
+ path = sidecar_path(project_root)
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n")
103
+ return path
104
+ except OSError:
105
+ return None
106
+
107
+
108
+ def read_run_sidecar(project_root: Path) -> Optional[dict]:
109
+ """Load ``output/.steppy-run.json``. ``None`` when absent or unreadable."""
110
+ try:
111
+ raw = json.loads(sidecar_path(project_root).read_text(encoding="utf-8"))
112
+ except (OSError, ValueError):
113
+ return None
114
+ return raw if isinstance(raw, dict) else None
steppy/selection.py ADDED
@@ -0,0 +1,183 @@
1
+ """Shared scenario / locale / layout selection logic (FLAWS §8.3).
2
+
3
+ The single-worker ``steppy run`` path (``cli.run``) and the parallel path
4
+ (``coordinator.run_parallel``) both have to answer the same three questions from
5
+ the same flags: *which scenarios* does ``--scenario`` / ``--tag`` / ``--guide`` /
6
+ ``--no-guide`` select, and *which locales / layouts* does ``--locales`` /
7
+ ``--layouts`` / ``--guide`` parametrize over. Historically each path re-implemented
8
+ that decision, kept equivalent only by the parallel integration tests — a drift here
9
+ would make ``--workers N`` silently run a **different scenario set** than
10
+ ``--workers 1``. This module is the one home for the decision; each caller keeps its
11
+ own *projection* (cli builds ``test_<slug>.py`` paths, coordinator enqueues bare
12
+ slugs) and its own console messaging.
13
+
14
+ Kept deliberately pure — depends only on ``.parser``, ``.models`` and stdlib, no
15
+ ``typer``/``rich`` — so ``cli.py`` can import it **eagerly** without tripping the
16
+ no-parallel-import guard (which blocks ``coordinator``/``worker``/``_parallel``, not
17
+ this leaf). User errors surface as :class:`SelectionError`; each caller translates it
18
+ to its own exit idiom (``typer.Exit`` / ``coordinator._SelectionExit``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from .locales import LocaleSyntaxError, normalize_locale
27
+ from .models import SteppyConfig
28
+ from .parser import ParseError, parse_scenarios_from_directory
29
+
30
+
31
+ class SelectionError(Exception):
32
+ """A user-facing selection error (bad flag value, missing file/dir, parse
33
+ failure). Carries the primary ``message``, the intended process ``exit_code``
34
+ (always 1 today), and an optional ``hint`` line (may contain rich markup — only
35
+ printed by callers that render markup, i.e. never through cli's markup-escaping
36
+ ``_error``)."""
37
+
38
+ def __init__(self, message: str, *, exit_code: int = 1, hint: Optional[str] = None):
39
+ super().__init__(message)
40
+ self.message = message
41
+ self.exit_code = exit_code
42
+ self.hint = hint
43
+
44
+
45
+ def _split_csv(value: str, flag: str, what: str) -> list[str]:
46
+ """Split a comma-separated flag value, stripping blanks. Raises
47
+ ``SelectionError`` when nothing survives (mirrors the historical
48
+ ``--<flag> did not contain any <what>`` message in both callers)."""
49
+ out = [item.strip() for item in value.split(",") if item.strip()]
50
+ if not out:
51
+ raise SelectionError(f"--{flag} did not contain any {what}")
52
+ return out
53
+
54
+
55
+ def apply_guide_defaults(guide: bool, screenshots: bool, no_asserts: bool) -> tuple[bool, bool]:
56
+ """Resolve the flags ``--guide`` implies. Returns ``(screenshots, no_asserts)``.
57
+
58
+ Guide mode captures screenshots by definition, and forces ``--no-asserts``
59
+ because text assertions (toasts, row-contains) are language-specific and
60
+ would fail every non-default locale during multilingual capture.
61
+
62
+ Lives here, beside :func:`resolve_locales` / :func:`resolve_layouts` — the
63
+ other two things ``--guide`` implies — so the whole "what does guide mode
64
+ mean" contract is in one module. It is shared by ``cli.run``, ``cli.rerun``
65
+ and ``coordinator.run_parallel``, which previously each spelled the rule
66
+ out; a fourth implication added to only some of them would silently make
67
+ the parallel path diverge from the single-worker path.
68
+ """
69
+ if guide:
70
+ return True, True
71
+ return screenshots, no_asserts
72
+
73
+
74
+ def resolve_locales(explicit: Optional[str], guide: bool, config: Optional[SteppyConfig]) -> list[str]:
75
+ """Resolve the locale parametrization list.
76
+
77
+ Explicit ``--locales`` wins (CSV split; error if it yields nothing); else
78
+ ``--guide`` pulls ``config.locales``; else empty (no parametrization).
79
+ ``config=None`` in guide mode yields ``[]`` — that is how cli models its
80
+ "config failed to load, degrade to empty" path.
81
+ """
82
+ if explicit:
83
+ codes = _split_csv(explicit, "locales", "locale codes")
84
+ # Each code becomes a screenshot directory segment that ``steppy build``
85
+ # reads back, and the build side rejects a malformed tag — so accepting
86
+ # one here only defers the failure to a place that cannot explain it.
87
+ for code in codes:
88
+ try:
89
+ normalize_locale(code)
90
+ except LocaleSyntaxError as exc:
91
+ raise SelectionError(f"--locales: {exc}") from exc
92
+ return codes
93
+ if guide and config is not None:
94
+ return list(config.locales or [])
95
+ return []
96
+
97
+
98
+ def resolve_layouts(explicit: Optional[str], guide: bool, config: Optional[SteppyConfig]) -> list[str]:
99
+ """Resolve the layout parametrization list — mirror of :func:`resolve_locales`
100
+ over ``config.screenshot.layouts``."""
101
+ if explicit:
102
+ return _split_csv(explicit, "layouts", "layout names")
103
+ if guide and config is not None:
104
+ return list(config.screenshot.layouts.keys())
105
+ return []
106
+
107
+
108
+ def select_scenario_slugs(
109
+ *,
110
+ scenarios_dir: Path,
111
+ tests_dir: Path,
112
+ scenario: Optional[str],
113
+ tag: Optional[list[str]],
114
+ guide: bool,
115
+ no_guide: bool,
116
+ ) -> list[str]:
117
+ """Return the selected scenario slugs for ``(scenario, tag, guide, no_guide)``.
118
+
119
+ Order: a single ``scenario`` (verifying its generated test file exists) wins;
120
+ otherwise parse ``scenarios_dir`` filtered by ``tag`` (``None`` = all), then keep
121
+ guide-enabled (``guide``) or non-guide (``no_guide``) scenarios. Returns slugs in
122
+ parse order. Raises :class:`SelectionError` on a missing test file / missing
123
+ scenarios dir / parse error instead of printing — the caller renders it.
124
+ """
125
+ if scenario:
126
+ test_file = tests_dir / f"test_{scenario}.py"
127
+ if not test_file.exists():
128
+ raise SelectionError(
129
+ f"Test file not found: {test_file}",
130
+ hint="Run [bold]steppy generate[/bold] first",
131
+ )
132
+ return [scenario]
133
+
134
+ if not scenarios_dir.exists():
135
+ raise SelectionError(f"Scenarios directory not found: {scenarios_dir}")
136
+
137
+ try:
138
+ all_scenarios = parse_scenarios_from_directory(scenarios_dir, tag_filter=tag)
139
+ except ParseError as e:
140
+ raise SelectionError(f"Parse error: {e}")
141
+
142
+ if guide:
143
+ all_scenarios = [s for s in all_scenarios if s.guide.enabled]
144
+ elif no_guide:
145
+ all_scenarios = [s for s in all_scenarios if not s.guide.enabled]
146
+
147
+ return [s.slug for s in all_scenarios]
148
+
149
+
150
+ def exclusive_groups_for(scenarios_dir: Path, slugs: list[str]) -> dict[str, Optional[str]]:
151
+ """Map each selected slug to its ``exclusive:`` group, or None.
152
+
153
+ Only slugs carrying a group appear in the result; the caller reads it with
154
+ ``.get(slug)``. Returns ``{}`` when fewer than two scenarios are selected —
155
+ exclusivity is vacuous then, and it keeps the ``--scenario X`` path from newly
156
+ parsing the whole directory (where a broken *sibling* file would start failing
157
+ a run that works today).
158
+
159
+ **Fails closed.** A parse failure raises :class:`SelectionError` rather than
160
+ degrading to ``{}``, unlike its neighbours in ``coordinator`` (which return an
161
+ empty set on ``ParseError`` and swallow ordering failures wholesale). Those are
162
+ optimisations; this is a correctness constraint, and silently losing it runs the
163
+ mutually-exclusive scenarios concurrently with nothing printed — precisely the
164
+ bug the field exists to prevent. Being strict is cheap: for any selection this
165
+ matters for, ``select_scenario_slugs`` parsed the same directory successfully
166
+ moments earlier.
167
+ """
168
+ if len(slugs) < 2:
169
+ return {}
170
+ if not scenarios_dir.exists():
171
+ raise SelectionError(f"Scenarios directory not found: {scenarios_dir}")
172
+
173
+ try:
174
+ parsed = parse_scenarios_from_directory(scenarios_dir)
175
+ except ParseError as e:
176
+ raise SelectionError(
177
+ f"Parse error while reading exclusion groups: {e}",
178
+ hint="Exclusion groups cannot be resolved, and running without them "
179
+ "would execute mutually-exclusive scenarios concurrently.",
180
+ )
181
+
182
+ selected = set(slugs)
183
+ return {s.slug: s.exclusive for s in parsed if s.slug in selected and s.exclusive}
steppy/sitemap.py ADDED
@@ -0,0 +1,205 @@
1
+ """``sitemap.xml`` serialisation — the one place the XML is built.
2
+
3
+ Why this is a module and not a Jinja template, stated honestly, because the
4
+ obvious reason is the weak one:
5
+
6
+ * **Escaping** buys less than it looks like it does. Jinja's ``| e`` and
7
+ ``ElementTree`` behave identically on ``&``, ``<``, ``>`` and ``]]>``, and
8
+ *both* pass control characters and NUL straight through into a document no
9
+ parser will accept. The one real bug ET closes is an **attribute** that was
10
+ interpolated with no filter at all: ``hreflang="{{ alt.hreflang }}"`` in the
11
+ template this replaces, under an ``autoescape=False`` environment. Here
12
+ escaping is unconditional rather than dependent on someone remembering a
13
+ filter, and the characters neither engine can escape are **rejected** (see
14
+ ``_XML_ILLEGAL``) instead of silently emitted.
15
+ * The real reasons: a schema-bearing machine format should have a generator
16
+ rather than a string template that any edit can push out of
17
+ well-formedness; ``<lastmod>`` and future per-URL extensions become
18
+ structural instead of textual; and the whole thing becomes a pure function
19
+ testable with no build, no ``tmp_path`` and no Jinja environment.
20
+
21
+ A project that wants more than this emits — ``<image:image>`` over a guide's
22
+ screenshots, ``<video:video>``, a ``<sitemapindex>`` split — still overrides
23
+ ``templates/static/sitemap.xml.j2`` and wins; see ``_write_static_sitemap``.
24
+
25
+ Stdlib-only leaf: imports nothing from ``steppy``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ import xml.etree.ElementTree as ET
32
+ from dataclasses import dataclass
33
+ from datetime import datetime
34
+ from typing import Iterable, Optional, Sequence
35
+
36
+ # ── Errors ───────────────────────────────────────────────────────────────────
37
+
38
+
39
+ class SitemapError(ValueError):
40
+ """Raised when a sitemap cannot be serialised as well-formed XML."""
41
+
42
+
43
+ # ── Constants ────────────────────────────────────────────────────────────────
44
+
45
+ SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"
46
+ XHTML_NS = "http://www.w3.org/1999/xhtml"
47
+
48
+ # ElementTree cannot produce this line. `encoding="utf-8"` emits single quotes
49
+ # and a lowercase name; `encoding="UTF-8"` suppresses the declaration entirely,
50
+ # because `ElementTree.write` only writes one when the encoding is *not*
51
+ # utf-8/us-ascii/unicode and "UTF-8".lower() is "utf-8". So it is prepended.
52
+ XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8"?>'
53
+
54
+ # sitemaps.org protocol limits. Exceeding either makes a search engine reject the
55
+ # *whole* file rather than the excess, and nothing about that is visible locally.
56
+ MAX_SITEMAP_URLS = 50_000
57
+ MAX_SITEMAP_BYTES = 50 * 1024 * 1024
58
+
59
+ # XML 1.0 section 2.2 Char production. Anything outside it has no escaped
60
+ # spelling -- a character reference to it is itself illegal -- so it can only be
61
+ # rejected. Lone surrogates land here too (D800-DFFF is excluded), which is what
62
+ # keeps them from reaching `Path.write_text` as a UnicodeEncodeError far from the
63
+ # value that caused it.
64
+ _XML_ILLEGAL = re.compile(r"[^\x09\x0A\x0D\x20-퟿-�\U00010000-\U0010FFFF]")
65
+
66
+ # W3C Datetime, as the sitemap protocol cites it, narrowed to the two shapes
67
+ # anyone means. A timezone is mandatory whenever a time is present -- an
68
+ # offset-less "2026-09-14T12:00:00" is not a valid W3C datetime, and it is the
69
+ # trap `datetime.fromisoformat` accepts without complaint. Bare `YYYY` and
70
+ # `YYYY-MM` are deliberately dropped: nobody means them and crawlers handle them
71
+ # poorly.
72
+ _LASTMOD_RE = re.compile(r"^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2}))?$")
73
+
74
+ _LASTMOD_HELP = (
75
+ "must be a W3C datetime: 'YYYY-MM-DD', or 'YYYY-MM-DDThh:mm:ssZ' / "
76
+ "'YYYY-MM-DDThh:mm:ss+hh:mm' (a timezone is required when a time is given)"
77
+ )
78
+
79
+
80
+ # ── Value types ──────────────────────────────────────────────────────────────
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Alternate:
85
+ """One ``<xhtml:link rel="alternate">`` row: a language and its URL."""
86
+
87
+ hreflang: str
88
+ href: str
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class SitemapUrl:
93
+ """One ``<url>`` entry.
94
+
95
+ ``lastmod`` is per-URL from day one even though the config sets a single
96
+ site-wide value today: the version that earns its keep is a guide stamped
97
+ with its own scenario's commit date, and leaving the field off would mean
98
+ rewriting every call site to add it later.
99
+ """
100
+
101
+ loc: str
102
+ alternates: tuple[Alternate, ...] = ()
103
+ lastmod: Optional[str] = None
104
+
105
+
106
+ # ── Validation ───────────────────────────────────────────────────────────────
107
+
108
+
109
+ def _check_xml_text(value: str, field: str) -> str:
110
+ """Reject text no XML document can carry, naming the field that carried it.
111
+
112
+ Rejecting rather than stripping is the point: a control character in a
113
+ ``loc`` means the URL was built from a bad slug, locale or path, and a
114
+ silently-stripped one yields a URL that 404s from a sitemap that looks fine.
115
+ """
116
+ if not isinstance(value, str):
117
+ raise SitemapError(f"sitemap {field} must be a string, got {type(value).__name__}")
118
+ bad = _XML_ILLEGAL.search(value)
119
+ if bad is not None:
120
+ raise SitemapError(
121
+ f"sitemap {field} contains a character XML cannot represent "
122
+ f"(U+{ord(bad.group()):04X}) and therefore cannot be escaped: {value!r}"
123
+ )
124
+ return value
125
+
126
+
127
+ def normalize_lastmod(value: object) -> str:
128
+ """Validate a ``<lastmod>`` value and return it unchanged.
129
+
130
+ Shape is checked by regex and *then* by ``datetime.fromisoformat``, because
131
+ neither is sufficient alone: the regex cannot tell that ``2026-02-31`` is
132
+ not a day, and ``fromisoformat`` happily accepts ``20260914``,
133
+ ``2026-W12-1``, a space separator, and a time with no timezone -- none of
134
+ which are valid W3C datetimes.
135
+ """
136
+ if not isinstance(value, str):
137
+ raise SitemapError(f"lastmod {_LASTMOD_HELP}, got {type(value).__name__}")
138
+ text = value.strip()
139
+ if not _LASTMOD_RE.match(text):
140
+ raise SitemapError(f"lastmod {_LASTMOD_HELP}, got {value!r}")
141
+ try:
142
+ datetime.fromisoformat(text.replace("Z", "+00:00"))
143
+ except ValueError as exc:
144
+ raise SitemapError(f"lastmod {_LASTMOD_HELP}, got {value!r} ({exc})") from exc
145
+ return text
146
+
147
+
148
+ def limit_warnings(url_count: int, byte_size: int) -> list[str]:
149
+ """Protocol-limit warnings for a rendered sitemap, most severe first.
150
+
151
+ Pure, so the leaf owns the thresholds and the wording while ``build.py``
152
+ owns the console. Empty when the document is within both limits.
153
+ """
154
+ warnings: list[str] = []
155
+ if url_count > MAX_SITEMAP_URLS:
156
+ warnings.append(
157
+ f"sitemap.xml lists {url_count:,} URLs, over the {MAX_SITEMAP_URLS:,}-URL sitemaps.org "
158
+ "limit — search engines reject the whole file, not the excess. Reduce the built "
159
+ "surface, or override templates/static/sitemap.xml.j2 to emit a <sitemapindex>."
160
+ )
161
+ if byte_size > MAX_SITEMAP_BYTES:
162
+ warnings.append(
163
+ f"sitemap.xml is {byte_size / 1024 / 1024:.1f} MB, over the "
164
+ f"{MAX_SITEMAP_BYTES // 1024 // 1024} MB uncompressed sitemaps.org limit — search "
165
+ "engines reject the whole file."
166
+ )
167
+ return warnings
168
+
169
+
170
+ # ── Serialisation ────────────────────────────────────────────────────────────
171
+
172
+
173
+ def render_sitemap(urls: Sequence[SitemapUrl] | Iterable[SitemapUrl], *, default_lastmod: Optional[str] = None) -> str:
174
+ """Serialise ``urls`` as a sitemaps.org 0.9 document.
175
+
176
+ Element order inside ``<url>`` is ``<loc>``, the ``xhtml:link`` alternates,
177
+ then ``<lastmod>`` — the shape Google's own hreflang example uses, and the
178
+ layout the Jinja template this replaces produced.
179
+ """
180
+ # Namespaces go in as ordinary attributes and the tags carry literal
181
+ # prefixes, rather than building a namespace-aware tree. Two reasons, both
182
+ # dead ends otherwise: `tostring(default_namespace=...)` raises outright
183
+ # ("cannot use non-qualified names with default_namespace option") because
184
+ # rel/hreflang/href are un-namespaced attributes; and `register_namespace`
185
+ # mutates `ET._namespace_map` process-wide and permanently, which would make
186
+ # any later XML-serialising test in the same pytest session order-dependent.
187
+ # The output is byte-identical to the registered form. The tree is therefore
188
+ # not namespace-aware, but it never leaves this function.
189
+ root = ET.Element("urlset", {"xmlns": SITEMAP_NS, "xmlns:xhtml": XHTML_NS})
190
+ count = 0
191
+ for entry in urls:
192
+ count += 1
193
+ url_el = ET.SubElement(root, "url")
194
+ ET.SubElement(url_el, "loc").text = _check_xml_text(entry.loc, "loc")
195
+ for alt in entry.alternates:
196
+ link = ET.SubElement(url_el, "xhtml:link")
197
+ link.set("rel", "alternate")
198
+ link.set("hreflang", _check_xml_text(alt.hreflang, "hreflang"))
199
+ link.set("href", _check_xml_text(alt.href, "href"))
200
+ lastmod = entry.lastmod if entry.lastmod is not None else default_lastmod
201
+ if lastmod is not None:
202
+ ET.SubElement(url_el, "lastmod").text = _check_xml_text(lastmod, "lastmod")
203
+
204
+ ET.indent(root, space=" ")
205
+ return f"{XML_DECLARATION}\n{ET.tostring(root, encoding='unicode')}\n"