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
@@ -0,0 +1,58 @@
1
+ """Clients for a Steppy control plane.
2
+
3
+ Steppy works with no control plane at all, and nothing outside this package may
4
+ assume one exists. What lives here is the *client* half of the versioned wire
5
+ contracts a plane publishes — pushing results (``steppy bundle upload``), and
6
+ later claiming work (``steppy ci run``, ``steppy agent``). They are open source
7
+ on purpose: they run inside a customer's CI holding their repository and their
8
+ credentials, and a closed binary doing that fails the security review Steppy's
9
+ whole positioning is built on passing.
10
+
11
+ **Lazy-imported only.** ``httpx`` costs ~40ms at import and no other command
12
+ needs it, so every import of this package lives inside a command body. Guard:
13
+ ``tests/test_no_cloud_import_on_default_path.py``.
14
+
15
+ Split by concern, and the split is the boundary worth keeping:
16
+
17
+ ``client`` transport — endpoint resolution, bearer auth, retries, errors
18
+ ``upload`` the bundle upload protocol, and the only module that knows what a
19
+ bundle is
20
+ """
21
+
22
+ from typing import Optional
23
+
24
+ from .client import CloudAuthError, CloudClient, CloudError, Endpoint, resolve_endpoint
25
+ from .upload import ArchiveSource, BlobSource, TreeSource, UploadOutcome, derive_exit_code, upload_bundle
26
+
27
+
28
+ def cloud_client(
29
+ *,
30
+ url: Optional[str] = None,
31
+ token: Optional[str] = None,
32
+ config_url: Optional[str] = None,
33
+ timeout: float = 60.0,
34
+ ) -> CloudClient:
35
+ """Resolve an endpoint and open a session against it.
36
+
37
+ The two halves are separately useful — tests build a client around a fake
38
+ transport, and a future ``steppy cloud status`` will want the endpoint
39
+ without a session — so this is only the pairing every command actually
40
+ performs, not a replacement for either.
41
+ """
42
+ return CloudClient(resolve_endpoint(url, token, config_url=config_url), timeout=timeout)
43
+
44
+
45
+ __all__ = [
46
+ "ArchiveSource",
47
+ "BlobSource",
48
+ "CloudAuthError",
49
+ "CloudClient",
50
+ "CloudError",
51
+ "Endpoint",
52
+ "TreeSource",
53
+ "UploadOutcome",
54
+ "cloud_client",
55
+ "derive_exit_code",
56
+ "resolve_endpoint",
57
+ "upload_bundle",
58
+ ]
steppy/cloud/client.py ADDED
@@ -0,0 +1,267 @@
1
+ """HTTP transport for a Steppy control plane.
2
+
3
+ Knows about bearer tokens, retries and error shapes. Knows nothing about
4
+ bundles — that is :mod:`steppy.cloud.upload`'s job, and keeping the two apart is
5
+ what will let ``steppy ci run`` and ``steppy agent`` reuse this unchanged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import random
12
+ import time
13
+ from dataclasses import dataclass
14
+ from typing import Any, BinaryIO, Optional
15
+
16
+ import httpx
17
+
18
+ #: Environment variables, checked after an explicit flag and before config.
19
+ ENV_URL = "STEPPY_CLOUD_URL"
20
+ ENV_TOKEN = "STEPPY_CLOUD_TOKEN"
21
+
22
+ #: The API version this client speaks. Appended to the configured base URL, so a
23
+ #: project's `cloud.url` never has to be edited when the server adds v2.
24
+ API_VERSION = "v1"
25
+
26
+ DEFAULT_TIMEOUT = 60.0
27
+ DEFAULT_RETRIES = 3
28
+
29
+ #: Retried with backoff. 429 is included because it is the server asking us to
30
+ #: slow down, not to stop; 5xx because a control plane behind a load balancer
31
+ #: will occasionally return one that a second attempt does not.
32
+ _RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
33
+
34
+
35
+ class CloudError(Exception):
36
+ """A request the plane refused, or a transport that never got there."""
37
+
38
+ def __init__(self, message: str, *, status: Optional[int] = None) -> None:
39
+ super().__init__(message)
40
+ self.status = status
41
+
42
+
43
+ class CloudAuthError(CloudError):
44
+ """401 or 403.
45
+
46
+ Split out because it is the one failure that must **never** be retried: a
47
+ revoked token retried with backoff is a client that hammers a server it can
48
+ never satisfy, and the same rule governs the agent long-poll ("401 means
49
+ stop, not back off"). It is also the error most likely to be a user's typo,
50
+ so its message names the two places a token can come from.
51
+ """
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Endpoint:
56
+ """A resolved control plane: where it is, and what proves who we are."""
57
+
58
+ url: str
59
+ token: str
60
+
61
+ @property
62
+ def api(self) -> str:
63
+ return f"{self.url}/{API_VERSION}"
64
+
65
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
66
+ # Never let a token reach a traceback, a log, or a --verbose dump.
67
+ return f"Endpoint(url={self.url!r}, token='***')"
68
+
69
+
70
+ def _clean_url(raw: str) -> str:
71
+ url = raw.strip().rstrip("/")
72
+ # Forgive the URL people actually have in front of them. `/v1` is what the
73
+ # docs and every error message show, so it is what gets pasted; silently
74
+ # accepting it beats a 404 on `…/v1/v1/runs` that reads like the server is
75
+ # broken.
76
+ if url.endswith(f"/{API_VERSION}"):
77
+ url = url[: -(len(API_VERSION) + 1)]
78
+ return url
79
+
80
+
81
+ def resolve_endpoint(
82
+ url: Optional[str] = None,
83
+ token: Optional[str] = None,
84
+ *,
85
+ config_url: Optional[str] = None,
86
+ ) -> Endpoint:
87
+ """Resolve the plane's address and credential: flag → env → config.
88
+
89
+ **The token has no config source, deliberately.** ``config.yaml`` is
90
+ committed, and a control-plane token is a credential — the same line
91
+ :func:`steppy.config._check_credential_hygiene` already draws for
92
+ ``apps.*.auth.credentials``. Leaving the key out entirely is stronger than
93
+ warning about it, because there is then nowhere to paste it by accident.
94
+ """
95
+ resolved_url = url or os.environ.get(ENV_URL) or config_url
96
+ if not resolved_url:
97
+ raise CloudError(
98
+ f"no control plane URL. Pass --url, set {ENV_URL}, or add `cloud: {{ url: https://… }}` to config.yaml"
99
+ )
100
+
101
+ resolved_token = token or os.environ.get(ENV_TOKEN)
102
+ if not resolved_token:
103
+ raise CloudError(
104
+ f"no control plane token. Set {ENV_TOKEN} or pass --token. "
105
+ "There is no config.yaml key for it on purpose — that file is committed"
106
+ )
107
+
108
+ return Endpoint(url=_clean_url(resolved_url), token=resolved_token.strip())
109
+
110
+
111
+ def _detail(response: httpx.Response) -> str:
112
+ """The server's own explanation, which is written to be read by a user."""
113
+ try:
114
+ payload = response.json()
115
+ except ValueError:
116
+ return response.text.strip()[:500] or f"HTTP {response.status_code}"
117
+ if isinstance(payload, dict):
118
+ detail = payload.get("detail")
119
+ if isinstance(detail, str):
120
+ return detail
121
+ if detail is not None:
122
+ return str(detail)
123
+ return str(payload)[:500]
124
+
125
+
126
+ class CloudClient:
127
+ """A session against one plane.
128
+
129
+ Two httpx clients, and the second one is not an oversight. Presigned URLs
130
+ carry their authorisation in the query string, and an object store that
131
+ receives a presigned signature **and** an ``Authorization`` header rejects
132
+ the pair as ambiguous — a 400 that reads like a bad URL. So blob uploads go
133
+ out on a client that has no default headers at all.
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ endpoint: Endpoint,
139
+ *,
140
+ timeout: float = DEFAULT_TIMEOUT,
141
+ retries: int = DEFAULT_RETRIES,
142
+ user_agent: Optional[str] = None,
143
+ transport: Optional[httpx.BaseTransport] = None,
144
+ ) -> None:
145
+ from .. import __version__
146
+
147
+ self.endpoint = endpoint
148
+ self._retries = max(0, retries)
149
+ # `transport` exists for tests, and it is worth the parameter: an
150
+ # `httpx.MockTransport` exercises the real client — its header merging,
151
+ # its Content-Length, its retries — where a stubbed CloudClient would
152
+ # verify only that the test's own stub was called.
153
+ self._api = httpx.Client(
154
+ timeout=timeout,
155
+ transport=transport,
156
+ headers={
157
+ "Authorization": f"Bearer {endpoint.token}",
158
+ "User-Agent": user_agent or f"steppy/{__version__}",
159
+ "Accept": "application/json",
160
+ },
161
+ )
162
+ # Longer timeout: a blob PUT is bytes over the wire, not a query.
163
+ self._blobs = httpx.Client(timeout=max(timeout, 300.0), transport=transport, headers={})
164
+
165
+ def __enter__(self) -> CloudClient:
166
+ return self
167
+
168
+ def __exit__(self, *exc: object) -> None:
169
+ self.close()
170
+
171
+ def close(self) -> None:
172
+ self._api.close()
173
+ self._blobs.close()
174
+
175
+ # --- API calls ----------------------------------------------------------
176
+
177
+ def get(self, path: str, *, params: Optional[dict[str, Any]] = None, token: Optional[str] = None) -> Any:
178
+ return self._json("GET", path, params=params, token=token)
179
+
180
+ def post(
181
+ self,
182
+ path: str,
183
+ *,
184
+ json: Any = None,
185
+ headers: Optional[dict[str, str]] = None,
186
+ token: Optional[str] = None,
187
+ ) -> Any:
188
+ return self._json("POST", path, json=json, headers=headers, token=token)
189
+
190
+ def _json(
191
+ self,
192
+ method: str,
193
+ path: str,
194
+ *,
195
+ json: Any = None,
196
+ params: Optional[dict[str, Any]] = None,
197
+ headers: Optional[dict[str, str]] = None,
198
+ token: Optional[str] = None,
199
+ ) -> Any:
200
+ url = f"{self.endpoint.api}{path}"
201
+ request_headers = dict(headers or {})
202
+ if token is not None:
203
+ # A claim token authorises exactly one task. Swapping it in per
204
+ # request rather than per client keeps one connection pool for the
205
+ # whole upload — and makes the narrower credential the explicit
206
+ # choice at each call site.
207
+ request_headers["Authorization"] = f"Bearer {token}"
208
+
209
+ response = self._send(lambda: self._api.request(method, url, json=json, params=params, headers=request_headers))
210
+ if not response.content:
211
+ return None
212
+ try:
213
+ return response.json()
214
+ except ValueError as e:
215
+ raise CloudError(f"{method} {path}: the server did not return JSON ({e})") from e
216
+
217
+ def put_blob(self, url: str, opener, size: int) -> None:
218
+ """Upload one blob to a presigned URL.
219
+
220
+ ``Content-Length`` is set explicitly because it is signed into the URL:
221
+ without it httpx would stream chunked and the store would reject the
222
+ request. It is also what makes a truncated read a rejected upload rather
223
+ than a silently corrupt blob.
224
+ """
225
+ with opener() as handle:
226
+ self._send(
227
+ lambda: self._blobs.put(url, content=handle, headers={"Content-Length": str(size)}),
228
+ rewind=handle,
229
+ # A 403 here is an *expired presigned URL*, not a bad credential —
230
+ # there is no credential on this request. Raising CloudAuthError
231
+ # would send a fixable condition down the "stop, do not retry"
232
+ # path; the caller re-offers instead. See `upload._EXPIRED`.
233
+ auth_fatal=False,
234
+ )
235
+
236
+ # --- transport ----------------------------------------------------------
237
+
238
+ def _send(self, call, *, rewind: Optional[BinaryIO] = None, auth_fatal: bool = True) -> httpx.Response:
239
+ """Issue a request, retrying only what retrying can fix."""
240
+ last: Optional[Exception] = None
241
+ for attempt in range(self._retries + 1):
242
+ if attempt and rewind is not None:
243
+ # A retried upload must resend from byte zero. Without this the
244
+ # second attempt sends whatever the first one had not yet read,
245
+ # and the store stores a valid-looking truncated blob.
246
+ rewind.seek(0)
247
+ try:
248
+ response = call()
249
+ except httpx.HTTPError as e:
250
+ last = CloudError(f"could not reach the control plane: {e}")
251
+ else:
252
+ if auth_fatal and response.status_code in (401, 403):
253
+ raise CloudAuthError(_detail(response), status=response.status_code)
254
+ if response.status_code < 400:
255
+ return response
256
+ error = CloudError(_detail(response), status=response.status_code)
257
+ if response.status_code not in _RETRY_STATUSES:
258
+ raise error
259
+ last = error
260
+
261
+ if attempt < self._retries:
262
+ # Full jitter: N clients that failed together must not retry
263
+ # together.
264
+ time.sleep(random.uniform(0, min(8.0, 0.5 * 2**attempt)))
265
+
266
+ assert last is not None
267
+ raise last