TabSail Python SDK
Hosted endpoint: https://tabsail.com. Set TABSAIL_BASE_URL=https://tabsail.com and TABSAIL_API_KEY to your project key. For local or separate deployments, use that instance's root URL.
An asynchronous, typed Python >=3.12 client for TabSail v1 session, file, handoff and Profile resources. Distribution tabsail, import tabsail, version 0.1.0. It requires httpx, and does not install Playwright, a model provider, Chromium or Docker.
Fund the Tenant through the console before creating a browser. At the base rate of 100 credits/hour, each billable session rounds up to whole credits (minimum 1 credit, or one per started 36 seconds). The maximum-duration budget is also rounded up before reservation; unused credits are released and platform failures are free. Raw duration remains measured in milliseconds; $1 purchases 1,000 credits. 402 INSUFFICIENT_CREDITS and 403 BILLING_FROZEN never enter capacity waiting. Projects and Keys share their Tenant's credits and concurrency.
Install from PyPI:
uv add "tabsail==0.1.0"
from tabsail import AsyncTabSail, RequestOptions
async with AsyncTabSail(base_url="https://tabsail.com", api_key="PROJECT_KEY") as client:
async with client.with_session(
timeout_seconds=900,
options=RequestOptions(idempotency_key="your-task-id"),
) as session:
grant = await client.sessions.connections.create(session.id, type="cdp")
# Pass grant.url to your native async Playwright connect_over_cdp().
# Reuse the browser's existing context/page; actions belong to Playwright.
The base URL is the instance root; trailing slashes are removed and /v1 is appended. Explicit configuration overrides TABSAIL_BASE_URL and TABSAIL_API_KEY. No .env or consumer filesystem secrets are searched. Redirects are not followed, so the Project Key is not forwarded to another origin. Treat grant URLs as credentials; their model repr hides the URL.
client.aclose() closes local HTTP connections. CDP disconnect does not release the remote browser. with_session() creates and owns one BrowserSession, waits for ready and yields that resource; it does not create a Playwright Browser. On exit it closes its session and waits for non-null released_at. Explicitly created sessions are caller-owned and are never closed by the client's HTTP context. Failed status alone does not prove resource/quota release.
All SDK timeouts are named with _ms: request_timeout_ms bounds one HTTP attempt; RequestOptions(timeout_ms=...) bounds one logical HTTP operation including retry/backoff; lifecycle timeout_ms bounds the entire polling operation including requests and sleeps. timeout_seconds is the server's browser lifetime, independent of HTTP/wait deadlines. Omitted server creation fields remain omitted. Default request timeout is 15,000 ms, lifecycle polling 500 ms, ready/released wait 60,000 ms, handoff wait 300,000 ms. Capacity errors return immediately with default max_retries=0. Explicit retries are limited to 10; only GET, keyed create/handoff create and state-idempotent close/complete can replay. Exponential backoff starts with a 250 ms window capped at 5 seconds, randomly waiting between half and all of that window after the full Retry-After minimum. A deadline can end the call before another retry; the server minimum is never shortened. Grants and uploads never replay automatically.
with_session(wait_for_capacity=True) waits for 503 NO_CAPACITY/STARTUP_BUSY and 429 CREATE_RATE_LIMITED using one idempotency key and unchanged creation parameters. Its backoff window grows from 250 ms to 10 seconds, randomized after Retry-After. The default total create + ready deadline is 300,000 ms; override it with RequestOptions(timeout_ms=...). Startup retains its separate ready limit and task cancellation interrupts waiting. Tenant concurrency limits, insufficient credits and billing freezes are not capacity waits. This helper does not promise a queue position or eventual allocation, and never replays your business code.
from tabsail import HandoffCheckpoint
stable = await client.sessions.get(session.id) # ready + automation, one snapshot
checkpoint = HandoffCheckpoint.from_session(stable)
resumed = await client.sessions.wait_for_handoff(session.id, after=checkpoint)
# resumed.latest_handoff.status == "completed", resumed.controller == "automation"
checkpoint = HandoffCheckpoint.from_session(resumed)
# Request a new CDP grant, reconnect the SAME session and retrieve fresh context/page.
# Observe human modifications before choosing the next action.
wait_for_handoff compares arbitrary-size decimal epochs as integers and requires a new latest Handoff ID. It detects a completed handoff between observations, never consumes the old completion again, and waits through consecutive handoffs until the current latest handoff is completed with automation control in the same snapshot. The checkpoint must originate from stable ready + automation; do not capture it during human control. This helper returns a BrowserSession; known IDs can use handoffs.wait_until_completed(id, handoff_id), which returns a Handoff. Completion payload returning is pending even if an older server returns HTTP 200. Session termination, cancellation and cancelled handoffs are explicit errors. The SDK does not reconnect Playwright or replay webpage actions/callbacks. Natural CDP disconnect without an epoch change is not an observed handoff.
Lifecycle helpers use an authenticated SSE snapshot subscription after the initial GET. Reconnects read the latest complete state. Older servers without this endpoint retain polling compatibility; poll_interval_ms controls fallback and reconnect backoff. No action or callback is replayed.
from contextlib import aclosing
async with aclosing(client.sessions.watch(session.id, timeout_ms=300000)) as updates:
async for snapshot in updates:
print(snapshot.status, snapshot.controller)
if snapshot.released_at:
break
aclosing releases the local subscription on early exit. It does not close the remote browser. Native asyncio cancellation and total wait deadlines remain supported.
Resources: sessions.create/get/list/close, sessions.connections.create(type="cdp" or "live", mode="view" or "control", handoff_id=...), sessions.handoffs.create/get/complete, sessions.files.upload/list/get/download/delete. Request fields use Python snake_case and serialize to the existing camelCase HTTP contract. List methods return Page(items, next_cursor); pass its cursor explicitly for the next page. Models reject unknown state values with ProtocolError and tolerate extra response fields.
from pathlib import Path
file = await client.sessions.files.upload(session.id, Path("报告.bin"))
async with client.sessions.files.download(session.id, file.id) as download:
with Path("retrieved.bin").open("wb") as output:
async for chunk in download.aiter_bytes():
output.write(chunk)
await client.sessions.files.delete(session.id, file.id) # 204 has no JSON body
Uploads accept bytes, Path or a binary file object and use multipart; callers close their own file objects. A Path is opened and closed by the SDK. Downloads are streamed and must be used with async with; cancellation or early context exit closes the local response. The entire HTTP attempt, including headers and stream reads, shares request_timeout_ms; an optional timeout_ms additionally limits the logical operation including retries. Each read checks the remaining absolute deadline. Processing a yielded chunk does not arm a timer that cancels your task; if processing uses the remaining budget, the next read raises RequestTimeoutError and closes the stream. browser_path is remote, and files must be retrieved before closing the session. No durable file hosting or resume is promised.
Errors distinguish configuration, network, HTTP request timeout, wait deadline, asyncio cancellation, API errors, protocol errors and unknown mutation outcomes. SDK error fields include status, code, retryable, request_id, retry_after, session_id. Unknown API codes remain APIError. Gateway HTML/empty 502 responses retain bounded sanitized HTTP context. Raw credentials/grant URLs do not appear in SDK exception messages.
Session/handoff creation generates one Idempotency-Key per logical call; supply RequestOptions(idempotency_key=...) for cross-process recovery. Replay can return an old starting snapshot; always GET/wait for current state. Mutation 5xx errors can follow a commit and are ResultUnknownError with their original APIError/status/request ID; explicit NO_CAPACITY and STARTUP_BUSY rejection remain APIError. A failed/lost upload response is ResultUnknownError: inspect files instead of blindly uploading again.
Owned creation cancelled before sending is not transmitted. Once creation outcome is unknown, cleanup uses the same key within an independent cleanup_timeout_ms budget (30,000 ms by default), obtains the unique ID, closes and confirms release. If the original request never arrived, this recovery may briefly create then close a new session. Cancellation never cancels this shielded cleanup, and business code is not retried. A primary exception remains primary; cleanup failure is attached as .cleanup_error, with .session_id and .idempotency_key for manual recovery. Without a primary failure, CleanupError is raised. Never reuse the recovery key with a changed creation body.
Development: uv sync --group dev, uv run pytest, uv run mypy, uv run ruff check src tests, uv build. uv run python tests/pack_consumer.py builds wheel/sdist, installs each in a clean external environment (rebuilding the sdist wheel), checks typed imports and runs a real loopback HTTP consumer. These local tests do not claim PG/Steel/CDP qualification; that evidence belongs to the repository's separate isolated SDK acceptance suite.
For elastic capacity, use client.with_session(wait_for_capacity=True, options=RequestOptions(timeout_ms=300000)). Enabling it defaults the total acquisition budget to five minutes, including capacity retries and readiness. Only confirmed NO_CAPACITY / STARTUP_BUSY refusals wait; tenant quota failures return immediately. The same idempotency key and request are retained. Browser lifetime and the context body have separate budgets.
Browser region
Creation defaults to automatic selection from the API caller's public egress IP: Asia/Oceania use asia, other or unknown locations use west. Override when a backend's location differs from the desired browser location.
async with client.with_session(region="asia", wait_for_capacity=True) as session:
print(session.region) # actual "asia" or "west"
await client.sessions.create(region="west") also accepts the override. Region stays fixed across idempotent retries and the session lifetime. Capacity waiting does not switch regions; use a new request and idempotency key to change region. Use the returned CDP/Live connection URL.
Browser settings, lifetime, and Profiles
With an initialized client:
profile = await client.profiles.create("Report login")
async with client.with_session(
browser={"locale": "en-US", "timezone": "Asia/Tokyo", "headless": True},
idle_timeout_seconds=120,
profile={"id": profile.id, "persistOnClose": True},
) as session:
await client.sessions.update(session.id, timeout_seconds=1200)
await client.sessions.touch(session.id)
# Connect and perform your task. The scope closes and releases the browser.
Nested browser and profile dictionaries use the HTTP field names shown above. timeout_seconds in an update is the new total lifetime from started_at, not extra seconds. It cannot decrease; extra budget is reserved atomically. Omit idle_timeout_seconds to leave it unchanged; pass None to disable idle expiry. GET, SSE and read-only Live do not renew idle activity.
Profiles support create, list, get, and delete. One unreleased session may use a Profile. Normal close/expiry saves cookies and localStorage when persistOnClose is true; inspect the ended session's profile["saveStatus"]. A failed save preserves the previous version. Profiles exclude downloads, page memory, and Agent history.
Metadata
Release files for tabsail 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| tabsail-0.1.0.tar.gz | 47.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tabsail-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 69.9 kB
Release files / tabsail-0.1.0.tar.gz
| Download URL | tabsail-0.1.0.tar.gz |
|---|---|
| Size | 47.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a545f8e73153e12c56fdf738b3790fb6ae20c28b5a3cf816afbb44109535c974
|
|
BLAKE2b-256 checksum How to use checksums |
4fe322e60ab9a79a5ca25aa279cf62a085e04283444e43ff58c9374cf24e7cf3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / tabsail-0.1.0-py3-none-any.whl
| Download URL | tabsail-0.1.0-py3-none-any.whl |
|---|---|
| Size | 22.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
57233785cfa881cd622c7a6f7faaa89e54bc2b3b65f3f111b6235bbdce63f2e2
|
|
BLAKE2b-256 checksum How to use checksums |
18d7891ecfa6e76f24e608a1c18abdd5cbd38e27acce254a01a5e2422f34a13a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for tabsail 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.