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
|
@@ -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()
|