tabsail 0.1.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.
tabsail/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """TabSail v1 asynchronous SDK; browser actions belong to your CDP client."""
2
+
3
+ from .client import AsyncTabSail, OwnedSession
4
+ from .errors import (
5
+ APIError,
6
+ CleanupError,
7
+ ConfigurationError,
8
+ HandoffCancelledError,
9
+ NetworkError,
10
+ ProtocolError,
11
+ RequestCancelledError,
12
+ RequestTimeoutError,
13
+ ResultUnknownError,
14
+ SDKError,
15
+ SessionTerminatedError,
16
+ WaitTimeoutError,
17
+ )
18
+ from .models import (
19
+ BrowserSession,
20
+ Profile,
21
+ ConnectionGrant,
22
+ Controller,
23
+ FileOrigin,
24
+ FileStatus,
25
+ Handoff,
26
+ HandoffCheckpoint,
27
+ HandoffStatus,
28
+ HandoffSummary,
29
+ Page,
30
+ ResourceError,
31
+ SessionFile,
32
+ SessionStatus,
33
+ )
34
+ from .resources import DownloadStream
35
+ from .transport import RequestOptions
36
+
37
+ __version__ = "0.1.0"
38
+ __all__ = [
39
+ "AsyncTabSail",
40
+ "OwnedSession",
41
+ "RequestOptions",
42
+ "BrowserSession",
43
+ "Profile",
44
+ "ConnectionGrant",
45
+ "Handoff",
46
+ "HandoffSummary",
47
+ "HandoffCheckpoint",
48
+ "SessionFile",
49
+ "Page",
50
+ "ResourceError",
51
+ "SessionStatus",
52
+ "Controller",
53
+ "HandoffStatus",
54
+ "FileStatus",
55
+ "FileOrigin",
56
+ "DownloadStream",
57
+ "SDKError",
58
+ "APIError",
59
+ "ConfigurationError",
60
+ "NetworkError",
61
+ "RequestTimeoutError",
62
+ "WaitTimeoutError",
63
+ "ProtocolError",
64
+ "RequestCancelledError",
65
+ "ResultUnknownError",
66
+ "SessionTerminatedError",
67
+ "HandoffCancelledError",
68
+ "CleanupError",
69
+ ]
tabsail/client.py ADDED
@@ -0,0 +1,286 @@
1
+ """Async HTTP client and ownership scoped session context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import replace
7
+ from copy import deepcopy
8
+ import math
9
+ import os
10
+ import time
11
+ from types import TracebackType
12
+ from typing import Any, Literal
13
+
14
+ import httpx
15
+
16
+ from .errors import (
17
+ APIError,
18
+ CleanupError,
19
+ ConfigurationError,
20
+ ProtocolError,
21
+ ResultUnknownError,
22
+ WaitTimeoutError,
23
+ )
24
+ from .models import BrowserSession
25
+ from .resources import Profiles, Sessions, keyed
26
+ from .transport import RequestOptions, Transport
27
+
28
+
29
+ class AsyncTabSail:
30
+ def __init__(
31
+ self,
32
+ *,
33
+ base_url: str | None = None,
34
+ api_key: str | None = None,
35
+ request_timeout_ms: float = 15000,
36
+ max_retries: int = 0,
37
+ transport: httpx.AsyncBaseTransport | None = None,
38
+ ) -> None:
39
+ base_url = base_url if base_url is not None else os.environ.get("TABSAIL_BASE_URL")
40
+ api_key = api_key if api_key is not None else os.environ.get("TABSAIL_API_KEY")
41
+ if not base_url or not api_key:
42
+ raise ConfigurationError(
43
+ "Set base_url and api_key or TABSAIL_BASE_URL and TABSAIL_API_KEY."
44
+ )
45
+ self._transport = Transport(base_url, api_key, request_timeout_ms, max_retries, transport)
46
+ self.sessions = Sessions(self._transport)
47
+ self.profiles = Profiles(self._transport)
48
+
49
+ def __repr__(self) -> str:
50
+ return "<AsyncTabSail credentials=[REDACTED]>"
51
+
52
+ async def __aenter__(self) -> AsyncTabSail:
53
+ return self
54
+
55
+ async def __aexit__(
56
+ self,
57
+ exc_type: type[BaseException] | None,
58
+ exc: BaseException | None,
59
+ tb: TracebackType | None,
60
+ ) -> None:
61
+ await self.aclose()
62
+
63
+ async def aclose(self) -> None:
64
+ """Close local HTTP connections; remote BrowserSessions are unchanged."""
65
+ await self._transport.aclose()
66
+
67
+ def with_session(
68
+ self,
69
+ *,
70
+ timeout_seconds: int | None = None,
71
+ viewport: dict[str, int] | None = None,
72
+ metadata: dict[str, str] | None = None,
73
+ region: Literal["auto", "west", "asia"] | None = None,
74
+ options: RequestOptions | None = None,
75
+ ready_timeout_ms: float = 60000,
76
+ cleanup_timeout_ms: float = 30000,
77
+ poll_interval_ms: float = 500,
78
+ wait_for_capacity: bool = False,
79
+ idle_timeout_seconds: int | None = None,
80
+ browser: dict[str, Any] | None = None,
81
+ profile: dict[str, Any] | None = None,
82
+ ) -> OwnedSession:
83
+ return OwnedSession(
84
+ self,
85
+ timeout_seconds,
86
+ viewport,
87
+ metadata,
88
+ region,
89
+ options,
90
+ ready_timeout_ms,
91
+ cleanup_timeout_ms,
92
+ poll_interval_ms,
93
+ wait_for_capacity,
94
+ idle_timeout_seconds,
95
+ browser,
96
+ profile,
97
+ )
98
+
99
+
100
+ class OwnedSession:
101
+ def __init__(
102
+ self,
103
+ client: AsyncTabSail,
104
+ timeout_seconds: int | None,
105
+ viewport: dict[str, int] | None,
106
+ metadata: dict[str, str] | None,
107
+ region: Literal["auto", "west", "asia"] | None,
108
+ options: RequestOptions | None,
109
+ ready_timeout_ms: float,
110
+ cleanup_timeout_ms: float,
111
+ poll_interval_ms: float,
112
+ wait_for_capacity: bool,
113
+ idle_timeout_seconds: int | None = None,
114
+ browser: dict[str, Any] | None = None,
115
+ profile: dict[str, Any] | None = None,
116
+ ) -> None:
117
+ if any(
118
+ not math.isfinite(v) or v <= 0
119
+ for v in (ready_timeout_ms, cleanup_timeout_ms, poll_interval_ms)
120
+ ):
121
+ raise ConfigurationError(
122
+ "Session wait and cleanup budgets must be finite positive milliseconds."
123
+ )
124
+ self._client = client
125
+ self._timeout_seconds = timeout_seconds
126
+ self._idle_timeout_seconds = idle_timeout_seconds
127
+ self._browser = deepcopy(browser)
128
+ self._profile = deepcopy(profile)
129
+ self._viewport = deepcopy(viewport)
130
+ self._metadata = deepcopy(metadata)
131
+ self._region = region
132
+ self._options = keyed(options)
133
+ if wait_for_capacity and self._options.timeout_ms is None:
134
+ self._options = replace(self._options, timeout_ms=300000)
135
+ self._wait_for_capacity = wait_for_capacity
136
+ self._ready_timeout_ms = ready_timeout_ms
137
+ self._cleanup_timeout_ms = cleanup_timeout_ms
138
+ self._poll_interval_ms = poll_interval_ms
139
+ self.session_id: str | None = None
140
+ self._unknown = False
141
+ self._entered = False
142
+
143
+ async def __aenter__(self) -> BrowserSession:
144
+ if self._entered:
145
+ raise ConfigurationError("An owned session context may only be entered once.")
146
+ self._entered = True
147
+ try:
148
+ # Cancellation here proves creation was not sent.
149
+ await asyncio.sleep(0)
150
+ started = time.monotonic()
151
+ capacity_attempt = 0
152
+ while True:
153
+ remaining_create = (
154
+ None if self._options.timeout_ms is None
155
+ else self._options.timeout_ms - (time.monotonic() - started) * 1000
156
+ )
157
+ if remaining_create is not None and remaining_create <= 0:
158
+ raise WaitTimeoutError("Browser capacity wait timed out.", code="CAPACITY_WAIT_TIMEOUT")
159
+ try:
160
+ session = await self._client.sessions.create(
161
+ timeout_seconds=self._timeout_seconds,
162
+ idle_timeout_seconds=self._idle_timeout_seconds,
163
+ browser=self._browser,
164
+ profile=self._profile,
165
+ viewport=self._viewport,
166
+ metadata=self._metadata,
167
+ region=self._region,
168
+ options=replace(self._options, timeout_ms=remaining_create),
169
+ )
170
+ break
171
+ except APIError as error:
172
+ if not (
173
+ self._wait_for_capacity and error.retryable and (
174
+ (error.status == 503 and error.code in ("NO_CAPACITY", "STARTUP_BUSY"))
175
+ or (error.status == 429 and error.code == "CREATE_RATE_LIMITED")
176
+ )
177
+ ):
178
+ raise
179
+ assert self._options.timeout_ms is not None
180
+ left = self._options.timeout_ms - (time.monotonic() - started) * 1000
181
+ if left <= 0:
182
+ raise WaitTimeoutError("Browser capacity wait timed out.", code="CAPACITY_WAIT_TIMEOUT") from None
183
+ delay = self._client._transport._delay(capacity_attempt, error.retry_after, 10.0)
184
+ capacity_attempt += 1
185
+ await asyncio.sleep(min(left / 1000, delay))
186
+ if delay >= left / 1000:
187
+ raise WaitTimeoutError("Browser capacity wait timed out.", code="CAPACITY_WAIT_TIMEOUT") from None
188
+ self.session_id = session.id
189
+ remaining = self._ready_timeout_ms
190
+ if self._options.timeout_ms is not None:
191
+ remaining = min(
192
+ remaining, self._options.timeout_ms - (time.monotonic() - started) * 1000
193
+ )
194
+ if remaining <= 0:
195
+ raise WaitTimeoutError(
196
+ "The owned session readiness deadline elapsed.", session_id=session.id
197
+ )
198
+ return await self._client.sessions.wait_until_ready(
199
+ session.id,
200
+ timeout_ms=remaining,
201
+ poll_interval_ms=self._poll_interval_ms,
202
+ options=self._options,
203
+ )
204
+ except BaseException as primary:
205
+ self._unknown = isinstance(primary, (ResultUnknownError, ProtocolError)) or getattr(
206
+ primary, "result_unknown", False
207
+ )
208
+ if self.session_id is not None or self._unknown:
209
+ await self._finish(primary)
210
+ raise
211
+
212
+ async def __aexit__(
213
+ self,
214
+ exc_type: type[BaseException] | None,
215
+ exc: BaseException | None,
216
+ tb: TracebackType | None,
217
+ ) -> Literal[False]:
218
+ await self._finish(exc)
219
+ return False
220
+
221
+ async def _cleanup(self) -> None:
222
+ # This deadline covers recovery, close, all requests/retries and released confirmation.
223
+ async with asyncio.timeout(self._cleanup_timeout_ms / 1000):
224
+ options = replace(
225
+ self._options,
226
+ timeout_ms=None,
227
+ max_retries=2,
228
+ request_timeout_ms=min(self._cleanup_timeout_ms, 5000),
229
+ )
230
+ if self.session_id is None:
231
+ recovered = await self._client.sessions.create(
232
+ timeout_seconds=self._timeout_seconds,
233
+ idle_timeout_seconds=self._idle_timeout_seconds,
234
+ browser=self._browser,
235
+ profile=self._profile,
236
+ viewport=self._viewport,
237
+ metadata=self._metadata,
238
+ region=self._region,
239
+ options=options,
240
+ )
241
+ self.session_id = recovered.id
242
+ await self._client.sessions.close(self.session_id, options=options)
243
+ await self._client.sessions.wait_until_released(
244
+ self.session_id,
245
+ timeout_ms=self._cleanup_timeout_ms,
246
+ poll_interval_ms=self._poll_interval_ms,
247
+ options=options,
248
+ )
249
+
250
+ async def _finish(self, primary: BaseException | None) -> None:
251
+ task = asyncio.create_task(self._cleanup())
252
+ cancellation: asyncio.CancelledError | None = None
253
+ # Shield makes business cancellation independent of the bounded cleanup task.
254
+ while not task.done():
255
+ try:
256
+ await asyncio.shield(task)
257
+ except asyncio.CancelledError as error:
258
+ if task.done():
259
+ break
260
+ cancellation = cancellation or error
261
+ continue
262
+ except BaseException:
263
+ break
264
+ cleanup_error: BaseException | None
265
+ try:
266
+ task.result()
267
+ cleanup_error = None
268
+ except BaseException as error:
269
+ cleanup_error = CleanupError(
270
+ error,
271
+ session_id=self.session_id,
272
+ idempotency_key=self._options.idempotency_key or "",
273
+ )
274
+ original = primary or cancellation
275
+ if original is not None:
276
+ original.session_id = self.session_id # type: ignore[attr-defined]
277
+ original.idempotency_key = self._options.idempotency_key # type: ignore[attr-defined]
278
+ if cleanup_error is not None:
279
+ original.cleanup_error = cleanup_error # type: ignore[attr-defined]
280
+ original.add_note(
281
+ "Owned session cleanup failed; see cleanup_error and session_id/idempotency_key."
282
+ )
283
+ if primary is None:
284
+ raise original
285
+ elif cleanup_error is not None:
286
+ raise cleanup_error from None
tabsail/errors.py ADDED
@@ -0,0 +1,125 @@
1
+ """Safe errors: credentials and connection URLs never appear in SDK error messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+
8
+
9
+ def redact(value: str, api_key: str = "") -> str:
10
+ if api_key:
11
+ value = value.replace(api_key, "[REDACTED]")
12
+ value = re.sub(r"(?i)Bearer\s+\S+", "Bearer [REDACTED]", value)
13
+ value = re.sub(r"(?:https?|wss?)://[^\s<>\"']+", "[REDACTED URL]", value)
14
+ value = re.sub(
15
+ r"(?i)(?:api[_-]?key|authorization|token)\s*[=:]\s*[^\s,;]+", "[REDACTED]", value
16
+ )
17
+ return value[:512]
18
+
19
+
20
+ class SDKError(Exception):
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ status: int | None = None,
26
+ code: str | None = None,
27
+ retryable: bool = False,
28
+ request_id: str | None = None,
29
+ retry_after: str | None = None,
30
+ session_id: str | None = None,
31
+ ) -> None:
32
+ super().__init__(redact(message))
33
+ self.status = status
34
+ self.code = code
35
+ self.retryable = retryable
36
+ self.request_id = request_id
37
+ self.retry_after = retry_after
38
+ self.session_id = session_id
39
+
40
+
41
+ class ConfigurationError(SDKError):
42
+ pass
43
+
44
+
45
+ class APIError(SDKError):
46
+ pass
47
+
48
+
49
+ class NetworkError(SDKError):
50
+ pass
51
+
52
+
53
+ class RequestTimeoutError(SDKError):
54
+ pass
55
+
56
+
57
+ class WaitTimeoutError(SDKError):
58
+ pass
59
+
60
+
61
+ class ProtocolError(SDKError):
62
+ pass
63
+
64
+
65
+ class SessionTerminatedError(SDKError):
66
+ pass
67
+
68
+
69
+ class HandoffCancelledError(SDKError):
70
+ pass
71
+
72
+
73
+ class RequestCancelledError(asyncio.CancelledError):
74
+ """Cancellation retains normal asyncio cancellation semantics."""
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ session_id: str | None = None,
80
+ idempotency_key: str | None = None,
81
+ result_unknown: bool = False,
82
+ ) -> None:
83
+ super().__init__("TabSail request was cancelled.")
84
+ self.session_id = session_id
85
+ self.idempotency_key = idempotency_key
86
+ self.result_unknown = result_unknown
87
+ self.cleanup_error: BaseException | None = None
88
+
89
+
90
+ class ResultUnknownError(SDKError):
91
+ def __init__(
92
+ self,
93
+ primary_error: SDKError,
94
+ *,
95
+ idempotency_key: str | None = None,
96
+ session_id: str | None = None,
97
+ ) -> None:
98
+ super().__init__(
99
+ "The request outcome is unknown. Do not blindly repeat this operation; "
100
+ "recover session creation with the same idempotency key or inspect session files.",
101
+ status=primary_error.status,
102
+ code=primary_error.code,
103
+ retryable=primary_error.retryable,
104
+ retry_after=primary_error.retry_after,
105
+ request_id=primary_error.request_id,
106
+ session_id=session_id,
107
+ )
108
+ self.primary_error = primary_error
109
+ self.idempotency_key = idempotency_key
110
+ self.result_unknown = True
111
+ self.cleanup_error: BaseException | None = None
112
+
113
+
114
+ class CleanupError(SDKError):
115
+ def __init__(
116
+ self, cleanup_error: BaseException, *, session_id: str | None, idempotency_key: str
117
+ ) -> None:
118
+ super().__init__(
119
+ "Owned session cleanup could not confirm release. Recover using the same "
120
+ "idempotency key, close the session, and wait for released_at.",
121
+ session_id=session_id,
122
+ )
123
+ self.cleanup_error = cleanup_error
124
+ self.idempotency_key = idempotency_key
125
+ self.result_unknown = session_id is None