steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/video_cache.py ADDED
@@ -0,0 +1,239 @@
1
+ """Inspect and reclaim the two render caches — a **stdlib-only leaf**.
2
+
3
+ ``.steppy/tts-cache/`` and ``.steppy/video-cache/`` are content-addressed, which
4
+ makes them correct and makes them grow forever: every edited sentence and every
5
+ re-render leaves its predecessor behind, and a Piper upgrade orphans every clip
6
+ at once because the engine version is in the key. Nothing ever collected them,
7
+ and an MP4 corpus is measured in gigabytes.
8
+
9
+ Two rules this module exists to keep:
10
+
11
+ * **An entry is its primary file plus its sidecars.** Dropping a ``.mp4`` and
12
+ leaving its ``.vtt`` behind would leave the directory looking populated while
13
+ every lookup misses, and the orphan would never be collected by anything.
14
+ * **Recency means last *use*, not last write.** The lookup paths touch an entry
15
+ on every hit (``ClipCache.get``, ``video._touch_entry``), so a clip rebuilt
16
+ into every release stays young. Without that, LRU here would evict exactly the
17
+ entries that are earning their keep.
18
+
19
+ Imports nothing from ``steppy`` — ``video_cli`` must stay cheap enough to sit on
20
+ the eager CLI path (guard: ``tests/test_no_video_import_on_default_path.py``).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import shutil
26
+ import time
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Iterable, Optional
30
+
31
+ # An entry's primary artifact, and the companions that travel with it.
32
+ SIDECARS: dict[str, tuple[str, ...]] = {
33
+ ".mp4": (".vtt", ".jpg", ".meta"),
34
+ ".wav": (".json",),
35
+ }
36
+
37
+ # A staging file younger than this may belong to a build running right now.
38
+ SCRATCH_GRACE_SECONDS = 24 * 3600
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Entry:
43
+ key: str
44
+ primary: Path
45
+ sidecars: tuple[Path, ...]
46
+ size: int
47
+ used_at: float
48
+
49
+ @property
50
+ def paths(self) -> tuple[Path, ...]:
51
+ return (self.primary, *self.sidecars)
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Stats:
56
+ root: Path
57
+ exists: bool
58
+ entries: int
59
+ size: int
60
+ oldest: Optional[float]
61
+ newest: Optional[float]
62
+ scratch_files: int
63
+ scratch_size: int
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Reclaimed:
68
+ entries: int
69
+ size: int
70
+
71
+ def __add__(self, other: "Reclaimed") -> "Reclaimed":
72
+ return Reclaimed(self.entries + other.entries, self.size + other.size)
73
+
74
+
75
+ def scan(root: Path, suffix: str) -> list[Entry]:
76
+ """Every complete entry under *root*, newest use first."""
77
+ root = Path(root)
78
+ if not root.is_dir():
79
+ return []
80
+ entries: list[Entry] = []
81
+ for primary in sorted(root.glob(f"*{suffix}")):
82
+ if primary.name.startswith("."): # staging file, not an entry
83
+ continue
84
+ sidecars = tuple(p for p in (primary.with_suffix(s) for s in SIDECARS.get(suffix, ())) if p.is_file())
85
+ size = _size(primary) + sum(_size(p) for p in sidecars)
86
+ entries.append(
87
+ Entry(
88
+ key=primary.stem,
89
+ primary=primary,
90
+ sidecars=sidecars,
91
+ size=size,
92
+ used_at=_used_at(primary, sidecars),
93
+ )
94
+ )
95
+ entries.sort(key=lambda e: e.used_at, reverse=True)
96
+ return entries
97
+
98
+
99
+ def stats(root: Path, suffix: str) -> Stats:
100
+ root = Path(root)
101
+ entries = scan(root, suffix)
102
+ scratch = list(scratch_files(root))
103
+ used = [e.used_at for e in entries]
104
+ return Stats(
105
+ root=root,
106
+ exists=root.is_dir(),
107
+ entries=len(entries),
108
+ size=sum(e.size for e in entries),
109
+ oldest=min(used) if used else None,
110
+ newest=max(used) if used else None,
111
+ scratch_files=len(scratch),
112
+ scratch_size=sum(_size(p) for p in scratch),
113
+ )
114
+
115
+
116
+ def scratch_files(root: Path) -> Iterable[Path]:
117
+ """Staging leftovers and pre-transcode scratch — never an entry.
118
+
119
+ ``tts-cache/work/`` holds the raw container a provider returned before it
120
+ was normalized to WAV; the normalized clip is the artifact, so anything left
121
+ here is the residue of a killed run.
122
+ """
123
+ root = Path(root)
124
+ if not root.is_dir():
125
+ return []
126
+ found = [p for p in root.glob(".*.tmp*") if p.is_file()]
127
+ work = root / "work"
128
+ if work.is_dir():
129
+ found.extend(p for p in work.rglob("*") if p.is_file())
130
+ return found
131
+
132
+
133
+ def prune(
134
+ root: Path,
135
+ suffix: str,
136
+ *,
137
+ older_than_days: Optional[float] = None,
138
+ max_bytes: Optional[int] = None,
139
+ now: Optional[float] = None,
140
+ ) -> Reclaimed:
141
+ """Drop entries by age, then by budget. Least recently *used* goes first."""
142
+ entries = scan(root, suffix)
143
+ now = time.time() if now is None else now
144
+ doomed: list[Entry] = []
145
+
146
+ if older_than_days is not None:
147
+ cutoff = now - older_than_days * 86400
148
+ doomed = [e for e in entries if e.used_at <= cutoff]
149
+ entries = [e for e in entries if e.used_at > cutoff]
150
+
151
+ if max_bytes is not None:
152
+ running = 0
153
+ keep: list[Entry] = []
154
+ for entry in entries: # already newest-first
155
+ if running + entry.size > max_bytes:
156
+ doomed.append(entry)
157
+ else:
158
+ running += entry.size
159
+ keep.append(entry)
160
+ entries = keep
161
+
162
+ freed = 0
163
+ removed = 0
164
+ for entry in doomed:
165
+ if _remove(entry.paths):
166
+ removed += 1
167
+ freed += entry.size
168
+ return Reclaimed(removed, freed)
169
+
170
+
171
+ def sweep_scratch(
172
+ root: Path, *, grace_seconds: float = SCRATCH_GRACE_SECONDS, now: Optional[float] = None
173
+ ) -> Reclaimed:
174
+ """Remove staging leftovers old enough that no live build can own them."""
175
+ now = time.time() if now is None else now
176
+ freed = 0
177
+ removed = 0
178
+ for path in scratch_files(root):
179
+ try:
180
+ if now - path.stat().st_mtime < grace_seconds:
181
+ continue
182
+ size = _size(path)
183
+ except OSError:
184
+ continue
185
+ if _remove((path,)):
186
+ removed += 1
187
+ freed += size
188
+ return Reclaimed(removed, freed)
189
+
190
+
191
+ def clear(root: Path, suffix: str) -> Reclaimed:
192
+ """Drop every entry, plus the scratch dirs. The directory itself survives,
193
+ so a subsequent run does not have to reason about whether it exists."""
194
+ root = Path(root)
195
+ entries = scan(root, suffix)
196
+ freed = sum(e.size for e in entries)
197
+ removed = 0
198
+ for entry in entries:
199
+ if _remove(entry.paths):
200
+ removed += 1
201
+ for path in scratch_files(root):
202
+ freed += _size(path)
203
+ _remove((path,))
204
+ work = root / "work"
205
+ if work.is_dir():
206
+ shutil.rmtree(work, ignore_errors=True)
207
+ return Reclaimed(removed, freed)
208
+
209
+
210
+ def _used_at(primary: Path, sidecars: tuple[Path, ...]) -> float:
211
+ """Newest mtime across the entry — a hit touches whatever it reads, and a
212
+ sidecar-only touch still counts as the entry having been used."""
213
+ times = []
214
+ for path in (primary, *sidecars):
215
+ try:
216
+ times.append(path.stat().st_mtime)
217
+ except OSError:
218
+ continue
219
+ return max(times) if times else 0.0
220
+
221
+
222
+ def _size(path: Path) -> int:
223
+ try:
224
+ return path.stat().st_size
225
+ except OSError:
226
+ return 0
227
+
228
+
229
+ def _remove(paths: tuple[Path, ...]) -> bool:
230
+ """Best-effort delete. A file held open by another process (Windows) is
231
+ skipped rather than fatal — the next prune will get it."""
232
+ ok = False
233
+ for path in paths:
234
+ try:
235
+ path.unlink(missing_ok=True)
236
+ ok = True
237
+ except OSError:
238
+ pass
239
+ return ok
steppy/video_cli.py ADDED
@@ -0,0 +1,271 @@
1
+ """``steppy video check`` — one answer instead of three mid-render failures.
2
+
3
+ Video export has more external moving parts than anything else in Steppy:
4
+ Chromium, ffmpeg, ffprobe, a TTS provider, an API key that may or may not have
5
+ survived ``${VAR}`` interpolation, and possibly a voice model on disk. Each of
6
+ them fails at a different point in a render that takes minutes per guide, and
7
+ each failure looks unrelated to the others. This command checks all of them in
8
+ about five seconds.
9
+
10
+ A leaf module in the ``register_flaky`` mould: it imports ``_cli_common`` plus
11
+ stdlib at module scope and nothing else, so ``import steppy.cli`` stays free of
12
+ the video and TTS stacks (guard:
13
+ ``tests/test_no_video_import_on_default_path.py``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ import subprocess
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ import typer
25
+
26
+ from ._cli_common import _cli_errors, _error, console
27
+
28
+ video_app = typer.Typer(help="Narrated video export utilities.", no_args_is_help=True)
29
+
30
+
31
+ def register_video(app: typer.Typer) -> None:
32
+ """Attach the ``video`` command group to the root Typer app."""
33
+ app.add_typer(video_app, name="video")
34
+
35
+
36
+ @video_app.command("check")
37
+ def check(
38
+ config_file: Path = typer.Option(
39
+ Path("config.yaml"),
40
+ "--config",
41
+ "-c",
42
+ help="Path to config.yaml",
43
+ ),
44
+ ) -> None:
45
+ """Verify everything ``--video`` needs before spending minutes finding out."""
46
+ from .config import load_config
47
+
48
+ with _cli_errors():
49
+ console.print("\n[bold]Video export readiness[/bold]\n")
50
+
51
+ try:
52
+ config = load_config(config_file)
53
+ options = config.video
54
+ except Exception as exc:
55
+ _error(f"could not load {config_file}: {exc}")
56
+ raise typer.Exit(1)
57
+
58
+ problems: list[str] = []
59
+ _report("config", str(config_file), ok=True)
60
+
61
+ # ── Encoder ──────────────────────────────────────────────────────
62
+ from .video import VideoRenderError, find_ffmpeg
63
+
64
+ try:
65
+ ffmpeg = find_ffmpeg(options)
66
+ _report("ffmpeg", f"{ffmpeg}{_version_suffix(ffmpeg)}", ok=True)
67
+ except VideoRenderError as exc:
68
+ _report("ffmpeg", "not found", ok=False)
69
+ problems.append(str(exc))
70
+
71
+ probe = shutil.which("ffprobe")
72
+ _report("ffprobe", probe or "not found (only needed for non-WAV narration)", ok=bool(probe))
73
+
74
+ # ── Browser ──────────────────────────────────────────────────────
75
+ ok, detail = _check_chromium()
76
+ _report("chromium", detail, ok=ok)
77
+ if not ok:
78
+ problems.append("Chromium is unavailable: python -m playwright install chromium")
79
+
80
+ # ── Narration ────────────────────────────────────────────────────
81
+ from .tts import describe, readiness
82
+
83
+ _report("tts provider", describe(options.tts), ok=True)
84
+ trouble = readiness(options.tts)
85
+ if trouble:
86
+ _report("tts readiness", "not ready", ok=False)
87
+ problems.append(trouble)
88
+ else:
89
+ voices = _voice_summary(options.tts, config.locales)
90
+ _report("tts readiness", voices, ok=True)
91
+
92
+ # ── Caches ───────────────────────────────────────────────────────
93
+ from .captures import resolve_output_dir
94
+
95
+ root = Path.cwd()
96
+ video_cache = resolve_output_dir(root, options.cache_dir, ".steppy/video-cache")
97
+ tts_cache = resolve_output_dir(root, options.tts.cache_dir, ".steppy/tts-cache")
98
+ _report("video cache", _cache_detail(video_cache, options.cache, ".mp4"), ok=True)
99
+ _report("tts cache", _cache_detail(tts_cache, options.tts.cache, ".wav"), ok=True)
100
+
101
+ console.print()
102
+ if problems:
103
+ for problem in problems:
104
+ _error(problem)
105
+ raise typer.Exit(1)
106
+ console.print("[green]OK[/green] ready to render — steppy build --video / steppy guide --video\n")
107
+
108
+
109
+ @video_app.command("cache")
110
+ def cache(
111
+ config_file: Path = typer.Option(Path("config.yaml"), "--config", "-c", help="Path to config.yaml"),
112
+ prune_older_than: Optional[float] = typer.Option(
113
+ None,
114
+ "--prune-older-than",
115
+ metavar="DAYS",
116
+ help="Drop entries not used in DAYS days.",
117
+ ),
118
+ max_size: Optional[float] = typer.Option(
119
+ None,
120
+ "--max-size",
121
+ metavar="MB",
122
+ help="Evict least-recently-used entries until each cache fits in MB.",
123
+ ),
124
+ clear_all: bool = typer.Option(False, "--clear", help="Remove every entry."),
125
+ tts_only: bool = typer.Option(False, "--tts", help="Only the narration clip cache."),
126
+ video_only: bool = typer.Option(False, "--video", help="Only the rendered video cache."),
127
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation for --clear."),
128
+ ) -> None:
129
+ """Inspect or reclaim the render caches.
130
+
131
+ Both are content-addressed and therefore grow forever: every edited sentence
132
+ leaves its old clip behind, and a Piper upgrade orphans all of them at once.
133
+ With no flags this only reports.
134
+ """
135
+ from .captures import resolve_output_dir
136
+ from .config import load_config
137
+ from .video_cache import Reclaimed, clear, prune, stats, sweep_scratch
138
+
139
+ with _cli_errors():
140
+ if tts_only and video_only:
141
+ _error("--tts and --video are mutually exclusive; omit both to act on each cache.")
142
+ raise typer.Exit(2)
143
+
144
+ try:
145
+ options = load_config(config_file).video
146
+ except Exception as exc:
147
+ _error(f"could not load {config_file}: {exc}")
148
+ raise typer.Exit(1)
149
+
150
+ root = Path.cwd()
151
+ targets = []
152
+ if not tts_only:
153
+ targets.append(("video", resolve_output_dir(root, options.cache_dir, ".steppy/video-cache"), ".mp4"))
154
+ if not video_only:
155
+ targets.append(("tts", resolve_output_dir(root, options.tts.cache_dir, ".steppy/tts-cache"), ".wav"))
156
+
157
+ if clear_all and not yes:
158
+ names = " and ".join(name for name, _, _ in targets)
159
+ if not typer.confirm(f"Remove every entry from the {names} cache?"):
160
+ console.print("[yellow]Aborted[/yellow] — nothing was removed.")
161
+ raise typer.Exit(0)
162
+
163
+ console.print("\n[bold]Render caches[/bold]\n")
164
+ for name, path, suffix in targets:
165
+ # Scratch is always swept: a staging file a day old belongs to no
166
+ # live build, and leaving it costs disk while helping nobody.
167
+ swept = sweep_scratch(path)
168
+ if clear_all:
169
+ freed = clear(path, suffix)
170
+ elif prune_older_than is not None or max_size is not None:
171
+ freed = prune(
172
+ path,
173
+ suffix,
174
+ older_than_days=prune_older_than,
175
+ max_bytes=int(max_size * 1024 * 1024) if max_size is not None else None,
176
+ )
177
+ else:
178
+ freed = Reclaimed(0, 0)
179
+
180
+ after = stats(path, suffix)
181
+ if not after.exists:
182
+ summary = "not created yet"
183
+ else:
184
+ summary = f"{_entries(after.entries)}, {_bytes(after.size)}"
185
+ if after.newest and after.oldest:
186
+ summary += f" [dim](last used {_ago(after.newest)} ago, oldest {_ago(after.oldest)})[/dim]"
187
+ _report(f"{name} cache", summary, ok=True)
188
+ console.print(f" [dim]{after.root}[/dim]")
189
+ if freed.entries:
190
+ console.print(f" [green]reclaimed[/green] {_entries(freed.entries)}, {_bytes(freed.size)}")
191
+ if swept.entries:
192
+ console.print(f" [dim]swept {swept.entries} scratch file(s), {_bytes(swept.size)}[/dim]")
193
+ if after.scratch_files:
194
+ console.print(
195
+ f" [dim]{after.scratch_files} scratch file(s) held back "
196
+ f"({_bytes(after.scratch_size)}) — a build may still own them[/dim]"
197
+ )
198
+ console.print()
199
+
200
+
201
+ def _entries(count: int) -> str:
202
+ return f"{count} entry" if count == 1 else f"{count} entries"
203
+
204
+
205
+ def _bytes(size: int) -> str:
206
+ value = float(size)
207
+ for unit in ("B", "KB", "MB", "GB"):
208
+ if value < 1024 or unit == "GB":
209
+ return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
210
+ value /= 1024
211
+ return f"{value:.1f} GB" # pragma: no cover - unreachable, the loop returns
212
+
213
+
214
+ def _ago(when: float) -> str:
215
+ seconds = max(0.0, time.time() - when)
216
+ for limit, div, unit in ((3600, 60, "min"), (86400, 3600, "h"), (float("inf"), 86400, "d")):
217
+ if seconds < limit:
218
+ return f"{seconds / div:.0f} {unit}"
219
+ return "?" # pragma: no cover - the last bucket is unbounded
220
+
221
+
222
+ def _report(label: str, detail: str, *, ok: bool) -> None:
223
+ mark = "[green]OK[/green] " if ok else "[red]FAIL[/red]"
224
+ console.print(f" {mark} [dim]{label:<14}[/dim] {detail}")
225
+
226
+
227
+ def _version_suffix(binary: str) -> str:
228
+ try:
229
+ proc = subprocess.run([binary, "-version"], capture_output=True, timeout=20)
230
+ first = proc.stdout.decode("utf-8", "replace").splitlines()[0]
231
+ return f" [dim]({first.split(' Copyright')[0].strip()})[/dim]"
232
+ except Exception:
233
+ return ""
234
+
235
+
236
+ def _check_chromium() -> tuple[bool, str]:
237
+ """Launch and close Chromium — the only check that actually proves it works."""
238
+ try:
239
+ from playwright.sync_api import sync_playwright
240
+ except ImportError as exc:
241
+ return False, f"playwright not installed ({exc})"
242
+ try:
243
+ with sync_playwright() as p:
244
+ browser = p.chromium.launch()
245
+ version = browser.version
246
+ browser.close()
247
+ return True, f"launches [dim]({version})[/dim]"
248
+ except Exception as exc:
249
+ return False, f"cannot launch ({str(exc).splitlines()[0]})"
250
+
251
+
252
+ def _voice_summary(tts, locales: list[str]) -> str:
253
+ if tts.provider == "none":
254
+ return "captions only"
255
+ missing = [loc for loc in locales if not tts.voice_for(loc)]
256
+ if missing:
257
+ # Not a failure: a provider may have a usable default voice. But a
258
+ # multilingual project almost certainly meant to set one per locale.
259
+ return f"voices set for {len(locales) - len(missing)}/{len(locales)} locales (missing: {', '.join(missing)})"
260
+ return f"voices set for all {len(locales)} locale(s)"
261
+
262
+
263
+ def _cache_detail(path: Path, enabled: bool, suffix: str) -> str:
264
+ if not enabled:
265
+ return "disabled — every run re-renders (and re-bills a paid provider)"
266
+ if not path.exists():
267
+ return "empty [dim](steppy video cache)[/dim]"
268
+ from .video_cache import stats
269
+
270
+ got = stats(path, suffix)
271
+ return f"{_entries(got.entries)}, {_bytes(got.size)} [dim](steppy video cache)[/dim]"