scalebrowser 0.2.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.
- scalebrowser/__init__.py +234 -0
- scalebrowser/_http.py +180 -0
- scalebrowser/_sync.py +604 -0
- scalebrowser/_version.py +3 -0
- scalebrowser/cdp.py +429 -0
- scalebrowser/client.py +825 -0
- scalebrowser/errors.py +97 -0
- scalebrowser/events.py +52 -0
- scalebrowser/models.py +861 -0
- scalebrowser/models_control.py +128 -0
- scalebrowser/models_identity.py +130 -0
- scalebrowser/models_runs.py +56 -0
- scalebrowser-0.2.0.dist-info/METADATA +170 -0
- scalebrowser-0.2.0.dist-info/RECORD +16 -0
- scalebrowser-0.2.0.dist-info/WHEEL +4 -0
- scalebrowser-0.2.0.dist-info/licenses/LICENSE +21 -0
scalebrowser/errors.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Typed SDK errors and the daemon's stable numeric error codes.
|
|
2
|
+
|
|
3
|
+
The daemon returns error bodies shaped ``{ "code": <4001-4010>, "message": "…" }``
|
|
4
|
+
with the HTTP status from ``core::Error::http_status()`` (docs/features/api.md, SPEC §5).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Final, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ErrorCode:
|
|
13
|
+
"""Stable numeric API error codes (SPEC §5, ``crates/core/src/error.rs``)."""
|
|
14
|
+
|
|
15
|
+
NOT_FOUND: Final = 4001
|
|
16
|
+
PROXY_GEO: Final = 4002
|
|
17
|
+
CAPACITY: Final = 4003
|
|
18
|
+
ENGINE_MISSING: Final = 4004
|
|
19
|
+
PREFLIGHT: Final = 4005
|
|
20
|
+
ALREADY_RUNNING: Final = 4006
|
|
21
|
+
# Retired: the removed Methode-D (VM) tier produced this. Kept reserved (never
|
|
22
|
+
# reused) to match the server's append-only code contract; never returned now.
|
|
23
|
+
VM_PROVISION_FAILED: Final = 4007
|
|
24
|
+
# The subscription's abo-wide concurrent-browser allowance is used up (DEC-012).
|
|
25
|
+
# Distinct from CAPACITY (4003 = this machine is full, wait): the caller's move
|
|
26
|
+
# is to stop a running browser anywhere or upgrade the plan.
|
|
27
|
+
CONCURRENCY_LIMIT: Final = 4008
|
|
28
|
+
UNAUTHORIZED: Final = 4010
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_CODE_LABELS: Final[dict[int, str]] = {
|
|
32
|
+
4001: "Not found",
|
|
33
|
+
4002: "Proxy or geo check failed",
|
|
34
|
+
4003: "Capacity exceeded",
|
|
35
|
+
4004: "Engine missing",
|
|
36
|
+
4005: "Launch preflight failed",
|
|
37
|
+
4006: "Profile already running",
|
|
38
|
+
4008: "Concurrent-browser limit reached (subscription-wide)",
|
|
39
|
+
4010: "Unauthorized",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def code_label(code: Optional[int]) -> Optional[str]:
|
|
44
|
+
"""Human-readable label for a coded business error, if known."""
|
|
45
|
+
return _CODE_LABELS.get(code) if code is not None else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ScalebrowserError(Exception):
|
|
49
|
+
"""Base class for every error raised by this SDK."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ApiError(ScalebrowserError):
|
|
53
|
+
"""A transport/contract error from the daemon REST API.
|
|
54
|
+
|
|
55
|
+
Carries the HTTP ``status`` and the daemon's numeric ``code`` so callers can
|
|
56
|
+
branch on auth failures or specific business errors (4001-4010).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
status: int,
|
|
62
|
+
code: Optional[int] = None,
|
|
63
|
+
message: Optional[str] = None,
|
|
64
|
+
body: Optional[dict[str, Any]] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.status = status
|
|
67
|
+
self.code = code
|
|
68
|
+
self.body = body
|
|
69
|
+
label = code_label(code)
|
|
70
|
+
super().__init__(message or label or f"Request failed (HTTP {status})")
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_auth_error(self) -> bool:
|
|
74
|
+
"""True for auth failures that should trigger re-authentication."""
|
|
75
|
+
return self.status == 401 or self.code == ErrorCode.UNAUTHORIZED
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def code_label(self) -> Optional[str]:
|
|
79
|
+
"""Short label for the error code, if any."""
|
|
80
|
+
return code_label(self.code)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class NetworkError(ScalebrowserError):
|
|
84
|
+
"""A network-level failure — the daemon was unreachable (no HTTP response)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
87
|
+
super().__init__(message)
|
|
88
|
+
self.cause = cause
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CdpError(ScalebrowserError):
|
|
92
|
+
"""An error returned by the browser over the direct-CDP channel."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, code: Optional[int], message: str, data: Any = None) -> None:
|
|
95
|
+
self.code = code
|
|
96
|
+
self.data = data
|
|
97
|
+
super().__init__(f"CDP error {code}: {message}" if code is not None else f"CDP error: {message}")
|
scalebrowser/events.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Live lifecycle event stream (``GET /v1/events``, AC-API-003).
|
|
2
|
+
|
|
3
|
+
We read the ``text/event-stream`` over the authenticated transport and frame it
|
|
4
|
+
into the typed :data:`~scalebrowser.models.Event` union, mirroring the behaviour
|
|
5
|
+
of ``web-ui/src/api/events.ts``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import AsyncIterator, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from ._http import AsyncTransport
|
|
15
|
+
from .models import EVENT_ADAPTER, Event
|
|
16
|
+
|
|
17
|
+
EVENTS_PATH = "/v1/events"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_sse_frame(frame: str) -> Optional[Event]:
|
|
21
|
+
"""Extract and validate the ``data:`` payload of a single SSE frame."""
|
|
22
|
+
data_lines: list[str] = []
|
|
23
|
+
for line in frame.splitlines():
|
|
24
|
+
if line.startswith("data:"):
|
|
25
|
+
data_lines.append(line[5:].lstrip(" "))
|
|
26
|
+
if not data_lines:
|
|
27
|
+
return None
|
|
28
|
+
payload = "\n".join(data_lines)
|
|
29
|
+
if not payload or payload == "[DONE]":
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
return EVENT_ADAPTER.validate_json(payload)
|
|
33
|
+
except (ValidationError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def iter_events(transport: AsyncTransport, path: str = EVENTS_PATH) -> AsyncIterator[Event]:
|
|
38
|
+
"""Stream typed lifecycle events until the connection ends."""
|
|
39
|
+
frame_lines: list[str] = []
|
|
40
|
+
async for line in transport.stream_sse(path):
|
|
41
|
+
if line == "":
|
|
42
|
+
if frame_lines:
|
|
43
|
+
event = parse_sse_frame("\n".join(frame_lines))
|
|
44
|
+
frame_lines = []
|
|
45
|
+
if event is not None:
|
|
46
|
+
yield event
|
|
47
|
+
else:
|
|
48
|
+
frame_lines.append(line)
|
|
49
|
+
if frame_lines:
|
|
50
|
+
event = parse_sse_frame("\n".join(frame_lines))
|
|
51
|
+
if event is not None:
|
|
52
|
+
yield event
|