steplight-sdk 0.2.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.
steplight/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Steplight: record and replay what an AI browser agent does.
2
+
3
+ Records Playwright (async and sync) and Browser Use runs in the Steplight run format, so the ``steplight`` CLI
4
+ (``view``, ``check``, ``diff``, ``report``, ``analyze``) and the GitHub Action work on them unchanged. Everything is
5
+ redacted before it is written; nothing leaves the machine.
6
+
7
+ >>> run = await steplight.record(page, task="Book the cheapest flight") # doctest: +SKIP
8
+ >>> run.note("model chose Premium") # doctest: +SKIP
9
+ >>> await run.end("success") # doctest: +SKIP
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __version__ = "0.2.0"
15
+
16
+ from .analyze import analyze
17
+ from .crypto import EncryptedRunError, EncryptionOptions
18
+ from .handles import Run, SyncRun, record, record_sync, recording, recording_sync
19
+ from .redact import redact_text
20
+ from .settings import CaptureLevel
21
+ from .store import read_run, read_snapshot
22
+
23
+ __all__ = [
24
+ "CaptureLevel",
25
+ "EncryptedRunError",
26
+ "EncryptionOptions",
27
+ "Run",
28
+ "SyncRun",
29
+ "__version__",
30
+ "analyze",
31
+ "read_run",
32
+ "read_snapshot",
33
+ "record",
34
+ "record_sync",
35
+ "recording",
36
+ "recording_sync",
37
+ "redact_text",
38
+ ]
steplight/_attach.py ADDED
@@ -0,0 +1,256 @@
1
+ """Hooks a Playwright page (sync or async API) up to a :class:`RecorderCore`.
2
+
3
+ The same handlers serve both APIs: they are plain callables that capture data and return at once, wrapped so
4
+ that an error is logged and never raised into the agent. Only the setup calls differ (``await`` or not).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from functools import wraps
11
+ from importlib import resources
12
+ from typing import Any
13
+ from weakref import WeakKeyDictionary
14
+
15
+ from ._log import log_error
16
+ from .recorder import BINDING_NAME, RecorderCore
17
+
18
+ _script_cache: str | None = None
19
+
20
+
21
+ def page_script() -> str:
22
+ """The shared page script (``spec/page-scan.js``), bundled as package data."""
23
+ global _script_cache
24
+ if _script_cache is None:
25
+ _script_cache = resources.files("steplight").joinpath("_data/page-scan.js").read_text(encoding="utf-8")
26
+ return _script_cache
27
+
28
+
29
+ class _Dispatcher:
30
+ """The one binding a page can have: a page can be recorded again after a run ended, but a binding cannot be exposed twice."""
31
+
32
+ def __init__(self) -> None:
33
+ self.current: Attachment | None = None
34
+
35
+ def __call__(self, source: Any, event: Any) -> None:
36
+ target = self.current
37
+ if target is not None:
38
+ target.binding(source, event)
39
+
40
+
41
+ _dispatchers: WeakKeyDictionary[Any, _Dispatcher] = WeakKeyDictionary()
42
+
43
+
44
+ def _safe(where: str, fn: Callable[..., None]) -> Callable[..., None]:
45
+ @wraps(fn)
46
+ def wrapper(*args: Any, **kwargs: Any) -> None:
47
+ try:
48
+ fn(*args, **kwargs)
49
+ except Exception as err:
50
+ log_error(where, err)
51
+
52
+ return wrapper
53
+
54
+
55
+ class Attachment:
56
+ """The listeners installed on one page, so they can be removed again."""
57
+
58
+ def __init__(self, page: Any, core: RecorderCore) -> None:
59
+ self.page = page
60
+ self.core = core
61
+ self.listeners: list[tuple[str, Callable[..., None]]] = []
62
+ self.on_activity: Callable[[Any], None] | None = None
63
+
64
+ # -- handlers (all fail open) ------------------------------------------------------------
65
+
66
+ def binding(self, source: Any, event: Any) -> None:
67
+ try:
68
+ frame = source["frame"] if not hasattr(source, "frame") else source.frame
69
+ if frame is not self.page.main_frame or not isinstance(event, dict):
70
+ return
71
+ if self.on_activity:
72
+ self.on_activity(self.page)
73
+ if event.get("type") == "scan":
74
+ self.core.scan(event)
75
+ else:
76
+ self.core.page_event(event, self.page.url)
77
+ except Exception as err:
78
+ log_error("event", err)
79
+
80
+ def _on_framenavigated(self, frame: Any) -> None:
81
+ if frame is self.page.main_frame:
82
+ if self.on_activity:
83
+ self.on_activity(self.page)
84
+ self.core.navigate(frame.url)
85
+
86
+ def _on_request(self, request: Any) -> None:
87
+ body: bytes | None
88
+ try:
89
+ body = request.post_data_buffer
90
+ except Exception:
91
+ body = None
92
+ self.core.request_started(request, request.method, request.url, request.resource_type, body)
93
+
94
+ def _on_response(self, response: Any) -> None:
95
+ self.core.request_finished(response.request, response.status, response.headers)
96
+
97
+ def _on_request_failed(self, request: Any) -> None:
98
+ self.core.request_finished(request, None, None, str(request.failure or "request failed"))
99
+
100
+ def _on_websocket(self, ws: Any) -> None:
101
+ on_frame = self.core.websocket(ws.url)
102
+ ws.on("framesent", _safe("websocket", on_frame))
103
+
104
+ def _on_download(self, download: Any) -> None:
105
+ self.core.download(download.url, download.suggested_filename)
106
+
107
+ def _on_crash(self, *_: Any) -> None:
108
+ self.core.crashed()
109
+
110
+ def listen(self) -> None:
111
+ """Install the event listeners (the binding and the page script are installed separately)."""
112
+ for event, handler in (
113
+ ("framenavigated", self._on_framenavigated),
114
+ ("request", self._on_request),
115
+ ("response", self._on_response),
116
+ ("requestfailed", self._on_request_failed),
117
+ ("websocket", self._on_websocket),
118
+ ("download", self._on_download),
119
+ ("crash", self._on_crash),
120
+ ):
121
+ safe = _safe(event, handler)
122
+ self.listeners.append((event, safe))
123
+ self.page.on(event, safe)
124
+
125
+ def _initial_page(self) -> bool:
126
+ url = self.page.url
127
+ if url and url != "about:blank":
128
+ self.core.navigate(url)
129
+ return True
130
+ return False
131
+
132
+ # -- setup -------------------------------------------------------------------------------
133
+
134
+ def _dispatcher(self) -> tuple[_Dispatcher, bool]:
135
+ existing = _dispatchers.get(self.page)
136
+ if existing is not None:
137
+ return existing, False
138
+ fresh = _Dispatcher()
139
+ _dispatchers[self.page] = fresh
140
+ return fresh, True
141
+
142
+ async def attach_async(self) -> None:
143
+ dispatcher, first = self._dispatcher()
144
+ if first:
145
+ await self.page.expose_binding(BINDING_NAME, dispatcher)
146
+ await self.page.add_init_script(script=page_script())
147
+ dispatcher.current = self
148
+ self.listen()
149
+ if self._initial_page():
150
+ try:
151
+ await self.page.evaluate(page_script())
152
+ except Exception as err:
153
+ log_error("initial scan", err)
154
+
155
+ def attach_sync(self) -> None:
156
+ dispatcher, first = self._dispatcher()
157
+ if first:
158
+ self.page.expose_binding(BINDING_NAME, dispatcher)
159
+ self.page.add_init_script(script=page_script())
160
+ dispatcher.current = self
161
+ self.listen()
162
+ if self._initial_page():
163
+ try:
164
+ self.page.evaluate(page_script())
165
+ except Exception as err:
166
+ log_error("initial scan", err)
167
+
168
+ def detach(self) -> None:
169
+ dispatcher = _dispatchers.get(self.page)
170
+ if dispatcher is not None and dispatcher.current is self:
171
+ dispatcher.current = None
172
+ for event, handler in self.listeners:
173
+ try:
174
+ self.page.remove_listener(event, handler)
175
+ except Exception:
176
+ pass
177
+ self.listeners.clear()
178
+
179
+
180
+ class ContextAttachment:
181
+ """Records every page of a browser context (used when the agent opens tabs, as Browser Use does).
182
+
183
+ The binding and the page script are installed once on the context; each page gets its own listeners, and
184
+ all of them feed one recorder. ``url()`` is the address of the page that was last active.
185
+ """
186
+
187
+ def __init__(self, context: Any, core: RecorderCore | None = None) -> None:
188
+ self.context = context
189
+ self.core = core
190
+ self.attachments: dict[Any, Attachment] = {}
191
+ self._active: Any = None
192
+
193
+ def url(self) -> str:
194
+ try:
195
+ return str(self._active.url) if self._active is not None else ""
196
+ except Exception:
197
+ return ""
198
+
199
+ def _activate(self, page: Any) -> None:
200
+ self._active = page
201
+
202
+ def _binding(self, source: Any, event: Any) -> None:
203
+ try:
204
+ page = source["page"] if not hasattr(source, "page") else source.page
205
+ attachment = self.attachments.get(page)
206
+ if attachment is not None:
207
+ attachment.binding(source, event)
208
+ except Exception as err:
209
+ log_error("event", err)
210
+
211
+ def _add_page(self, page: Any, evaluate: bool) -> None:
212
+ if self.core is None or page in self.attachments:
213
+ return
214
+ attachment = Attachment(page, self.core)
215
+ attachment.on_activity = self._activate
216
+ self.attachments[page] = attachment
217
+ attachment.listen()
218
+ if self._active is None:
219
+ self._active = page
220
+ if evaluate:
221
+ url = page.url
222
+ if url and url != "about:blank":
223
+ self.core.navigate(url)
224
+
225
+ def _on_page(self, page: Any) -> None:
226
+ try:
227
+ self._add_page(page, evaluate=False)
228
+ except Exception as err:
229
+ log_error("page", err)
230
+
231
+ async def attach_to(self, context: Any, core: RecorderCore) -> None:
232
+ """Start recording every page of ``context`` (existing and future) into ``core``."""
233
+ self.context = context
234
+ await self.attach(core)
235
+
236
+ async def attach(self, core: RecorderCore) -> None:
237
+ self.core = core
238
+ await self.context.expose_binding(BINDING_NAME, self._binding)
239
+ await self.context.add_init_script(script=page_script())
240
+ self.context.on("page", self._on_page)
241
+ for page in list(self.context.pages):
242
+ self._add_page(page, evaluate=True)
243
+ if page.url and page.url != "about:blank":
244
+ try:
245
+ await page.evaluate(page_script())
246
+ except Exception as err:
247
+ log_error("initial scan", err)
248
+
249
+ def detach(self) -> None:
250
+ for attachment in self.attachments.values():
251
+ attachment.detach()
252
+ self.attachments.clear()
253
+ try:
254
+ self.context.remove_listener("page", self._on_page)
255
+ except Exception:
256
+ pass