bobby-browser 0.17.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.
- bobby_browser/__init__.py +20 -0
- bobby_browser/client.py +470 -0
- bobby_browser/errors.py +101 -0
- bobby_browser/py.typed +0 -0
- bobby_browser-0.17.0.dist-info/METADATA +56 -0
- bobby_browser-0.17.0.dist-info/RECORD +8 -0
- bobby_browser-0.17.0.dist-info/WHEEL +5 -0
- bobby_browser-0.17.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""``bobby-browser`` -- typed HTTP client for a Bobby Browser runtime
|
|
2
|
+
(``bobby serve``) speaking the authenticated ``/v1`` interface.
|
|
3
|
+
|
|
4
|
+
Pair with ``@cavi-ai/bobby-browser`` (TypeScript) or ``bobby-browser-client``
|
|
5
|
+
(Rust) for the same surface from other callers. Auth headers on every
|
|
6
|
+
request: ``Authorization: Bearer ...``, ``x-interface-version``,
|
|
7
|
+
``x-correlation-id``, and ``x-deadline``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .client import INTERFACE_VERSION, BrowserRuntimeClient, RequestOptions
|
|
11
|
+
from .errors import RuntimeClientError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BrowserRuntimeClient",
|
|
15
|
+
"RequestOptions",
|
|
16
|
+
"RuntimeClientError",
|
|
17
|
+
"INTERFACE_VERSION",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.17.0"
|
bobby_browser/client.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""HTTP client for the Bobby Browser ``/v1`` runtime interface.
|
|
2
|
+
|
|
3
|
+
Mirrors ``packages/typescript-sdk/src/client.ts``: every request sends
|
|
4
|
+
``Authorization``, ``x-interface-version``, ``x-correlation-id``, and
|
|
5
|
+
``x-deadline``; mutating calls accept an idempotency key; failures raise
|
|
6
|
+
:class:`~bobby_browser.errors.RuntimeClientError`. Stdlib only
|
|
7
|
+
(``urllib``, ``json``, ``dataclasses``, ``typing``) -- no third-party HTTP
|
|
8
|
+
client.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
from .errors import RuntimeClientError
|
|
25
|
+
|
|
26
|
+
# Interface version negotiated via the `x-interface-version` request header.
|
|
27
|
+
# Keep aligned with packages/typescript-sdk/src/contracts.ts INTERFACE_VERSION.
|
|
28
|
+
INTERFACE_VERSION = "2026-08-19"
|
|
29
|
+
|
|
30
|
+
_DEFAULT_TIMEOUT_MS = 30_000
|
|
31
|
+
_JSON_CONTENT_TYPE = re.compile(r"^application/json(?:\s*;|$)", re.IGNORECASE)
|
|
32
|
+
|
|
33
|
+
# CommandOutcome.status -> expected HTTP status, mirroring client.ts's
|
|
34
|
+
# commandStatus().
|
|
35
|
+
_COMMAND_STATUS_HTTP: Dict[str, int] = {
|
|
36
|
+
"completed": 200,
|
|
37
|
+
"restarted": 200,
|
|
38
|
+
"retryableFailure": 503,
|
|
39
|
+
"needsReconciliation": 409,
|
|
40
|
+
"policyDenied": 403,
|
|
41
|
+
"resourceExhausted": 429,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# RecoveryDecision.status -> expected HTTP status, mirroring client.ts's
|
|
45
|
+
# `recover()` mapping.
|
|
46
|
+
_RECOVERY_NEEDS_RECONCILIATION_STATUS = "needsReconciliation"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class RequestOptions:
|
|
51
|
+
"""Per-call overrides. All fields are optional.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
timeout_ms: Relative timeout in milliseconds (default 30_000).
|
|
55
|
+
deadline: Absolute RFC3339 deadline string; combined with
|
|
56
|
+
``timeout_ms`` as the earlier of the two, same as the TS client.
|
|
57
|
+
correlation_id: Value for ``x-correlation-id`` (a UUID4 is
|
|
58
|
+
generated when omitted).
|
|
59
|
+
idempotency_key: Value for ``idempotency-key`` on mutating POSTs.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
timeout_ms: Optional[int] = None
|
|
63
|
+
deadline: Optional[str] = None
|
|
64
|
+
correlation_id: Optional[str] = None
|
|
65
|
+
idempotency_key: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _uuid4() -> str:
|
|
69
|
+
return str(uuid.uuid4())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _deadline_header(options: Optional[RequestOptions], default_timeout_ms: int) -> str:
|
|
73
|
+
if options is not None and options.deadline:
|
|
74
|
+
return options.deadline
|
|
75
|
+
timeout_ms = default_timeout_ms
|
|
76
|
+
if options is not None and options.timeout_ms is not None:
|
|
77
|
+
timeout_ms = options.timeout_ms
|
|
78
|
+
deadline = datetime.now(timezone.utc) + timedelta(milliseconds=timeout_ms)
|
|
79
|
+
return deadline.strftime("%Y-%m-%dT%H:%M:%S.") + f"{deadline.microsecond // 1000:03d}Z"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _header_get(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
83
|
+
lowered = name.lower()
|
|
84
|
+
for key, value in headers.items():
|
|
85
|
+
if key.lower() == lowered:
|
|
86
|
+
return value
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _content_type(headers: Mapping[str, str]) -> str:
|
|
91
|
+
raw = _header_get(headers, "content-type") or ""
|
|
92
|
+
return raw.split(";", 1)[0].strip().lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _media_type_essence(value: str) -> Optional[str]:
|
|
96
|
+
essence = value.split(";", 1)[0].strip().lower()
|
|
97
|
+
if essence and re.match(r"^[!#$%&'*+.^_`|~0-9a-z-]+/[!#$%&'*+.^_`|~0-9a-z-]+$", essence):
|
|
98
|
+
return essence
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BrowserRuntimeClient:
|
|
103
|
+
"""Authenticated HTTP client for a Bobby Browser runtime (``bobby serve``).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
base_url: Runtime origin. A trailing slash and a trailing ``/v1``
|
|
107
|
+
are stripped, so either ``http://127.0.0.1:7777`` or
|
|
108
|
+
``http://127.0.0.1:7777/v1`` works.
|
|
109
|
+
bearer_token: Bearer credential for ``Authorization``. Never sent
|
|
110
|
+
anywhere but that header, never logged, never put in a URL.
|
|
111
|
+
timeout_ms: Default relative timeout for calls that do not pass
|
|
112
|
+
``options`` (default 30_000).
|
|
113
|
+
opener: Override ``urllib.request`` opener (tests only).
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
base_url: str,
|
|
119
|
+
bearer_token: str,
|
|
120
|
+
*,
|
|
121
|
+
timeout_ms: int = _DEFAULT_TIMEOUT_MS,
|
|
122
|
+
opener: Optional[urllib.request.OpenerDirector] = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
if not base_url or not bearer_token:
|
|
125
|
+
raise ValueError("base_url and bearer_token are required")
|
|
126
|
+
stripped = base_url.rstrip("/")
|
|
127
|
+
if stripped.endswith("/v1"):
|
|
128
|
+
stripped = stripped[: -len("/v1")]
|
|
129
|
+
self._base_url = stripped
|
|
130
|
+
self._bearer_token = bearer_token
|
|
131
|
+
self._timeout_ms = timeout_ms
|
|
132
|
+
self._opener = opener or urllib.request.build_opener()
|
|
133
|
+
|
|
134
|
+
def __repr__(self) -> str: # never print the bearer token
|
|
135
|
+
return "BrowserRuntimeClient(bearer_token=[redacted])"
|
|
136
|
+
|
|
137
|
+
# ---- sessions ---------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def runtime_info(self, options: Optional[RequestOptions] = None) -> Dict[str, Any]:
|
|
140
|
+
"""``GET /v1/runtime`` -- version, capabilities, and load counters."""
|
|
141
|
+
return self._json("GET", "/v1/runtime", None, options, expected_status=200)
|
|
142
|
+
|
|
143
|
+
def create_session(
|
|
144
|
+
self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
145
|
+
) -> Dict[str, Any]:
|
|
146
|
+
"""``POST /v1/sessions`` -- create a browser session."""
|
|
147
|
+
return self._json("POST", "/v1/sessions", input, options, expected_status=200)
|
|
148
|
+
|
|
149
|
+
def read_session(
|
|
150
|
+
self, session_id: Optional[str] = None, options: Optional[RequestOptions] = None
|
|
151
|
+
) -> Any:
|
|
152
|
+
"""Read session state.
|
|
153
|
+
|
|
154
|
+
There is no ``GET /v1/sessions/{id}`` on the wire, only
|
|
155
|
+
``GET /v1/sessions`` (full array, no pagination). With
|
|
156
|
+
``session_id`` omitted this returns that full list; with it set,
|
|
157
|
+
this filters client-side and returns the one matching
|
|
158
|
+
:class:`SessionState`, raising :class:`RuntimeClientError` with
|
|
159
|
+
``kind="protocol"`` if no session with that id is active.
|
|
160
|
+
"""
|
|
161
|
+
sessions = self._json("GET", "/v1/sessions", None, options, expected_status=200)
|
|
162
|
+
if not isinstance(sessions, list):
|
|
163
|
+
raise self._protocol("sessions response has an unexpected shape")
|
|
164
|
+
if session_id is None:
|
|
165
|
+
return sessions
|
|
166
|
+
for session in sessions:
|
|
167
|
+
if isinstance(session, dict) and session.get("id") == session_id:
|
|
168
|
+
return session
|
|
169
|
+
raise self._protocol(f"no active session with id {session_id!r}", 200)
|
|
170
|
+
|
|
171
|
+
def delete_session(self, session_id: str, options: Optional[RequestOptions] = None) -> None:
|
|
172
|
+
"""``DELETE /v1/sessions/{id}`` -- tear down a session (204 on success)."""
|
|
173
|
+
self._empty("DELETE", f"/v1/sessions/{urllib.parse.quote(session_id)}", options)
|
|
174
|
+
|
|
175
|
+
# ---- pages --------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def open_page(
|
|
178
|
+
self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
179
|
+
) -> Dict[str, Any]:
|
|
180
|
+
"""``POST /v1/pages`` -- open a page in a session."""
|
|
181
|
+
return self._json("POST", "/v1/pages", input, options, expected_status=200)
|
|
182
|
+
|
|
183
|
+
def read_page(
|
|
184
|
+
self,
|
|
185
|
+
session_id: str,
|
|
186
|
+
page_id: str,
|
|
187
|
+
*,
|
|
188
|
+
max_controls: Optional[int] = None,
|
|
189
|
+
options: Optional[RequestOptions] = None,
|
|
190
|
+
) -> Dict[str, Any]:
|
|
191
|
+
"""``GET /v1/sessions/{session}/pages/{page}/forms`` -- read-only
|
|
192
|
+
``FormSnapshot`` (the PageRead HTTP surface; same contract as MCP
|
|
193
|
+
``form_snapshot``). Page-derived controls carry ``pageDerived: true``.
|
|
194
|
+
``max_controls`` is optional, 1 through 512.
|
|
195
|
+
"""
|
|
196
|
+
if max_controls is not None and not (1 <= max_controls <= 512):
|
|
197
|
+
raise self._protocol("max_controls must be between 1 and 512")
|
|
198
|
+
query = f"?maxControls={max_controls}" if max_controls is not None else ""
|
|
199
|
+
path = (
|
|
200
|
+
f"/v1/sessions/{urllib.parse.quote(session_id)}"
|
|
201
|
+
f"/pages/{urllib.parse.quote(page_id)}/forms{query}"
|
|
202
|
+
)
|
|
203
|
+
return self._json("GET", path, None, options, expected_status=200)
|
|
204
|
+
|
|
205
|
+
# ---- commands -------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def submit_command(
|
|
208
|
+
self, envelope: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
209
|
+
) -> Dict[str, Any]:
|
|
210
|
+
"""``POST /v1/commands`` -- submit a raw ``CommandEnvelope``.
|
|
211
|
+
|
|
212
|
+
Returns the ``CommandOutcome`` body unmodified (its ``status``
|
|
213
|
+
discriminator field is preserved exactly as the server sent it --
|
|
214
|
+
``completed``, ``retryableFailure``, ``needsReconciliation``,
|
|
215
|
+
``policyDenied``, ``resourceExhausted``, ``restarted``, or
|
|
216
|
+
``failed``) after checking the HTTP status matches the documented
|
|
217
|
+
mapping for that status.
|
|
218
|
+
Page-derived inspection, accessibility, form, and extraction evidence
|
|
219
|
+
retains its ``pageDerived: true`` marker in the returned dictionary.
|
|
220
|
+
"""
|
|
221
|
+
status, payload = self._request("POST", "/v1/commands", envelope, options)
|
|
222
|
+
if not isinstance(payload, dict) or "status" not in payload:
|
|
223
|
+
raise self._response_error(status, payload)
|
|
224
|
+
outcome_status = payload["status"]
|
|
225
|
+
expected = _command_outcome_http_status(payload)
|
|
226
|
+
if expected is None:
|
|
227
|
+
raise self._protocol(f"unknown command outcome status: {outcome_status!r}", status)
|
|
228
|
+
if expected != status:
|
|
229
|
+
raise self._protocol(
|
|
230
|
+
"command outcome status does not match HTTP mapping", status
|
|
231
|
+
)
|
|
232
|
+
return payload
|
|
233
|
+
|
|
234
|
+
# ---- checkpoints / recovery ------------------------------------------
|
|
235
|
+
|
|
236
|
+
def create_checkpoint(
|
|
237
|
+
self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
238
|
+
) -> Dict[str, Any]:
|
|
239
|
+
"""``POST /v1/checkpoints`` -- persist a workflow checkpoint."""
|
|
240
|
+
return self._json("POST", "/v1/checkpoints", input, options, expected_status=200)
|
|
241
|
+
|
|
242
|
+
def recovery_status(
|
|
243
|
+
self, workflow_id: str, options: Optional[RequestOptions] = None
|
|
244
|
+
) -> Dict[str, Any]:
|
|
245
|
+
"""``GET /v1/recovery/{workflowId}`` -- current recovery status."""
|
|
246
|
+
path = f"/v1/recovery/{urllib.parse.quote(workflow_id)}"
|
|
247
|
+
return self._json("GET", path, None, options, expected_status=200)
|
|
248
|
+
|
|
249
|
+
def recover_workflow(
|
|
250
|
+
self, workflow_id: str, options: Optional[RequestOptions] = None
|
|
251
|
+
) -> Dict[str, Any]:
|
|
252
|
+
"""``POST /v1/recovery/{workflowId}`` -- resume, reconcile, or
|
|
253
|
+
restart a workflow. ``needsReconciliation`` maps to HTTP 409;
|
|
254
|
+
every other decision maps to 200.
|
|
255
|
+
"""
|
|
256
|
+
path = f"/v1/recovery/{urllib.parse.quote(workflow_id)}"
|
|
257
|
+
status, payload = self._request("POST", path, None, options)
|
|
258
|
+
if not isinstance(payload, dict) or "status" not in payload:
|
|
259
|
+
raise self._response_error(status, payload)
|
|
260
|
+
expected = 409 if payload["status"] == _RECOVERY_NEEDS_RECONCILIATION_STATUS else 200
|
|
261
|
+
if expected != status:
|
|
262
|
+
raise self._protocol(
|
|
263
|
+
"recovery decision status does not match HTTP mapping", status
|
|
264
|
+
)
|
|
265
|
+
return payload
|
|
266
|
+
|
|
267
|
+
# ---- context --------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def context_ask(
|
|
270
|
+
self,
|
|
271
|
+
session_id: str,
|
|
272
|
+
page_id: str,
|
|
273
|
+
description: str,
|
|
274
|
+
options: Optional[RequestOptions] = None,
|
|
275
|
+
) -> Dict[str, Any]:
|
|
276
|
+
"""``GET /v1/context/ask`` -- remembered target for a description.
|
|
277
|
+
|
|
278
|
+
The result carries ``pageDerived: true`` on either a hit or miss.
|
|
279
|
+
"""
|
|
280
|
+
encoded = len(description.encode("utf-8"))
|
|
281
|
+
if not (1 <= encoded <= 256):
|
|
282
|
+
raise self._protocol("description must contain between 1 and 256 bytes")
|
|
283
|
+
query = urllib.parse.urlencode(
|
|
284
|
+
{"sessionId": session_id, "pageId": page_id, "description": description}
|
|
285
|
+
)
|
|
286
|
+
return self._json("GET", f"/v1/context/ask?{query}", None, options, expected_status=200)
|
|
287
|
+
|
|
288
|
+
def context_site(
|
|
289
|
+
self, site_key: str, options: Optional[RequestOptions] = None
|
|
290
|
+
) -> Dict[str, Any]:
|
|
291
|
+
"""``GET /v1/context/site/{key}`` -- durable per-site context view."""
|
|
292
|
+
if not site_key:
|
|
293
|
+
raise self._protocol("site key must not be empty")
|
|
294
|
+
path = f"/v1/context/site/{urllib.parse.quote(site_key)}"
|
|
295
|
+
return self._json("GET", path, None, options, expected_status=200)
|
|
296
|
+
|
|
297
|
+
# ---- artifacts --------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def read_artifact(
|
|
300
|
+
self, reference: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
301
|
+
) -> bytes:
|
|
302
|
+
"""``GET /v1/artifacts/{artifactId}`` -- verified artifact bytes.
|
|
303
|
+
|
|
304
|
+
``reference`` is an ``ArtifactReference``: ``artifactId``,
|
|
305
|
+
``sha256``, ``bytes``, and ``mediaType``. Checks ``Content-Type``
|
|
306
|
+
and ``Content-Length`` against the reference, then verifies
|
|
307
|
+
SHA-256 before returning anything -- no bytes are handed back on a
|
|
308
|
+
mismatch.
|
|
309
|
+
"""
|
|
310
|
+
artifact_id = reference.get("artifactId")
|
|
311
|
+
if not artifact_id or not isinstance(artifact_id, str):
|
|
312
|
+
raise self._protocol("artifact reference is missing artifactId")
|
|
313
|
+
path = f"/v1/artifacts/{urllib.parse.quote(artifact_id)}"
|
|
314
|
+
status, content_type, headers, raw = self._request_raw("GET", path, None, options)
|
|
315
|
+
if status != 200:
|
|
316
|
+
raise self._response_error(status, self._decode_json_or_none(raw, content_type))
|
|
317
|
+
expected_media_type = _media_type_essence(str(reference.get("mediaType", "")))
|
|
318
|
+
if expected_media_type is None or content_type != expected_media_type:
|
|
319
|
+
raise self._protocol("artifact media type does not match its reference", status)
|
|
320
|
+
expected_bytes = reference.get("bytes")
|
|
321
|
+
content_length = _header_get(headers, "content-length")
|
|
322
|
+
if (
|
|
323
|
+
content_length is None
|
|
324
|
+
or not content_length.isdigit()
|
|
325
|
+
or int(content_length) != expected_bytes
|
|
326
|
+
or len(raw) != expected_bytes
|
|
327
|
+
):
|
|
328
|
+
raise self._protocol("artifact content length does not match its reference", status)
|
|
329
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
330
|
+
expected_sha256 = str(reference.get("sha256", "")).lower()
|
|
331
|
+
if digest != expected_sha256:
|
|
332
|
+
raise self._protocol("artifact digest does not match its reference", status)
|
|
333
|
+
return raw
|
|
334
|
+
|
|
335
|
+
# ---- jobs --------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
def submit_job(
|
|
338
|
+
self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
|
|
339
|
+
) -> Dict[str, Any]:
|
|
340
|
+
"""``POST /v1/jobs`` -- submit a bounded runtime job."""
|
|
341
|
+
return self._json("POST", "/v1/jobs", input, options, expected_status=201)
|
|
342
|
+
|
|
343
|
+
def job_status(self, job_id: str, options: Optional[RequestOptions] = None) -> Dict[str, Any]:
|
|
344
|
+
"""``GET /v1/jobs/{jobId}`` -- read the authenticated principal's job."""
|
|
345
|
+
path = f"/v1/jobs/{urllib.parse.quote(job_id)}"
|
|
346
|
+
return self._json("GET", path, None, options, expected_status=200)
|
|
347
|
+
|
|
348
|
+
def cancel_job(self, job_id: str, options: Optional[RequestOptions] = None) -> None:
|
|
349
|
+
"""``DELETE /v1/jobs/{jobId}`` -- cancel the authenticated principal's job."""
|
|
350
|
+
self._empty("DELETE", f"/v1/jobs/{urllib.parse.quote(job_id)}", options)
|
|
351
|
+
|
|
352
|
+
# ---- transport --------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def _headers(self, options: Optional[RequestOptions], has_body: bool) -> Dict[str, str]:
|
|
355
|
+
correlation_id = (options.correlation_id if options else None) or _uuid4()
|
|
356
|
+
headers = {
|
|
357
|
+
"Authorization": f"Bearer {self._bearer_token}",
|
|
358
|
+
"x-interface-version": INTERFACE_VERSION,
|
|
359
|
+
"x-correlation-id": correlation_id,
|
|
360
|
+
"x-deadline": _deadline_header(options, self._timeout_ms),
|
|
361
|
+
}
|
|
362
|
+
if options is not None and options.idempotency_key:
|
|
363
|
+
headers["idempotency-key"] = options.idempotency_key
|
|
364
|
+
if has_body:
|
|
365
|
+
headers["content-type"] = "application/json"
|
|
366
|
+
return headers
|
|
367
|
+
|
|
368
|
+
def _request_raw(
|
|
369
|
+
self,
|
|
370
|
+
method: str,
|
|
371
|
+
path: str,
|
|
372
|
+
body: Optional[Mapping[str, Any]],
|
|
373
|
+
options: Optional[RequestOptions],
|
|
374
|
+
) -> tuple:
|
|
375
|
+
"""Returns (status, content_type, headers, raw_bytes)."""
|
|
376
|
+
url = f"{self._base_url}{path}"
|
|
377
|
+
headers = self._headers(options, body is not None)
|
|
378
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
379
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
380
|
+
timeout_ms = self._timeout_ms
|
|
381
|
+
if options is not None and options.timeout_ms is not None:
|
|
382
|
+
timeout_ms = options.timeout_ms
|
|
383
|
+
try:
|
|
384
|
+
response = self._opener.open(request, timeout=timeout_ms / 1000)
|
|
385
|
+
try:
|
|
386
|
+
status = response.status
|
|
387
|
+
response_headers = dict(response.headers.items())
|
|
388
|
+
raw = response.read()
|
|
389
|
+
finally:
|
|
390
|
+
response.close()
|
|
391
|
+
except urllib.error.HTTPError as error:
|
|
392
|
+
status = error.code
|
|
393
|
+
response_headers = dict(error.headers.items()) if error.headers else {}
|
|
394
|
+
raw = error.read()
|
|
395
|
+
error.close()
|
|
396
|
+
except TimeoutError as error:
|
|
397
|
+
raise RuntimeClientError("deadline", message="Request deadline exceeded") from error
|
|
398
|
+
except urllib.error.URLError as error:
|
|
399
|
+
raise RuntimeClientError(
|
|
400
|
+
"transport", message=f"Runtime transport request failed: {error.reason}"
|
|
401
|
+
) from error
|
|
402
|
+
content_type = _content_type(response_headers)
|
|
403
|
+
return status, content_type, response_headers, raw
|
|
404
|
+
|
|
405
|
+
def _decode_json_or_none(self, raw: bytes, content_type: str) -> Any:
|
|
406
|
+
if not _JSON_CONTENT_TYPE.match(content_type):
|
|
407
|
+
return None
|
|
408
|
+
try:
|
|
409
|
+
return json.loads(raw.decode("utf-8"))
|
|
410
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
def _request(
|
|
414
|
+
self,
|
|
415
|
+
method: str,
|
|
416
|
+
path: str,
|
|
417
|
+
body: Optional[Mapping[str, Any]],
|
|
418
|
+
options: Optional[RequestOptions],
|
|
419
|
+
) -> tuple:
|
|
420
|
+
"""Returns (status, parsed_json_payload)."""
|
|
421
|
+
status, content_type, _headers, raw = self._request_raw(method, path, body, options)
|
|
422
|
+
if not _JSON_CONTENT_TYPE.match(content_type):
|
|
423
|
+
raise self._protocol("response content type must be application/json", status)
|
|
424
|
+
try:
|
|
425
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
426
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
427
|
+
raise self._protocol("response body is not valid JSON", status) from error
|
|
428
|
+
return status, payload
|
|
429
|
+
|
|
430
|
+
def _json(
|
|
431
|
+
self,
|
|
432
|
+
method: str,
|
|
433
|
+
path: str,
|
|
434
|
+
body: Optional[Mapping[str, Any]],
|
|
435
|
+
options: Optional[RequestOptions],
|
|
436
|
+
*,
|
|
437
|
+
expected_status: int,
|
|
438
|
+
) -> Any:
|
|
439
|
+
status, payload = self._request(method, path, body, options)
|
|
440
|
+
if status != expected_status:
|
|
441
|
+
raise self._response_error(status, payload)
|
|
442
|
+
return payload
|
|
443
|
+
|
|
444
|
+
def _empty(self, method: str, path: str, options: Optional[RequestOptions]) -> None:
|
|
445
|
+
status, content_type, _headers, raw = self._request_raw(method, path, None, options)
|
|
446
|
+
if status == 204:
|
|
447
|
+
return
|
|
448
|
+
payload = self._decode_json_or_none(raw, content_type)
|
|
449
|
+
raise self._response_error(status, payload)
|
|
450
|
+
|
|
451
|
+
def _response_error(self, status: int, payload: Any) -> RuntimeClientError:
|
|
452
|
+
if isinstance(payload, dict) and set(payload.keys()) == {"error"} and isinstance(
|
|
453
|
+
payload["error"], dict
|
|
454
|
+
):
|
|
455
|
+
return RuntimeClientError.from_interface_error("http", status, payload["error"])
|
|
456
|
+
return self._protocol("response has an unexpected status or shape", status)
|
|
457
|
+
|
|
458
|
+
def _protocol(self, message: str, status: Optional[int] = None) -> RuntimeClientError:
|
|
459
|
+
return RuntimeClientError("protocol", status=status, message=message)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _command_outcome_http_status(outcome: Mapping[str, Any]) -> Optional[int]:
|
|
463
|
+
"""Mirrors client.ts's commandStatus(): CommandOutcome.status -> HTTP status."""
|
|
464
|
+
status = outcome.get("status")
|
|
465
|
+
if status in _COMMAND_STATUS_HTTP:
|
|
466
|
+
return _COMMAND_STATUS_HTTP[status]
|
|
467
|
+
if status == "failed":
|
|
468
|
+
error = outcome.get("error") or {}
|
|
469
|
+
return 422 if error.get("code") == "invalidRequest" else 500
|
|
470
|
+
return None
|
bobby_browser/errors.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Client-side error type for the Bobby Browser Python SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors ``RuntimeClientError`` from the TypeScript SDK
|
|
4
|
+
(``packages/typescript-sdk/src/errors.ts``): a single exception class
|
|
5
|
+
classified by ``kind``, carrying the fields a caller needs to decide whether
|
|
6
|
+
to retry, without ever retaining the bearer token.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
# Classification of RuntimeClientError, matching the TypeScript
|
|
14
|
+
# RuntimeClientErrorKind union exactly.
|
|
15
|
+
RUNTIME_CLIENT_ERROR_KINDS = ("transport", "protocol", "http", "aborted", "deadline")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RuntimeClientError(Exception):
|
|
19
|
+
"""Raised for every failure the client cannot recover from itself.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
kind: One of ``"transport"``, ``"protocol"``, ``"http"``,
|
|
23
|
+
``"aborted"``, ``"deadline"``.
|
|
24
|
+
status: HTTP status code, when the failure followed a response.
|
|
25
|
+
code: Wire ``InterfaceErrorCode``, when the server returned one.
|
|
26
|
+
correlation_id: The request's ``x-correlation-id``, when known.
|
|
27
|
+
command_id: The command id an interface error referenced, if any.
|
|
28
|
+
retryable: Whether the server marked the failure retryable.
|
|
29
|
+
retry_after_ms: Minimum backoff before retrying, when the server
|
|
30
|
+
supplied one (notably on HTTP 429).
|
|
31
|
+
reconciliation_required: Whether the caller must reconcile a
|
|
32
|
+
workflow's checkpoint before retrying.
|
|
33
|
+
required_capability: The capability the caller was missing, if any.
|
|
34
|
+
event_gap: The ``EventGap`` payload, for a 409 on ``events()``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
kind: str,
|
|
40
|
+
*,
|
|
41
|
+
status: Optional[int] = None,
|
|
42
|
+
code: Optional[str] = None,
|
|
43
|
+
message: Optional[str] = None,
|
|
44
|
+
correlation_id: Optional[str] = None,
|
|
45
|
+
command_id: Optional[Any] = None,
|
|
46
|
+
retryable: Optional[bool] = None,
|
|
47
|
+
retry_after_ms: Optional[int] = None,
|
|
48
|
+
reconciliation_required: Optional[bool] = None,
|
|
49
|
+
required_capability: Optional[Any] = None,
|
|
50
|
+
event_gap: Optional[Mapping[str, Any]] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if kind not in RUNTIME_CLIENT_ERROR_KINDS:
|
|
53
|
+
raise ValueError(f"unknown RuntimeClientError kind: {kind!r}")
|
|
54
|
+
resolved_message = message or f"Runtime client {kind} failure"
|
|
55
|
+
super().__init__(resolved_message)
|
|
56
|
+
self.kind = kind
|
|
57
|
+
self.status = status
|
|
58
|
+
self.code = code
|
|
59
|
+
self.correlation_id = correlation_id
|
|
60
|
+
self.command_id = command_id
|
|
61
|
+
self.retryable = retryable
|
|
62
|
+
self.retry_after_ms = retry_after_ms
|
|
63
|
+
self.reconciliation_required = reconciliation_required
|
|
64
|
+
self.required_capability = required_capability
|
|
65
|
+
self.event_gap = dict(event_gap) if event_gap is not None else None
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_interface_error(
|
|
69
|
+
cls, kind: str, status: int, error: Mapping[str, Any]
|
|
70
|
+
) -> "RuntimeClientError":
|
|
71
|
+
"""Build from a wire ``InterfaceError`` (``{"error": {...}}`` body)."""
|
|
72
|
+
return cls(
|
|
73
|
+
kind,
|
|
74
|
+
status=status,
|
|
75
|
+
code=error.get("code"),
|
|
76
|
+
message=f"Runtime request failed: {status} {error.get('code')}",
|
|
77
|
+
correlation_id=error.get("correlationId"),
|
|
78
|
+
command_id=error.get("commandId"),
|
|
79
|
+
retryable=error.get("retryable"),
|
|
80
|
+
retry_after_ms=error.get("retryAfterMs"),
|
|
81
|
+
reconciliation_required=error.get("reconciliationRequired"),
|
|
82
|
+
required_capability=error.get("requiredCapability"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
86
|
+
return f"RuntimeClientError(kind={self.kind!r}, status={self.status!r}, code={self.code!r})"
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict:
|
|
89
|
+
"""JSON-safe projection for logging."""
|
|
90
|
+
return {
|
|
91
|
+
"kind": self.kind,
|
|
92
|
+
"status": self.status,
|
|
93
|
+
"code": self.code,
|
|
94
|
+
"correlationId": self.correlation_id,
|
|
95
|
+
"commandId": self.command_id,
|
|
96
|
+
"retryable": self.retryable,
|
|
97
|
+
"retryAfterMs": self.retry_after_ms,
|
|
98
|
+
"reconciliationRequired": self.reconciliation_required,
|
|
99
|
+
"requiredCapability": self.required_capability,
|
|
100
|
+
"eventGap": self.event_gap,
|
|
101
|
+
}
|
bobby_browser/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bobby-browser
|
|
3
|
+
Version: 0.17.0
|
|
4
|
+
Summary: Typed client for the authenticated Bobby Browser runtime interface
|
|
5
|
+
Author: Sasan Sotoodehfar
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://cavi-ai.xyz/docs/bobby-browser
|
|
8
|
+
Project-URL: Repository, https://github.com/cavi-ai/bobby-browser
|
|
9
|
+
Project-URL: Issues, https://github.com/cavi-ai/bobby-browser/issues
|
|
10
|
+
Keywords: browser-automation,cdp,mcp,playwright,puppeteer,bobby-browser
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# bobby-browser (Python SDK)
|
|
15
|
+
|
|
16
|
+
Typed HTTP client for the authenticated Bobby Browser `/v1` runtime
|
|
17
|
+
interface. Stdlib only (`urllib`, `json`, `dataclasses`, `typing`) -- no
|
|
18
|
+
third-party HTTP client dependency. Python >= 3.10.
|
|
19
|
+
|
|
20
|
+
Mirrors `@cavi-ai/bobby-browser` (TypeScript,
|
|
21
|
+
`packages/typescript-sdk`) and `bobby-browser-client` (Rust,
|
|
22
|
+
`crates/bobby-browser-client`): same auth header contract, idempotency-key
|
|
23
|
+
passthrough, and `CommandOutcome` status discriminator.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install bobby-browser
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From a bobby-browser checkout: `pip install -e packages/python-sdk`.
|
|
32
|
+
|
|
33
|
+
`bobby install --skill-hermes` copies `skill/hermes/SKILL.md` into
|
|
34
|
+
`$HERMES_HOME/skills/bobby-browser/` (else `~/.hermes/skills/`).
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import os
|
|
40
|
+
from bobby_browser import BrowserRuntimeClient
|
|
41
|
+
|
|
42
|
+
client = BrowserRuntimeClient("http://127.0.0.1:7777", os.environ["AUTOMATION_RUNTIME_TOKEN"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Full method catalog, headers, and error shape:
|
|
46
|
+
[docs/bobby-browser/source/pages/surfaces/python-sdk.md](../../docs/bobby-browser/source/pages/surfaces/python-sdk.md).
|
|
47
|
+
|
|
48
|
+
## Test
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python3 -m unittest discover -s tests
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`tests/test_live_runtime.py` additionally starts the real `bobby` runtime
|
|
55
|
+
from this worktree's release build and skips cleanly when
|
|
56
|
+
`BOBBY_CHROME_EXECUTABLE` is unset.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
bobby_browser/__init__.py,sha256=7s_o0QE-XuN8mSbakXafw2rmtD8WQaHYYr4SycuaoeQ,650
|
|
2
|
+
bobby_browser/client.py,sha256=vouT2Cy5Q2VrNZUa-fuRkYIKFAf5cjHqHX8EmgsSqkM,20308
|
|
3
|
+
bobby_browser/errors.py,sha256=syhunPsnI66Y9BcWW5GtjCgpzIgPLvbdj0diHuhD0zw,4251
|
|
4
|
+
bobby_browser/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
bobby_browser-0.17.0.dist-info/METADATA,sha256=Houl8CUskwwFd2ycKw70o4ZpXZQJy4FvXzmeSLo8M8k,1796
|
|
6
|
+
bobby_browser-0.17.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
bobby_browser-0.17.0.dist-info/top_level.txt,sha256=1vVWDlJiCki5rjLocUlQSdm7jokGvdTSFP4Gds2hS-A,14
|
|
8
|
+
bobby_browser-0.17.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bobby_browser
|