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 +38 -0
- steplight/_attach.py +256 -0
- steplight/_data/never-capture.json +487 -0
- steplight/_data/page-scan.js +395 -0
- steplight/_log.py +14 -0
- steplight/analyze.py +47 -0
- steplight/checksums.py +75 -0
- steplight/crypto.py +189 -0
- steplight/custom_patterns.py +199 -0
- steplight/domain.py +75 -0
- steplight/handles.py +249 -0
- steplight/ids.py +25 -0
- steplight/integrations/__init__.py +0 -0
- steplight/integrations/browser_use.py +283 -0
- steplight/network.py +37 -0
- steplight/never_capture.py +84 -0
- steplight/otel.py +111 -0
- steplight/policy.py +240 -0
- steplight/py.typed +0 -0
- steplight/recorder.py +551 -0
- steplight/redact.py +568 -0
- steplight/settings.py +63 -0
- steplight/store.py +246 -0
- steplight/sync_api.py +9 -0
- steplight_sdk-0.2.0.dist-info/METADATA +136 -0
- steplight_sdk-0.2.0.dist-info/RECORD +28 -0
- steplight_sdk-0.2.0.dist-info/WHEEL +4 -0
- steplight_sdk-0.2.0.dist-info/licenses/LICENSE +55 -0
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
|