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/tts/track.py ADDED
@@ -0,0 +1,158 @@
1
+ """Narration track assembly — per-step clips into one WAV, on the stdlib.
2
+
3
+ The video timeline decides when each step starts; this lays the matching clip
4
+ at that offset and pads the gaps with silence, so the voiceover stays locked to
5
+ the cursor no matter how the timeline stretched to accommodate it.
6
+
7
+ Assembly uses the stdlib ``wave`` module rather than an ffmpeg filter graph:
8
+ concatenating PCM is arithmetic, it is deterministic, and it unit-tests without
9
+ a subprocess. ffmpeg is still used for the one thing it is needed for —
10
+ normalising whatever container a cloud provider returned (usually MP3) into the
11
+ PCM this works on.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import subprocess
18
+ import tempfile
19
+ import wave
20
+ from pathlib import Path
21
+ from typing import Optional, Sequence
22
+
23
+ from .cache import atomic_replace, atomic_write_bytes
24
+ from .types import TtsError
25
+
26
+ # One target format for everything, so concatenation is a byte-copy.
27
+ SAMPLE_RATE = 48000
28
+ CHANNELS = 1
29
+ SAMPLE_WIDTH = 2 # 16-bit
30
+
31
+
32
+ def wav_duration_ms(path: Path) -> int:
33
+ """Length of a WAV file, from its own header."""
34
+ with wave.open(str(path), "rb") as handle:
35
+ frames = handle.getnframes()
36
+ rate = handle.getframerate() or SAMPLE_RATE
37
+ return int(round(frames * 1000 / rate))
38
+
39
+
40
+ def is_intact_wav(path: Path) -> bool:
41
+ """Does this file actually contain the audio its header promises?
42
+
43
+ A header read alone is not enough to trust a cached clip: a truncated write
44
+ usually leaves the 44-byte header intact, so ``getnframes()`` still reports
45
+ the *intended* length and the timeline gets sized against audio that is not
46
+ there. Comparing the declared payload against the bytes on disk is what
47
+ turns a poisoned entry into an honest miss.
48
+ """
49
+ try:
50
+ with wave.open(str(path), "rb") as handle:
51
+ declared = handle.getnframes() * handle.getnchannels() * handle.getsampwidth()
52
+ # A zero-frame clip is intact, just useless. Rejecting it would make an
53
+ # engine that returns silence for one line re-bill for it every run.
54
+ return path.stat().st_size >= declared
55
+ except (wave.Error, OSError, EOFError):
56
+ return False
57
+
58
+
59
+ def normalize(source: Path, dest: Path, ffmpeg: str) -> Path:
60
+ """Transcode any clip into 48 kHz mono 16-bit PCM WAV.
61
+
62
+ Piper and espeak already emit WAV, so the free path usually skips this
63
+ entirely — but a cloud provider's MP3 has to become PCM before it can be
64
+ laid onto a timeline by arithmetic.
65
+
66
+ ``dest`` only ever appears complete. ffmpeg writes a sibling temp file that
67
+ is ``os.replace``d into position on success, because *dest* is routinely a
68
+ cache path: a half-written file there is not a slow build, it is a
69
+ permanently poisoned entry that every later run happily serves.
70
+ """
71
+ if _is_target_wav(source):
72
+ if source != dest:
73
+ atomic_write_bytes(dest, source.read_bytes())
74
+ return dest
75
+
76
+ dest.parent.mkdir(parents=True, exist_ok=True)
77
+ fd, staging = tempfile.mkstemp(dir=str(dest.parent), prefix=f".{dest.name}.", suffix=".wav")
78
+ os.close(fd)
79
+ args = [
80
+ ffmpeg,
81
+ "-y",
82
+ "-hide_banner",
83
+ "-nostats",
84
+ "-loglevel",
85
+ "error",
86
+ "-i",
87
+ str(source),
88
+ "-ar",
89
+ str(SAMPLE_RATE),
90
+ "-ac",
91
+ str(CHANNELS),
92
+ "-sample_fmt",
93
+ "s16",
94
+ staging,
95
+ ]
96
+ try:
97
+ proc = subprocess.run(args, capture_output=True)
98
+ if proc.returncode != 0:
99
+ tail = (proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()[-4:]
100
+ raise TtsError(f"could not convert {source.name} to WAV: " + " / ".join(tail))
101
+ return atomic_replace(Path(staging), dest)
102
+ except BaseException:
103
+ Path(staging).unlink(missing_ok=True)
104
+ raise
105
+
106
+
107
+ def _is_target_wav(path: Path) -> bool:
108
+ try:
109
+ with wave.open(str(path), "rb") as handle:
110
+ return (
111
+ handle.getframerate() == SAMPLE_RATE
112
+ and handle.getnchannels() == CHANNELS
113
+ and handle.getsampwidth() == SAMPLE_WIDTH
114
+ )
115
+ except (wave.Error, OSError):
116
+ return False
117
+
118
+
119
+ def silence(duration_ms: int) -> bytes:
120
+ frames = max(0, int(round(duration_ms * SAMPLE_RATE / 1000)))
121
+ return b"\x00" * (frames * CHANNELS * SAMPLE_WIDTH)
122
+
123
+
124
+ def assemble(clips: Sequence[tuple[int, Optional[Path]]], total_ms: int, dest: Path) -> Optional[Path]:
125
+ """Lay each clip at its offset and pad the rest with silence.
126
+
127
+ ``clips`` is ``(start_ms, wav_path | None)`` in timeline order. Returns None
128
+ when there is nothing to say — the caller then renders a silent video rather
129
+ than muxing an empty track.
130
+
131
+ A clip that would overrun the next one is **truncated, not dropped**: the
132
+ timeline already stretched each step to fit its narration, so an overrun
133
+ means something upstream disagreed, and a clipped last syllable is a far
134
+ better failure than losing a whole step's voiceover.
135
+ """
136
+ usable = [(start, path) for start, path in clips if path and path.is_file()]
137
+ if not usable:
138
+ return None
139
+
140
+ dest.parent.mkdir(parents=True, exist_ok=True)
141
+ with wave.open(str(dest), "wb") as out:
142
+ out.setnchannels(CHANNELS)
143
+ out.setsampwidth(SAMPLE_WIDTH)
144
+ out.setframerate(SAMPLE_RATE)
145
+
146
+ cursor_ms = 0
147
+ for start_ms, path in sorted(usable, key=lambda item: item[0]):
148
+ if start_ms > cursor_ms:
149
+ out.writeframes(silence(start_ms - cursor_ms))
150
+ cursor_ms = start_ms
151
+ with wave.open(str(path), "rb") as clip:
152
+ frames = clip.readframes(clip.getnframes())
153
+ out.writeframes(frames)
154
+ cursor_ms += int(round(len(frames) / (CHANNELS * SAMPLE_WIDTH) * 1000 / SAMPLE_RATE))
155
+
156
+ if total_ms > cursor_ms:
157
+ out.writeframes(silence(total_ms - cursor_ms))
158
+ return dest
steppy/tts/types.py ADDED
@@ -0,0 +1,94 @@
1
+ """Core TTS types — the contract every provider implements.
2
+
3
+ Stdlib only, deliberately. This module sits on the import path of the registry
4
+ and every provider, so anything heavier here would leak into ``steppy.models``
5
+ and back onto the default CLI path (guard:
6
+ ``tests/test_no_video_import_on_default_path.py``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from typing import Optional, Protocol, runtime_checkable
14
+
15
+ # Same grammar config.py resolves at load time. Repeated (not imported) to keep
16
+ # this module free of steppy imports beyond models.
17
+ _ENV_REF = re.compile(r"\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}")
18
+
19
+
20
+ class TtsError(Exception):
21
+ """Narration could not be synthesized.
22
+
23
+ Never fatal to a build on its own: the caller falls back to a silent,
24
+ captioned video and reports why, because losing the voiceover is better than
25
+ losing the release.
26
+ """
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SpeechRequest:
31
+ """One utterance to synthesize — normally one guide step's ``guide-text``."""
32
+
33
+ text: str
34
+ locale: str
35
+ voice: Optional[str] = None
36
+ model: Optional[str] = None
37
+ speed: float = 1.0
38
+ fmt: Optional[str] = None
39
+ options: dict = field(default_factory=dict)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SpeechClip:
44
+ """Synthesized audio, in whatever container the provider produced."""
45
+
46
+ audio: bytes
47
+ fmt: str = "wav"
48
+
49
+ @property
50
+ def empty(self) -> bool:
51
+ return not self.audio
52
+
53
+
54
+ @runtime_checkable
55
+ class TtsProvider(Protocol):
56
+ """What a provider must offer. Deliberately one method.
57
+
58
+ Third parties register implementations through the ``steppy.tts_providers``
59
+ entry-point group, so this signature is a public contract: widen it only
60
+ additively.
61
+ """
62
+
63
+ name: str
64
+
65
+ def synthesize(self, request: SpeechRequest) -> SpeechClip: # pragma: no cover - protocol
66
+ ...
67
+
68
+ def version(self) -> str: # pragma: no cover - protocol
69
+ """Identifier folded into the cache key.
70
+
71
+ Load-bearing for the local engines: espeak-ng and Piper produce
72
+ different audio across binary versions, so clip durations shift and the
73
+ whole video timeline moves — silently, unless the version is in the key.
74
+ """
75
+ ...
76
+
77
+
78
+ def check_resolved(value: Optional[str], field_name: str) -> Optional[str]:
79
+ """Reject a config value that is still an unexpanded ``${VAR}`` reference.
80
+
81
+ ``config._interpolate_env`` leaves the literal in place and only warns when
82
+ the variable is unset, so without this the user's first symptom is a 401
83
+ from a vendor rather than a message about their own configuration.
84
+ """
85
+ if not value:
86
+ return value
87
+ match = _ENV_REF.search(value)
88
+ if match:
89
+ raise TtsError(
90
+ f"{field_name} is still the literal {match.group(0)!r} — the environment "
91
+ f"variable {match.group(1)} is not set. Export it, put the value in the "
92
+ f"gitignored credentials.yaml overlay, or use ${{{match.group(1)}:-default}}."
93
+ )
94
+ return value