Metadata-Version: 2.4
Name: owl-browser
Version: 2.1.6
Summary: Python SDK for Owl Browser automation - async-first with dynamic OpenAPI method generation
Project-URL: Homepage, https://www.owlbrowser.net
Project-URL: Documentation, https://www.owlbrowser.net/docs
Project-URL: Repository, https://github.com/Olib-AI/olib-browser
Author-email: Olib AI <support@olib.ai>
License-Expression: MIT
Keywords: antidetect,async,automation,browser,owl,testing,web-scraping
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: aiohttp>=3.9.0
Requires-Dist: beautifulsoup4>=4.12.0
Requires-Dist: cryptography>=42.0.0
Requires-Dist: pyjwt[crypto]>=2.8.0
Provides-Extra: dev
Requires-Dist: mypy>=1.8.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: ruff>=0.2.0; extra == 'dev'
Description-Content-Type: text/markdown

# Owl Browser Python SDK

Async Python client for the [Owl Browser](https://www.owlbrowser.net) HTTP server: one method per browser tool, generated from the bundled OpenAPI schema.

## Install

```bash
pip install owl-browser
```

Python 3.12+. Runtime dependencies: `aiohttp`, `pyjwt[crypto]`, `cryptography`, `beautifulsoup4`.

## Quick start

```python
import asyncio
from owl_browser import OwlBrowser, RemoteConfig

async def main():
    config = RemoteConfig(
        url="http://localhost:8080",
        token="your-token",
        api_prefix="",  # direct to http-server; keep the default "/api" behind nginx
    )

    async with OwlBrowser(config) as browser:
        ctx = await browser.create_context()
        await browser.navigate(context_id=ctx, url="https://example.com", wait_until="load")

        text = await browser.extract_text(context_id=ctx)
        print(text)

        await browser.close_context(context_id=ctx)

asyncio.run(main())
```

`async with` calls `connect()` on entry and `close()` on exit. Without it, call them yourself.

`create_context()` returns the context id as a `str`. Every other tool takes `context_id` as a keyword argument.

## Configuration

`RemoteConfig` is a dataclass in `owl_browser.types` (re-exported from `owl_browser`).

| Field | Type | Default | Meaning |
|---|---|---|---|
| `url` | `str` | required | Server base URL. A trailing `/` is stripped. |
| `token` | `str \| None` | `None` | Bearer token. Required when `auth_mode` is `TOKEN`. |
| `auth_mode` | `AuthMode` | `AuthMode.TOKEN` | `TOKEN` or `JWT`. |
| `jwt` | `JWTConfig \| None` | `None` | Required when `auth_mode` is `JWT`. |
| `transport` | `TransportMode` | `TransportMode.HTTP` | `HTTP` or `WEBSOCKET`. |
| `timeout` | `float` | `30.0` | Per-request timeout in seconds. |
| `max_concurrent` | `int` | `10` | Concurrent in-flight requests (semaphore and aiohttp connector limit). |
| `retry` | `RetryConfig` | `RetryConfig()` | Retry policy, HTTP transport only. |
| `verify_ssl` | `bool` | `True` | TLS certificate verification. |
| `api_prefix` | `str` | `"/api"` | Path prefix for every request. Normalised to a leading `/` with no trailing `/`. |

`__post_init__` raises `ValueError` if `auth_mode` is `TOKEN` with no `token`, or `JWT` with no `jwt`.

Paths are built as `{url}{api_prefix}/execute/{tool}`, so use the default `"/api"` behind the nginx proxy and `api_prefix=""` when talking to the http-server directly on port 8080.

`RetryConfig`: `max_retries=3` (total attempts, not extra attempts), `initial_delay_ms=100`, `max_delay_ms=10000`, `backoff_multiplier=2.0`, `jitter_factor=0.1`.

```python
from owl_browser import RemoteConfig, RetryConfig

config = RemoteConfig(
    url="https://your-host",
    token="your-token",
    timeout=30.0,
    max_concurrent=10,
    retry=RetryConfig(max_retries=5, initial_delay_ms=200),
)
```

JWT (RS256, key read from disk, refreshed automatically before expiry):

```python
from owl_browser import RemoteConfig, AuthMode, JWTConfig

config = RemoteConfig(
    url="https://your-host",
    auth_mode=AuthMode.JWT,
    jwt=JWTConfig(
        private_key_path="/path/to/private.pem",
        expires_in=3600,
        refresh_threshold=300,
        issuer="my-app",
        subject="user-123",
    ),
)
```

`timeout` applies per request, with one exception: over the HTTP transport a fixed set of slow tools (navigation, waits, content extraction, screenshots, CAPTCHA and the AI tools) uses `max(120.0, timeout * 4)` instead. Anything slower than that belongs in a job, see below.

## Calling tools

`execute()` runs any tool by its server name:

```python
result = await browser.execute(
    "browser_navigate",
    context_id=ctx,
    url="https://example.com",
)
```

`tool_name` is positional-only, so tools that themselves take a `tool_name` parameter work:

```python
await browser.execute(
    "browser_webmcp_call_tool",
    context_id=ctx,
    tool_name="echo",
    input='{"message":"hi"}',
)
```

At construction the client reads the bundled OpenAPI schema and builds one async method per tool, named without the `browser_` prefix. These are equivalent:

```python
await browser.execute("browser_click", context_id=ctx, selector="#submit")
await browser.click(context_id=ctx, selector="#submit")
```

Tools whose names do not start with `browser_` (for example `http_request`, `server_read_logs`) keep their full name as the method name. All parameters are keyword arguments. Fields the schema declares as integers are coerced from `float` to `int` before the request is sent.

`create_context` is the one name that is not dynamic: it is a real method that unwraps the response and returns the context id string, raising `OwlBrowserError` if the server returns anything else.

Introspection is synchronous and offline (it reads the bundled schema, not the server):

```python
browser.list_tools()        # ['browser_navigate', 'browser_click', ...] server tool names
browser.list_methods()      # ['navigate', 'click', ...] generated method names
browser.has_method("click") # True
browser.get_tool("browser_click")  # ToolDefinition | None
```

`ToolDefinition` carries `name`, `description`, `parameters` (a dict of `ParameterDef`), `required_params` and `integer_fields`.

Two more client methods: `health_check()` (HTTP transport only) and the sync wrappers `connect_sync()`, `execute_sync()`, `close_sync()`. Each sync wrapper calls `asyncio.run()` on a fresh event loop, so they do not share a connection; prefer the async API.

## Long running work

An agentic run (`browser_nla`) routinely takes minutes and will blow past the server's request timeout if you run it synchronously. The connection is dropped while the browser keeps working and the result is lost. Submit it as a background job instead.

### run_task

This is the one to use. One call: it submits the job, polls it, and returns the unwrapped answer.

```python
answer = await browser.run_task(ctx, "Find the cheapest book and report its price")
```

```text
run_task(context_id, command, *, timeout=600.0, poll_interval=2.0, **params) -> Any
```

`timeout` and `poll_interval` are in seconds. Extra keyword arguments are passed through to `browser_nla`. It raises `OwlBrowserError` if the job fails, is cancelled, or does not finish within `timeout` (the job keeps running server-side in the timeout case).

Worked example. `browser_nla` needs an LLM on the context, either the built-in model or a third-party endpoint:

```python
import asyncio
from owl_browser import OwlBrowser, RemoteConfig

URL = "https://books.toscrape.com/catalogue/category/books/travel_2/index.html"

async def main():
    config = RemoteConfig(url="http://localhost:8080", token="your-token", api_prefix="")

    async with OwlBrowser(config) as browser:
        ctx = await browser.create_context(
            render_mode="agent",
            llm_use_builtin=False,
            llm_is_third_party=True,
            llm_endpoint="http://192.168.1.197:1234",
            llm_model="qwen/qwen3.6-27b",
            llm_api_key="lmstudio",
        )
        await browser.navigate(context_id=ctx, url=URL, wait_until="load")

        answer = await browser.run_task(
            ctx,
            "Open the product page for 'The Great Railway Bazaar' and report how "
            "many copies are in stock. Answer with just the number.",
        )
        print(answer)  # -> the answer payload, envelopes already stripped

        await browser.close_context(context_id=ctx)

asyncio.run(main())
```

### The job API

Use these when you want the job id itself: to run several tasks at once, to report progress, or to cancel.

```python
job_id = await browser.submit_job("browser_nla", context_id=ctx, command="...")

job = await browser.get_job(job_id)     # dict for one job
jobs = await browser.list_jobs()        # list of dicts, no result payloads
await browser.cancel_job(job_id)        # dict, the job as it stands after the request

result = await browser.wait_for_job(job_id, timeout=600.0, poll_interval=2.0)
```

- `submit_job(tool_name, /, **params) -> str` sets `async=True` on the payload and returns the server's `job_id`. It works for any tool, not just `browser_nla`. It raises `OwlBrowserError` if the server does not return a job id.
- `get_job(job_id)` returns `{"job_id", "state", "tool", "created_ms", "updated_ms"}` plus `"result"` and `"error"` once they exist. An unknown id raises `OwlBrowserError`, it does not hang or return `None`.
- `list_jobs()` returns the live jobs, newest first, without their result payloads.
- `cancel_job(job_id)` requests cancellation, see the state notes below.
- `wait_for_job(job_id, *, timeout=600.0, poll_interval=2.0)` polls until the job leaves the non-terminal states, then returns the unwrapped result. Raises `OwlBrowserError` on `failed`, on `cancelled`, and on its own timeout.

`run_task` is exactly `submit_job("browser_nla", ...)` followed by `wait_for_job(...)`.

### Job states

`queued`, `running`, `done`, `failed`, `cancelled`, and `cancelling`.

Cancellation is cooperative. A queued job cancels immediately. A running job that is inside a browser call cannot be interrupted, so `cancel_job` only records the intent: the job reports `cancelling` until the in-flight call returns, and only then becomes `cancelled`. Do not treat `cancelling` as terminal.

### Results

Job results are stored as the raw tool response, which means a REST envelope and, for `browser_nla`, a JSON string inside it. `run_task` and `wait_for_job` peel those layers off and hand back the payload. Reading `job["result"]` from `get_job()` yourself gives you the unpeeled string.

### Requirements

Async jobs are plain REST endpoints under `/jobs` and exist only on the HTTP transport. A client built with `transport=TransportMode.WEBSOCKET` raises `OwlBrowserError` telling you so.

Jobs are held in memory per server process. They do not survive a server restart, and neither does the browser context they were driving.

## Errors

Every exception derives from `OwlBrowserError`, importable from `owl_browser`.

| Exception | Raised when | Extra attributes |
|---|---|---|
| `OwlBrowserError` | Base class. Also raised directly for job failure, cancellation and polling timeouts, for job calls on a WebSocket client, for `health_check()` on a WebSocket client, for a bad `create_context` response, and for non-2xx replies from `/jobs`. | |
| `ConnectionError` | Transport cannot connect, retries are exhausted, or the WebSocket is closed or not connected. | `message`, `cause` |
| `AuthenticationError` | HTTP 401, or a WebSocket handshake rejected with 401. | `message`, `reason`, `status_code` |
| `IPBlockedError` | HTTP 403. | `message`, `ip_address`, `status_code` |
| `RateLimitError` | HTTP 429. | `message`, `retry_after`, `limit`, `remaining`, `status_code` |
| `ToolExecutionError` | Any other HTTP status at or above 400, or a 2xx envelope with `success: false`. | `tool_name`, `message`, `status`, `result` |
| `TimeoutError` | The request exceeds the transport timeout. | `message`, `timeout_ms` |
| `OpenAPISchemaError` | The bundled schema is missing or unparseable. | `message`, `cause` |

`ConnectionError` and `TimeoutError` shadow the Python builtins of the same name. Import them explicitly, or alias them, if you also catch the builtins.

```python
from owl_browser import (
    AuthenticationError,
    ConnectionError,
    OwlBrowserError,
    RateLimitError,
    TimeoutError,
    ToolExecutionError,
)

async def click(browser, ctx):
    try:
        await browser.click(context_id=ctx, selector="#missing")
    except ToolExecutionError as e:
        print(f"{e.tool_name} failed: {e.message}")
    except RateLimitError as e:
        print(f"rate limited, retry after {e.retry_after}s")
    except (AuthenticationError, ConnectionError, TimeoutError) as e:
        print(f"transport problem: {e}")
    except OwlBrowserError as e:
        print(f"other SDK error: {e}")
```

401, 403 and 429 are never retried. Other connection and response errors are retried per `RetryConfig`.

`owl_browser.exceptions` also defines `ElementNotFoundError`, `NavigationError`, `ContextLimitError`, `ExpectationError` and `FlowExecutionError`. Apart from `FlowExecutionError` (used by the flow executor), the SDK does not raise these on its own: they come from the `raise_for_action_result(result)` helper in the same module, which you call on a tool result when you want a failed action to become an exception.

## Transports

Default is HTTP. Select the WebSocket transport on the config:

```python
from owl_browser import RemoteConfig, TransportMode

config = RemoteConfig(
    url="http://localhost:8080",
    token="your-token",
    transport=TransportMode.WEBSOCKET,
)
```

Both transports expose the same `execute()` and the same generated methods. What differs:

| | HTTP | WebSocket |
|---|---|---|
| Endpoint | `POST {api_prefix}/execute/{tool}` | one connection to `{api_prefix}/ws`, requests correlated by integer id |
| Retries | per `RetryConfig` | none |
| Timeout | `timeout`, raised to `max(120.0, timeout * 4)` for known slow tools | `timeout` for every call, no exception list |
| Concurrency | limited by `max_concurrent` | multiplexed over the single connection, not limited by `max_concurrent` |
| `health_check()` | supported | raises `OwlBrowserError` |
| Async jobs | supported | raises `OwlBrowserError` |
| Non-JSON responses | returned as text | not applicable, frames are JSON |

Use HTTP unless you specifically need a persistent connection. It is the only transport with retries, extended timeouts for slow tools, and the job API.

## Also in the package

Three optional layers ship alongside the tool client. Each is independent, and
none of them is needed for the sections above.

| Import | What it is |
|---|---|
| `from owl_browser import FlowExecutor` | Runs declarative JSON flows: steps, captured variables, `for_each`, retries, expectations. See [docs/FLOW_EXECUTOR.md](docs/FLOW_EXECUTOR.md). |
| `from owl_browser import Extractor` | Field specs to pull structured records out of a page. |
| `from owl_browser.playwright import chromium` | A Playwright-shaped facade (`Browser`, `BrowserContext`, `Page`) over the same client, for porting existing scripts. |
