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/bundle_cli.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""``steppy bundle`` — package a run's results into a portable archive.
|
|
2
|
+
|
|
3
|
+
Leaf CLI module, registered by ``cli.py`` like ``coverage_cli`` /
|
|
4
|
+
``video_cli``. Only ``typer`` + ``_cli_common`` + stdlib at module scope; every
|
|
5
|
+
``steppy.bundle`` import happens inside a command body so ``import steppy.cli``
|
|
6
|
+
never pulls in the walker or the optional ``zstandard``. Guard:
|
|
7
|
+
``tests/test_no_bundle_import_on_default_path.py``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json as _json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from ._cli_common import _cli_errors, _error, console
|
|
19
|
+
|
|
20
|
+
bundle_app = typer.Typer(
|
|
21
|
+
help="Package a run's output/ tree into a portable results bundle.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_bundle(app: typer.Typer) -> None:
|
|
27
|
+
"""Attach the ``bundle`` command group to the root Typer app."""
|
|
28
|
+
app.add_typer(bundle_app, name="bundle")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_size(value: str) -> int:
|
|
32
|
+
"""``2GiB`` / ``500MB`` / ``0`` → bytes. ``0`` disables the budget."""
|
|
33
|
+
text = value.strip().lower().replace(" ", "")
|
|
34
|
+
if not text:
|
|
35
|
+
return 0
|
|
36
|
+
units = (("gib", 1024**3), ("mib", 1024**2), ("kib", 1024), ("gb", 10**9), ("mb", 10**6), ("kb", 10**3), ("b", 1))
|
|
37
|
+
for suffix, factor in units:
|
|
38
|
+
if text.endswith(suffix):
|
|
39
|
+
number = text[: -len(suffix)]
|
|
40
|
+
try:
|
|
41
|
+
return int(float(number) * factor)
|
|
42
|
+
except ValueError:
|
|
43
|
+
break
|
|
44
|
+
try:
|
|
45
|
+
return int(text)
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise typer.BadParameter(f"could not parse a size from {value!r} (try 2GiB, 500MB or 0)")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _human(size: int) -> str:
|
|
51
|
+
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
52
|
+
if size < 1024 or unit == "GiB":
|
|
53
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
54
|
+
size /= 1024.0
|
|
55
|
+
return f"{size:.1f} GiB"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _print_summary(manifest, *, run_id_source: str) -> None:
|
|
59
|
+
from collections import Counter
|
|
60
|
+
|
|
61
|
+
run = manifest.run
|
|
62
|
+
console.print(f"\n[bold]Bundle[/bold] {run.run_id} [dim]({run_id_source})[/dim]")
|
|
63
|
+
console.print(f"[dim]steppy {run.steppy_version} · executor {run.executor} · created {run.created_at}[/dim]")
|
|
64
|
+
if run.git.sha:
|
|
65
|
+
branch = f" on {run.git.branch}" if run.git.branch else ""
|
|
66
|
+
pr = f" (PR #{run.git.pr})" if run.git.pr else ""
|
|
67
|
+
console.print(f"[dim]git {run.git.sha[:12]}{branch}{pr}[/dim]")
|
|
68
|
+
|
|
69
|
+
spec = run.spec
|
|
70
|
+
dimensions = []
|
|
71
|
+
if spec.locales:
|
|
72
|
+
dimensions.append(f"locales {', '.join(spec.locales)}")
|
|
73
|
+
if spec.layouts:
|
|
74
|
+
dimensions.append(f"layouts {', '.join(spec.layouts)}")
|
|
75
|
+
if spec.guide:
|
|
76
|
+
dimensions.append("guide mode")
|
|
77
|
+
if spec.coverage:
|
|
78
|
+
dimensions.append("coverage")
|
|
79
|
+
if dimensions:
|
|
80
|
+
console.print(f"[dim]{' · '.join(dimensions)}[/dim]")
|
|
81
|
+
|
|
82
|
+
if manifest.tests:
|
|
83
|
+
outcomes = Counter(t.outcome for t in manifest.tests)
|
|
84
|
+
parts = [f"{count} {name}" for name, count in sorted(outcomes.items())]
|
|
85
|
+
console.print(f"\n[bold]Tests[/bold] ({len(manifest.tests)}): {', '.join(parts)}")
|
|
86
|
+
else:
|
|
87
|
+
console.print("\n[bold]Tests[/bold]: none recorded")
|
|
88
|
+
|
|
89
|
+
kinds = Counter(f.kind for f in manifest.files)
|
|
90
|
+
total = sum(f.size for f in manifest.files)
|
|
91
|
+
console.print(f"[bold]Files[/bold] ({len(manifest.files)}, {_human(total)}):")
|
|
92
|
+
for kind, count in sorted(kinds.items()):
|
|
93
|
+
kind_size = sum(f.size for f in manifest.files if f.kind == kind)
|
|
94
|
+
console.print(f" [dim]{kind:<22}[/dim] {count:>5} {_human(kind_size)}")
|
|
95
|
+
|
|
96
|
+
if manifest.coverage and manifest.coverage.records:
|
|
97
|
+
console.print(f"[bold]Coverage[/bold]: {manifest.coverage.records} raw record(s)")
|
|
98
|
+
if manifest.truncated:
|
|
99
|
+
console.print("[yellow]TRUNCATED[/yellow] — some artifacts were dropped to fit the size budget")
|
|
100
|
+
for warning in manifest.warnings:
|
|
101
|
+
console.print(f"[yellow]WARNING[/yellow] {warning}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@bundle_app.command("create")
|
|
105
|
+
def create(
|
|
106
|
+
output: Optional[Path] = typer.Option(
|
|
107
|
+
None,
|
|
108
|
+
"--output",
|
|
109
|
+
"-o",
|
|
110
|
+
help="Where to write the bundle. A directory gets a generated filename; "
|
|
111
|
+
"a path with an extension is used verbatim. "
|
|
112
|
+
"Default: output/bundles/steppy-bundle-<run-id>.tar.zst",
|
|
113
|
+
),
|
|
114
|
+
run_id: Optional[str] = typer.Option(
|
|
115
|
+
None,
|
|
116
|
+
"--run-id",
|
|
117
|
+
help="Run identity for manifest.run.run_id. Defaults to the run recorded "
|
|
118
|
+
"in output/.steppy-run.json, else the newest recorded run on disk.",
|
|
119
|
+
),
|
|
120
|
+
fmt: str = typer.Option(
|
|
121
|
+
"auto",
|
|
122
|
+
"--format",
|
|
123
|
+
help="Compression: auto (zstd when the steppy-dev[bundle] extra is installed, else gzip), zst, or gz.",
|
|
124
|
+
),
|
|
125
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Project root."),
|
|
126
|
+
output_dir: Optional[Path] = typer.Option(
|
|
127
|
+
None, "--output-dir", help="The tree to archive. Default: <project-root>/output"
|
|
128
|
+
),
|
|
129
|
+
executor: str = typer.Option("local", "--executor", help="What ran the tests: local, github-actions, agent, ..."),
|
|
130
|
+
git_sha: Optional[str] = typer.Option(None, "--git-sha", help="Override the detected commit sha."),
|
|
131
|
+
git_branch: Optional[str] = typer.Option(None, "--git-branch", help="Override the detected branch."),
|
|
132
|
+
pr: Optional[int] = typer.Option(None, "--pr", help="Pull-request number this run belongs to."),
|
|
133
|
+
guides: bool = typer.Option(True, "--guides/--no-guides", help="Include output/guides/."),
|
|
134
|
+
traces: bool = typer.Option(True, "--traces/--no-traces", help="Include output/traces/."),
|
|
135
|
+
videos: bool = typer.Option(True, "--videos/--no-videos", help="Include output/videos/."),
|
|
136
|
+
coverage: bool = typer.Option(True, "--coverage/--no-coverage", help="Include output/coverage/."),
|
|
137
|
+
max_size: str = typer.Option(
|
|
138
|
+
"0",
|
|
139
|
+
"--max-size",
|
|
140
|
+
help="Uncompressed payload budget, e.g. 2GiB. Over budget, the cheapest-to-lose "
|
|
141
|
+
"artifacts are dropped (videos, then traces, then guides) and the manifest is "
|
|
142
|
+
"marked truncated. 0 disables the budget.",
|
|
143
|
+
),
|
|
144
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be archived; write nothing."),
|
|
145
|
+
as_json: bool = typer.Option(False, "--json", help="Print the manifest as JSON instead of a summary."),
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Package a run's output/ tree into a results bundle.
|
|
148
|
+
|
|
149
|
+
The archive holds output/ exactly as steppy wrote it — no path rewriting —
|
|
150
|
+
so unpacking it gives a tree `steppy guide` and `steppy build` consume
|
|
151
|
+
unchanged. Alongside it sit a manifest.json describing every file, a merged
|
|
152
|
+
junit.xml, and the run's per-test event records.
|
|
153
|
+
|
|
154
|
+
Run your scenarios with `steppy run --bundle` first: that captures the event
|
|
155
|
+
records and JUnit XML a plain run does not produce. Without them the bundle
|
|
156
|
+
is still valid, but manifest.tests[] will be empty.
|
|
157
|
+
"""
|
|
158
|
+
with _cli_errors():
|
|
159
|
+
from .bundle import BundleError, create_bundle
|
|
160
|
+
from .bundle.create import manifest_bytes
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = create_bundle(
|
|
164
|
+
project_root=project_root,
|
|
165
|
+
output_dir=output_dir,
|
|
166
|
+
target=output,
|
|
167
|
+
run_id=run_id,
|
|
168
|
+
fmt=fmt,
|
|
169
|
+
executor=executor,
|
|
170
|
+
git_sha=git_sha,
|
|
171
|
+
git_branch=git_branch,
|
|
172
|
+
pr=pr,
|
|
173
|
+
guides=guides,
|
|
174
|
+
traces=traces,
|
|
175
|
+
videos=videos,
|
|
176
|
+
coverage=coverage,
|
|
177
|
+
max_size=_parse_size(max_size),
|
|
178
|
+
dry_run=dry_run,
|
|
179
|
+
)
|
|
180
|
+
except BundleError as e:
|
|
181
|
+
_error(str(e))
|
|
182
|
+
raise typer.Exit(1)
|
|
183
|
+
|
|
184
|
+
if as_json:
|
|
185
|
+
typer.echo(manifest_bytes(result.manifest).decode("utf-8"), nl=False)
|
|
186
|
+
raise typer.Exit(0)
|
|
187
|
+
|
|
188
|
+
_print_summary(result.manifest, run_id_source=result.run_id_source)
|
|
189
|
+
if dry_run:
|
|
190
|
+
console.print(f"\n[dim]Dry run — nothing written. Format would be: {result.fmt}[/dim]")
|
|
191
|
+
else:
|
|
192
|
+
ratio = f" ({100 * result.compressed_size / result.payload_size:.0f}%)" if result.payload_size else ""
|
|
193
|
+
console.print(
|
|
194
|
+
f"\n[green]OK[/green] {result.path} [dim]{_human(result.compressed_size)}{ratio}, {result.fmt}[/dim]"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _config_cloud_url(project_root: Path) -> Optional[str]:
|
|
199
|
+
"""``cloud.url`` from the project's config, or None.
|
|
200
|
+
|
|
201
|
+
A config that does not load is not an error here — the flag and the
|
|
202
|
+
environment variable both still work, and refusing to upload a finished run
|
|
203
|
+
because ``config.yaml`` no longer parses would be the same poor trade
|
|
204
|
+
``resolve_roots`` already declines to make.
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
from .config import load_config
|
|
208
|
+
|
|
209
|
+
config = load_config(project_root / "config.yaml")
|
|
210
|
+
except Exception:
|
|
211
|
+
return None
|
|
212
|
+
return config.cloud.url if config.cloud else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@bundle_app.command("upload")
|
|
216
|
+
def upload(
|
|
217
|
+
bundle_path: Optional[Path] = typer.Argument(
|
|
218
|
+
None,
|
|
219
|
+
help="An existing .tar.zst/.tar.gz bundle to upload. Omit to upload straight "
|
|
220
|
+
"from output/ without writing an archive (the usual case).",
|
|
221
|
+
),
|
|
222
|
+
url: Optional[str] = typer.Option(
|
|
223
|
+
None, "--url", help="Control plane base URL. Else $STEPPY_CLOUD_URL, else cloud.url."
|
|
224
|
+
),
|
|
225
|
+
token: Optional[str] = typer.Option(
|
|
226
|
+
None, "--token", help="Project token. Else $STEPPY_CLOUD_TOKEN. There is no config key for it on purpose."
|
|
227
|
+
),
|
|
228
|
+
attach: Optional[str] = typer.Option(
|
|
229
|
+
None,
|
|
230
|
+
"--attach",
|
|
231
|
+
help="Id of an existing run on the plane to upload into, instead of creating one. "
|
|
232
|
+
"What `steppy ci run` will use, where the plane created the run first.",
|
|
233
|
+
),
|
|
234
|
+
run_id: Optional[str] = typer.Option(
|
|
235
|
+
None,
|
|
236
|
+
"--run-id",
|
|
237
|
+
help="Run identity for manifest.run.run_id, as in `bundle create`. Doubles as the "
|
|
238
|
+
"idempotency key, so re-uploading attaches to the same run rather than making a second.",
|
|
239
|
+
),
|
|
240
|
+
project_root: Path = typer.Option(Path("."), "--project-root", help="Project root."),
|
|
241
|
+
output_dir: Optional[Path] = typer.Option(
|
|
242
|
+
None, "--output-dir", help="The tree to upload. Default: <project-root>/output"
|
|
243
|
+
),
|
|
244
|
+
executor: str = typer.Option("local", "--executor", help="What ran the tests, recorded on a created run."),
|
|
245
|
+
exit_code: Optional[int] = typer.Option(
|
|
246
|
+
None, "--exit-code", help="The run's verdict. Default: derived from the manifest's test outcomes."
|
|
247
|
+
),
|
|
248
|
+
git_sha: Optional[str] = typer.Option(None, "--git-sha", help="Override the detected commit sha."),
|
|
249
|
+
git_branch: Optional[str] = typer.Option(None, "--git-branch", help="Override the detected branch."),
|
|
250
|
+
pr: Optional[int] = typer.Option(None, "--pr", help="Pull-request number this run belongs to."),
|
|
251
|
+
guides: bool = typer.Option(True, "--guides/--no-guides", help="Include output/guides/."),
|
|
252
|
+
traces: bool = typer.Option(True, "--traces/--no-traces", help="Include output/traces/."),
|
|
253
|
+
videos: bool = typer.Option(True, "--videos/--no-videos", help="Include output/videos/."),
|
|
254
|
+
coverage: bool = typer.Option(True, "--coverage/--no-coverage", help="Include output/coverage/."),
|
|
255
|
+
max_size: str = typer.Option("0", "--max-size", help="Uncompressed payload budget, e.g. 2GiB. 0 disables it."),
|
|
256
|
+
concurrency: int = typer.Option(4, "--concurrency", min=1, help="Parallel uploads (ignored for an archive)."),
|
|
257
|
+
timeout: float = typer.Option(60.0, "--timeout", help="Per-request timeout, seconds."),
|
|
258
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be uploaded; contact nothing."),
|
|
259
|
+
as_json: bool = typer.Option(False, "--json", help="Print the outcome as JSON."),
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Push a run's results to a Steppy control plane.
|
|
262
|
+
|
|
263
|
+
Uploads are content-addressed: the server is offered the manifest and hands
|
|
264
|
+
back URLs only for the files it does not already hold, so a re-run that
|
|
265
|
+
changed two screenshots uploads two screenshots. Interrupted? Run it again —
|
|
266
|
+
it attaches to the same run and sends only what is missing.
|
|
267
|
+
|
|
268
|
+
Run your scenarios with `steppy run --bundle` first. Two things depend on
|
|
269
|
+
it: the per-test event records, without which the plane gets an empty
|
|
270
|
+
tests[] and no failure detail; and the run identity in
|
|
271
|
+
output/.steppy-run.json, which is the idempotency key that makes a retried
|
|
272
|
+
upload attach rather than create a second run. Without a recorded identity
|
|
273
|
+
a fresh one is minted per invocation — pass --run-id to pin it, or --attach
|
|
274
|
+
to upload into a run that already exists.
|
|
275
|
+
"""
|
|
276
|
+
with _cli_errors():
|
|
277
|
+
from .bundle import BundleError, plan_bundle
|
|
278
|
+
from .cloud import ArchiveSource, CloudError, TreeSource, cloud_client, derive_exit_code, upload_bundle
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
if bundle_path is not None:
|
|
282
|
+
source = ArchiveSource(bundle_path)
|
|
283
|
+
else:
|
|
284
|
+
source = TreeSource(
|
|
285
|
+
plan_bundle(
|
|
286
|
+
project_root=project_root,
|
|
287
|
+
output_dir=output_dir,
|
|
288
|
+
run_id=run_id,
|
|
289
|
+
git_sha=git_sha,
|
|
290
|
+
git_branch=git_branch,
|
|
291
|
+
pr=pr,
|
|
292
|
+
guides=guides,
|
|
293
|
+
traces=traces,
|
|
294
|
+
videos=videos,
|
|
295
|
+
coverage=coverage,
|
|
296
|
+
max_size=_parse_size(max_size),
|
|
297
|
+
executor=executor,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
except BundleError as e:
|
|
301
|
+
_error(str(e))
|
|
302
|
+
raise typer.Exit(1)
|
|
303
|
+
|
|
304
|
+
manifest = source.manifest
|
|
305
|
+
if dry_run:
|
|
306
|
+
_print_summary(manifest, run_id_source="planned")
|
|
307
|
+
console.print(
|
|
308
|
+
f"\n[dim]Dry run — nothing uploaded. {len(manifest.files)} file(s), "
|
|
309
|
+
f"{_human(sum(f.size for f in manifest.files))} before dedup; "
|
|
310
|
+
f"verdict would be exit {derive_exit_code(manifest) if exit_code is None else exit_code}.[/dim]"
|
|
311
|
+
)
|
|
312
|
+
raise typer.Exit(0)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
client = cloud_client(url=url, token=token, config_url=_config_cloud_url(project_root), timeout=timeout)
|
|
316
|
+
except CloudError as e:
|
|
317
|
+
_error(str(e))
|
|
318
|
+
raise typer.Exit(1)
|
|
319
|
+
|
|
320
|
+
with client:
|
|
321
|
+
try:
|
|
322
|
+
outcome = _run_upload(
|
|
323
|
+
upload_bundle,
|
|
324
|
+
client,
|
|
325
|
+
source,
|
|
326
|
+
run_id=attach,
|
|
327
|
+
executor=executor,
|
|
328
|
+
exit_code=exit_code,
|
|
329
|
+
concurrency=concurrency,
|
|
330
|
+
)
|
|
331
|
+
except CloudError as e:
|
|
332
|
+
_error(str(e))
|
|
333
|
+
raise typer.Exit(1)
|
|
334
|
+
|
|
335
|
+
if as_json:
|
|
336
|
+
typer.echo(_json.dumps(outcome.as_dict(), indent=2))
|
|
337
|
+
raise typer.Exit(0)
|
|
338
|
+
|
|
339
|
+
_print_upload_summary(outcome, client.endpoint.url)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _run_upload(upload_bundle, client, source, *, run_id, executor, exit_code, concurrency):
|
|
343
|
+
"""Drive the upload behind a byte-oriented progress bar.
|
|
344
|
+
|
|
345
|
+
Progress is measured in bytes rather than files: a bundle is a handful of
|
|
346
|
+
megabyte screenshots among hundreds of tiny sidecars, so a file counter
|
|
347
|
+
would sit at 90% for most of the wall clock.
|
|
348
|
+
"""
|
|
349
|
+
from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
|
|
350
|
+
|
|
351
|
+
total = sum(f.size for f in source.manifest.files)
|
|
352
|
+
with Progress(
|
|
353
|
+
TextColumn("[progress.description]{task.description}"),
|
|
354
|
+
BarColumn(),
|
|
355
|
+
TextColumn("{task.fields[sent]}"),
|
|
356
|
+
TimeElapsedColumn(),
|
|
357
|
+
console=console,
|
|
358
|
+
transient=True,
|
|
359
|
+
) as progress:
|
|
360
|
+
bar = progress.add_task("uploading", total=total or 1, sent="")
|
|
361
|
+
sent = 0
|
|
362
|
+
|
|
363
|
+
def advance(blob) -> None:
|
|
364
|
+
nonlocal sent
|
|
365
|
+
sent += blob.size
|
|
366
|
+
progress.update(bar, completed=sent, sent=_human(sent))
|
|
367
|
+
|
|
368
|
+
return upload_bundle(
|
|
369
|
+
client,
|
|
370
|
+
source,
|
|
371
|
+
run_id=run_id,
|
|
372
|
+
executor=executor,
|
|
373
|
+
exit_code=exit_code,
|
|
374
|
+
concurrency=concurrency,
|
|
375
|
+
on_progress=advance,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _print_upload_summary(outcome, base_url: str) -> None:
|
|
380
|
+
verb = "Created run" if outcome.created_run else "Uploaded to run"
|
|
381
|
+
console.print(f"\n[bold]{verb}[/bold] #{outcome.run_number} [dim]{outcome.run_id}[/dim]")
|
|
382
|
+
|
|
383
|
+
# Blobs, not files: uploads are content-addressed, so a screenshot that is
|
|
384
|
+
# byte-identical across two locales is one upload serving two manifest
|
|
385
|
+
# entries. Reporting "3 of 4 files" for that would be a lie in both numbers.
|
|
386
|
+
if outcome.uploaded:
|
|
387
|
+
console.print(
|
|
388
|
+
f"[bold]Uploaded[/bold] {outcome.uploaded} of {outcome.total_blobs} blob(s), "
|
|
389
|
+
f"{_human(outcome.uploaded_bytes)} [dim]({outcome.total_files} file(s))[/dim]"
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
console.print("[bold]Uploaded[/bold] nothing — the server already held every blob in this run")
|
|
393
|
+
if outcome.skipped_bytes:
|
|
394
|
+
console.print(f"[dim]{_human(outcome.skipped_bytes)} not sent — already held (content-addressed dedup)[/dim]")
|
|
395
|
+
|
|
396
|
+
for warning in outcome.warnings:
|
|
397
|
+
console.print(f"[yellow]WARNING[/yellow] {warning}")
|
|
398
|
+
|
|
399
|
+
colour = {"completed": "green", "failed": "red", "error": "red", "expired": "red", "canceled": "yellow"}.get(
|
|
400
|
+
outcome.run_status, "yellow"
|
|
401
|
+
)
|
|
402
|
+
console.print(f"[bold]Status[/bold] [{colour}]{outcome.run_status}[/{colour}]")
|
|
403
|
+
console.print(f"[dim]{base_url}/runs/{outcome.run_id}[/dim]")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@bundle_app.command("inspect")
|
|
407
|
+
def inspect(
|
|
408
|
+
bundle_path: Path = typer.Argument(..., help="Path to a .tar.zst or .tar.gz bundle."),
|
|
409
|
+
as_json: bool = typer.Option(False, "--json", help="Print the raw manifest JSON."),
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Print a bundle's manifest without extracting it."""
|
|
412
|
+
with _cli_errors():
|
|
413
|
+
from .bundle import BundleError, inspect_bundle
|
|
414
|
+
from .bundle.create import manifest_bytes
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
manifest = inspect_bundle(bundle_path)
|
|
418
|
+
except BundleError as e:
|
|
419
|
+
_error(str(e))
|
|
420
|
+
raise typer.Exit(1)
|
|
421
|
+
|
|
422
|
+
if as_json:
|
|
423
|
+
typer.echo(manifest_bytes(manifest).decode("utf-8"), nl=False)
|
|
424
|
+
raise typer.Exit(0)
|
|
425
|
+
_print_summary(manifest, run_id_source="archived")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@bundle_app.command("verify")
|
|
429
|
+
def verify(
|
|
430
|
+
bundle_path: Path = typer.Argument(..., help="Path to a .tar.zst or .tar.gz bundle."),
|
|
431
|
+
as_json: bool = typer.Option(False, "--json", help="Print the problem list as JSON."),
|
|
432
|
+
) -> None:
|
|
433
|
+
"""Re-hash every archived file against the manifest.
|
|
434
|
+
|
|
435
|
+
Exits non-zero if anything is missing, extra, or corrupted.
|
|
436
|
+
"""
|
|
437
|
+
with _cli_errors():
|
|
438
|
+
from .bundle import BundleError, verify_bundle
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
manifest, problems = verify_bundle(bundle_path)
|
|
442
|
+
except BundleError as e:
|
|
443
|
+
_error(str(e))
|
|
444
|
+
raise typer.Exit(1)
|
|
445
|
+
|
|
446
|
+
if as_json:
|
|
447
|
+
typer.echo(_json.dumps({"ok": not problems, "problems": problems}, indent=2))
|
|
448
|
+
raise typer.Exit(1 if problems else 0)
|
|
449
|
+
|
|
450
|
+
if problems:
|
|
451
|
+
_error(f"{len(problems)} problem(s) in {bundle_path.name}:")
|
|
452
|
+
for problem in problems:
|
|
453
|
+
console.print(f" [red]·[/red] {problem}")
|
|
454
|
+
raise typer.Exit(1)
|
|
455
|
+
console.print(f"[green]OK[/green] {bundle_path.name}: {len(manifest.files)} file(s) match the manifest")
|
steppy/captures.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Capture-path grammar — the filename and directory shape of ``output/screenshots``.
|
|
2
|
+
|
|
3
|
+
The *write* side of this grammar lives in the generated conftest
|
|
4
|
+
(``_screenshot_folder`` / ``take_screenshot`` in ``templates/conftest.py.j2``):
|
|
5
|
+
a capture lands at
|
|
6
|
+
|
|
7
|
+
<screenshots_dir>/<slug>/[<locale>/][<layout>/]step-NN[-<frame>].(png|json)
|
|
8
|
+
|
|
9
|
+
with the locale segment appended before the layout segment, and either omitted
|
|
10
|
+
when that dimension is not parametrized.
|
|
11
|
+
|
|
12
|
+
Three consumers have to agree on that shape, and historically each answered it
|
|
13
|
+
independently (see :meth:`steppy.guide.GuideAssembler.resolve_capture_segments`
|
|
14
|
+
for what that cost): the guide read side, the build's copy/URL side, and — since
|
|
15
|
+
the results bundle — the *inverse* direction, turning a path found on disk back
|
|
16
|
+
into ``(scenario, locale, layout, step, frame)``.
|
|
17
|
+
|
|
18
|
+
This module is the one home for the grammar — including its *root*, which
|
|
19
|
+
:func:`resolve_output_dir` derives from ``config.output`` for every consumer
|
|
20
|
+
that used to hardcode ``output/screenshots`` of its own (FLAWS §4.6).
|
|
21
|
+
|
|
22
|
+
It is a **leaf**: stdlib only, no ``jinja2``/``markdown_it``/``pydantic``, so
|
|
23
|
+
the bundler can import it without dragging ``guide.py``'s rendering stack onto
|
|
24
|
+
the default CLI path. ``guide.py`` re-exports :func:`step_stem` and
|
|
25
|
+
:func:`_has_captures` so its own callers are unaffected.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: ``step-07.png``, ``step-07.json``, ``step-07-b.png``. The step number is
|
|
37
|
+
#: written zero-padded to two digits but matched more loosely — a scenario with
|
|
38
|
+
#: 100+ steps must not silently stop being recognised.
|
|
39
|
+
STEP_FILE_RE = re.compile(r"^step-(\d{2,})(?:-([a-z]+))?\.(png|json)$")
|
|
40
|
+
|
|
41
|
+
#: Directory holding full-page screenshots of failed tests, keyed by a sanitised
|
|
42
|
+
#: pytest nodeid rather than by scenario/step. Written by the generated
|
|
43
|
+
#: conftest's ``pytest_runtest_makereport`` hook.
|
|
44
|
+
FAILURES_DIRNAME = "_failures"
|
|
45
|
+
|
|
46
|
+
#: Fallbacks for ``config.output.*``. The grammar above hangs off a *root*, and
|
|
47
|
+
#: that root is the last piece of it that used to be re-derived per consumer —
|
|
48
|
+
#: four independent copies of the same literal (FLAWS §4.6). These are the only
|
|
49
|
+
#: two left, and the generated conftest carries a third by necessity (it is
|
|
50
|
+
#: standalone rendered code and cannot import this module).
|
|
51
|
+
DEFAULT_SCREENSHOTS_DIR = "output/screenshots"
|
|
52
|
+
DEFAULT_GUIDES_DIR = "output/guides"
|
|
53
|
+
#: Canonical in-app tour tree (``<tours_dir>/<build-id>/``); see ``steppy.tours``.
|
|
54
|
+
DEFAULT_TOURS_DIR = "output/tours"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_output_dir(project_root: Path, configured: Optional[str], default: str) -> Path:
|
|
58
|
+
"""Resolve a ``config.output.*`` value against the project root.
|
|
59
|
+
|
|
60
|
+
A relative value (``./output/screenshots``) lands under the project; an
|
|
61
|
+
absolute one wins outright. Both are plain ``Path.__truediv__`` semantics,
|
|
62
|
+
deliberately — the same rule the conftest applies to
|
|
63
|
+
``coverage.records_dir``, so writer and readers cannot disagree.
|
|
64
|
+
|
|
65
|
+
An empty/absent value falls back to *default*, because ``CONFIG`` is raw
|
|
66
|
+
YAML on the writer side: an absent ``output:`` block yields ``None`` there
|
|
67
|
+
and pydantic's ``default_factory`` never runs.
|
|
68
|
+
"""
|
|
69
|
+
return (Path(project_root) / (configured or default)).resolve()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def step_stem(step_index: int, frame: Optional[str] = None) -> str:
|
|
73
|
+
"""Filename stem for a captured step: ``step-07`` or ``step-07-b``.
|
|
74
|
+
|
|
75
|
+
One definition shared by the read side (guide.py) and the build's URL
|
|
76
|
+
builders, so a change to the capture naming scheme can't half-land.
|
|
77
|
+
"""
|
|
78
|
+
return f"step-{step_index:02d}" + (f"-{frame}" if frame else "")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _has_captures(directory: Path) -> bool:
|
|
82
|
+
"""True when *directory* actually holds screenshots.
|
|
83
|
+
|
|
84
|
+
The single definition of "this is a capture directory". Existence alone is
|
|
85
|
+
not enough: the generated conftest's ``_screenshot_folder`` mkdirs before
|
|
86
|
+
calling ``page.screenshot``, so an interrupted run leaves empty directories
|
|
87
|
+
behind, and a per-locale directory that only holds per-layout subdirs holds
|
|
88
|
+
no captures of its own.
|
|
89
|
+
"""
|
|
90
|
+
return directory.is_dir() and any(directory.glob("step-*.png"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class StepFile:
|
|
95
|
+
"""A parsed capture filename. The inverse of :func:`step_stem`."""
|
|
96
|
+
|
|
97
|
+
step: int
|
|
98
|
+
frame: Optional[str]
|
|
99
|
+
ext: str # "png" | "json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_step_stem(filename: str) -> Optional[StepFile]:
|
|
103
|
+
"""Parse ``step-NN[-<frame>].(png|json)``. ``None`` when it isn't one.
|
|
104
|
+
|
|
105
|
+
Round-trips with :func:`step_stem`::
|
|
106
|
+
|
|
107
|
+
parse_step_stem(step_stem(7, "b") + ".png") == StepFile(7, "b", "png")
|
|
108
|
+
"""
|
|
109
|
+
match = STEP_FILE_RE.match(filename)
|
|
110
|
+
if match is None:
|
|
111
|
+
return None
|
|
112
|
+
number, frame, ext = match.groups()
|
|
113
|
+
return StepFile(step=int(number), frame=frame, ext=ext)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class CaptureRef:
|
|
118
|
+
"""A capture file located in the screenshots tree."""
|
|
119
|
+
|
|
120
|
+
scenario: str
|
|
121
|
+
locale: Optional[str]
|
|
122
|
+
layout: Optional[str]
|
|
123
|
+
step: int
|
|
124
|
+
frame: Optional[str]
|
|
125
|
+
ext: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def classify_capture_path(
|
|
129
|
+
rel_parts: tuple[str, ...] | list[str],
|
|
130
|
+
*,
|
|
131
|
+
locales: frozenset[str] | set[str] = frozenset(),
|
|
132
|
+
layouts: frozenset[str] | set[str] = frozenset(),
|
|
133
|
+
) -> Optional[CaptureRef]:
|
|
134
|
+
"""Invert the capture path shape. ``rel_parts`` is relative to the
|
|
135
|
+
screenshots dir, e.g. ``("order-lifecycle", "fr", "mobile", "step-01.png")``.
|
|
136
|
+
|
|
137
|
+
Returns ``None`` for anything that is not a step capture — including the
|
|
138
|
+
``_failures/`` tree, whose filenames are sanitised pytest nodeids rather
|
|
139
|
+
than steps.
|
|
140
|
+
|
|
141
|
+
The middle segments are what need deciding, and only one case is genuinely
|
|
142
|
+
ambiguous:
|
|
143
|
+
|
|
144
|
+
* **0 segments** — flat/legacy capture, ``(None, None)``.
|
|
145
|
+
* **2 segments** — unambiguous. The writer appends locale then layout, in
|
|
146
|
+
that order, and never any other pair.
|
|
147
|
+
* **1 segment** — ``<slug>/fr/`` and ``<slug>/desktop/`` are structurally
|
|
148
|
+
identical, so the name is the only signal. Resolved by membership in the
|
|
149
|
+
configured sets; when it matches neither (a locale or layout removed
|
|
150
|
+
from ``config.yaml`` after the capture), it is read as a **locale**,
|
|
151
|
+
mirroring the "locale beats layout" precedence
|
|
152
|
+
:meth:`~steppy.guide.GuideAssembler.resolve_capture_segments` documents
|
|
153
|
+
— the locale determines the language actually visible in the image, and
|
|
154
|
+
mislabelling that is worse than mislabelling a viewport.
|
|
155
|
+
"""
|
|
156
|
+
parts = tuple(rel_parts)
|
|
157
|
+
if len(parts) < 2 or parts[0] == FAILURES_DIRNAME:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
parsed = parse_step_stem(parts[-1])
|
|
161
|
+
if parsed is None:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
scenario = parts[0]
|
|
165
|
+
middle = parts[1:-1]
|
|
166
|
+
|
|
167
|
+
if len(middle) == 0:
|
|
168
|
+
locale, layout = None, None
|
|
169
|
+
elif len(middle) == 1:
|
|
170
|
+
segment = middle[0]
|
|
171
|
+
if segment in locales:
|
|
172
|
+
locale, layout = segment, None
|
|
173
|
+
elif segment in layouts:
|
|
174
|
+
locale, layout = None, segment
|
|
175
|
+
else:
|
|
176
|
+
locale, layout = segment, None
|
|
177
|
+
elif len(middle) == 2:
|
|
178
|
+
locale, layout = middle[0], middle[1]
|
|
179
|
+
else:
|
|
180
|
+
# Deeper than the writer can produce — not ours to interpret.
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
return CaptureRef(
|
|
184
|
+
scenario=scenario,
|
|
185
|
+
locale=locale,
|
|
186
|
+
layout=layout,
|
|
187
|
+
step=parsed.step,
|
|
188
|
+
frame=parsed.frame,
|
|
189
|
+
ext=parsed.ext,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def sanitise_nodeid(nodeid: str) -> str:
|
|
194
|
+
"""Make a pytest nodeid filesystem-safe. Mirror of ``_sanitise_nodeid``.
|
|
195
|
+
|
|
196
|
+
**Byte-mirrored** from ``_sanitise_nodeid`` in ``templates/conftest.py.j2``,
|
|
197
|
+
which names the failure screenshots, traces and videos. Guard:
|
|
198
|
+
``tests/test_nodeid_sanitiser_mirror.py`` — same arrangement as
|
|
199
|
+
``totp._totp_now`` (CLAUDE.md). Apply any change to both.
|
|
200
|
+
|
|
201
|
+
The mapping is **lossy and not invertible**: a slug containing ``_`` is
|
|
202
|
+
indistinguishable from a separator, and ``(fr-mobile)`` cannot be told from
|
|
203
|
+
a literal parenthesis in a test name. So consumers must build the *forward*
|
|
204
|
+
map — sanitise every nodeid they know about and look filenames up in it —
|
|
205
|
+
never attempt to reverse a filename.
|
|
206
|
+
"""
|
|
207
|
+
return nodeid.replace("/", "_").replace("\\", "_").replace("::", "__").replace("[", "(").replace("]", ")")
|