Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
- `poetry run ruff check .` — run lint checks.
- `poetry run ruff format .` — format code with ruff.
- `poetry run pytest tests --ignore=tests/sandbox/e2e` — run the local non-E2E suite.
- Example run: create a small script using the README snippets and run with `python path/to/script.py` after setting `HYPERBROWSER_API_KEY`.
- Example run: create a small script using the README snippets and run with `python path/to/script.py` after setting `HYPERBROWSER_API_KEY`, or after `hx auth login`.

## Coding Style & Naming Conventions
- Python 3.8+ with 4‑space indentation.
Expand All @@ -33,6 +33,7 @@

## Configuration & Secrets
- Set `HYPERBROWSER_API_KEY` via environment variables or pass `api_key=` in client constructors.
- If no API key is set, the client uses a saved `hx auth login` session from `~/.hx_config/auth/`. Select a profile with `HYPERBROWSER_PROFILE` or `profile=`.
- Never commit API keys or session data; use `.env` or local shell exports for development.

## Cursor Cloud specific instructions
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ Response and legacy models are re-exported from

## Key Patterns

- **API Key**: Via constructor `api_key=` or `HYPERBROWSER_API_KEY` env var
- **API Key**: Via constructor `api_key=` or `HYPERBROWSER_API_KEY` env var. If omitted, the client uses a saved `hx auth login` session (`~/.hx_config/auth/<profile>.json`).
- **Profile**: `HYPERBROWSER_PROFILE`, `profile=`, or `ClientConfig(profile=...)` (default `default`)
- **Base URL**: Defaults to `https://api.hyperbrowser.ai`, configurable via `base_url=` or `HYPERBROWSER_BASE_URL`
- **Job Polling**: Managers provide `start_and_wait()` methods that poll until completion
- **Context Managers**: `AsyncHyperbrowser` supports `async with` for automatic cleanup
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ Both the sync and async client follow similar configuration params
### API Key
The API key can be configured either from the constructor arguments or environment variables using `HYPERBROWSER_API_KEY`

If no API key is provided, the client falls back to a saved OAuth session created by `hx auth login`. By default it reads `~/.hx_config/auth/default.json`, or `~/.hx_config/auth/<profile>.json` when `HYPERBROWSER_PROFILE` or `ClientConfig(profile=...)` is set.
If no API key is provided, the client falls back to a saved OAuth session created by `hx auth login`. By default it reads `~/.hx_config/auth/default.json`, or `~/.hx_config/auth/<profile>.json` when `HYPERBROWSER_PROFILE`, `Hyperbrowser(profile=...)`, or `ClientConfig(profile=...)` is set.

Profile names must match `^[A-Za-z0-9._-]+$`.

`base_url` and `HYPERBROWSER_BASE_URL` accept either `https://host` or `https://host/api`. The client normalizes both to the same control-plane base URL.

Token refresh uses `https://app.hyperbrowser.ai` for the default API host, or `HYPERBROWSER_FRONTEND_URL` / `ClientConfig(frontend_url=...)` when set. Custom `base_url` values refresh against that same host.

## Usage

Hyperbrowser 1.0 accepts plain dictionaries for request parameters. Method
Expand Down
2 changes: 2 additions & 0 deletions hyperbrowser/client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ def __init__(
base_url: Optional[str] = None,
timeout: Optional[int] = 30,
runtime_proxy_override: Optional[str] = None,
profile: Optional[str] = None,
):
super().__init__(
AsyncTransport,
config,
api_key,
base_url,
runtime_proxy_override,
profile,
)
self.timeout = timeout or 30
self.transport.client.timeout = timeout
Expand Down
31 changes: 11 additions & 20 deletions hyperbrowser/client/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from dataclasses import replace
from typing import Optional

from hyperbrowser.exceptions import HyperbrowserError
from ..config import ClientConfig
from ..control_auth import resolve_control_plane_config
from ..transport.base import TransportStrategy
import os


