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