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/migrate/verify.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Prove a proposed rewrite before it is written to disk.
|
|
2
|
+
|
|
3
|
+
A codemod that silently changes what a test does is worse than no codemod: the
|
|
4
|
+
scenario still *looks* complete, and the coverage it lost is invisible. So every
|
|
5
|
+
rewrite is checked, and anything that fails is reported rather than applied.
|
|
6
|
+
|
|
7
|
+
**What is NOT checked, and why.** The first design compared the two sides
|
|
8
|
+
textually. That is the wrong bar: ``page.click(sel)`` and
|
|
9
|
+
``page.locator(sel).click()`` are the same operation spelled two ways, and every
|
|
10
|
+
declarative step re-spells the call it replaces. Worse, the rewrites most worth
|
|
11
|
+
making are the ones that deliberately differ — a bare ``.check(force=True)``
|
|
12
|
+
becomes an *idempotent* ``toggle`` that will not untick an already-ticked box on
|
|
13
|
+
a re-run. A textual bar rejects exactly those.
|
|
14
|
+
|
|
15
|
+
**What is checked**, on every block:
|
|
16
|
+
|
|
17
|
+
1. the proposed steps build real ``Step`` models and generate parseable Python
|
|
18
|
+
(the render itself proves the first, ``ast.parse`` the second);
|
|
19
|
+
2. **no selector is lost** — every string literal the original named is still
|
|
20
|
+
named, compared as values so ``'[data-testid=\\"row\\"]'`` and
|
|
21
|
+
``'[data-testid="row"]'`` count as one;
|
|
22
|
+
3. **the operations are conserved** — every Playwright call the original made
|
|
23
|
+
still happens, up to the substitution table below, which is the complete list
|
|
24
|
+
of places a rewrite is allowed to change behaviour and says why for each.
|
|
25
|
+
|
|
26
|
+
That is a real bar and it catches the failures that matter: a rule that drops an
|
|
27
|
+
argument, mangles a timeout into the wrong slot, or quietly turns two operations
|
|
28
|
+
into one. It is not a proof of equivalence, and it is not a substitute for
|
|
29
|
+
reading the diff — which is why ``--dry-run`` prints one.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import ast
|
|
35
|
+
import re
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
|
|
38
|
+
__all__ = ["SUBSTITUTIONS", "VerificationResult", "verify_rewrite"]
|
|
39
|
+
|
|
40
|
+
#: Where a rewrite may legitimately change the emitted operations, and why.
|
|
41
|
+
#: ``original method -> (methods that may stand for it, reason)``.
|
|
42
|
+
SUBSTITUTIONS: dict[str, tuple[frozenset[str], str]] = {
|
|
43
|
+
"check": (frozenset({"check", "is_checked"}), "toggle is idempotent: a re-run cannot untick an already-ticked box"),
|
|
44
|
+
"uncheck": (frozenset({"uncheck", "is_checked"}), "toggle is idempotent"),
|
|
45
|
+
"wait_for_timeout": (
|
|
46
|
+
frozenset({"sleep"}),
|
|
47
|
+
"a hard sleep becomes `wait`'s time.sleep, or folds into the previous step's wait_after",
|
|
48
|
+
),
|
|
49
|
+
"wait_for": (frozenset({"wait_for", "wait_for_selector"}), "the locator and page forms are interchangeable"),
|
|
50
|
+
"wait_for_selector": (frozenset({"wait_for", "wait_for_selector"}), "as above"),
|
|
51
|
+
"wait_for_load_state": (
|
|
52
|
+
frozenset({"wait_for_load_state"}),
|
|
53
|
+
"folds into the preceding navigate's `wait:`, which emits the same call",
|
|
54
|
+
),
|
|
55
|
+
"is_visible": (frozenset({"to_be_visible", "expect"}), "an assert becomes Playwright's auto-waiting expect()"),
|
|
56
|
+
"count": (frozenset({"count", "to_have_count", "expect"}), "as above"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#: Calls that only *build* a locator. They carry no behaviour, so a rewrite that
|
|
60
|
+
#: adds or drops one has changed nothing.
|
|
61
|
+
_PLUMBING = frozenset(
|
|
62
|
+
{"locator", "first", "last", "nth", "_interp", "_interp_val", "print", "strip", "format", "get_attribute"}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
#: A helper call in the original becomes `call_helper` in the rewrite. The
|
|
66
|
+
#: helper's own name is not a Playwright operation, so it is matched by name.
|
|
67
|
+
_CALL_HELPER = "call_helper"
|
|
68
|
+
|
|
69
|
+
_STRING = re.compile(r"'(?:[^'\\]|\\.)*'" + r'|"(?:[^"\\]|\\.)*"')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class VerificationResult:
|
|
74
|
+
ok: bool
|
|
75
|
+
detail: str = ""
|
|
76
|
+
substitutions: list[str] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _literal_values(code: str) -> set[str]:
|
|
80
|
+
"""Every string literal in ``code``, as its *value*.
|
|
81
|
+
|
|
82
|
+
Collected by parsing, not by scanning. A regex over the source cannot see
|
|
83
|
+
where a string begins: the generated file opens with a triple-quoted
|
|
84
|
+
docstring, and a naive scanner reads ``\"\"\"`` as an empty string followed by
|
|
85
|
+
a real one, then mis-tokenises everything after it — which showed up as
|
|
86
|
+
"the rewrite would drop: , visible", a fragment of a call that was in fact
|
|
87
|
+
still there.
|
|
88
|
+
|
|
89
|
+
Values, not source: a ``custom`` body writes ``'[data-testid=\\"row\\"]'``
|
|
90
|
+
where a step writes ``'[data-testid="row"]'``, and those name one selector.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
tree = ast.parse(code)
|
|
94
|
+
except SyntaxError:
|
|
95
|
+
# A `custom` body is not always a parseable module on its own; fall back
|
|
96
|
+
# to the scanner, which is good enough for the fragments it can see.
|
|
97
|
+
return {m.group(0)[1:-1] for m in _STRING.finditer(code)}
|
|
98
|
+
return {node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _methods(code: str) -> set[str]:
|
|
102
|
+
"""Every called name in ``code``, excluding pure locator plumbing.
|
|
103
|
+
|
|
104
|
+
Parsed, not grepped: a method name inside a string or a comment is not a
|
|
105
|
+
call, and the whole point is to compare what actually runs.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
tree = ast.parse(code)
|
|
109
|
+
except SyntaxError:
|
|
110
|
+
return set()
|
|
111
|
+
names = set()
|
|
112
|
+
for node in ast.walk(tree):
|
|
113
|
+
if not isinstance(node, ast.Call):
|
|
114
|
+
continue
|
|
115
|
+
func = node.func
|
|
116
|
+
if isinstance(func, ast.Attribute):
|
|
117
|
+
names.add(func.attr)
|
|
118
|
+
elif isinstance(func, ast.Name):
|
|
119
|
+
names.add(func.id)
|
|
120
|
+
return names - _PLUMBING
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _without_preamble(code: str) -> str:
|
|
124
|
+
"""The original minus the import machinery a rewrite deliberately deletes.
|
|
125
|
+
|
|
126
|
+
``sys.path.insert`` and the helper import are not operations the test
|
|
127
|
+
performs; they are the scaffolding that let it reach a helper at all, and
|
|
128
|
+
removing them is the point of the ``call`` action.
|
|
129
|
+
"""
|
|
130
|
+
from .rules import _IMPORT, _NOISE, split_statements
|
|
131
|
+
|
|
132
|
+
kept = [s for s in split_statements(code) if not _NOISE.match(s) and not _IMPORT.match(s)]
|
|
133
|
+
return "\n".join(kept)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def verify_rewrite(original_code: str, generated: str, steps: list[dict]) -> VerificationResult:
|
|
137
|
+
"""Check a proposed rewrite against the ``custom`` block it replaces."""
|
|
138
|
+
try:
|
|
139
|
+
ast.parse(generated)
|
|
140
|
+
except SyntaxError as exc:
|
|
141
|
+
return VerificationResult(False, f"generated code does not parse: {exc}")
|
|
142
|
+
|
|
143
|
+
lost = _literal_values(original_code) - _literal_values(generated)
|
|
144
|
+
# The `sys.path` preamble names two paths that exist only to make the import
|
|
145
|
+
# work; the rewrite removes the import, so those literals go with it.
|
|
146
|
+
lost = {s for s in lost if s not in (".", "/steppy/project")}
|
|
147
|
+
if lost:
|
|
148
|
+
return VerificationResult(False, "the rewrite would drop: " + ", ".join(sorted(lost)[:3]))
|
|
149
|
+
|
|
150
|
+
before, after = _methods(_without_preamble(original_code)), _methods(generated)
|
|
151
|
+
# A `call` step routes the helper through `call_helper`, which is the whole
|
|
152
|
+
# point: the helper name moves from a call to a string argument.
|
|
153
|
+
if _CALL_HELPER in after:
|
|
154
|
+
after = after | {s.get("fn", "").rpartition(".")[2] for s in steps if s.get("action") == "call"}
|
|
155
|
+
missing = before - after
|
|
156
|
+
used: list[str] = []
|
|
157
|
+
for method in sorted(missing):
|
|
158
|
+
allowed, reason = SUBSTITUTIONS.get(method, (frozenset(), ""))
|
|
159
|
+
if allowed & after or (method == "wait_for_timeout" and any("wait_after" in s for s in steps)):
|
|
160
|
+
used.append(f"{method}: {reason}")
|
|
161
|
+
continue
|
|
162
|
+
return VerificationResult(False, f"the rewrite would stop calling {method}()")
|
|
163
|
+
return VerificationResult(True, substitutions=used)
|
steppy/migrate_cli.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""``steppy migrate custom`` — rewrite mechanical custom blocks as real steps.
|
|
2
|
+
|
|
3
|
+
A leaf CLI module, registered via ``register_migrate(app)`` like
|
|
4
|
+
``register_flaky``. Everything under :mod:`steppy.migrate` is imported *inside*
|
|
5
|
+
the command body, so ``import steppy.cli`` stays free of it
|
|
6
|
+
(``tests/test_no_migrate_import_on_default_path.py``).
|
|
7
|
+
|
|
8
|
+
The command is deliberately unexciting: it shows you a diff, tells you what it
|
|
9
|
+
would not touch and why, and only writes when you drop ``--dry-run``. The
|
|
10
|
+
residue it reports is the useful part — it is the input to the next round of
|
|
11
|
+
commands.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
import json as _json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich.markup import escape
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
from ._cli_common import _cli_errors, _load_config_or_exit, console
|
|
26
|
+
|
|
27
|
+
migrate_app = typer.Typer(help="Rewrite mechanical `action: custom` blocks as declarative steps.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_migrate(app: typer.Typer) -> None:
|
|
31
|
+
app.add_typer(migrate_app, name="migrate")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@migrate_app.command("custom")
|
|
35
|
+
def migrate_custom(
|
|
36
|
+
scenarios_dir: Path = typer.Option(Path("scenarios"), "--scenarios-dir", help="Where the scenario files live."),
|
|
37
|
+
scenario: Optional[str] = typer.Option(None, "--scenario", help="Only this scenario (by file stem)."),
|
|
38
|
+
dry_run: bool = typer.Option(True, "--dry-run/--write", help="Preview (default) or rewrite the files."),
|
|
39
|
+
show_diff: bool = typer.Option(False, "--diff", help="Print the rewrite for every converted block."),
|
|
40
|
+
show_skipped: bool = typer.Option(False, "--skipped", help="List every block left alone, with the reason."),
|
|
41
|
+
report: Optional[Path] = typer.Option(None, "--report", help="Write the full result as JSON."),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Convert the ``custom`` blocks that are pure Playwright plumbing.
|
|
44
|
+
|
|
45
|
+
A block is converted only when EVERY statement in it maps to a rule, and
|
|
46
|
+
only when the rewrite passes verification: the generated code parses, no
|
|
47
|
+
selector is lost, and every operation is conserved. Everything else is left
|
|
48
|
+
exactly as it was and reported.
|
|
49
|
+
"""
|
|
50
|
+
with _cli_errors():
|
|
51
|
+
from .generator import SteppyGenerator
|
|
52
|
+
from .migrate import apply_plan, plan_scenarios
|
|
53
|
+
|
|
54
|
+
if not scenarios_dir.is_dir():
|
|
55
|
+
console.print(f"[red]No scenarios directory at {scenarios_dir}[/red]")
|
|
56
|
+
raise typer.Exit(code=1)
|
|
57
|
+
|
|
58
|
+
config = _load_config_or_exit()
|
|
59
|
+
generator = SteppyGenerator(config)
|
|
60
|
+
|
|
61
|
+
plans = plan_scenarios(scenarios_dir, only=scenario)
|
|
62
|
+
if not plans:
|
|
63
|
+
console.print("[green]No `action: custom` blocks found — nothing to migrate.[/green]")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
results = []
|
|
67
|
+
converted = skipped = 0
|
|
68
|
+
steps_before = steps_after = 0
|
|
69
|
+
for plan in plans:
|
|
70
|
+
outcomes = apply_plan(plan, generator, write=not dry_run)
|
|
71
|
+
for outcome in outcomes:
|
|
72
|
+
verification = outcome.verification
|
|
73
|
+
if outcome.applied:
|
|
74
|
+
converted += 1
|
|
75
|
+
steps_before += 1
|
|
76
|
+
steps_after += len(outcome.step.steps or [])
|
|
77
|
+
else:
|
|
78
|
+
skipped += 1
|
|
79
|
+
results.append(
|
|
80
|
+
{
|
|
81
|
+
"file": plan.path.name,
|
|
82
|
+
"line": outcome.step.line,
|
|
83
|
+
"heading": outcome.step.heading.strip(),
|
|
84
|
+
"applied": outcome.applied,
|
|
85
|
+
"reason": (verification.detail if verification else outcome.step.reason)
|
|
86
|
+
or "; ".join(verification.substitutions if verification else []),
|
|
87
|
+
"steps": len(outcome.step.steps or []),
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
if show_diff and outcome.applied:
|
|
91
|
+
_print_block_diff(plan.path.name, outcome)
|
|
92
|
+
|
|
93
|
+
_print_summary(converted, skipped, steps_before, steps_after, dry_run)
|
|
94
|
+
if show_skipped:
|
|
95
|
+
_print_skipped(results)
|
|
96
|
+
if report:
|
|
97
|
+
report.write_text(_json.dumps(results, indent=2), encoding="utf-8")
|
|
98
|
+
console.print(f"[dim]report: {report}[/dim]")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _print_block_diff(filename: str, outcome) -> None:
|
|
102
|
+
original = ["- action: custom", "- code: |"] + [" " + line for line in outcome.step.code.splitlines()]
|
|
103
|
+
rewritten = [line for line in outcome.markdown.splitlines() if not line.startswith("###")]
|
|
104
|
+
console.print(f"\n[bold]{filename}:{outcome.step.line}[/bold] {outcome.step.heading.strip()}")
|
|
105
|
+
for line in difflib.unified_diff(original, rewritten, lineterm="", n=99):
|
|
106
|
+
if line.startswith(("---", "+++", "@@")):
|
|
107
|
+
continue
|
|
108
|
+
style = "red" if line.startswith("-") else "green" if line.startswith("+") else "dim"
|
|
109
|
+
console.print(f" [{style}]{escape(line)}[/{style}]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _print_summary(converted: int, skipped: int, before: int, after: int, dry_run: bool) -> None:
|
|
113
|
+
total = converted + skipped
|
|
114
|
+
verb = "would convert" if dry_run else "converted"
|
|
115
|
+
console.print()
|
|
116
|
+
table = Table(show_header=True, header_style="bold")
|
|
117
|
+
table.add_column("custom blocks", justify="right")
|
|
118
|
+
table.add_column(verb, justify="right")
|
|
119
|
+
table.add_column("left alone", justify="right")
|
|
120
|
+
table.add_column("steps produced", justify="right")
|
|
121
|
+
table.add_row(
|
|
122
|
+
str(total),
|
|
123
|
+
f"[green]{converted}[/green]" + (f" ({converted / total:.0%})" if total else ""),
|
|
124
|
+
str(skipped),
|
|
125
|
+
f"{after} from {before}" + (f" ({after / before:.2f}×)" if before else ""),
|
|
126
|
+
)
|
|
127
|
+
console.print(table)
|
|
128
|
+
if after > before:
|
|
129
|
+
# Said out loud because the collapse metric counts blocks removed and is
|
|
130
|
+
# silent about steps added — and in a guide scenario every added step is
|
|
131
|
+
# a published step with its own number and screenshot slot.
|
|
132
|
+
console.print(
|
|
133
|
+
f"[yellow]Note:[/yellow] {after - before} more steps than blocks. A block holding two "
|
|
134
|
+
"operations becomes two steps; trailing sleeps are folded into wait_after: rather than "
|
|
135
|
+
"becoming steps of their own."
|
|
136
|
+
)
|
|
137
|
+
if dry_run:
|
|
138
|
+
console.print("[dim]Nothing was written. Re-run with --write to apply.[/dim]")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _print_skipped(results: list[dict]) -> None:
|
|
142
|
+
reasons: dict[str, int] = {}
|
|
143
|
+
for row in results:
|
|
144
|
+
if row["applied"]:
|
|
145
|
+
continue
|
|
146
|
+
reasons[row["reason"] or "unknown"] = reasons.get(row["reason"] or "unknown", 0) + 1
|
|
147
|
+
if not reasons:
|
|
148
|
+
return
|
|
149
|
+
console.print("\n[bold]Why blocks were left alone[/bold] [dim](the input to the next round)[/dim]")
|
|
150
|
+
for reason, count in sorted(reasons.items(), key=lambda kv: -kv[1])[:15]:
|
|
151
|
+
# A selector is full of square brackets, which Rich reads as markup.
|
|
152
|
+
console.print(f" {count:5d} {escape(reason)}")
|