class HyperbrowserBase:
Expand All @@ -16,29 +16,20 @@ def __init__(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
runtime_proxy_override: Optional[str] = None,
profile: Optional[str] = None,
):
if config is None:
config = ClientConfig(
api_key=(
api_key
if api_key is not None
else os.environ.get("HYPERBROWSER_API_KEY", "")
),
base_url=(
base_url
if base_url is not None
else os.environ.get(
"HYPERBROWSER_BASE_URL", "https://api.hyperbrowser.ai"
)
),
config = ClientConfig.from_constructor(
api_key=api_key,
base_url=base_url,
runtime_proxy_override=runtime_proxy_override,
profile=profile,
)

if not config.api_key:
raise HyperbrowserError("API key must be provided")

self.config = config
self.transport = transport(config.api_key)
resolved_base_url, auth = resolve_control_plane_config(config)
self.config = replace(config, base_url=resolved_base_url)
self.auth = auth
self.transport = transport(auth)

def _build_url(self, path: str) -> str:
return f"{self.config.base_url}/api{path}"
4 changes: 3 additions & 1 deletion hyperbrowser/client/managers/async_manager/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
)
from ....sandbox_common import (
RuntimeConnection,
asend_control_http_request,
ensure_response_ok,
normalize_network_error,
parse_json_response,
Expand Down Expand Up @@ -760,7 +761,8 @@ async def _request(
data: Optional[Dict[str, object]] = None,
):
try:
response = await self._client.transport.client.request(
response = await asend_control_http_request(
self._client.transport,
method,
self._client._build_url(path),
params={k: v for k, v in (params or {}).items() if v is not None},
Expand Down
29 changes: 17 additions & 12 deletions hyperbrowser/client/managers/async_manager/session.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import warnings
from collections.abc import Mapping
from pathlib import Path
from typing import IO, List, Optional, Union, overload

from hyperbrowser.client._request import coerce_request, dump_request
Expand Down Expand Up @@ -42,6 +43,17 @@
CAPTCHA_EVALUATION_REQUEST_TIMEOUT_SECONDS = 185


def _replayable_upload_files(file_path: str):
path = Path(file_path)
return {
"file": (
path.name or "upload.bin",
path.read_bytes(),
"application/octet-stream",
)
}


class SessionEventLogsManager:
def __init__(self, client):
self._client = client
Expand Down Expand Up @@ -163,21 +175,14 @@ async def get_downloads_url(self, id: str) -> GetSessionDownloadsUrlResponse:
async def upload_file(
self, id: str, file_input: Union[str, IO]
) -> UploadFileResponse:
response = None
if isinstance(file_input, str):
with open(file_input, "rb") as file_obj:
files = {"file": file_obj}
response = await self._client.transport.post(
self._client._build_url(f"/session/{id}/uploads"),
files=files,
)
files = _replayable_upload_files(file_input)
else:
files = {"file": file_input}
response = await self._client.transport.post(
self._client._build_url(f"/session/{id}/uploads"),
files=files,
)

response = await self._client.transport.post(
self._client._build_url(f"/session/{id}/uploads"),
files=files,
)
return UploadFileResponse(**response.data)

async def extend_session(self, id: str, duration_minutes: int) -> BasicResponse:
Expand Down
4 changes: 3 additions & 1 deletion hyperbrowser/client/managers/sync_manager/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
ensure_response_ok,
normalize_network_error,
parse_json_response,
send_control_http_request,
)
from ..sandboxes.shared import (
_build_sandbox_exposed_url,
Expand Down Expand Up @@ -749,7 +750,8 @@ def _request(
data: Optional[Dict[str, object]] = None,
):
try:
response = self._client.transport.client.request(
response = send_control_http_request(
self._client.transport,
method,
self._client._build_url(path),
params={k: v for k, v in (params or {}).items() if v is not None},
Expand Down
29 changes: 17 additions & 12 deletions hyperbrowser/client/managers/sync_manager/session.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import warnings
from collections.abc import Mapping
from pathlib import Path
from typing import IO, List, Optional, Union, overload

from hyperbrowser.client._request import coerce_request, dump_request
Expand Down Expand Up @@ -42,6 +43,17 @@
CAPTCHA_EVALUATION_REQUEST_TIMEOUT_SECONDS = 185


def _replayable_upload_files(file_path: str):
path = Path(file_path)
return {
"file": (
path.name or "upload.bin",
path.read_bytes(),
"application/octet-stream",
)
}


class SessionEventLogsManager:
def __init__(self, client):
self._client = client
Expand Down Expand Up @@ -159,21 +171,14 @@ def get_downloads_url(self, id: str) -> GetSessionDownloadsUrlResponse:
return GetSessionDownloadsUrlResponse(**response.data)

def upload_file(self, id: str, file_input: Union[str, IO]) -> UploadFileResponse:
response = None
if isinstance(file_input, str):
with open(file_input, "rb") as file_obj:
files = {"file": file_obj}
response = self._client.transport.post(
self._client._build_url(f"/session/{id}/uploads"),
files=files,
)
files = _replayable_upload_files(file_input)
else:
files = {"file": file_input}
response = self._client.transport.post(
self._client._build_url(f"/session/{id}/uploads"),
files=files,
)

response = self._client.transport.post(
self._client._build_url(f"/session/{id}/uploads"),
files=files,
)
return UploadFileResponse(**response.data)

def extend_session(self, id: str, duration_minutes: int) -> BasicResponse:
Expand Down
2 changes: 2 additions & 0 deletions hyperbrowser/client/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ def __init__(
base_url: Optional[str] = None,
timeout: Optional[int] = 30,
runtime_proxy_override: Optional[str] = None,
profile: Optional[str] = None,
):
super().__init__(
SyncTransport,
config,
api_key,
base_url,
runtime_proxy_override,
profile,
)
self.timeout = timeout or 30
self.transport.client.timeout = timeout
Expand Down
69 changes: 61 additions & 8 deletions hyperbrowser/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,75 @@
from typing import Optional
import os

DEFAULT_BASE_URL = "https://api.hyperbrowser.ai"
DEFAULT_FRONTEND_BASE_URL = "https://app.hyperbrowser.ai"


def _env_text(name: str) -> Optional[str]:
value = os.environ.get(name)
if value is None:
return None
stripped = value.strip()
return stripped or None


def _env_positive_int(name: str) -> Optional[int]:
raw = os.environ.get(name)
if not raw:
return None
try:
parsed = int(raw)
except ValueError:
return None
return parsed if parsed > 0 else None


@dataclass
class ClientConfig:
"""Configuration for the Hyperbrowser client"""

api_key: str
base_url: str = "https://api.hyperbrowser.ai"
api_key: Optional[str] = None
base_url: str = DEFAULT_BASE_URL
runtime_proxy_override: Optional[str] = None
profile: Optional[str] = None
frontend_url: Optional[str] = None
auth_lock_timeout_ms: Optional[int] = None
auth_lock_poll_interval_ms: Optional[int] = None
auth_lock_stale_ms: Optional[int] = None

@classmethod
def from_constructor(
cls,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
runtime_proxy_override: Optional[str] = None,
profile: Optional[str] = None,
) -> "ClientConfig":
return cls(
api_key=api_key
if api_key is not None
else _env_text("HYPERBROWSER_API_KEY"),
base_url=(
base_url
if base_url is not None
else (_env_text("HYPERBROWSER_BASE_URL") or DEFAULT_BASE_URL)
),
runtime_proxy_override=runtime_proxy_override,
profile=(
profile if profile is not None else _env_text("HYPERBROWSER_PROFILE")
),
frontend_url=_env_text("HYPERBROWSER_FRONTEND_URL"),
auth_lock_timeout_ms=_env_positive_int("HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS"),
auth_lock_poll_interval_ms=_env_positive_int(
"HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS"
),
auth_lock_stale_ms=_env_positive_int("HYPERBROWSER_AUTH_LOCK_STALE_MS"),
)

@classmethod
def from_env(cls) -> "ClientConfig":
api_key = os.environ.get("HYPERBROWSER_API_KEY")
api_key = _env_text("HYPERBROWSER_API_KEY")
if api_key is None:
raise ValueError("HYPERBROWSER_API_KEY environment variable is required")

base_url = os.environ.get(
"HYPERBROWSER_BASE_URL", "https://api.hyperbrowser.ai"
)
return cls(api_key=api_key, base_url=base_url)
return cls.from_constructor(api_key=api_key)
Loading