real-browser-cli 0.14.2__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.
- browser_cli/__init__.py +164 -0
- browser_cli/async_sdk.py +237 -0
- browser_cli/auth.py +263 -0
- browser_cli/cli.py +151 -0
- browser_cli/client/__init__.py +47 -0
- browser_cli/client/auth.py +63 -0
- browser_cli/client/core.py +200 -0
- browser_cli/client/messages.py +45 -0
- browser_cli/client/targets.py +95 -0
- browser_cli/command_security.py +119 -0
- browser_cli/commands/__init__.py +81 -0
- browser_cli/commands/auth.py +157 -0
- browser_cli/commands/clients.py +173 -0
- browser_cli/commands/completion.py +56 -0
- browser_cli/commands/doctor.py +90 -0
- browser_cli/commands/dom.py +191 -0
- browser_cli/commands/events.py +52 -0
- browser_cli/commands/extension.py +42 -0
- browser_cli/commands/extract.py +70 -0
- browser_cli/commands/groups.py +108 -0
- browser_cli/commands/install.py +121 -0
- browser_cli/commands/navigate.py +96 -0
- browser_cli/commands/page.py +26 -0
- browser_cli/commands/perf.py +47 -0
- browser_cli/commands/raw.py +23 -0
- browser_cli/commands/remote.py +68 -0
- browser_cli/commands/script.py +68 -0
- browser_cli/commands/search.py +79 -0
- browser_cli/commands/serve.py +117 -0
- browser_cli/commands/serve_http.py +115 -0
- browser_cli/commands/session.py +163 -0
- browser_cli/commands/storage.py +36 -0
- browser_cli/commands/tabs.py +252 -0
- browser_cli/commands/watch.py +60 -0
- browser_cli/commands/windows.py +87 -0
- browser_cli/commands/workspace.py +91 -0
- browser_cli/compat/__init__.py +4 -0
- browser_cli/compat/auth.py +44 -0
- browser_cli/compat/commands.py +43 -0
- browser_cli/constants.py +95 -0
- browser_cli/endpoints.py +55 -0
- browser_cli/errors.py +9 -0
- browser_cli/framing.py +83 -0
- browser_cli/local_transport.py +64 -0
- browser_cli/markdown/__init__.py +8 -0
- browser_cli/markdown/html.py +259 -0
- browser_cli/markdown/render.py +188 -0
- browser_cli/models.py +182 -0
- browser_cli/native/__init__.py +1 -0
- browser_cli/native/host.py +211 -0
- browser_cli/native/local_server.py +111 -0
- browser_cli/native/protocol.py +30 -0
- browser_cli/platform.py +34 -0
- browser_cli/registry.py +99 -0
- browser_cli/remote/__init__.py +1 -0
- browser_cli/remote/registry.py +53 -0
- browser_cli/remote/transport.py +230 -0
- browser_cli/sdk/__init__.py +48 -0
- browser_cli/sdk/base.py +116 -0
- browser_cli/sdk/browser_data.py +37 -0
- browser_cli/sdk/decorators.py +107 -0
- browser_cli/sdk/dom.py +169 -0
- browser_cli/sdk/extension.py +24 -0
- browser_cli/sdk/factories.py +103 -0
- browser_cli/sdk/groups.py +51 -0
- browser_cli/sdk/navigation.py +122 -0
- browser_cli/sdk/perf.py +23 -0
- browser_cli/sdk/routing.py +149 -0
- browser_cli/sdk/session.py +72 -0
- browser_cli/sdk/tabs.py +213 -0
- browser_cli/sdk/windows.py +26 -0
- browser_cli/sdk/workflow_decorators.py +200 -0
- browser_cli/serve/__init__.py +0 -0
- browser_cli/serve/auth.py +107 -0
- browser_cli/serve/control.py +59 -0
- browser_cli/serve/logging.py +16 -0
- browser_cli/serve/proxy.py +79 -0
- browser_cli/serve/runtime.py +196 -0
- browser_cli/transport.py +214 -0
- browser_cli/version_manager.py +17 -0
- real_browser_cli-0.14.2.dist-info/METADATA +87 -0
- real_browser_cli-0.14.2.dist-info/RECORD +85 -0
- real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
- real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
- real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""TCP/TLS transport for talking to a remote ``browser-cli serve``.
|
|
2
|
+
|
|
3
|
+
Owns the wire mechanics of the remote leg: open a socket (TLS on :443),
|
|
4
|
+
complete the signed challenge/response handshake with an optional post-quantum
|
|
5
|
+
key exchange, frame the request, and read the framed (possibly encrypted)
|
|
6
|
+
response. The higher-level "which endpoint / which profile / which key"
|
|
7
|
+
decisions stay in :mod:`browser_cli.client.core`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import socket
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from typing import TypeVar
|
|
18
|
+
|
|
19
|
+
from browser_cli.errors import BrowserNotConnected
|
|
20
|
+
from browser_cli.endpoints import _resolve_connect_endpoint
|
|
21
|
+
from browser_cli.framing import async_recv_exact, async_recv_frame, async_send_frame, frame, recv_exact, recv_frame
|
|
22
|
+
from browser_cli.version_manager import USER_AGENT as _USER_AGENT
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
_AUTH_FIELDS = {"token", "pubkey", "sig", "pq_kex", "encrypted", "_suppress_pq_warning"}
|
|
26
|
+
_PQ_WARNING = (
|
|
27
|
+
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
|
|
28
|
+
"** This session may be vulnerable to store now, decrypt later attacks.\n"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
32
|
+
return recv_exact(sock, n) or b""
|
|
33
|
+
|
|
34
|
+
def _recv_all(sock: socket.socket) -> bytes:
|
|
35
|
+
return recv_frame(sock, label="Response") or b""
|
|
36
|
+
|
|
37
|
+
async def _async_recv_exact(reader: asyncio.StreamReader, n: int) -> bytes:
|
|
38
|
+
return await async_recv_exact(reader, n) or b""
|
|
39
|
+
|
|
40
|
+
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
|
|
41
|
+
return await async_recv_frame(reader, label="Response") or b""
|
|
42
|
+
|
|
43
|
+
def _split_endpoint(endpoint: str) -> tuple[str, int]:
|
|
44
|
+
connect_ep = _resolve_connect_endpoint(endpoint)
|
|
45
|
+
host, _, port_str = connect_ep.rpartition(":")
|
|
46
|
+
return host, int(port_str)
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def _open_socket(endpoint: str):
|
|
50
|
+
host, port = _split_endpoint(endpoint)
|
|
51
|
+
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
52
|
+
raw_sock.settimeout(30)
|
|
53
|
+
try:
|
|
54
|
+
raw_sock.connect((host, port))
|
|
55
|
+
if port == 443:
|
|
56
|
+
import ssl
|
|
57
|
+
sock = ssl.create_default_context().wrap_socket(raw_sock, server_hostname=host)
|
|
58
|
+
else:
|
|
59
|
+
sock = raw_sock
|
|
60
|
+
except Exception:
|
|
61
|
+
raw_sock.close()
|
|
62
|
+
raise
|
|
63
|
+
with sock:
|
|
64
|
+
yield sock
|
|
65
|
+
|
|
66
|
+
async def _open_async_connection(endpoint: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
67
|
+
host, port = _split_endpoint(endpoint)
|
|
68
|
+
ssl_ctx = None
|
|
69
|
+
if port == 443:
|
|
70
|
+
import ssl
|
|
71
|
+
ssl_ctx = ssl.create_default_context()
|
|
72
|
+
return await asyncio.open_connection(host, port, ssl=ssl_ctx, server_hostname=host if ssl_ctx else None)
|
|
73
|
+
|
|
74
|
+
def _parse_challenge(raw: bytes) -> tuple[dict | None, str | None]:
|
|
75
|
+
try:
|
|
76
|
+
challenge = json.loads(raw)
|
|
77
|
+
nonce_hex = challenge.get("nonce") if challenge.get("type") == "challenge" else None
|
|
78
|
+
return challenge, nonce_hex
|
|
79
|
+
except (json.JSONDecodeError, AttributeError):
|
|
80
|
+
return None, None
|
|
81
|
+
|
|
82
|
+
def _check_min_client_version(challenge: dict | None) -> None:
|
|
83
|
+
min_ver = challenge.get("min_client_version") if isinstance(challenge, dict) else None
|
|
84
|
+
if not min_ver:
|
|
85
|
+
return
|
|
86
|
+
from browser_cli.version_manager import parse_version
|
|
87
|
+
try:
|
|
88
|
+
client_ver = _USER_AGENT.split("/", 1)[1]
|
|
89
|
+
if parse_version(client_ver) < parse_version(min_ver):
|
|
90
|
+
raise BrowserNotConnected(
|
|
91
|
+
f"Client version {client_ver} is too old for this server "
|
|
92
|
+
f"(requires >= {min_ver}). Run: pip install --upgrade browser-cli"
|
|
93
|
+
)
|
|
94
|
+
except (IndexError, ValueError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def _clean_message(msg: dict) -> dict:
|
|
98
|
+
return {k: v for k, v in msg.items() if k not in _AUTH_FIELDS}
|
|
99
|
+
|
|
100
|
+
def _get_pq_public_key(challenge: dict | None) -> str | None:
|
|
101
|
+
if not isinstance(challenge, dict):
|
|
102
|
+
return None
|
|
103
|
+
from browser_cli.auth import PQ_KEX_ALG
|
|
104
|
+
kex = challenge.get("pq_kex")
|
|
105
|
+
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
|
|
106
|
+
return str(kex["public_key"])
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def _signed_payload(clean_msg: dict, private_key, nonce_hex: str, pq_shared_secret: bytes | None) -> dict:
|
|
110
|
+
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, public_key_hex, sign
|
|
111
|
+
|
|
112
|
+
nonce = bytes.fromhex(nonce_hex)
|
|
113
|
+
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
|
|
114
|
+
pubkey = public_key_hex(private_key)
|
|
115
|
+
if pq_shared_secret is None:
|
|
116
|
+
return {**clean_msg, "pubkey": pubkey, "sig": sig.hex()}
|
|
117
|
+
|
|
118
|
+
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
|
|
119
|
+
return {
|
|
120
|
+
"id": clean_msg.get("id"),
|
|
121
|
+
"user_agent": clean_msg.get("user_agent"),
|
|
122
|
+
"pubkey": pubkey,
|
|
123
|
+
"sig": sig.hex(),
|
|
124
|
+
"pq_kex": clean_msg["pq_kex"],
|
|
125
|
+
"encrypted": encrypted,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def _warn_no_pq(enabled: bool) -> None:
|
|
129
|
+
if enabled:
|
|
130
|
+
sys.stderr.write(_PQ_WARNING)
|
|
131
|
+
|
|
132
|
+
def _build_auth_message(
|
|
133
|
+
msg: dict,
|
|
134
|
+
challenge: dict | None,
|
|
135
|
+
nonce_hex: str | None,
|
|
136
|
+
private_key,
|
|
137
|
+
encapsulate: Callable[[str], tuple[str, bytes]],
|
|
138
|
+
*,
|
|
139
|
+
warn_no_pq: bool = True,
|
|
140
|
+
) -> tuple[dict, bytes | None]:
|
|
141
|
+
if not nonce_hex or private_key is None:
|
|
142
|
+
_warn_no_pq(warn_no_pq)
|
|
143
|
+
return msg, None
|
|
144
|
+
|
|
145
|
+
clean_msg = _clean_message(msg)
|
|
146
|
+
pq_shared_secret = None
|
|
147
|
+
pq_public_key = _get_pq_public_key(challenge)
|
|
148
|
+
if pq_public_key:
|
|
149
|
+
from browser_cli.auth import PQ_KEX_ALG
|
|
150
|
+
ciphertext_hex, pq_shared_secret = encapsulate(pq_public_key)
|
|
151
|
+
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
|
|
152
|
+
else:
|
|
153
|
+
_warn_no_pq(warn_no_pq)
|
|
154
|
+
|
|
155
|
+
return _signed_payload(clean_msg, private_key, nonce_hex, pq_shared_secret), pq_shared_secret
|
|
156
|
+
|
|
157
|
+
async def _build_auth_message_async(
|
|
158
|
+
msg: dict,
|
|
159
|
+
challenge: dict | None,
|
|
160
|
+
nonce_hex: str | None,
|
|
161
|
+
private_key,
|
|
162
|
+
*,
|
|
163
|
+
warn_no_pq: bool = True,
|
|
164
|
+
) -> tuple[dict, bytes | None]:
|
|
165
|
+
def encapsulate(public_key: str) -> tuple[str, bytes]:
|
|
166
|
+
from browser_cli.auth import pq_kex_client_encapsulate
|
|
167
|
+
return pq_kex_client_encapsulate(public_key)
|
|
168
|
+
|
|
169
|
+
return await asyncio.to_thread(
|
|
170
|
+
_build_auth_message,
|
|
171
|
+
msg,
|
|
172
|
+
challenge,
|
|
173
|
+
nonce_hex,
|
|
174
|
+
private_key,
|
|
175
|
+
encapsulate,
|
|
176
|
+
warn_no_pq=warn_no_pq,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _decode_pq_response(response: bytes | None, pq_shared_secret: bytes | None) -> bytes | None:
|
|
180
|
+
if response is None or pq_shared_secret is None:
|
|
181
|
+
return response
|
|
182
|
+
try:
|
|
183
|
+
from browser_cli.auth import pq_decrypt
|
|
184
|
+
envelope = json.loads(response)
|
|
185
|
+
if isinstance(envelope, dict) and "encrypted" in envelope:
|
|
186
|
+
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
|
|
187
|
+
except Exception as e:
|
|
188
|
+
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
|
|
189
|
+
return response
|
|
190
|
+
|
|
191
|
+
def _with_challenge(challenge_raw: bytes, msg: dict, private_key, build_auth: Callable[[dict, dict | None, str | None, object], T]) -> T:
|
|
192
|
+
if challenge_raw is None:
|
|
193
|
+
raise BrowserNotConnected("No challenge received from remote endpoint")
|
|
194
|
+
challenge, nonce_hex = _parse_challenge(challenge_raw)
|
|
195
|
+
_check_min_client_version(challenge)
|
|
196
|
+
return build_auth(msg, challenge, nonce_hex, private_key)
|
|
197
|
+
|
|
198
|
+
def _should_warn_no_pq(msg: dict) -> bool:
|
|
199
|
+
return not bool(msg.pop("_suppress_pq_warning", False))
|
|
200
|
+
|
|
201
|
+
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
|
202
|
+
reader, writer = await _open_async_connection(endpoint)
|
|
203
|
+
try:
|
|
204
|
+
challenge_raw = await _async_recv_all(reader)
|
|
205
|
+
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
|
206
|
+
|
|
207
|
+
async def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
|
208
|
+
return await _build_auth_message_async(sync_msg, challenge, nonce_hex, key, warn_no_pq=warn)
|
|
209
|
+
|
|
210
|
+
payload_msg, pq_shared_secret = await _with_challenge(challenge_raw, msg, private_key, build_auth)
|
|
211
|
+
await async_send_frame(writer, json.dumps(payload_msg).encode("utf-8"))
|
|
212
|
+
return _decode_pq_response(await _async_recv_all(reader), pq_shared_secret)
|
|
213
|
+
finally:
|
|
214
|
+
writer.close()
|
|
215
|
+
try:
|
|
216
|
+
await writer.wait_closed()
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
|
221
|
+
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
|
222
|
+
|
|
223
|
+
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
|
224
|
+
from browser_cli.auth import pq_kex_client_encapsulate
|
|
225
|
+
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
|
|
226
|
+
|
|
227
|
+
with _open_socket(endpoint) as sock:
|
|
228
|
+
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
|
229
|
+
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
|
230
|
+
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""SDK command namespaces for :class:`browser_cli.BrowserCLI`.
|
|
2
|
+
|
|
3
|
+
Each namespace groups related browser commands under a short accessor on the
|
|
4
|
+
client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups
|
|
5
|
+
in the browser extension.
|
|
6
|
+
"""
|
|
7
|
+
from browser_cli.sdk.browser_data import StorageNS
|
|
8
|
+
from browser_cli.sdk.decorators import DecoratorsNS
|
|
9
|
+
from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS
|
|
10
|
+
from browser_cli.sdk.extension import ExtensionNS
|
|
11
|
+
from browser_cli.sdk.groups import GroupsNS
|
|
12
|
+
from browser_cli.sdk.navigation import NavigationNS
|
|
13
|
+
from browser_cli.sdk.perf import PerfNS
|
|
14
|
+
from browser_cli.sdk.session import SessionNS
|
|
15
|
+
from browser_cli.sdk.tabs import TabsNS
|
|
16
|
+
from browser_cli.sdk.windows import WindowsNS
|
|
17
|
+
|
|
18
|
+
NAMESPACE_SPECS = (
|
|
19
|
+
("nav", NavigationNS),
|
|
20
|
+
("tabs", TabsNS),
|
|
21
|
+
("groups", GroupsNS),
|
|
22
|
+
("windows", WindowsNS),
|
|
23
|
+
("dom", DomNS),
|
|
24
|
+
("extract", ExtractNS),
|
|
25
|
+
("page", PageNS),
|
|
26
|
+
("storage", StorageNS),
|
|
27
|
+
("session", SessionNS),
|
|
28
|
+
("perf", PerfNS),
|
|
29
|
+
("extension", ExtensionNS),
|
|
30
|
+
)
|
|
31
|
+
NAMESPACE_NAMES = tuple(name for name, _ in NAMESPACE_SPECS)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"NavigationNS",
|
|
35
|
+
"TabsNS",
|
|
36
|
+
"GroupsNS",
|
|
37
|
+
"WindowsNS",
|
|
38
|
+
"DomNS",
|
|
39
|
+
"ExtractNS",
|
|
40
|
+
"PageNS",
|
|
41
|
+
"StorageNS",
|
|
42
|
+
"SessionNS",
|
|
43
|
+
"PerfNS",
|
|
44
|
+
"ExtensionNS",
|
|
45
|
+
"DecoratorsNS",
|
|
46
|
+
"NAMESPACE_SPECS",
|
|
47
|
+
"NAMESPACE_NAMES",
|
|
48
|
+
]
|
browser_cli/sdk/base.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Base helpers for SDK command namespaces.
|
|
2
|
+
|
|
3
|
+
Each namespace (``b.tabs``, ``b.dom``, ...) is a thin object bound to its
|
|
4
|
+
:class:`~browser_cli.BrowserCLI` client. Namespaces hold no state of their own;
|
|
5
|
+
they delegate to the client's shared infrastructure (command dispatch, the
|
|
6
|
+
multi-browser helpers, and the ``Tab``/``Group`` factories).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
F = TypeVar("F", bound=Callable)
|
|
15
|
+
_MISSING = object()
|
|
16
|
+
|
|
17
|
+
def _clone_default(value):
|
|
18
|
+
if isinstance(value, (dict, list, set)):
|
|
19
|
+
return value.copy()
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
def sdk_command(
|
|
23
|
+
name: str,
|
|
24
|
+
args: Callable | None = None,
|
|
25
|
+
*,
|
|
26
|
+
default=_MISSING,
|
|
27
|
+
field: str | None = None,
|
|
28
|
+
fallback=_MISSING,
|
|
29
|
+
mapper: Callable | None = None,
|
|
30
|
+
return_result: bool = True,
|
|
31
|
+
):
|
|
32
|
+
"""Decorate a namespace method as a browser wire command.
|
|
33
|
+
|
|
34
|
+
This keeps the public method signature/docstring on the normal Python
|
|
35
|
+
method, while moving repetitive command-dispatch plumbing into one place.
|
|
36
|
+
The wrapped function body is intentionally unused; it exists only for
|
|
37
|
+
signature, docs, and type hints.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def decorator(func: F) -> F:
|
|
41
|
+
@wraps(func)
|
|
42
|
+
def wrapper(self, *method_args, **method_kwargs):
|
|
43
|
+
payload = args(self, *method_args, **method_kwargs) if args is not None else {}
|
|
44
|
+
result = self.command(name, payload)
|
|
45
|
+
if not return_result:
|
|
46
|
+
return None
|
|
47
|
+
if mapper is not None:
|
|
48
|
+
return mapper(self, result, *method_args, **method_kwargs)
|
|
49
|
+
if field is not None:
|
|
50
|
+
if fallback is _MISSING:
|
|
51
|
+
return self.field(result, field, default)
|
|
52
|
+
return self.field(result, field, default, fallback=fallback)
|
|
53
|
+
if default is not _MISSING and not result:
|
|
54
|
+
return _clone_default(default)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
wrapper._browser_cli_command = name # type: ignore[attr-defined]
|
|
58
|
+
return wrapper # type: ignore[return-value]
|
|
59
|
+
|
|
60
|
+
return decorator
|
|
61
|
+
|
|
62
|
+
class Namespace:
|
|
63
|
+
"""A group of related SDK methods, bound to a BrowserCLI client."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, client: Any):
|
|
66
|
+
self._c = client
|
|
67
|
+
|
|
68
|
+
def command(self, name: str, args: dict | None = None):
|
|
69
|
+
"""Dispatch a browser command through the owning client."""
|
|
70
|
+
return self._c.dispatch(name, args)
|
|
71
|
+
|
|
72
|
+
def tab_from(self, data: dict):
|
|
73
|
+
"""Build a bound Tab from a raw command response dict."""
|
|
74
|
+
return self._c.tab_from(data)
|
|
75
|
+
|
|
76
|
+
def group_from(self, data: dict):
|
|
77
|
+
"""Build a bound Group from a raw command response dict."""
|
|
78
|
+
return self._c.group_from(data)
|
|
79
|
+
|
|
80
|
+
def tab_from_target(self, data: dict, target):
|
|
81
|
+
"""Build a bound Tab for a multi-browser target."""
|
|
82
|
+
return self._c.tab_from_target(data, target)
|
|
83
|
+
|
|
84
|
+
def group_from_target(self, data: dict, target):
|
|
85
|
+
"""Build a bound Group for a multi-browser target."""
|
|
86
|
+
return self._c.group_from_target(data, target)
|
|
87
|
+
|
|
88
|
+
def tag_browser(self, item: dict, target):
|
|
89
|
+
"""Annotate a raw dict with its browser in multi-browser mode."""
|
|
90
|
+
return self._c.tag_browser(item, target)
|
|
91
|
+
|
|
92
|
+
def multi_list(self, name: str, args: dict | None, mapper: Callable):
|
|
93
|
+
"""Run a list command with multi-browser fan-out support."""
|
|
94
|
+
return self._c.multi_list(name, args, mapper)
|
|
95
|
+
|
|
96
|
+
def multi_count(self, name: str, args: dict | None = None):
|
|
97
|
+
"""Run a count command with multi-browser fan-out support."""
|
|
98
|
+
return self._c.multi_count(name, args)
|
|
99
|
+
|
|
100
|
+
def apply_tab_filter(self, filter_fn: Callable):
|
|
101
|
+
"""Apply a Python-side tab filter using client semantics."""
|
|
102
|
+
return self._c.apply_tab_filter(filter_fn)
|
|
103
|
+
|
|
104
|
+
def toggle_tab(self, name: str, tab_id: int | None):
|
|
105
|
+
"""Run a tab toggle command and return the affected tab ID."""
|
|
106
|
+
return self._c.toggle_tab(name, tab_id)
|
|
107
|
+
|
|
108
|
+
def require_tab(self, data, error: str):
|
|
109
|
+
"""Convert a tab-like response into a bound Tab or raise a clean error."""
|
|
110
|
+
return self._c.require_tab(data, error)
|
|
111
|
+
|
|
112
|
+
def field(self, result, key, default=None, *, fallback=_MISSING):
|
|
113
|
+
"""Read a field from command output using the client's SDK semantics."""
|
|
114
|
+
if fallback is _MISSING:
|
|
115
|
+
return self._c.field(result, key, default)
|
|
116
|
+
return self._c.field(result, key, default, fallback=fallback)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Storage namespace: ``b.storage.*``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from browser_cli.sdk.base import Namespace, sdk_command
|
|
5
|
+
|
|
6
|
+
class StorageNS(Namespace):
|
|
7
|
+
"""Read and write localStorage / sessionStorage."""
|
|
8
|
+
|
|
9
|
+
@sdk_command("storage.get", lambda self, key=None, *, type="local", tab_id=None: {
|
|
10
|
+
"key": key,
|
|
11
|
+
"type": type,
|
|
12
|
+
"tabId": tab_id,
|
|
13
|
+
})
|
|
14
|
+
def get(
|
|
15
|
+
self,
|
|
16
|
+
key: str | None = None,
|
|
17
|
+
*,
|
|
18
|
+
type: str = "local",
|
|
19
|
+
tab_id: int | None = None,
|
|
20
|
+
) -> str | dict | None:
|
|
21
|
+
"""Get a localStorage/sessionStorage entry (or all entries if key omitted)."""
|
|
22
|
+
|
|
23
|
+
@sdk_command("storage.set", lambda self, key, value, *, type="local", tab_id=None: {
|
|
24
|
+
"key": key,
|
|
25
|
+
"value": value,
|
|
26
|
+
"type": type,
|
|
27
|
+
"tabId": tab_id,
|
|
28
|
+
})
|
|
29
|
+
def set(
|
|
30
|
+
self,
|
|
31
|
+
key: str,
|
|
32
|
+
value: str,
|
|
33
|
+
*,
|
|
34
|
+
type: str = "local",
|
|
35
|
+
tab_id: int | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Set a localStorage/sessionStorage entry."""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Synchronous workflow decorator namespace for the Python SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
from browser_cli.sdk.base import Namespace
|
|
11
|
+
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
|
|
12
|
+
|
|
13
|
+
F = TypeVar("F", bound=Callable)
|
|
14
|
+
|
|
15
|
+
class DecoratorsNS(WorkflowDecoratorsMixin, Namespace):
|
|
16
|
+
"""Workflow decorators bound to a :class:`browser_cli.BrowserCLI` client.
|
|
17
|
+
|
|
18
|
+
The normal SDK is synchronous, but these decorators also work on ``async def``
|
|
19
|
+
functions: browser operations run via ``asyncio.to_thread`` so the event loop
|
|
20
|
+
is not blocked while waiting for the browser response.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def _run(self, func: Callable, *args, **kwargs):
|
|
24
|
+
if inspect.iscoroutinefunction(func):
|
|
25
|
+
raise TypeError("sync BrowserCLI decorators cannot call async browser methods")
|
|
26
|
+
return func(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
def _call_wrapped(self, func: Callable, *args, **kwargs):
|
|
29
|
+
if inspect.iscoroutinefunction(func):
|
|
30
|
+
async def run_async():
|
|
31
|
+
return await func(*args, **kwargs)
|
|
32
|
+
return run_async()
|
|
33
|
+
return func(*args, **kwargs)
|
|
34
|
+
|
|
35
|
+
def _value_decorator(
|
|
36
|
+
self,
|
|
37
|
+
func: F | None,
|
|
38
|
+
get_value: Callable,
|
|
39
|
+
*,
|
|
40
|
+
keyword: str | None | object = "tab",
|
|
41
|
+
cleanup: Callable | None = None,
|
|
42
|
+
):
|
|
43
|
+
def decorator(fn: F) -> F:
|
|
44
|
+
if inspect.iscoroutinefunction(fn):
|
|
45
|
+
@functools.wraps(fn)
|
|
46
|
+
async def async_wrapper(*args, **kwargs):
|
|
47
|
+
value = await asyncio.to_thread(get_value)
|
|
48
|
+
try:
|
|
49
|
+
extra_args = ()
|
|
50
|
+
if keyword is not _NO_INJECT:
|
|
51
|
+
extra_args, kwargs = self._inject(kwargs, keyword, value)
|
|
52
|
+
return await fn(*extra_args, *args, **kwargs)
|
|
53
|
+
finally:
|
|
54
|
+
if cleanup is not None:
|
|
55
|
+
await asyncio.to_thread(cleanup, value)
|
|
56
|
+
return async_wrapper # type: ignore[return-value]
|
|
57
|
+
return WorkflowDecoratorsMixin._value_decorator(
|
|
58
|
+
self, fn, get_value, keyword=keyword, cleanup=cleanup
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return decorator(func) if func is not None else decorator
|
|
62
|
+
|
|
63
|
+
def performance_profile(self, profile: str, *, restore: bool = True):
|
|
64
|
+
def decorator(fn: F) -> F:
|
|
65
|
+
if inspect.iscoroutinefunction(fn):
|
|
66
|
+
@functools.wraps(fn)
|
|
67
|
+
async def async_wrapper(*args, **kwargs):
|
|
68
|
+
previous = None
|
|
69
|
+
if restore:
|
|
70
|
+
previous = (await asyncio.to_thread(self._c.perf.status)).get("performanceProfile")
|
|
71
|
+
await asyncio.to_thread(self._c.perf.set_profile, profile)
|
|
72
|
+
try:
|
|
73
|
+
return await fn(*args, **kwargs)
|
|
74
|
+
finally:
|
|
75
|
+
if previous:
|
|
76
|
+
await asyncio.to_thread(self._c.perf.set_profile, previous)
|
|
77
|
+
return async_wrapper # type: ignore[return-value]
|
|
78
|
+
return WorkflowDecoratorsMixin.performance_profile(self, profile, restore=restore)(fn)
|
|
79
|
+
return decorator
|
|
80
|
+
|
|
81
|
+
def retry(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
times: int = 3,
|
|
85
|
+
delay: float = 0.0,
|
|
86
|
+
exceptions: tuple[type[BaseException], ...] = (Exception,),
|
|
87
|
+
):
|
|
88
|
+
attempts = max(1, times)
|
|
89
|
+
|
|
90
|
+
def decorator(fn: F) -> F:
|
|
91
|
+
if inspect.iscoroutinefunction(fn):
|
|
92
|
+
@functools.wraps(fn)
|
|
93
|
+
async def async_wrapper(*args, **kwargs):
|
|
94
|
+
last_error = None
|
|
95
|
+
for attempt in range(attempts):
|
|
96
|
+
try:
|
|
97
|
+
return await fn(*args, **kwargs)
|
|
98
|
+
except exceptions as exc:
|
|
99
|
+
last_error = exc
|
|
100
|
+
if attempt == attempts - 1:
|
|
101
|
+
raise
|
|
102
|
+
if delay > 0:
|
|
103
|
+
await asyncio.sleep(delay)
|
|
104
|
+
raise last_error # type: ignore[misc]
|
|
105
|
+
return async_wrapper # type: ignore[return-value]
|
|
106
|
+
return WorkflowDecoratorsMixin.retry(self, times=times, delay=delay, exceptions=exceptions)(fn)
|
|
107
|
+
return decorator
|