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
@@ -0,0 +1,141 @@
1
+ """Group failures by root cause instead of listing them one by one.
2
+
3
+ With hundreds of scenarios, one broken selector shows up as forty stack traces.
4
+ Reading them means scrolling past thirty-nine copies of the same information to
5
+ discover there were only three real problems. This module collapses that: it
6
+ normalizes each failure tail into a shape that ignores run-specific noise
7
+ (addresses, timings, ids, paths) and hashes it, so identical causes land in one
8
+ bucket.
9
+
10
+ The normalizer is deliberately aggressive. A false merge costs the reader one
11
+ extra glance at the grouped list; a false split puts them back where they
12
+ started. Everything here is pure — no I/O, no Playwright, no pytest — so the
13
+ substitutions can be unit-tested directly against real tails.
14
+
15
+ Lazy-imported from the renderer, like the rest of ``_parallel``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import re
22
+ from dataclasses import dataclass, field
23
+
24
+
25
+ # Order matters: the more specific patterns run before the general ones, so a
26
+ # `0x7f…` address isn't first mangled into `<num>x<num>`.
27
+ _SCRUBBERS: tuple[tuple[re.Pattern, str], ...] = (
28
+ # Windows + POSIX absolute paths → keep only the basename.
29
+ (re.compile(r"(?:[A-Za-z]:)?[\\/](?:[\w.\-]+[\\/])+([\w.\-]+\.py)"), r"<path>/\1"),
30
+ # ISO-ish timestamps and clock times.
31
+ (re.compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?"), "<ts>"),
32
+ (re.compile(r"\b\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\b"), "<time>"),
33
+ # UUIDs, then long hex blobs (object addresses, sha digests, container ids).
34
+ (re.compile(r"\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b"), "<uuid>"),
35
+ (re.compile(r"\b0x[0-9a-fA-F]+\b"), "<addr>"),
36
+ (re.compile(r"\b[0-9a-fA-F]{12,}\b"), "<hex>"),
37
+ # Durations pytest/playwright print in messages ("Timeout 30000ms exceeded").
38
+ (re.compile(r"\b\d+(?:\.\d+)?\s*(?:ms|s|sec|seconds|m)\b"), "<dur>"),
39
+ # Source line references: `foo.py:123` and pytest's `E File "...", line 12`.
40
+ (re.compile(r"(\.py)[:\"']?,?\s*line\s+\d+", re.IGNORECASE), r"\1:<line>"),
41
+ (re.compile(r"(\.py):\d+"), r"\1:<line>"),
42
+ # Ports and any remaining bare integers. Last, so the specific rules win.
43
+ (re.compile(r":\d{2,5}\b"), ":<port>"),
44
+ (re.compile(r"\b\d+\b"), "<n>"),
45
+ )
46
+
47
+ _WS = re.compile(r"[ \t]+")
48
+
49
+
50
+ def normalize_tail(tail: str) -> str:
51
+ """Reduce a failure tail to its run-independent shape.
52
+
53
+ Strips leading pytest gutter markers (``E ``), scrubs run-specific values,
54
+ and collapses whitespace. Two failures with the same cause but different
55
+ timings / element ids / worker paths normalize to the same string.
56
+ """
57
+ if not tail:
58
+ return ""
59
+ lines: list[str] = []
60
+ for raw in tail.splitlines():
61
+ line = raw.strip()
62
+ if not line:
63
+ continue
64
+ # pytest prefixes assertion detail with `E `; drop it so the same
65
+ # error reported in different phases still matches.
66
+ if line.startswith("E ") or line == "E":
67
+ line = line[1:].strip()
68
+ for pattern, repl in _SCRUBBERS:
69
+ line = pattern.sub(repl, line)
70
+ lines.append(_WS.sub(" ", line))
71
+ return "\n".join(lines)
72
+
73
+
74
+ def fingerprint(tail: str) -> str:
75
+ """Stable short digest of a normalized tail. Empty tail ⇒ ``"unknown"``."""
76
+ normalized = normalize_tail(tail)
77
+ if not normalized:
78
+ return "unknown"
79
+ return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:8]
80
+
81
+
82
+ def headline(tail: str, max_len: int = 100) -> str:
83
+ """The single most informative line of a failure tail, for the group label.
84
+
85
+ Prefers the last line that names an exception (that is where pytest puts the
86
+ actual error), else the last non-empty line — the bottom of a traceback is
87
+ almost always more useful than the top.
88
+ """
89
+ lines = [ln.strip().lstrip("E").strip() for ln in tail.splitlines() if ln.strip()]
90
+ if not lines:
91
+ return "(no failure detail captured)"
92
+ chosen = next(
93
+ (ln for ln in reversed(lines) if re.search(r"\b(?:Error|Exception|assert|Timeout|Failed)\b", ln)),
94
+ lines[-1],
95
+ )
96
+ return chosen if len(chosen) <= max_len else chosen[: max_len - 1] + "…"
97
+
98
+
99
+ @dataclass
100
+ class FailureGroup:
101
+ """One distinct cause and every scenario that hit it."""
102
+
103
+ fingerprint: str
104
+ headline: str
105
+ # (slug, nodeid, tail) — the first entry is the exemplar shown in full.
106
+ occurrences: list[tuple[str, str, str]] = field(default_factory=list)
107
+
108
+ @property
109
+ def count(self) -> int:
110
+ return len(self.occurrences)
111
+
112
+ @property
113
+ def slugs(self) -> list[str]:
114
+ seen, out = set(), []
115
+ for slug, _nodeid, _tail in self.occurrences:
116
+ if slug not in seen:
117
+ seen.add(slug)
118
+ out.append(slug)
119
+ return out
120
+
121
+
122
+ def group_failures(rows) -> list[FailureGroup]:
123
+ """Bucket ``_SummaryRow``-ish objects by failure cause, biggest group first.
124
+
125
+ Each row needs ``.slug`` and ``.fail_tails`` (a list of ``(nodeid, tail)``).
126
+ Rows with no captured tail are grouped under a single ``unknown`` bucket
127
+ rather than dropped — a failure with no detail is still a failure the reader
128
+ needs to see.
129
+ """
130
+ groups: dict[str, FailureGroup] = {}
131
+ for row in rows:
132
+ tails = getattr(row, "fail_tails", None) or [("", "")]
133
+ for nodeid, tail in tails:
134
+ fp = fingerprint(tail)
135
+ group = groups.get(fp)
136
+ if group is None:
137
+ group = FailureGroup(fingerprint=fp, headline=headline(tail))
138
+ groups[fp] = group
139
+ group.occurrences.append((row.slug, nodeid, tail))
140
+ # Biggest first; ties broken by fingerprint so output is deterministic.
141
+ return sorted(groups.values(), key=lambda g: (-g.count, g.fingerprint))
@@ -0,0 +1,380 @@
1
+ """Resolve, probe and build the steppy-worker Docker image.
2
+
3
+ The image tag is ``<image_prefix>:<package_version>-<src_hash>`` (e.g.
4
+ ``steppy-worker:0.3.0-ab12cd34ef56``). The hash covers every ``*.py`` under
5
+ the installed package root, so any change to steppy's source — a dev edit on
6
+ an editable install, or a reinstall of a different build of the same version
7
+ — produces a new tag and therefore a rebuild, instead of silently shipping
8
+ stale code into the container.
9
+
10
+ Build inputs (Dockerfile + context) are resolved through three modes, in
11
+ priority order — see :func:`resolve_build_inputs`:
12
+
13
+ ``config``
14
+ ``parallel.dockerfile`` / ``parallel.build_context`` are set. Escape hatch
15
+ for a fully custom worker image.
16
+ ``repo``
17
+ steppy is running out of a source checkout (``<root>/src/steppy`` with a
18
+ sibling ``Dockerfile`` + ``pyproject.toml``). Builds from the repo root,
19
+ exactly as steppy always has.
20
+ ``installed``
21
+ Everything else — steppy installed as a wheel, with no source tree on the
22
+ machine. Synthesizes a self-contained build context from the installed
23
+ package (see :func:`synthesize_installed_context`) and builds it with the
24
+ bundled ``_docker/Dockerfile.installed``.
25
+
26
+ Auto-build on first run is the default — :func:`ensure_worker_image` probes
27
+ ``docker images`` for the tag and builds if missing. The CLI's ``--no-build``
28
+ flag flips ``build=False`` to fail fast instead.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import importlib.metadata
35
+ import re
36
+ import shutil
37
+ import subprocess
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+ from typing import Any, Optional
41
+
42
+ from .. import DISTRIBUTION
43
+
44
+
45
+ # Playwright base image for the synthesized worker build. Kept in lockstep
46
+ # with the repo-root ``Dockerfile`` and ``_docker/Dockerfile.installed`` —
47
+ # drift guard: ``tests/test_parallel_worker_image.py``. Overridable per
48
+ # project via ``parallel.worker_base_image``.
49
+ DEFAULT_PLAYWRIGHT_BASE = "mcr.microsoft.com/playwright/python:v1.60.0-noble"
50
+
51
+ # Build-arg name declared by both Dockerfiles.
52
+ _BASE_IMAGE_ARG = "PLAYWRIGHT_BASE"
53
+
54
+ # Optional extras baked into the worker image by BOTH build modes: the repo
55
+ # Dockerfile via ``pip install '/tmp/steppy[retry,report]'``, the installed
56
+ # mode via :func:`runtime_requirements`. ``retry`` is load-bearing —
57
+ # ``steppy run --workers N --retries K`` resolves host-side but executes
58
+ # ``--reruns`` inside the container.
59
+ BAKED_EXTRAS = ("retry", "report")
60
+
61
+ # Matches ``extra == "report"`` / ``extra == 'report'`` in a requirement marker.
62
+ _EXTRA_MARKER_RE = re.compile(r"""extra\s*==\s*['"]([^'"]+)['"]""")
63
+
64
+ # Bumped whenever the worker image's CONTENT changes without any *.py under the
65
+ # package changing — i.e. a Dockerfile or baked-extras edit. ``compute_image_tag``
66
+ # hashes only ``*.py``, so without this a warm image would silently be reused and
67
+ # the new content would never land. Bump = one rebuild for everyone, once.
68
+ # 1 → original
69
+ # 2 → [retry,report] extras baked in
70
+ WORKER_IMAGE_CONTRACT = "2"
71
+
72
+ # Subdirectory of the coordinator's run tmp dir that holds a synthesized context.
73
+ _CONTEXT_DIRNAME = "worker-build-context"
74
+
75
+ # Never copied into a synthesized build context.
76
+ _COPY_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class BuildInputs:
81
+ """What ``docker build`` needs, plus which mode produced it."""
82
+
83
+ dockerfile: Path
84
+ context: Path
85
+ mode: str # "config" | "repo" | "installed"
86
+ build_args: dict[str, str] = field(default_factory=dict)
87
+
88
+
89
+ def _package_version() -> str:
90
+ """Installed steppy version, e.g. '0.3.0'. Falls back to '0-unknown' if
91
+ the metadata isn't readable (shouldn't happen but is non-fatal)."""
92
+ try:
93
+ return importlib.metadata.version(DISTRIBUTION)
94
+ except importlib.metadata.PackageNotFoundError:
95
+ return "0-unknown"
96
+
97
+
98
+ def _src_content_hash(package_root: Path) -> str:
99
+ """Short hex digest over the package source. Used as a tag suffix so
100
+ worker images rebuild when the steppy source changes.
101
+
102
+ Hashes file paths + contents in sorted order — deterministic, fast on
103
+ the steppy package size (~10K LOC). Excludes ``__pycache__``.
104
+ """
105
+ sha = hashlib.sha256()
106
+ for path in sorted(package_root.rglob("*.py")):
107
+ if "__pycache__" in path.parts:
108
+ continue
109
+ rel = path.relative_to(package_root).as_posix().encode("utf-8")
110
+ sha.update(rel)
111
+ sha.update(b"\0")
112
+ sha.update(path.read_bytes())
113
+ sha.update(b"\0")
114
+ return sha.hexdigest()[:12]
115
+
116
+
117
+ def compute_image_tag(package_root: Path, extra: str = "") -> str:
118
+ """Tag to append after the image prefix: ``<version>-<src_hash>``.
119
+
120
+ The hash is unconditional. It used to be added only on editable installs
121
+ (detected via a sibling ``pyproject.toml``), which meant a wheel install
122
+ collapsed to a bare ``0.3.0`` — so reinstalling a different build of the
123
+ same version silently reused a stale worker image.
124
+
125
+ ``extra`` folds additional build-affecting inputs (currently
126
+ ``parallel.worker_base_image``) into the digest. Empty by default, so
127
+ tags are unchanged for projects that don't set it.
128
+ """
129
+ version = _package_version()
130
+ digest = _src_content_hash(package_root)
131
+ if extra:
132
+ digest = hashlib.sha256((digest + "\0" + extra).encode("utf-8")).hexdigest()[:12]
133
+ # `-` separator (not `+`) — the Docker tag spec is [a-zA-Z0-9_.-] and rejects `+`.
134
+ return f"{version}-{digest}"
135
+
136
+
137
+ def image_exists(image_ref: str) -> bool:
138
+ """True if ``docker images`` lists the given <image>:<tag>."""
139
+ try:
140
+ result = subprocess.run(
141
+ ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}", image_ref],
142
+ capture_output=True,
143
+ text=True,
144
+ encoding="utf-8",
145
+ errors="replace",
146
+ timeout=10,
147
+ )
148
+ except (FileNotFoundError, subprocess.TimeoutExpired):
149
+ return False
150
+ if result.returncode != 0:
151
+ return False
152
+ return image_ref in result.stdout.splitlines()
153
+
154
+
155
+ def build_image(
156
+ image_ref: str,
157
+ dockerfile: Path,
158
+ build_context: Path,
159
+ build_args: Optional[dict[str, str]] = None,
160
+ ) -> None:
161
+ """Run ``docker build -t <image_ref> -f <dockerfile> <context>``. Streams
162
+ output to stdout so the user sees progress on a long first build."""
163
+ cmd = ["docker", "build", "-t", image_ref]
164
+ for key, value in (build_args or {}).items():
165
+ cmd.extend(["--build-arg", f"{key}={value}"])
166
+ cmd.extend(["-f", str(dockerfile), str(build_context)])
167
+ result = subprocess.run(cmd)
168
+ if result.returncode != 0:
169
+ raise RuntimeError(
170
+ f"Failed to build worker image {image_ref!r}. "
171
+ f"Re-run the build manually to see the full error: {' '.join(cmd)}"
172
+ )
173
+
174
+
175
+ # --------------------------------------------------------------------------
176
+ # Build-input resolution
177
+ # --------------------------------------------------------------------------
178
+
179
+
180
+ def _bundled_dockerfile() -> Path:
181
+ """Path of the wheel-bundled ``Dockerfile.installed``."""
182
+ path = Path(__file__).resolve().parent.parent / "_docker" / "Dockerfile.installed"
183
+ if not path.is_file():
184
+ raise RuntimeError(
185
+ f"Bundled worker Dockerfile missing at {path}. This means the steppy install is "
186
+ f"incomplete — reinstall steppy, or point `parallel.dockerfile` at a worker "
187
+ f"Dockerfile you control."
188
+ )
189
+ return path
190
+
191
+
192
+ def _abs_path(raw: str, project_root: Path) -> Path:
193
+ """Resolve a config path: absolute wins, otherwise relative to project root."""
194
+ path = Path(raw).expanduser()
195
+ if not path.is_absolute():
196
+ path = project_root / path
197
+ return Path(path)
198
+
199
+
200
+ def _dist_info_stem(name: str) -> str:
201
+ """A distribution name as its ``.dist-info`` folder spells it: runs of
202
+ ``-``, ``_`` and ``.`` become one ``_``, lowercased (the wheel spec).
203
+ """
204
+ return re.sub(r"[-_.]+", "_", name).lower()
205
+
206
+
207
+ def runtime_requirements() -> list[str]:
208
+ """steppy's runtime dependencies, read from the installed metadata.
209
+
210
+ Extras named in :data:`BAKED_EXTRAS` are **included** (with the marker
211
+ stripped, since the container unconditionally wants them) so this image
212
+ matches what the repo-root ``Dockerfile`` installs via
213
+ ``pip install '/tmp/steppy[retry,report]'``. Any other optional-extra
214
+ requirement is dropped. Non-extra environment markers are preserved
215
+ verbatim for pip to evaluate.
216
+ """
217
+ try:
218
+ reqs = importlib.metadata.requires(DISTRIBUTION)
219
+ except importlib.metadata.PackageNotFoundError:
220
+ reqs = None
221
+ if not reqs:
222
+ raise RuntimeError(
223
+ "Cannot read steppy's dependency metadata, so the worker image build context "
224
+ "cannot be synthesized. Point `parallel.dockerfile` (and optionally "
225
+ "`parallel.build_context`) at a worker Dockerfile you control, or run steppy "
226
+ "from a source checkout."
227
+ )
228
+ out: list[str] = []
229
+ for raw in reqs:
230
+ spec, _, marker = raw.partition(";")
231
+ if "extra" in marker:
232
+ named = _EXTRA_MARKER_RE.search(marker)
233
+ if named and named.group(1) in BAKED_EXTRAS:
234
+ # Strip the marker: inside the container the extra is not
235
+ # conditional, it is simply wanted.
236
+ out.append(spec.strip())
237
+ continue
238
+ line = raw.strip() if marker.strip() else spec.strip()
239
+ if line:
240
+ out.append(line)
241
+ return out
242
+
243
+
244
+ def synthesize_installed_context(*, package_root: Path, staging_dir: Path) -> Path:
245
+ """Build a self-contained ``docker build`` context from the INSTALLED package.
246
+
247
+ Produces, under ``<staging_dir>/worker-build-context/``::
248
+
249
+ Dockerfile copy of the bundled _docker/Dockerfile.installed
250
+ requirements.txt runtime deps from the installed metadata
251
+ pkg/steppy/ copy of the installed package
252
+ pkg/steppy_dev-<ver>.dist-info/ so importlib.metadata works in the container
253
+
254
+ This exists because the repo-root ``Dockerfile`` COPYs ``pyproject.toml``,
255
+ ``hatch_build.py``, ``src/``, ``templates/``, ``skills/`` and
256
+ ``CHANGELOG.md`` — none of which exist when steppy was installed as a
257
+ wheel. The worker container only runs ``python -m steppy worker`` →
258
+ pytest on the already-generated tests bind-mounted from the project, so a
259
+ package copy is functionally equivalent to the repo build.
260
+ """
261
+ context = staging_dir / _CONTEXT_DIRNAME
262
+ if context.exists():
263
+ shutil.rmtree(context)
264
+ pkg_dir = context / "pkg"
265
+ pkg_dir.mkdir(parents=True)
266
+
267
+ shutil.copytree(package_root, pkg_dir / package_root.name, ignore=_COPY_IGNORE)
268
+
269
+ # dist-info is best-effort: without it the package still imports, only
270
+ # importlib.metadata.version(DISTRIBUTION) stops working inside the container.
271
+ # The folder is named after the distribution, not the import package, and
272
+ # in its normalised form (`steppy_dev-0.3.0.dist-info`).
273
+ for dist_info in sorted(package_root.parent.glob(f"{_dist_info_stem(DISTRIBUTION)}-*.dist-info")):
274
+ shutil.copytree(dist_info, pkg_dir / dist_info.name, ignore=_COPY_IGNORE)
275
+ break
276
+
277
+ requirements = "\n".join(runtime_requirements())
278
+ (context / "requirements.txt").write_text(requirements + "\n", encoding="utf-8")
279
+ shutil.copyfile(_bundled_dockerfile(), context / "Dockerfile")
280
+ return context
281
+
282
+
283
+ def resolve_build_inputs(
284
+ *,
285
+ package_root: Path,
286
+ project_root: Path,
287
+ parallel_cfg: Any,
288
+ staging_dir: Path,
289
+ ) -> BuildInputs:
290
+ """Pick the Dockerfile + build context for the worker image.
291
+
292
+ Priority: explicit config → source checkout → synthesized from the
293
+ installed package. Raises ``RuntimeError`` with an actionable message
294
+ when explicitly configured paths don't exist.
295
+ """
296
+ base_override = getattr(parallel_cfg, "worker_base_image", None)
297
+ build_args = {_BASE_IMAGE_ARG: base_override} if base_override else {}
298
+
299
+ dockerfile_cfg = getattr(parallel_cfg, "dockerfile", None)
300
+ context_cfg = getattr(parallel_cfg, "build_context", None)
301
+
302
+ if dockerfile_cfg:
303
+ dockerfile = _abs_path(dockerfile_cfg, project_root)
304
+ if not dockerfile.is_file():
305
+ raise RuntimeError(
306
+ f"`parallel.dockerfile` is set to {dockerfile_cfg!r} but no file exists at {dockerfile}. "
307
+ f"Fix the path in config.yaml (relative paths resolve from the project root), "
308
+ f"or remove the setting to let steppy build the worker image itself."
309
+ )
310
+ context = _abs_path(context_cfg, project_root) if context_cfg else dockerfile.parent
311
+ if not context.is_dir():
312
+ raise RuntimeError(
313
+ f"`parallel.build_context` is set to {context_cfg!r} but no directory exists at {context}."
314
+ )
315
+ return BuildInputs(dockerfile=dockerfile, context=context, mode="config", build_args=build_args)
316
+
317
+ if context_cfg:
318
+ raise RuntimeError(
319
+ "`parallel.build_context` is set but `parallel.dockerfile` is not. "
320
+ "Set both to use a custom worker image, or neither to let steppy build it."
321
+ )
322
+
323
+ # Source checkout: <repo>/src/steppy → <repo>. Unchanged from day one.
324
+ repo_root = package_root.parent.parent
325
+ repo_dockerfile = repo_root / "Dockerfile"
326
+ if repo_dockerfile.is_file() and (repo_root / "pyproject.toml").is_file():
327
+ return BuildInputs(dockerfile=repo_dockerfile, context=repo_root, mode="repo", build_args=build_args)
328
+
329
+ context = synthesize_installed_context(package_root=package_root, staging_dir=staging_dir)
330
+ return BuildInputs(
331
+ dockerfile=context / "Dockerfile",
332
+ context=context,
333
+ mode="installed",
334
+ build_args=build_args,
335
+ )
336
+
337
+
338
+ def ensure_worker_image(
339
+ *,
340
+ image_prefix: str,
341
+ package_root: Path,
342
+ project_root: Path,
343
+ parallel_cfg: Any,
344
+ staging_dir: Path,
345
+ build: bool = True,
346
+ ) -> tuple[str, str]:
347
+ """Return ``(image_ref, mode)``, building the image if it isn't present.
348
+
349
+ ``mode`` is ``"cached"`` when an existing local image was reused, else the
350
+ :class:`BuildInputs` mode that produced the build.
351
+
352
+ The local probe happens BEFORE build inputs are resolved, so a warm image
353
+ costs nothing — no context is synthesized. That probe is also where a
354
+ future registry ``docker pull`` slots in: local → pull → build.
355
+
356
+ Raises ``RuntimeError`` if the image is missing and ``build=False`` (the
357
+ user passed ``--no-build``), or if resolution or the build itself fails.
358
+ """
359
+ base_override = getattr(parallel_cfg, "worker_base_image", None) or ""
360
+ # WORKER_IMAGE_CONTRACT rides in the tag digest so an image-content change
361
+ # that touches no *.py (a Dockerfile / baked-extras edit) still invalidates
362
+ # warm images instead of being silently ignored.
363
+ tag_extra = f"{WORKER_IMAGE_CONTRACT}\0{base_override}"
364
+ image_ref = f"{image_prefix}:{compute_image_tag(package_root, extra=tag_extra)}"
365
+ if image_exists(image_ref):
366
+ return image_ref, "cached"
367
+ if not build:
368
+ raise RuntimeError(
369
+ f"Worker image {image_ref!r} not found locally and --no-build was passed. "
370
+ f"Re-run without --no-build to let steppy build it, or build it yourself and "
371
+ f"tag it {image_ref}."
372
+ )
373
+ inputs = resolve_build_inputs(
374
+ package_root=package_root,
375
+ project_root=project_root,
376
+ parallel_cfg=parallel_cfg,
377
+ staging_dir=staging_dir,
378
+ )
379
+ build_image(image_ref, inputs.dockerfile, inputs.context, build_args=inputs.build_args)
380
+ return image_ref, inputs.mode
@@ -0,0 +1,125 @@
1
+ """Write pytest's ``lastfailed`` cache from a parallel run's results so
2
+ ``steppy rerun`` re-runs the scenarios that failed in *that* run.
3
+
4
+ ``steppy rerun`` reads ``<cwd>/.pytest_cache/v/cache/lastfailed`` and feeds
5
+ it to ``pytest --lf``. Only the single-worker ``steppy run`` path populates
6
+ that cache (plain pytest, cache provider active). A parallel
7
+ ``steppy run --workers N`` never touches it — workers run with
8
+ ``-p no:cacheprovider`` and the project root is bind-mounted read-only into
9
+ each container — so after a parallel run the cache still reflects the last
10
+ *single-worker* run (or is absent), and ``rerun`` re-runs an OLD failure set.
11
+
12
+ This module closes that gap: the coordinator calls
13
+ :func:`write_lastfailed_cache` after it aggregates results, writing the same
14
+ cache file ``rerun`` reads. Both run modes then converge on one file, so
15
+ "last run wins" becomes automatic and ``rerun`` needs no changes.
16
+
17
+ **Granularity is file-level** — one key per failed *scenario file* (a key
18
+ with no ``::``). ``pytest --lf`` treats a bare file path as "re-run the whole
19
+ file": collection is restricted to the failed files via the plugin's
20
+ ``_last_failed_paths`` and, with no per-node key matching, nothing is
21
+ deselected, so every test in each failed file runs. This is robust — it
22
+ covers orphaned/crashed workers and the "events plugin didn't load"
23
+ degenerate case, neither of which yields node IDs — and it never drops a
24
+ failed scenario. (Mixing file-level and node-level keys is unsafe: a matching
25
+ node-level key makes pytest deselect everything else, including file-level
26
+ entries, so we keep the keys uniformly file-level.)
27
+
28
+ Lazy-imported from :mod:`steppy.coordinator` so it only loads in parallel
29
+ mode (the single-worker ``test_no_parallel_import_on_default_path`` guard
30
+ relies on this).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ from .event_bus import EventBus
40
+ from .queue import ScenarioResult
41
+
42
+
43
+ def write_lastfailed_cache(
44
+ *,
45
+ project_root: Path,
46
+ tests_dir: Path,
47
+ scenario_results: list[ScenarioResult],
48
+ orphans: dict[str, list[str]],
49
+ bus: EventBus,
50
+ failed_slugs: Optional[set[str]] = None,
51
+ ) -> Optional[Path]:
52
+ """Write ``project_root/.pytest_cache/v/cache/lastfailed`` from a parallel
53
+ run's outcome.
54
+
55
+ Returns the path written, or ``None`` if writing failed. The function
56
+ NEVER raises — cache writing is best-effort; any ``OSError`` is swallowed
57
+ so a filesystem hiccup on teardown can't crash the coordinator.
58
+
59
+ The cache is written **unconditionally** — an all-pass run writes an empty
60
+ dict so a subsequent ``rerun`` correctly reports "all passed" instead of
61
+ re-running a stale older failure set. That reset is the crux of the fix.
62
+
63
+ ``failed_slugs`` lets the caller supply the authoritative failure set. The
64
+ retry pass uses it to pass only the scenarios that failed *every* attempt —
65
+ a flaky scenario ended green, so putting it here would make ``steppy rerun``
66
+ re-run known-good tests. Omitted ⇒ derived from ``scenario_results`` +
67
+ ``orphans`` exactly as before.
68
+ """
69
+ if failed_slugs is None:
70
+ # Failed scenarios: authoritative per-scenario exit codes (mirrors
71
+ # coordinator's `failed = sum(...)`) plus orphans (claimed, never reported).
72
+ failed_slugs = {r.slug for r in scenario_results if r.exit_code != 0}
73
+ for slugs in orphans.values():
74
+ failed_slugs.update(slugs)
75
+
76
+ # Map slug -> real test-file path exactly as pytest spelled it inside the
77
+ # worker (rootdir-relative). The container mounts the same project files at
78
+ # /steppy/project, so the worker's rootdir-relative nodeid matches what the
79
+ # host `pytest --lf` will compute. Prefer this over guessing the rootdir.
80
+ file_part_by_slug: dict[str, str] = {}
81
+ for t in bus.snapshot().tests:
82
+ if not t.scenario_slug:
83
+ continue
84
+ file_part = t.nodeid.split("::", 1)[0]
85
+ # Skip synthetic nodeids (e.g. "<synthetic>::slug") and anything that
86
+ # isn't a real test file.
87
+ if file_part.endswith(".py"):
88
+ file_part_by_slug.setdefault(t.scenario_slug, file_part)
89
+
90
+ tests_rel = _tests_rel_posix(tests_dir, project_root)
91
+
92
+ lastfailed: dict[str, bool] = {}
93
+ for slug in sorted(failed_slugs):
94
+ key = file_part_by_slug.get(slug) or f"{tests_rel}/test_{slug}.py"
95
+ lastfailed[key] = True
96
+
97
+ cache_dir = project_root / ".pytest_cache" / "v" / "cache"
98
+ try:
99
+ cache_dir.mkdir(parents=True, exist_ok=True)
100
+ (cache_dir / "lastfailed").write_text(json.dumps(lastfailed, indent=2, sort_keys=True), encoding="utf-8")
101
+ except OSError:
102
+ return None
103
+
104
+ # Keep the cache out of git, exactly as pytest does when it owns the dir.
105
+ gitignore = project_root / ".pytest_cache" / ".gitignore"
106
+ if not gitignore.exists():
107
+ try:
108
+ gitignore.write_text("# Created by steppy automatically.\n*\n", encoding="utf-8")
109
+ except OSError:
110
+ pass
111
+
112
+ return cache_dir / "lastfailed"
113
+
114
+
115
+ def _tests_rel_posix(tests_dir: Path, project_root: Path) -> str:
116
+ """The tests dir as a posix path relative to the project root.
117
+
118
+ pytest node IDs are always posix (``/``) even on Windows, so the file-level
119
+ fallback key must be too. Falls back to ``tests_dir`` as-is when it isn't
120
+ under ``project_root``.
121
+ """
122
+ try:
123
+ return tests_dir.resolve().relative_to(project_root.resolve()).as_posix()
124
+ except (ValueError, OSError):
125
+ return tests_dir.as_posix()