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.
Files changed (85) hide show
  1. browser_cli/__init__.py +164 -0
  2. browser_cli/async_sdk.py +237 -0
  3. browser_cli/auth.py +263 -0
  4. browser_cli/cli.py +151 -0
  5. browser_cli/client/__init__.py +47 -0
  6. browser_cli/client/auth.py +63 -0
  7. browser_cli/client/core.py +200 -0
  8. browser_cli/client/messages.py +45 -0
  9. browser_cli/client/targets.py +95 -0
  10. browser_cli/command_security.py +119 -0
  11. browser_cli/commands/__init__.py +81 -0
  12. browser_cli/commands/auth.py +157 -0
  13. browser_cli/commands/clients.py +173 -0
  14. browser_cli/commands/completion.py +56 -0
  15. browser_cli/commands/doctor.py +90 -0
  16. browser_cli/commands/dom.py +191 -0
  17. browser_cli/commands/events.py +52 -0
  18. browser_cli/commands/extension.py +42 -0
  19. browser_cli/commands/extract.py +70 -0
  20. browser_cli/commands/groups.py +108 -0
  21. browser_cli/commands/install.py +121 -0
  22. browser_cli/commands/navigate.py +96 -0
  23. browser_cli/commands/page.py +26 -0
  24. browser_cli/commands/perf.py +47 -0
  25. browser_cli/commands/raw.py +23 -0
  26. browser_cli/commands/remote.py +68 -0
  27. browser_cli/commands/script.py +68 -0
  28. browser_cli/commands/search.py +79 -0
  29. browser_cli/commands/serve.py +117 -0
  30. browser_cli/commands/serve_http.py +115 -0
  31. browser_cli/commands/session.py +163 -0
  32. browser_cli/commands/storage.py +36 -0
  33. browser_cli/commands/tabs.py +252 -0
  34. browser_cli/commands/watch.py +60 -0
  35. browser_cli/commands/windows.py +87 -0
  36. browser_cli/commands/workspace.py +91 -0
  37. browser_cli/compat/__init__.py +4 -0
  38. browser_cli/compat/auth.py +44 -0
  39. browser_cli/compat/commands.py +43 -0
  40. browser_cli/constants.py +95 -0
  41. browser_cli/endpoints.py +55 -0
  42. browser_cli/errors.py +9 -0
  43. browser_cli/framing.py +83 -0
  44. browser_cli/local_transport.py +64 -0
  45. browser_cli/markdown/__init__.py +8 -0
  46. browser_cli/markdown/html.py +259 -0
  47. browser_cli/markdown/render.py +188 -0
  48. browser_cli/models.py +182 -0
  49. browser_cli/native/__init__.py +1 -0
  50. browser_cli/native/host.py +211 -0
  51. browser_cli/native/local_server.py +111 -0
  52. browser_cli/native/protocol.py +30 -0
  53. browser_cli/platform.py +34 -0
  54. browser_cli/registry.py +99 -0
  55. browser_cli/remote/__init__.py +1 -0
  56. browser_cli/remote/registry.py +53 -0
  57. browser_cli/remote/transport.py +230 -0
  58. browser_cli/sdk/__init__.py +48 -0
  59. browser_cli/sdk/base.py +116 -0
  60. browser_cli/sdk/browser_data.py +37 -0
  61. browser_cli/sdk/decorators.py +107 -0
  62. browser_cli/sdk/dom.py +169 -0
  63. browser_cli/sdk/extension.py +24 -0
  64. browser_cli/sdk/factories.py +103 -0
  65. browser_cli/sdk/groups.py +51 -0
  66. browser_cli/sdk/navigation.py +122 -0
  67. browser_cli/sdk/perf.py +23 -0
  68. browser_cli/sdk/routing.py +149 -0
  69. browser_cli/sdk/session.py +72 -0
  70. browser_cli/sdk/tabs.py +213 -0
  71. browser_cli/sdk/windows.py +26 -0
  72. browser_cli/sdk/workflow_decorators.py +200 -0
  73. browser_cli/serve/__init__.py +0 -0
  74. browser_cli/serve/auth.py +107 -0
  75. browser_cli/serve/control.py +59 -0
  76. browser_cli/serve/logging.py +16 -0
  77. browser_cli/serve/proxy.py +79 -0
  78. browser_cli/serve/runtime.py +196 -0
  79. browser_cli/transport.py +214 -0
  80. browser_cli/version_manager.py +17 -0
  81. real_browser_cli-0.14.2.dist-info/METADATA +87 -0
  82. real_browser_cli-0.14.2.dist-info/RECORD +85 -0
  83. real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
  84. real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
  85. 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
+ ]
@@ -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