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/tours/model.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The compiled intermediate representation and the replay hand-off.
|
|
2
|
+
|
|
3
|
+
Pure data: frozen dataclasses with no behaviour beyond small lookups, so the
|
|
4
|
+
inference, text, emission and replay layers can be tested in isolation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Literal, Optional
|
|
11
|
+
|
|
12
|
+
Disposition = Literal["setup", "tour", "run", "drop"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TourStepPlan:
|
|
17
|
+
"""One step of a tour, before text resolution. ``step_id`` is set by ``ids``."""
|
|
18
|
+
|
|
19
|
+
step_index: int # index into scenario.steps
|
|
20
|
+
step_number: int
|
|
21
|
+
step_title: str
|
|
22
|
+
action: str
|
|
23
|
+
target: Optional[str] # test-id value, or None for a centred step
|
|
24
|
+
source_selector: Optional[str] # authored selector CI acts on (None when centred)
|
|
25
|
+
advance: str
|
|
26
|
+
wait_for: Optional[str] = None
|
|
27
|
+
placement: Optional[str] = None
|
|
28
|
+
layouts: Optional[tuple[str, ...]] = None
|
|
29
|
+
field: Optional[str] = None # fill-form: the `fields` key this sub-step fills
|
|
30
|
+
step_id: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ReplayTourStep:
|
|
35
|
+
"""A tour step as the replay test sees it."""
|
|
36
|
+
|
|
37
|
+
step_id: str
|
|
38
|
+
index: int # 0-based position in the tour (identical in every locale)
|
|
39
|
+
target: Optional[str]
|
|
40
|
+
wait_for: Optional[str]
|
|
41
|
+
advance: str
|
|
42
|
+
layouts: Optional[tuple[str, ...]]
|
|
43
|
+
field: Optional[str] = None
|
|
44
|
+
source_selector: Optional[str] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ReplayEntry:
|
|
49
|
+
"""What the replay does with one scenario step (aligned 1:1 with scenario.steps)."""
|
|
50
|
+
|
|
51
|
+
step_number: int
|
|
52
|
+
disposition: Disposition
|
|
53
|
+
reason: str
|
|
54
|
+
tour_steps: tuple[ReplayTourStep, ...] = ()
|
|
55
|
+
reloads_page: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ReplayPlan:
|
|
60
|
+
"""Everything the replay renderer needs; produced by the compiler."""
|
|
61
|
+
|
|
62
|
+
scenario_slug: str
|
|
63
|
+
tour_id: str
|
|
64
|
+
role: str
|
|
65
|
+
auth: bool
|
|
66
|
+
default_locale: str
|
|
67
|
+
locales: tuple[str, ...]
|
|
68
|
+
entry_route: Optional[str]
|
|
69
|
+
entry_params: tuple[tuple[str, str], ...] # (param, _interp template) in route order
|
|
70
|
+
entry_step_number: Optional[int]
|
|
71
|
+
start_index: int
|
|
72
|
+
entries: tuple[ReplayEntry, ...]
|
|
73
|
+
tour_step_count: int
|
|
74
|
+
layouts: tuple[tuple[str, str], ...] = () # (layout, media query) for layouts kept steps use
|
|
75
|
+
test_id_attribute: str = "data-testid"
|
|
76
|
+
|
|
77
|
+
def entry_for(self, step_number: int) -> ReplayEntry:
|
|
78
|
+
for entry in self.entries:
|
|
79
|
+
if entry.step_number == step_number:
|
|
80
|
+
return entry
|
|
81
|
+
raise KeyError(step_number)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ScenarioInference:
|
|
86
|
+
"""The locale-independent result of inferring one scenario."""
|
|
87
|
+
|
|
88
|
+
role: str
|
|
89
|
+
entry_route: Optional[str]
|
|
90
|
+
entry_params: tuple[tuple[str, str], ...]
|
|
91
|
+
entry_step_index: Optional[int]
|
|
92
|
+
start_index: int
|
|
93
|
+
steps: list[TourStepPlan]
|
|
94
|
+
entries: list[ReplayEntry] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class CompiledTour:
|
|
99
|
+
"""One tour, ready to serialize: inference + per-locale text."""
|
|
100
|
+
|
|
101
|
+
id: str
|
|
102
|
+
scenario_slug: str
|
|
103
|
+
role: str
|
|
104
|
+
entry_route: Optional[str]
|
|
105
|
+
steps: list[TourStepPlan]
|
|
106
|
+
locales: tuple[str, ...] # emitted locales, in config order
|
|
107
|
+
titles: dict[str, str] # locale -> tour title
|
|
108
|
+
step_texts: dict[str, list[tuple[Optional[str], Optional[str]]]] # locale -> [(title, text)] per step
|
|
109
|
+
guide_urls: dict[str, str] # locale -> guideUrl (absent when no guide link)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class TourCompileResult:
|
|
114
|
+
tours: list[CompiledTour]
|
|
115
|
+
replay_plans: dict[str, ReplayPlan]
|
|
116
|
+
diagnostics: list # list[Diagnostic]
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def has_errors(self) -> bool:
|
|
120
|
+
return any(d.severity == "error" for d in self.diagnostics)
|
steppy/tours/paths.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Filesystem guards for tour output: where we may write, and what we may delete.
|
|
2
|
+
|
|
3
|
+
Tour files land in two kinds of place: the canonical tree under
|
|
4
|
+
``output.tours_dir`` and app-assets directories inside *someone else's*
|
|
5
|
+
repository. Pruning stale files there is only safe when the directory is
|
|
6
|
+
provably ours, so the rules are strict:
|
|
7
|
+
|
|
8
|
+
* a directory is ours only when its ``tours.manifest.json`` carries our
|
|
9
|
+
``kind`` marker — a foreign or unreadable manifest stops everything;
|
|
10
|
+
* only files that previous manifest *listed* (and that look like tour file
|
|
11
|
+
names) are ever deleted — never a glob — and an existing file is only ever
|
|
12
|
+
overwritten or deleted when it is itself a tour file (its JSON ``kind``);
|
|
13
|
+
* no target may be, contain, or sit inside the project root, the scenarios,
|
|
14
|
+
the generated tests, the captures, or another target.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import tempfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Iterable, Optional
|
|
25
|
+
|
|
26
|
+
from ..naming import TOUR_FILE_KIND, TOURS_MANIFEST_FILENAME, TOURS_MANIFEST_KIND
|
|
27
|
+
from ..tour_spec import TOUR_FILE_NAME_PATTERN
|
|
28
|
+
|
|
29
|
+
#: Names that betray a project or package root — never write into a directory holding one.
|
|
30
|
+
GUARDED_NAMES = ("config.yaml", "pyproject.toml", ".git", "scenarios", "src", "package.json", "angular.json")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TourPathError(ValueError):
|
|
34
|
+
"""A tour target or prune would touch a path it must not."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolved(path: Path) -> Path:
|
|
38
|
+
return Path(os.path.abspath(path))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def overlaps(a: Path, b: Path) -> bool:
|
|
42
|
+
"""True when ``a`` and ``b`` are the same path or one contains the other."""
|
|
43
|
+
a, b = _resolved(a), _resolved(b)
|
|
44
|
+
return a == b or a in b.parents or b in a.parents
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_emit_dir(path: Path, *, project_root: Path, owned_roots: Iterable[Path], label: str) -> None:
|
|
48
|
+
"""Refuse a write target that could clobber something that is not ours."""
|
|
49
|
+
target = _resolved(path)
|
|
50
|
+
root = _resolved(project_root)
|
|
51
|
+
if target == Path(target.anchor):
|
|
52
|
+
raise TourPathError(f"{label} {path} is a filesystem root")
|
|
53
|
+
if target == root or target in root.parents:
|
|
54
|
+
raise TourPathError(f"{label} {path} is the project root or one of its parents")
|
|
55
|
+
for owned in owned_roots:
|
|
56
|
+
if overlaps(target, owned):
|
|
57
|
+
raise TourPathError(f"{label} {path} overlaps {owned}, which Steppy owns for something else")
|
|
58
|
+
if target.is_dir():
|
|
59
|
+
clash = [name for name in GUARDED_NAMES if (target / name).exists()]
|
|
60
|
+
if clash:
|
|
61
|
+
raise TourPathError(
|
|
62
|
+
f"{label} {path} contains {', '.join(clash)} — it looks like a project directory, not a tours folder"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_marker(directory: Path) -> Optional[dict]:
|
|
67
|
+
"""The previous manifest if ``directory`` is ours, None if it has none; raise if foreign."""
|
|
68
|
+
manifest = directory / TOURS_MANIFEST_FILENAME
|
|
69
|
+
if not manifest.exists():
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(manifest.read_bytes().decode("utf-8"))
|
|
73
|
+
except (ValueError, UnicodeDecodeError, OSError) as exc:
|
|
74
|
+
raise TourPathError(f"{manifest} is not readable JSON ({exc}); refusing to touch {directory}") from exc
|
|
75
|
+
if not isinstance(data, dict) or data.get("kind") != TOURS_MANIFEST_KIND:
|
|
76
|
+
raise TourPathError(
|
|
77
|
+
f"{manifest} is not a Steppy tours manifest; refusing to touch {directory} — delete it manually"
|
|
78
|
+
)
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def listed_files(manifest: Optional[dict]) -> set[str]:
|
|
83
|
+
"""Tour file names a (marker-checked) manifest lists; anything path-like is ignored."""
|
|
84
|
+
if not manifest:
|
|
85
|
+
return set()
|
|
86
|
+
names: set[str] = set()
|
|
87
|
+
for tour in manifest.get("tours") or []:
|
|
88
|
+
if not isinstance(tour, dict):
|
|
89
|
+
continue
|
|
90
|
+
for name in (tour.get("files") or {}).values():
|
|
91
|
+
if isinstance(name, str) and re.fullmatch(TOUR_FILE_NAME_PATTERN, name):
|
|
92
|
+
names.add(name)
|
|
93
|
+
return names
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_tour_file(path: Path) -> bool:
|
|
97
|
+
"""True when ``path`` is a JSON file carrying the tour-file ``kind`` marker."""
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(path.read_bytes().decode("utf-8"))
|
|
100
|
+
except (ValueError, UnicodeDecodeError, OSError):
|
|
101
|
+
return False
|
|
102
|
+
return isinstance(data, dict) and data.get("kind") == TOUR_FILE_KIND
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_tour_file(path: Path, verb: str) -> None:
|
|
106
|
+
"""Refuse to ``verb`` (overwrite/delete) a file that is not a Steppy tour file."""
|
|
107
|
+
if not is_tour_file(path):
|
|
108
|
+
raise TourPathError(
|
|
109
|
+
f"{path} is not a Steppy tour file; refusing to {verb} it — move it away or delete it manually"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
114
|
+
"""Write ``data`` to ``path`` via a sibling temp file + ``os.replace``."""
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
117
|
+
try:
|
|
118
|
+
with os.fdopen(fd, "wb") as handle:
|
|
119
|
+
handle.write(data)
|
|
120
|
+
os.replace(tmp, path)
|
|
121
|
+
except BaseException:
|
|
122
|
+
try:
|
|
123
|
+
os.unlink(tmp)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
raise
|
steppy/tours/pipeline.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""The entry point ``steppy generate`` and ``steppy tours`` share.
|
|
2
|
+
|
|
3
|
+
:func:`prepare_tours` does everything that can fail — parsing, compiling,
|
|
4
|
+
resolving the build id, serializing, self-checking, guarding every target and
|
|
5
|
+
reading every marker — **before** anything is written, so a refusal never
|
|
6
|
+
leaves half an output behind. :meth:`TourPipeline.emit` then writes.
|
|
7
|
+
|
|
8
|
+
Tours are always compiled from **every** scenario in the directory, even when
|
|
9
|
+
``generate`` was asked for one file or a tag: a manifest must never depend on
|
|
10
|
+
CLI arguments.
|
|
11
|
+
|
|
12
|
+
``mode="check"`` (``steppy tours --check``) plans only the app-assets
|
|
13
|
+
targets — the copies an app commits — and compares their *content* for the
|
|
14
|
+
build id each one records: the canonical tree is a gitignored build output, and
|
|
15
|
+
committed files always carry an older id under ``git-sha`` or a CI-stamped id.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Literal, Optional
|
|
23
|
+
|
|
24
|
+
from ..buildid import BuildIdError, is_build_id, resolve_build_id
|
|
25
|
+
from ..captures import DEFAULT_GUIDES_DIR, DEFAULT_SCREENSHOTS_DIR, resolve_output_dir
|
|
26
|
+
from ..contracts.tours import TourDataInvalid
|
|
27
|
+
from ..models import SteppyConfig, Scenario
|
|
28
|
+
from ..naming import TOURS_MANIFEST_FILENAME
|
|
29
|
+
from ..parser import ParseError, parse_scenario_file, validate_scenario_layouts, validate_scenario_roles
|
|
30
|
+
from .compiler import compile_tours, opted_in
|
|
31
|
+
from .diagnostics import Diagnostic, sort_diagnostics
|
|
32
|
+
from .emit import (
|
|
33
|
+
DirPlan,
|
|
34
|
+
FileStatus,
|
|
35
|
+
TourArtifacts,
|
|
36
|
+
apply_dir,
|
|
37
|
+
plan_dir,
|
|
38
|
+
remove_build_dir,
|
|
39
|
+
render_artifacts,
|
|
40
|
+
sibling_build_dirs,
|
|
41
|
+
)
|
|
42
|
+
from .model import ReplayPlan, TourCompileResult
|
|
43
|
+
from .paths import TourPathError, check_emit_dir, read_marker
|
|
44
|
+
from .settings import ToursSettings, settings_diagnostics
|
|
45
|
+
|
|
46
|
+
Mode = Literal["generate", "tours", "validate", "check"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class TourPipeline:
|
|
51
|
+
settings: ToursSettings
|
|
52
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
53
|
+
result: Optional[TourCompileResult] = None
|
|
54
|
+
build_id: Optional[str] = None
|
|
55
|
+
artifacts: Optional[TourArtifacts] = None
|
|
56
|
+
plans: list[DirPlan] = field(default_factory=list)
|
|
57
|
+
stale_builds: list[Path] = field(default_factory=list)
|
|
58
|
+
replay_plans: dict[str, ReplayPlan] = field(default_factory=dict)
|
|
59
|
+
#: ``check`` mode: app-assets dir → the build id its manifest records (None: no tour files there).
|
|
60
|
+
checked: dict[Path, Optional[str]] = field(default_factory=dict)
|
|
61
|
+
#: ``check`` mode: why the configured build id could not be resolved here, if it could not.
|
|
62
|
+
build_id_error: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def has_errors(self) -> bool:
|
|
66
|
+
return any(d.severity == "error" for d in self.diagnostics)
|
|
67
|
+
|
|
68
|
+
def statuses(self) -> dict[str, str]:
|
|
69
|
+
out: dict[str, str] = {}
|
|
70
|
+
for plan in self.plans:
|
|
71
|
+
for status in plan.statuses:
|
|
72
|
+
out[str(status.path)] = status.status
|
|
73
|
+
for directory in self.stale_builds:
|
|
74
|
+
out[str(directory)] = "removed"
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
def emit(self, dry_run: bool = False) -> dict[str, str]:
|
|
78
|
+
"""Write every planned file (unless ``dry_run``); return path → status."""
|
|
79
|
+
statuses = self.statuses()
|
|
80
|
+
if dry_run or self.has_errors:
|
|
81
|
+
return statuses
|
|
82
|
+
for plan in self.plans:
|
|
83
|
+
apply_dir(plan)
|
|
84
|
+
for directory in self.stale_builds:
|
|
85
|
+
remove_build_dir(directory)
|
|
86
|
+
return statuses
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _err(code: str, message: str, scenario: Optional[str] = None) -> Diagnostic:
|
|
90
|
+
return Diagnostic("error", code, message, scenario=scenario)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_dir(scenarios_dir: Path, diagnostics: list[Diagnostic]) -> list[Scenario]:
|
|
94
|
+
out = []
|
|
95
|
+
for path in sorted(scenarios_dir.glob("*.md")):
|
|
96
|
+
try:
|
|
97
|
+
out.append(parse_scenario_file(path))
|
|
98
|
+
except ParseError as exc:
|
|
99
|
+
first = str(exc).splitlines()[0] if str(exc) else repr(exc)
|
|
100
|
+
diagnostics.append(
|
|
101
|
+
_err(
|
|
102
|
+
"scenario-unreadable",
|
|
103
|
+
f"{path.name} does not parse ({first}); in-app tours are compiled from every scenario, "
|
|
104
|
+
"so the tour set would be incomplete",
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def prepare_tours(
|
|
111
|
+
config: SteppyConfig,
|
|
112
|
+
*,
|
|
113
|
+
project_root: Path,
|
|
114
|
+
scenarios_dir: Path,
|
|
115
|
+
selected: Optional[list[Scenario]] = None,
|
|
116
|
+
all_scenarios: Optional[list[Scenario]] = None,
|
|
117
|
+
build_id_override: Optional[str] = None,
|
|
118
|
+
mode: Mode = "generate",
|
|
119
|
+
) -> TourPipeline:
|
|
120
|
+
root = Path(project_root).resolve()
|
|
121
|
+
settings = ToursSettings.from_config(config, root)
|
|
122
|
+
pipeline = TourPipeline(settings=settings)
|
|
123
|
+
diags = pipeline.diagnostics
|
|
124
|
+
diags.extend(settings_diagnostics(config, settings))
|
|
125
|
+
|
|
126
|
+
scenarios = all_scenarios if all_scenarios is not None else _read_dir(scenarios_dir, diags)
|
|
127
|
+
by_slug = {s.slug: s for s in scenarios}
|
|
128
|
+
|
|
129
|
+
resolved_dir = scenarios_dir.resolve()
|
|
130
|
+
selected_slugs = None
|
|
131
|
+
if selected is not None:
|
|
132
|
+
selected_slugs = {s.slug for s in selected}
|
|
133
|
+
for s in opted_in(selected, settings):
|
|
134
|
+
path = Path(s.file_path).resolve() if s.file_path else None
|
|
135
|
+
if path is None or path.parent != resolved_dir:
|
|
136
|
+
diags.append(
|
|
137
|
+
_err(
|
|
138
|
+
"scenario-unreadable",
|
|
139
|
+
f"{s.slug} opts into tours but is not in {scenarios_dir}; tours are compiled from that "
|
|
140
|
+
"directory, so run the command with --scenarios-dir pointing at it",
|
|
141
|
+
scenario=s.slug,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
by_slug.setdefault(s.slug, s)
|
|
145
|
+
|
|
146
|
+
roles, layouts = set(config.apps), set(config.screenshot.layouts)
|
|
147
|
+
for s in opted_in(list(by_slug.values()), settings):
|
|
148
|
+
if selected_slugs is not None and s.slug in selected_slugs:
|
|
149
|
+
continue # the caller already validated the selection
|
|
150
|
+
for problem in validate_scenario_roles(s, roles) + validate_scenario_layouts(s, layouts):
|
|
151
|
+
diags.append(_err("scenario-unreadable", f"{problem}", scenario=s.slug))
|
|
152
|
+
|
|
153
|
+
result = compile_tours(list(by_slug.values()), settings)
|
|
154
|
+
pipeline.result = result
|
|
155
|
+
diags.extend(result.diagnostics)
|
|
156
|
+
if not result.tours and not result.has_errors:
|
|
157
|
+
diags.append(
|
|
158
|
+
Diagnostic(
|
|
159
|
+
"warning",
|
|
160
|
+
"no-tours",
|
|
161
|
+
"tours are enabled but no scenario opts in (`tour: true`); an empty manifest is written",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
if settings.replay:
|
|
165
|
+
pipeline.replay_plans = {
|
|
166
|
+
slug: plan for slug, plan in result.replay_plans.items() if selected_slugs is None or slug in selected_slugs
|
|
167
|
+
}
|
|
168
|
+
if pipeline.has_errors or mode == "validate":
|
|
169
|
+
pipeline.diagnostics[:] = sort_diagnostics(diags)
|
|
170
|
+
return pipeline
|
|
171
|
+
|
|
172
|
+
owned = [
|
|
173
|
+
resolve_output_dir(root, config.output.screenshots_dir, DEFAULT_SCREENSHOTS_DIR),
|
|
174
|
+
resolve_output_dir(root, config.output.guides_dir, DEFAULT_GUIDES_DIR),
|
|
175
|
+
resolved_dir,
|
|
176
|
+
root / "tests",
|
|
177
|
+
root / ".steppy",
|
|
178
|
+
root / ".auth",
|
|
179
|
+
root / ".git",
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
pipeline.build_id = resolve_build_id(settings.build_id_raw, root, override=build_id_override)
|
|
184
|
+
except BuildIdError as exc:
|
|
185
|
+
if mode == "check":
|
|
186
|
+
pipeline.build_id_error = str(exc) # only shown in a note: check compares recorded ids
|
|
187
|
+
else:
|
|
188
|
+
diags.append(_err("build-id", str(exc)))
|
|
189
|
+
pipeline.diagnostics[:] = sort_diagnostics(diags)
|
|
190
|
+
return pipeline
|
|
191
|
+
|
|
192
|
+
if mode == "check":
|
|
193
|
+
_prepare_check(pipeline, result, root, owned)
|
|
194
|
+
pipeline.diagnostics[:] = sort_diagnostics(diags)
|
|
195
|
+
return pipeline
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
pipeline.artifacts = render_artifacts(result, settings, pipeline.build_id)
|
|
199
|
+
except TourDataInvalid as exc:
|
|
200
|
+
diags.append(_err("limit-exceeded", str(exc)))
|
|
201
|
+
pipeline.diagnostics[:] = sort_diagnostics(diags)
|
|
202
|
+
return pipeline
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
check_emit_dir(
|
|
206
|
+
settings.tours_root,
|
|
207
|
+
project_root=root,
|
|
208
|
+
owned_roots=[*owned, *settings.app_assets_dirs],
|
|
209
|
+
label="output.tours_dir",
|
|
210
|
+
)
|
|
211
|
+
canonical = settings.tours_root / pipeline.build_id
|
|
212
|
+
pipeline.plans.append(plan_dir(canonical, pipeline.artifacts))
|
|
213
|
+
pipeline.stale_builds = sibling_build_dirs(settings.tours_root, pipeline.build_id)
|
|
214
|
+
for directory in settings.app_assets_dirs:
|
|
215
|
+
others = [d for d in settings.app_assets_dirs if d != directory]
|
|
216
|
+
check_emit_dir(
|
|
217
|
+
directory,
|
|
218
|
+
project_root=root,
|
|
219
|
+
owned_roots=[*owned, settings.tours_root, *others],
|
|
220
|
+
label="tours app-assets target",
|
|
221
|
+
)
|
|
222
|
+
if not directory.parent.is_dir():
|
|
223
|
+
message = f"the app-assets target {directory} has no parent directory (is the app checked out?)"
|
|
224
|
+
if mode == "generate":
|
|
225
|
+
diags.append(Diagnostic("warning", "app-assets-skipped", message + "; skipped"))
|
|
226
|
+
continue
|
|
227
|
+
diags.append(_err("target-path", message))
|
|
228
|
+
continue
|
|
229
|
+
pipeline.plans.append(plan_dir(directory, pipeline.artifacts))
|
|
230
|
+
except TourPathError as exc:
|
|
231
|
+
diags.append(_err("target-path", str(exc)))
|
|
232
|
+
pipeline.diagnostics[:] = sort_diagnostics(diags)
|
|
233
|
+
return pipeline
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _prepare_check(pipeline: TourPipeline, result: TourCompileResult, root: Path, owned: list[Path]) -> None:
|
|
237
|
+
"""Plan the app-assets targets only, each against the build id its own manifest records."""
|
|
238
|
+
settings = pipeline.settings
|
|
239
|
+
diags = pipeline.diagnostics
|
|
240
|
+
rendered: dict[str, TourArtifacts] = {}
|
|
241
|
+
for directory in settings.app_assets_dirs:
|
|
242
|
+
manifest_path = directory / TOURS_MANIFEST_FILENAME
|
|
243
|
+
try:
|
|
244
|
+
others = [d for d in settings.app_assets_dirs if d != directory]
|
|
245
|
+
check_emit_dir(
|
|
246
|
+
directory,
|
|
247
|
+
project_root=root,
|
|
248
|
+
owned_roots=[*owned, settings.tours_root, *others],
|
|
249
|
+
label="tours app-assets target",
|
|
250
|
+
)
|
|
251
|
+
if not directory.parent.is_dir():
|
|
252
|
+
diags.append(
|
|
253
|
+
_err(
|
|
254
|
+
"target-path",
|
|
255
|
+
f"the app-assets target {directory} has no parent directory (is the app checked out?)",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
continue
|
|
259
|
+
previous = read_marker(directory) if directory.is_dir() else None
|
|
260
|
+
if previous is None:
|
|
261
|
+
pipeline.checked[directory] = None
|
|
262
|
+
pipeline.plans.append(DirPlan(directory=directory, statuses=[FileStatus(manifest_path, "missing")]))
|
|
263
|
+
continue
|
|
264
|
+
recorded = previous.get("buildId")
|
|
265
|
+
if not is_build_id(recorded):
|
|
266
|
+
diags.append(
|
|
267
|
+
_err("build-id", f"{manifest_path} records the build id {recorded!r}, which is not a valid one")
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
pipeline.checked[directory] = recorded
|
|
271
|
+
if recorded not in rendered:
|
|
272
|
+
rendered[recorded] = render_artifacts(result, settings, recorded)
|
|
273
|
+
pipeline.plans.append(plan_dir(directory, rendered[recorded]))
|
|
274
|
+
except TourDataInvalid as exc:
|
|
275
|
+
diags.append(_err("limit-exceeded", str(exc)))
|
|
276
|
+
except TourPathError as exc:
|
|
277
|
+
diags.append(_err("target-path", str(exc)))
|
steppy/tours/publish.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The ``steppy build`` side of in-app tours: the ``guide`` target.
|
|
2
|
+
|
|
3
|
+
``steppy generate`` / ``steppy tours`` write the canonical tree
|
|
4
|
+
(``output/tours/<build-id>/``); a static guide build republishes it byte for
|
|
5
|
+
byte at ``<site>/tours/<build-id>/``, where an app in remote mode fetches it.
|
|
6
|
+
``build`` never compiles tours itself — it needs no scenario parsing for this,
|
|
7
|
+
and a guide site must serve exactly the bytes whose hashes the apps bundle.
|
|
8
|
+
|
|
9
|
+
Everything here runs **before** the build wipes its output directory: a tree
|
|
10
|
+
that is missing, stale (a file whose SRI no longer matches) or edited fails the
|
|
11
|
+
build while the previous site is still intact.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable, Optional
|
|
20
|
+
|
|
21
|
+
from ..buildid import BuildIdError, resolve_build_id
|
|
22
|
+
from ..contracts.tours import TourDataInvalid, TourSchemaUnsupported, check_consistency, parse_tours_manifest
|
|
23
|
+
from ..naming import GUIDE_SITE_TOURS_DIR, TOURS_MANIFEST_FILENAME
|
|
24
|
+
from .paths import TourPathError, overlaps, read_marker
|
|
25
|
+
from .settings import ToursSettings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TourPublishError(ValueError):
|
|
29
|
+
"""The guide target cannot be published as configured; the build must not wipe anything."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class GuidePublishPlan:
|
|
34
|
+
build_id: str
|
|
35
|
+
#: (file name, bytes): tour files first, the manifest last.
|
|
36
|
+
files: tuple[tuple[str, bytes], ...]
|
|
37
|
+
tour_ids: tuple[str, ...]
|
|
38
|
+
warnings: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def site_dir(self) -> str:
|
|
42
|
+
"""The site-relative directory the tree is published under."""
|
|
43
|
+
return f"{GUIDE_SITE_TOURS_DIR}/{self.build_id}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_build_overlaps(settings: ToursSettings, output_dir: Path, assets_dir: Path) -> None:
|
|
47
|
+
"""The build rmtrees ``output_dir`` and ``assets_dir``: no tour directory may live there."""
|
|
48
|
+
tour_dirs = [("output.tours_dir", settings.tours_root)]
|
|
49
|
+
tour_dirs += [("an app-assets target", d) for d in settings.app_assets_dirs]
|
|
50
|
+
for label, tour_dir in tour_dirs:
|
|
51
|
+
for build_label, build_dir in (("outputDir", output_dir), ("assetsDir", assets_dir)):
|
|
52
|
+
if overlaps(tour_dir, build_dir):
|
|
53
|
+
raise TourPublishError(
|
|
54
|
+
f"tours: {label} ({tour_dir}) overlaps the build's {build_label} ({build_dir}), which "
|
|
55
|
+
"`steppy build` deletes before writing — move one of them"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def plan_guide_publish(
|
|
60
|
+
settings: ToursSettings,
|
|
61
|
+
*,
|
|
62
|
+
guide_slugs: Iterable[str],
|
|
63
|
+
build_locales: Iterable[str],
|
|
64
|
+
) -> GuidePublishPlan:
|
|
65
|
+
"""Read and verify the canonical tree for the configured build id."""
|
|
66
|
+
try:
|
|
67
|
+
build_id = resolve_build_id(settings.build_id_raw, settings.project_root)
|
|
68
|
+
except BuildIdError as exc:
|
|
69
|
+
raise TourPublishError(f"tours: {exc}") from exc
|
|
70
|
+
tree = settings.tours_root / build_id
|
|
71
|
+
manifest_path = tree / TOURS_MANIFEST_FILENAME
|
|
72
|
+
if not manifest_path.is_file():
|
|
73
|
+
raise TourPublishError(
|
|
74
|
+
f"In-app tours for build {build_id} not found at {tree}. Run `steppy generate` or "
|
|
75
|
+
"`steppy tours` first (with the same tours.build_id)."
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
read_marker(tree)
|
|
79
|
+
raw = manifest_path.read_bytes()
|
|
80
|
+
manifest = parse_tours_manifest(json.loads(raw.decode("utf-8")))
|
|
81
|
+
except (TourPathError, TourDataInvalid, TourSchemaUnsupported, ValueError) as exc:
|
|
82
|
+
raise TourPublishError(f"tours: {manifest_path} is not a valid tours manifest: {exc}") from exc
|
|
83
|
+
if manifest.buildId != build_id:
|
|
84
|
+
raise TourPublishError(f"tours: {manifest_path} is for build {manifest.buildId!r}, expected {build_id!r}")
|
|
85
|
+
|
|
86
|
+
names = sorted({name for tour in manifest.tours for name in tour.files.values()})
|
|
87
|
+
files = {name: (tree / name).read_bytes() for name in names if (tree / name).is_file()}
|
|
88
|
+
problems = check_consistency(manifest, files)
|
|
89
|
+
if problems:
|
|
90
|
+
raise TourPublishError(
|
|
91
|
+
f"tours: the tree at {tree} is stale or was edited ({problems[0]}); re-run `steppy tours`"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
warnings = list(_guide_link_warnings(settings, manifest, files, set(guide_slugs), set(build_locales)))
|
|
95
|
+
return GuidePublishPlan(
|
|
96
|
+
build_id=build_id,
|
|
97
|
+
files=(*((name, files[name]) for name in names), (TOURS_MANIFEST_FILENAME, raw)),
|
|
98
|
+
tour_ids=tuple(tour.id for tour in manifest.tours),
|
|
99
|
+
warnings=tuple(warnings),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _guide_link_warnings(
|
|
104
|
+
settings: ToursSettings,
|
|
105
|
+
manifest,
|
|
106
|
+
files: dict[str, bytes],
|
|
107
|
+
guide_slugs: set[str],
|
|
108
|
+
build_locales: set[str],
|
|
109
|
+
) -> Iterable[str]:
|
|
110
|
+
"""A tour's "open the full guide" link that this build will not satisfy."""
|
|
111
|
+
base = (settings.guide_base_url or "").rstrip("/") + "/"
|
|
112
|
+
seen: set[str] = set()
|
|
113
|
+
for tour in manifest.tours:
|
|
114
|
+
for name in tour.files.values():
|
|
115
|
+
url: Optional[str] = json.loads(files[name].decode("utf-8")).get("guideUrl")
|
|
116
|
+
if not url or not url.startswith(base) or url in seen:
|
|
117
|
+
continue
|
|
118
|
+
seen.add(url)
|
|
119
|
+
slug, _, page = url[len(base) :].partition("/")
|
|
120
|
+
locale = page.removesuffix(".html")
|
|
121
|
+
if slug not in guide_slugs:
|
|
122
|
+
yield f"tours: tour {tour.id!r} links to guide {slug!r}, which this build does not publish ({url})"
|
|
123
|
+
elif locale not in build_locales:
|
|
124
|
+
yield f"tours: tour {tour.id!r} links to the {locale!r} page of {slug!r}, which this build has no locale for"
|