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 @@
|
|
|
1
|
+
browser_daemon_client
|