browser-daemon-client 0.1.0__tar.gz
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.
- browser_daemon_client-0.1.0/PKG-INFO +62 -0
- browser_daemon_client-0.1.0/README.md +51 -0
- browser_daemon_client-0.1.0/browser_daemon_client/__init__.py +398 -0
- browser_daemon_client-0.1.0/browser_daemon_client.egg-info/PKG-INFO +62 -0
- browser_daemon_client-0.1.0/browser_daemon_client.egg-info/SOURCES.txt +9 -0
- browser_daemon_client-0.1.0/browser_daemon_client.egg-info/dependency_links.txt +1 -0
- browser_daemon_client-0.1.0/browser_daemon_client.egg-info/requires.txt +1 -0
- browser_daemon_client-0.1.0/browser_daemon_client.egg-info/top_level.txt +1 -0
- browser_daemon_client-0.1.0/pyproject.toml +25 -0
- browser_daemon_client-0.1.0/setup.cfg +4 -0
- browser_daemon_client-0.1.0/tests/test_client.py +164 -0
|
@@ -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,51 @@
|
|
|
1
|
+
# browser-daemon-client
|
|
2
|
+
|
|
3
|
+
Async Python client for [browser-daemon](https://github.com/pschmitt/browser-daemon),
|
|
4
|
+
with nothing but `aiohttp`: made for Home Assistant integrations and other code
|
|
5
|
+
that cannot carry Playwright.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import aiohttp
|
|
9
|
+
from browser_daemon_client import BrowserDaemon, RunError, step
|
|
10
|
+
|
|
11
|
+
async with aiohttp.ClientSession() as http:
|
|
12
|
+
bd = BrowserDaemon(
|
|
13
|
+
http, "https://browser.example.org", secret, client="Home Assistant · HR WORKS"
|
|
14
|
+
)
|
|
15
|
+
try:
|
|
16
|
+
res = await bd.run(
|
|
17
|
+
profile="work",
|
|
18
|
+
url="https://hr.example.org/login",
|
|
19
|
+
steps=[
|
|
20
|
+
step.fill("#user", arg="user"),
|
|
21
|
+
step.fill("#password", arg="password"),
|
|
22
|
+
step.click("role=button[name='Sign in']"),
|
|
23
|
+
step.wait_for(".dashboard"),
|
|
24
|
+
step.text(".balance", as_="balance"),
|
|
25
|
+
],
|
|
26
|
+
args={"user": user, "password": password},
|
|
27
|
+
)
|
|
28
|
+
except RunError as err:
|
|
29
|
+
print(err, err.run.url) # err.run.screenshot: the tab, as a base64 PNG
|
|
30
|
+
else:
|
|
31
|
+
print(res.result["balance"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `run()`: a Playwright `script` (a JavaScript function expression) or `steps`,
|
|
35
|
+
in a profile's browser, a session or a throwaway browser. Runs for a profile
|
|
36
|
+
go to the node that has it. See [docs/run.md](../docs/run.md).
|
|
37
|
+
- `overview()`, `find_profile()`, `sessions()`, `session()`, `create_session()`,
|
|
38
|
+
`delete_session()`, `set_idle_timeout()`, `tabs()`, `open_tab()`,
|
|
39
|
+
`close_tab()`, `export_auth()` (a page's cookies and storage; never log them).
|
|
40
|
+
- `node=` sends a request to another node of the mesh through this one.
|
|
41
|
+
- `read_secret()` reads the cluster secret from its file.
|
|
42
|
+
|
|
43
|
+
Errors are `BrowserDaemonError` (with `status`), and for runs `RunError` and
|
|
44
|
+
`RunTimeout` (with the whole answer in `.run`).
|
|
45
|
+
|
|
46
|
+
## Releasing
|
|
47
|
+
|
|
48
|
+
Bump `version` in `pyproject.toml`, commit, and push a signed tag
|
|
49
|
+
`python-vX.Y.Z`: the `python-client` workflow tests, builds and publishes it to
|
|
50
|
+
PyPI through trusted publishing (the `pypi` environment, tags `python-v*` only;
|
|
51
|
+
no token is stored anywhere).
|
|
@@ -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,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
browser_daemon_client/__init__.py
|
|
4
|
+
browser_daemon_client.egg-info/PKG-INFO
|
|
5
|
+
browser_daemon_client.egg-info/SOURCES.txt
|
|
6
|
+
browser_daemon_client.egg-info/dependency_links.txt
|
|
7
|
+
browser_daemon_client.egg-info/requires.txt
|
|
8
|
+
browser_daemon_client.egg-info/top_level.txt
|
|
9
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.9
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
browser_daemon_client
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "browser-daemon-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async client for browser-daemon: Playwright runs over HTTP, sessions and logins, with nothing but aiohttp"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "GPL-3.0-only"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = ["aiohttp>=3.9"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/pschmitt/browser-daemon"
|
|
16
|
+
Documentation = "https://github.com/pschmitt/browser-daemon/blob/main/docs/run.md"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["browser_daemon_client"]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
asyncio_mode = "auto"
|
|
23
|
+
|
|
24
|
+
[tool.ruff]
|
|
25
|
+
line-length = 100
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The client against a fake daemon: what it sends and how it reads the answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
import pytest
|
|
9
|
+
from aiohttp import web
|
|
10
|
+
from aiohttp.test_utils import TestServer
|
|
11
|
+
|
|
12
|
+
from browser_daemon_client import (
|
|
13
|
+
BrowserDaemon,
|
|
14
|
+
BrowserDaemonError,
|
|
15
|
+
RunError,
|
|
16
|
+
RunTimeout,
|
|
17
|
+
read_secret,
|
|
18
|
+
step,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
async def daemon():
|
|
24
|
+
"""A fake node: records every request and answers from `answers`."""
|
|
25
|
+
seen: list[dict] = []
|
|
26
|
+
answers: dict[tuple[str, str], tuple[int, object]] = {}
|
|
27
|
+
|
|
28
|
+
async def handle(request: web.Request) -> web.Response:
|
|
29
|
+
body = await request.text()
|
|
30
|
+
seen.append(
|
|
31
|
+
{
|
|
32
|
+
"method": request.method,
|
|
33
|
+
"path": request.rel_url.raw_path,
|
|
34
|
+
"query": dict(request.query),
|
|
35
|
+
"secret": request.headers.get("X-Browser-Cluster-Secret"),
|
|
36
|
+
"json": json.loads(body) if body else None,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
status, data = answers.get(
|
|
40
|
+
(request.method, request.rel_url.raw_path), (404, {"error": "nope"})
|
|
41
|
+
)
|
|
42
|
+
return web.json_response(data, status=status)
|
|
43
|
+
|
|
44
|
+
app = web.Application()
|
|
45
|
+
app.router.add_route("*", "/{tail:.*}", handle)
|
|
46
|
+
server = TestServer(app)
|
|
47
|
+
await server.start_server()
|
|
48
|
+
async with aiohttp.ClientSession() as http:
|
|
49
|
+
bd = BrowserDaemon(http, str(server.make_url("/")), "s3cret", client="tests")
|
|
50
|
+
yield bd, seen, answers
|
|
51
|
+
await server.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def test_a_run_sends_only_what_is_set(daemon):
|
|
55
|
+
bd, seen, answers = daemon
|
|
56
|
+
answers[("POST", "/api/run")] = (200, {"ok": True, "result": {"t": "x"}, "ms": 5})
|
|
57
|
+
res = await bd.run(
|
|
58
|
+
profile="work",
|
|
59
|
+
steps=[step.fill("#pw", arg="password"), step.text("h1", as_="t")],
|
|
60
|
+
args={"password": "hunter2"},
|
|
61
|
+
timeout=30,
|
|
62
|
+
)
|
|
63
|
+
assert res.ok and res.result == {"t": "x"} and res.ms == 5
|
|
64
|
+
sent = seen[0]
|
|
65
|
+
assert sent["secret"] == "s3cret"
|
|
66
|
+
assert sent["json"] == {
|
|
67
|
+
"profile": "work",
|
|
68
|
+
"steps": [
|
|
69
|
+
{"action": "fill", "selector": "#pw", "value_arg": "password"},
|
|
70
|
+
{"action": "text", "selector": "h1", "as": "t"},
|
|
71
|
+
],
|
|
72
|
+
"args": {"password": "hunter2"},
|
|
73
|
+
"keep_open": False,
|
|
74
|
+
"persistent": False,
|
|
75
|
+
"timeout_seconds": 30,
|
|
76
|
+
"screenshot_on_error": True,
|
|
77
|
+
"client": "tests",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def test_a_failed_run_raises_with_its_answer(daemon):
|
|
82
|
+
bd, _, answers = daemon
|
|
83
|
+
answers[("POST", "/api/run")] = (
|
|
84
|
+
422,
|
|
85
|
+
{
|
|
86
|
+
"ok": False,
|
|
87
|
+
"error": "step 0 (click): timeout",
|
|
88
|
+
"screenshot": "iVBOR",
|
|
89
|
+
"url": "https://x/",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
with pytest.raises(RunError) as err:
|
|
93
|
+
await bd.run(script="async () => 1")
|
|
94
|
+
assert not isinstance(err.value, RunTimeout)
|
|
95
|
+
assert str(err.value) == "step 0 (click): timeout"
|
|
96
|
+
assert err.value.status == 422
|
|
97
|
+
assert err.value.run.screenshot == "iVBOR"
|
|
98
|
+
assert err.value.run.url == "https://x/"
|
|
99
|
+
res = await bd.run(script="async () => 1", check=False)
|
|
100
|
+
assert not res.ok
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def test_a_timed_out_run_raises_run_timeout(daemon):
|
|
104
|
+
bd, _, answers = daemon
|
|
105
|
+
answers[("POST", "/api/run")] = (504, {"ok": False, "timed_out": True, "error": "timed out"})
|
|
106
|
+
with pytest.raises(RunTimeout):
|
|
107
|
+
await bd.run(script="async () => 1")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def test_a_refused_request_carries_the_daemons_message(daemon):
|
|
111
|
+
bd, _, answers = daemon
|
|
112
|
+
answers[("POST", "/api/run")] = (501, {"error": "Runs are not enabled on this node"})
|
|
113
|
+
with pytest.raises(BrowserDaemonError) as err:
|
|
114
|
+
await bd.run(script="async () => 1")
|
|
115
|
+
assert not isinstance(err.value, RunError)
|
|
116
|
+
assert err.value.status == 501
|
|
117
|
+
assert "not enabled" in str(err.value)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_requests_for_other_nodes_go_through_the_peer_route(daemon):
|
|
121
|
+
bd, seen, answers = daemon
|
|
122
|
+
answers[("GET", "/api/mesh/overview")] = (
|
|
123
|
+
200,
|
|
124
|
+
{"local_node": {"name": "here"}, "profiles": [{"name": "work", "node_name": "there"}]},
|
|
125
|
+
)
|
|
126
|
+
answers[("GET", "/api/peers/there/json/list")] = (200, [])
|
|
127
|
+
answers[("GET", "/api/sessions")] = (200, [])
|
|
128
|
+
answers[("POST", "/api/peers/there/sessions/a%2Fb/idle-timeout")] = (204, None)
|
|
129
|
+
assert (await bd.find_profile("work"))["node_name"] == "there"
|
|
130
|
+
assert await bd.tabs(node="there") == []
|
|
131
|
+
assert await bd.sessions(node="here") == []
|
|
132
|
+
await bd.set_idle_timeout("a/b", 5, node="there")
|
|
133
|
+
assert [s["path"] for s in seen[1:]] == [
|
|
134
|
+
"/api/peers/there/json/list",
|
|
135
|
+
"/api/sessions",
|
|
136
|
+
"/api/peers/there/sessions/a%2Fb/idle-timeout",
|
|
137
|
+
]
|
|
138
|
+
assert seen[-1]["query"] == {"minutes": "5"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_a_session_is_named_after_the_client(daemon):
|
|
142
|
+
bd, seen, answers = daemon
|
|
143
|
+
answers[("POST", "/api/sessions")] = (201, {"id": "abc"})
|
|
144
|
+
assert (await bd.create_session(profile="work", default_tabs=False))["id"] == "abc"
|
|
145
|
+
assert seen[0]["json"] == {
|
|
146
|
+
"persistent": True,
|
|
147
|
+
"profile": "work",
|
|
148
|
+
"default_tabs": False,
|
|
149
|
+
"name": "tests",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def test_an_unreachable_daemon_is_a_browser_daemon_error():
|
|
154
|
+
async with aiohttp.ClientSession() as http:
|
|
155
|
+
bd = BrowserDaemon(http, "http://127.0.0.1:9", timeout=2)
|
|
156
|
+
with pytest.raises(BrowserDaemonError):
|
|
157
|
+
await bd.overview()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_the_secret_is_read_from_its_file(tmp_path):
|
|
161
|
+
path = tmp_path / "secret"
|
|
162
|
+
path.write_text("abc\n")
|
|
163
|
+
assert read_secret(str(path)) == "abc"
|
|
164
|
+
assert read_secret(str(tmp_path / "missing")) is None
|