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.
@@ -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"
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ bobby_browser