browser-daemon-client 0.1.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.
@@ -0,0 +1,398 @@
1
+ """Async client for browser-daemon's HTTP API, with nothing but aiohttp.
2
+
3
+ Made for Home Assistant integrations and other code that cannot carry
4
+ Playwright: :meth:`BrowserDaemon.run` runs a Playwright script or a list of
5
+ steps on the node (see docs/run.md), and the other methods cover sessions,
6
+ tabs and a page's login.
7
+
8
+ async with aiohttp.ClientSession() as http:
9
+ bd = BrowserDaemon(http, "https://browser.example.org", secret)
10
+ res = await bd.run(
11
+ profile="work",
12
+ url="https://example.org/login",
13
+ steps=[
14
+ step.fill("#user", arg="user"),
15
+ step.fill("#password", arg="password"),
16
+ step.click("role=button[name='Sign in']"),
17
+ step.text(".balance", as_="balance"),
18
+ ],
19
+ args={"user": user, "password": password},
20
+ )
21
+ print(res.result["balance"])
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+ from urllib.parse import quote
29
+
30
+ import aiohttp
31
+
32
+ __all__ = [
33
+ "BrowserDaemon",
34
+ "BrowserDaemonError",
35
+ "RunError",
36
+ "RunResult",
37
+ "RunTimeout",
38
+ "read_secret",
39
+ "step",
40
+ ]
41
+
42
+ SECRET_HEADER = "X-Browser-Cluster-Secret"
43
+ DEFAULT_SECRET_FILE = "/run/secrets/browser-daemon/cluster-secret"
44
+
45
+
46
+ class BrowserDaemonError(Exception):
47
+ """The daemon refused a request or could not be reached."""
48
+
49
+ def __init__(self, message: str, status: int | None = None) -> None:
50
+ super().__init__(message)
51
+ self.status = status
52
+
53
+
54
+ @dataclass
55
+ class RunResult:
56
+ """The answer of a run (see docs/run.md, "The answer")."""
57
+
58
+ ok: bool
59
+ result: Any = None
60
+ error: str | None = None
61
+ timed_out: bool = False
62
+ steps: list[dict[str, Any]] = field(default_factory=list)
63
+ logs: list[dict[str, Any]] = field(default_factory=list)
64
+ url: str | None = None
65
+ page: str | None = None
66
+ #: The tab when the run failed: a base64 PNG.
67
+ screenshot: str | None = None
68
+ ms: int | None = None
69
+
70
+ @classmethod
71
+ def from_json(cls, data: dict[str, Any]) -> RunResult:
72
+ return cls(
73
+ ok=bool(data.get("ok")),
74
+ result=data.get("result"),
75
+ error=data.get("error"),
76
+ timed_out=bool(data.get("timed_out")),
77
+ steps=data.get("steps") or [],
78
+ logs=data.get("logs") or [],
79
+ url=data.get("url"),
80
+ page=data.get("page"),
81
+ screenshot=data.get("screenshot"),
82
+ ms=data.get("ms"),
83
+ )
84
+
85
+
86
+ class RunError(BrowserDaemonError):
87
+ """The script or a step failed; :attr:`run` holds the whole answer."""
88
+
89
+ def __init__(self, run: RunResult, status: int | None = None) -> None:
90
+ super().__init__(run.error or "The run failed", status)
91
+ self.run = run
92
+
93
+
94
+ class RunTimeout(RunError):
95
+ """The run hit its timeout."""
96
+
97
+
98
+ def read_secret(path: str = DEFAULT_SECRET_FILE) -> str | None:
99
+ """The cluster secret from a file, or None when it cannot be read."""
100
+ try:
101
+ with open(path, encoding="utf-8") as f:
102
+ return f.read().strip() or None
103
+ except OSError:
104
+ return None
105
+
106
+
107
+ class BrowserDaemon:
108
+ """A node of the mesh. Requests for other nodes go through it (`node=`)."""
109
+
110
+ def __init__(
111
+ self,
112
+ session: aiohttp.ClientSession,
113
+ url: str,
114
+ secret: str | None = None,
115
+ *,
116
+ client: str | None = None,
117
+ timeout: float = 30,
118
+ ) -> None:
119
+ self.session = session
120
+ self.url = url.rstrip("/")
121
+ self.secret = secret
122
+ #: Shown on the dashboard for sessions and runs ("Home Assistant · HR WORKS").
123
+ self.client = client
124
+ self.timeout = timeout
125
+ self.local_node: str | None = None
126
+
127
+ async def _request(
128
+ self,
129
+ method: str,
130
+ path: str,
131
+ *,
132
+ node: str | None = None,
133
+ timeout: float | None = None,
134
+ accept: tuple[int, ...] = (),
135
+ **kwargs: Any,
136
+ ) -> tuple[int, Any]:
137
+ if node and node != self.local_node:
138
+ path = f"/api/peers/{quote(node, safe='')}/{path.removeprefix('/api/').lstrip('/')}"
139
+ headers = {SECRET_HEADER: self.secret} if self.secret else {}
140
+ try:
141
+ async with self.session.request(
142
+ method,
143
+ self.url + path,
144
+ headers=headers,
145
+ timeout=aiohttp.ClientTimeout(total=timeout or self.timeout),
146
+ **kwargs,
147
+ ) as res:
148
+ data = await res.json(content_type=None) if res.status != 204 else None
149
+ if res.status < 300 or res.status in accept:
150
+ return res.status, data
151
+ message = data.get("error") if isinstance(data, dict) else None
152
+ raise BrowserDaemonError(
153
+ message or f"browser-daemon returned HTTP {res.status}", res.status
154
+ )
155
+ except (aiohttp.ClientError, TimeoutError) as err:
156
+ raise BrowserDaemonError(f"browser-daemon is unreachable: {err!r}") from err
157
+ except ValueError as err:
158
+ raise BrowserDaemonError("browser-daemon sent an unreadable answer") from err
159
+
160
+ async def _json(self, method: str, path: str, **kwargs: Any) -> Any:
161
+ return (await self._request(method, path, **kwargs))[1]
162
+
163
+ # The mesh
164
+
165
+ async def overview(self) -> dict[str, Any]:
166
+ """Nodes, sessions, profiles and tabs of the whole mesh."""
167
+ data = await self._json("GET", "/api/mesh/overview")
168
+ self.local_node = (data.get("local_node") or {}).get("name")
169
+ return data
170
+
171
+ async def find_profile(self, name: str) -> dict[str, Any] | None:
172
+ """A profile of the mesh by name, with its `node_name` and `session_id`."""
173
+ overview = await self.overview()
174
+ return next((p for p in overview.get("profiles", []) if p.get("name") == name), None)
175
+
176
+ # Runs
177
+
178
+ async def run(
179
+ self,
180
+ *,
181
+ script: str | None = None,
182
+ steps: list[dict[str, Any]] | None = None,
183
+ args: dict[str, Any] | None = None,
184
+ profile: str | None = None,
185
+ session_id: str | None = None,
186
+ tab: str | None = None,
187
+ url: str | None = None,
188
+ keep_open: bool = False,
189
+ persistent: bool = False,
190
+ timeout: float = 60,
191
+ action_timeout: float | None = None,
192
+ screenshot_on_error: bool = True,
193
+ idle_timeout_minutes: int | None = None,
194
+ node: str | None = None,
195
+ check: bool = True,
196
+ ) -> RunResult:
197
+ """Run a script or steps in a browser of the mesh (see docs/run.md).
198
+
199
+ Raises :class:`RunError` (:class:`RunTimeout`) when the run fails,
200
+ unless `check` is false, and :class:`BrowserDaemonError` when it could
201
+ not start.
202
+ """
203
+ body: dict[str, Any] = {
204
+ "script": script,
205
+ "steps": steps,
206
+ "args": args,
207
+ "profile": profile,
208
+ "session_id": session_id,
209
+ "tab": tab,
210
+ "url": url,
211
+ "keep_open": keep_open,
212
+ "persistent": persistent,
213
+ "timeout_seconds": max(1, round(timeout)),
214
+ "action_timeout_seconds": round(action_timeout) if action_timeout else None,
215
+ "screenshot_on_error": screenshot_on_error,
216
+ "idle_timeout_minutes": idle_timeout_minutes,
217
+ "client": self.client,
218
+ }
219
+ status, data = await self._request(
220
+ "POST",
221
+ "/api/run",
222
+ node=node,
223
+ json={k: v for k, v in body.items() if v is not None},
224
+ timeout=timeout + 45,
225
+ accept=(422, 504),
226
+ )
227
+ res = RunResult.from_json(data or {})
228
+ if check and not res.ok:
229
+ raise (RunTimeout if res.timed_out else RunError)(res, status)
230
+ return res
231
+
232
+ # Sessions and tabs
233
+
234
+ async def sessions(self, node: str | None = None) -> list[dict[str, Any]]:
235
+ return await self._json("GET", "/api/sessions", node=node)
236
+
237
+ async def session(self, session_id: str, node: str | None = None) -> dict[str, Any]:
238
+ """The session and its `targets` (tabs)."""
239
+ return await self._json("GET", f"/api/sessions/{quote(session_id, safe='')}", node=node)
240
+
241
+ async def create_session(
242
+ self, *, profile: str | None = None, node: str | None = None, **options: Any
243
+ ) -> dict[str, Any]:
244
+ """Start a session (or attach to the profile's); options as `POST /api/sessions`."""
245
+ body = {"persistent": True, "profile": profile, **options}
246
+ if self.client and "name" not in body:
247
+ body["name"] = self.client
248
+ return await self._json(
249
+ "POST",
250
+ "/api/sessions",
251
+ node=node,
252
+ json={k: v for k, v in body.items() if v is not None},
253
+ )
254
+
255
+ async def delete_session(self, session_id: str, node: str | None = None) -> None:
256
+ await self._request("DELETE", f"/api/sessions/{quote(session_id, safe='')}", node=node)
257
+
258
+ async def set_idle_timeout(
259
+ self, session_id: str, minutes: int, node: str | None = None
260
+ ) -> None:
261
+ await self._request(
262
+ "POST",
263
+ f"/api/sessions/{quote(session_id, safe='')}/idle-timeout",
264
+ node=node,
265
+ params={"minutes": minutes},
266
+ )
267
+
268
+ async def tabs(self, node: str | None = None) -> list[dict[str, Any]]:
269
+ """The pages of all sessions of the node (`/json/list`)."""
270
+ return await self._json("GET", "/json/list", node=node)
271
+
272
+ async def open_tab(
273
+ self, url: str, *, session_id: str, node: str | None = None
274
+ ) -> dict[str, Any]:
275
+ return await self._json(
276
+ "PUT", "/json/new", node=node, params={"url": url, "session_id": session_id}
277
+ )
278
+
279
+ async def close_tab(self, page_id: str, node: str | None = None) -> None:
280
+ await self._request("PUT", f"/json/close/{quote(page_id, safe='')}", node=node)
281
+
282
+ async def export_auth(
283
+ self,
284
+ page_id: str,
285
+ *,
286
+ session_id: str | None = None,
287
+ urls: list[str] | None = None,
288
+ storage: bool = False,
289
+ node: str | None = None,
290
+ ) -> dict[str, Any]:
291
+ """A page's cookies (and local/session storage): its login, for an API client.
292
+
293
+ The answer is credentials: never log it.
294
+ """
295
+ body: dict[str, Any] = {"urls": urls or [], "storage": storage}
296
+ if session_id:
297
+ body["session_id"] = session_id
298
+ return await self._json(
299
+ "POST", f"/api/pages/{quote(page_id, safe='')}/auth", node=node, json=body
300
+ )
301
+
302
+
303
+ class step:
304
+ """Builders for run steps (docs/run.md, "Steps"). `as_` names the value."""
305
+
306
+ @staticmethod
307
+ def _make(action: str, as_: str | None = None, **fields: Any) -> dict[str, Any]:
308
+ out = {"action": action, **{k: v for k, v in fields.items() if v is not None}}
309
+ if as_:
310
+ out["as"] = as_
311
+ return out
312
+
313
+ @staticmethod
314
+ def goto(url: str, *, wait_until: str | None = None, **kw: Any) -> dict[str, Any]:
315
+ return step._make("goto", url=url, wait_until=wait_until, **kw)
316
+
317
+ @staticmethod
318
+ def click(selector: str, **kw: Any) -> dict[str, Any]:
319
+ return step._make("click", selector=selector, **kw)
320
+
321
+ @staticmethod
322
+ def fill(
323
+ selector: str, value: str | None = None, *, arg: str | None = None, **kw: Any
324
+ ) -> dict[str, Any]:
325
+ """Fill an input with `value`, or with `args[arg]` (keeps secrets out of the steps)."""
326
+ return step._make("fill", selector=selector, value=value, value_arg=arg, **kw)
327
+
328
+ @staticmethod
329
+ def type(
330
+ selector: str, value: str | None = None, *, arg: str | None = None, **kw: Any
331
+ ) -> dict[str, Any]:
332
+ return step._make("type", selector=selector, value=value, value_arg=arg, **kw)
333
+
334
+ @staticmethod
335
+ def press(key: str, selector: str | None = None, **kw: Any) -> dict[str, Any]:
336
+ return step._make("press", key=key, selector=selector, **kw)
337
+
338
+ @staticmethod
339
+ def select(
340
+ selector: str, value: Any = None, *, arg: str | None = None, **kw: Any
341
+ ) -> dict[str, Any]:
342
+ return step._make("select", selector=selector, value=value, value_arg=arg, **kw)
343
+
344
+ @staticmethod
345
+ def check(selector: str, checked: bool = True, **kw: Any) -> dict[str, Any]:
346
+ return step._make("check", selector=selector, checked=checked, **kw)
347
+
348
+ @staticmethod
349
+ def wait_for(
350
+ selector: str | None = None,
351
+ *,
352
+ state: str | None = None,
353
+ url: str | None = None,
354
+ load_state: str | None = None,
355
+ ms: int | None = None,
356
+ **kw: Any,
357
+ ) -> dict[str, Any]:
358
+ return step._make(
359
+ "wait_for", selector=selector, state=state, url=url, load_state=load_state, ms=ms, **kw
360
+ )
361
+
362
+ @staticmethod
363
+ def text(selector: str, *, all: bool | None = None, **kw: Any) -> dict[str, Any]:
364
+ return step._make("text", selector=selector, all=all, **kw)
365
+
366
+ @staticmethod
367
+ def value(selector: str, **kw: Any) -> dict[str, Any]:
368
+ return step._make("value", selector=selector, **kw)
369
+
370
+ @staticmethod
371
+ def attribute(selector: str, name: str, **kw: Any) -> dict[str, Any]:
372
+ return step._make("attribute", selector=selector, name=name, **kw)
373
+
374
+ @staticmethod
375
+ def count(selector: str, **kw: Any) -> dict[str, Any]:
376
+ return step._make("count", selector=selector, **kw)
377
+
378
+ @staticmethod
379
+ def visible(selector: str, **kw: Any) -> dict[str, Any]:
380
+ return step._make("visible", selector=selector, **kw)
381
+
382
+ @staticmethod
383
+ def evaluate(expression: str, arg: Any = None, **kw: Any) -> dict[str, Any]:
384
+ return step._make("evaluate", expression=expression, arg=arg, **kw)
385
+
386
+ @staticmethod
387
+ def screenshot(
388
+ selector: str | None = None, *, full_page: bool | None = None, **kw: Any
389
+ ) -> dict[str, Any]:
390
+ return step._make("screenshot", selector=selector, full_page=full_page, **kw)
391
+
392
+ @staticmethod
393
+ def url(**kw: Any) -> dict[str, Any]:
394
+ return step._make("url", **kw)
395
+
396
+ @staticmethod
397
+ def title(**kw: Any) -> dict[str, Any]:
398
+ return step._make("title", **kw)
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: browser-daemon-client
3
+ Version: 0.1.0
4
+ Summary: Async client for browser-daemon: Playwright runs over HTTP, sessions and logins, with nothing but aiohttp
5
+ License-Expression: GPL-3.0-only
6
+ Project-URL: Homepage, https://github.com/pschmitt/browser-daemon
7
+ Project-URL: Documentation, https://github.com/pschmitt/browser-daemon/blob/main/docs/run.md
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: aiohttp>=3.9
11
+
12
+ # browser-daemon-client
13
+
14
+ Async Python client for [browser-daemon](https://github.com/pschmitt/browser-daemon),
15
+ with nothing but `aiohttp`: made for Home Assistant integrations and other code
16
+ that cannot carry Playwright.
17
+
18
+ ```python
19
+ import aiohttp
20
+ from browser_daemon_client import BrowserDaemon, RunError, step
21
+
22
+ async with aiohttp.ClientSession() as http:
23
+ bd = BrowserDaemon(
24
+ http, "https://browser.example.org", secret, client="Home Assistant · HR WORKS"
25
+ )
26
+ try:
27
+ res = await bd.run(
28
+ profile="work",
29
+ url="https://hr.example.org/login",
30
+ steps=[
31
+ step.fill("#user", arg="user"),
32
+ step.fill("#password", arg="password"),
33
+ step.click("role=button[name='Sign in']"),
34
+ step.wait_for(".dashboard"),
35
+ step.text(".balance", as_="balance"),
36
+ ],
37
+ args={"user": user, "password": password},
38
+ )
39
+ except RunError as err:
40
+ print(err, err.run.url) # err.run.screenshot: the tab, as a base64 PNG
41
+ else:
42
+ print(res.result["balance"])
43
+ ```
44
+
45
+ - `run()`: a Playwright `script` (a JavaScript function expression) or `steps`,
46
+ in a profile's browser, a session or a throwaway browser. Runs for a profile
47
+ go to the node that has it. See [docs/run.md](../docs/run.md).
48
+ - `overview()`, `find_profile()`, `sessions()`, `session()`, `create_session()`,
49
+ `delete_session()`, `set_idle_timeout()`, `tabs()`, `open_tab()`,
50
+ `close_tab()`, `export_auth()` (a page's cookies and storage; never log them).
51
+ - `node=` sends a request to another node of the mesh through this one.
52
+ - `read_secret()` reads the cluster secret from its file.
53
+
54
+ Errors are `BrowserDaemonError` (with `status`), and for runs `RunError` and
55
+ `RunTimeout` (with the whole answer in `.run`).
56
+
57
+ ## Releasing
58
+
59
+ Bump `version` in `pyproject.toml`, commit, and push a signed tag
60
+ `python-vX.Y.Z`: the `python-client` workflow tests, builds and publishes it to
61
+ PyPI through trusted publishing (the `pypi` environment, tags `python-v*` only;
62
+ no token is stored anywhere).
@@ -0,0 +1,5 @@
1
+ browser_daemon_client/__init__.py,sha256=mx6bGrXMWFwLNYx59e41wC1u-7EdgduQEMQzRctp8Eo,14154
2
+ browser_daemon_client-0.1.0.dist-info/METADATA,sha256=Nc-jOXCXaACblSNWAtH4izZhX8cqfCc7BWlavUfWWOI,2556
3
+ browser_daemon_client-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
4
+ browser_daemon_client-0.1.0.dist-info/top_level.txt,sha256=hyobKNFfO8NXBIa5aquGmSgBpWIkjcaGhLcHRvlYZSs,22
5
+ browser_daemon_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ browser_daemon_client