diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d92fa916 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +BROWSERBASE_API_KEY=bb_live_your_api_key_here +BROWSERBASE_PROJECT_ID=your-bb-project-uuid-here +MODEL_API_KEY=sk-proj-your-llm-api-key-here \ No newline at end of file diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 790bf4c3..3ded761c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,28 +1,170 @@ # This workflow is triggered when a GitHub release is created. # It can also be run manually to re-publish to PyPI in case it failed for some reason. -# You can run this workflow by navigating to https://www.github.com/browserbase/stagehand-python/actions/workflows/publish-pypi.yml name: Publish PyPI + on: workflow_dispatch: + inputs: + stagehand_tag: + description: "Stagehand repo git ref to build SEA binaries from (e.g. @browserbasehq/stagehand@3.0.6)" + required: true + type: string release: types: [published] jobs: + build_wheels: + name: build wheels (${{ matrix.binary_name }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + binary_name: stagehand-linux-x64 + output_path: src/stagehand/_sea/stagehand-linux-x64 + wheel_platform_tag: manylinux2014_x86_64 + - os: macos-latest + binary_name: stagehand-darwin-arm64 + output_path: src/stagehand/_sea/stagehand-darwin-arm64 + wheel_platform_tag: macosx_11_0_arm64 + - os: macos-13 + binary_name: stagehand-darwin-x64 + output_path: src/stagehand/_sea/stagehand-darwin-x64 + wheel_platform_tag: macosx_10_9_x86_64 + - os: windows-latest + binary_name: stagehand-win32-x64.exe + output_path: src/stagehand/_sea/stagehand-win32-x64.exe + wheel_platform_tag: win_amd64 + + runs-on: ${{ matrix.os }} + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.9.13" + + - name: Checkout stagehand (server source) + uses: actions/checkout@v4 + with: + repository: browserbase/stagehand + ref: ${{ inputs.stagehand_tag || vars.STAGEHAND_TAG }} + path: _stagehand + fetch-depth: 1 + # If browserbase/stagehand is private, set STAGEHAND_SOURCE_TOKEN (PAT) in this repo. + token: ${{ secrets.STAGEHAND_SOURCE_TOKEN || github.token }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "23" + cache: "pnpm" + cache-dependency-path: _stagehand/pnpm-lock.yaml + + - name: Build SEA server binary (from source) + shell: bash + run: | + set -euo pipefail + + if [[ -z "${{ inputs.stagehand_tag }}" && -z "${{ vars.STAGEHAND_TAG }}" ]]; then + echo "Missing stagehand ref: set repo variable STAGEHAND_TAG or provide workflow input stagehand_tag." >&2 + exit 1 + fi + + # Ensure we only ship the binary built for this runner's OS/arch. + python - <<'PY' + from pathlib import Path + sea_dir = Path("src/stagehand/_sea") + sea_dir.mkdir(parents=True, exist_ok=True) + for p in sea_dir.glob("stagehand-*"): + p.unlink(missing_ok=True) + for p in sea_dir.glob("*.exe"): + p.unlink(missing_ok=True) + PY + + pushd _stagehand >/dev/null + pnpm install --frozen-lockfile + CI=true pnpm --filter @browserbasehq/stagehand-server build:binary + popd >/dev/null + + cp "_stagehand/packages/server/dist/sea/${{ matrix.binary_name }}" "${{ matrix.output_path }}" + chmod +x "${{ matrix.output_path }}" 2>/dev/null || true + + - name: Build wheel + env: + STAGEHAND_WHEEL_TAG: py3-none-${{ matrix.wheel_platform_tag }} + run: uv build --wheel + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: wheel-${{ matrix.binary_name }} + path: dist/*.whl + retention-days: 7 + + build_sdist: + name: build sdist + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.9.13" + + - name: Build sdist + run: uv build --sdist + + - name: Upload sdist artifact + uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist/*.tar.gz + retention-days: 7 + publish: name: publish + needs: [build_wheels, build_sdist] runs-on: ubuntu-latest - + permissions: + contents: read steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v5 with: - version: '0.9.13' + version: "0.9.13" - - name: Publish to PyPI + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Flatten dist directory + shell: bash run: | - bash ./bin/publish-pypi + set -euo pipefail + mkdir -p dist_out + find dist -type f \( -name "*.whl" -o -name "*.tar.gz" \) -print0 | while IFS= read -r -d '' f; do + cp -f "$f" dist_out/ + done + ls -la dist_out + + - name: Publish to PyPI env: PYPI_TOKEN: ${{ secrets.STAGEHAND_PYPI_TOKEN || secrets.PYPI_TOKEN }} + run: | + set -euo pipefail + uv publish --token="$PYPI_TOKEN" dist_out/* diff --git a/.gitignore b/.gitignore index 95ceb189..433374d5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ dist .venv .idea +.DS_Store +src/stagehand/_sea/stagehand-* +src/stagehand/_sea/*.exe +bin/sea/ .env .envrc codegen.log diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3d2ac0bd..10f30916 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0" + ".": "0.2.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 5a89fb75..f4f95e84 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 7 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-ed52466945f2f8dfd3814a29e948d7bf30af7b76a7a7689079c03b8baf64e26f.yml -openapi_spec_hash: 5d57aaf2362b0d882372dbf76477ba23 -config_hash: 989ddfee371586e9156b4d484ec0a6cc +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-39cd9547d16412cf0568f6ce2ad8d43805dffe65bde830beeff630b903ae3b38.yml +openapi_spec_hash: 9cd7c9fefa686f9711392782d948470f +config_hash: 1f709f8775e13029dc60064ef3a94355 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f8ef786..49f49c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.2.0 (2026-01-06) + +Full Changelog: [v0.1.0...v0.2.0](https://github.com/browserbase/stagehand-python/compare/v0.1.0...v0.2.0) + +### Features + +* Added optional param to force empty object ([b15e097](https://github.com/browserbase/stagehand-python/commit/b15e0976bc356e0ce09b331705ccd2b8805e1bfa)) +* **api:** manual updates ([5a3f419](https://github.com/browserbase/stagehand-python/commit/5a3f419522d49d132c4a75bf310eef1d9695a5a4)) + + +### Documentation + +* prominently feature MCP server setup in root SDK readmes ([d5a8361](https://github.com/browserbase/stagehand-python/commit/d5a83610cd39ccdecc1825d67a56ab2835d9651f)) + ## 0.1.0 (2025-12-23) Full Changelog: [v0.0.1...v0.1.0](https://github.com/browserbase/stagehand-python/compare/v0.0.1...v0.1.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f29de26b..fa4edea5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,47 @@ Most of the SDK is generated code. Modifications to code will be persisted betwe result in merge conflicts between manual patches and changes from the generator. The generator will never modify the contents of the `src/stagehand/lib/` and `examples/` directories. +## Setting up the local server binary (for development) + +The SDK supports running a local Stagehand server for development and testing. To use this feature, you need to download the appropriate binary for your platform. + +### Quick setup + +Run the download script to automatically download the correct binary: + +```sh +$ uv run python scripts/download-binary.py +``` + +This will: +- Detect your platform (macOS, Linux, Windows) and architecture (x64, arm64) +- Download the latest stagehand-server binary from GitHub releases +- Place it in `bin/sea/` where the SDK expects to find it + +### Manual download (alternative) + +You can also manually download from [GitHub releases](https://github.com/browserbase/stagehand/releases): + +1. Find the latest `stagehand/server vX.X.X` release +2. Download the binary for your platform: + - macOS ARM: `stagehand-server-darwin-arm64` + - macOS Intel: `stagehand-server-darwin-x64` + - Linux: `stagehand-server-linux-x64` or `stagehand-server-linux-arm64` + - Windows: `stagehand-server-win32-x64.exe` or `stagehand-server-win32-arm64.exe` +3. Rename it to match the expected format (remove `-server` from the name): + - `stagehand-darwin-arm64`, `stagehand-linux-x64`, `stagehand-win32-x64.exe`, etc. +4. Place it in `bin/sea/` directory +5. Make it executable (Unix only): `chmod +x bin/sea/stagehand-*` + +### Using an environment variable (optional) + +Instead of placing the binary in `bin/sea/`, you can point to any binary location: + +```sh +$ export STAGEHAND_SEA_BINARY=/path/to/your/stagehand-binary +$ uv run python test_local_mode.py +``` + ## Adding and running examples All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. diff --git a/LICENSE b/LICENSE index 6b24314a..d15d0212 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Stagehand + Copyright 2026 Stagehand Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 6527e46a..4e58c8a8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,15 @@ and offers both synchronous and asynchronous clients powered by [httpx](https:// It is generated with [Stainless](https://www.stainless.com/). +## MCP Server + +Use the Stagehand MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application. + +[![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en-US/install-mcp?name=stagehand-mcp&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInN0YWdlaGFuZC1tY3AiXX0) +[![Install in VS Code](https://img.shields.io/badge/_-Add_to_VS_Code-blue?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCA0MCA0MCI+PHBhdGggZmlsbD0iI0VFRSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNMzAuMjM1IDM5Ljg4NGEyLjQ5MSAyLjQ5MSAwIDAgMS0xLjc4MS0uNzNMMTIuNyAyNC43OGwtMy40NiAyLjYyNC0zLjQwNiAyLjU4MmExLjY2NSAxLjY2NSAwIDAgMS0xLjA4Mi4zMzggMS42NjQgMS42NjQgMCAwIDEtMS4wNDYtLjQzMWwtMi4yLTJhMS42NjYgMS42NjYgMCAwIDEgMC0yLjQ2M0w3LjQ1OCAyMCA0LjY3IDE3LjQ1MyAxLjUwNyAxNC41N2ExLjY2NSAxLjY2NSAwIDAgMSAwLTIuNDYzbDIuMi0yYTEuNjY1IDEuNjY1IDAgMCAxIDIuMTMtLjA5N2w2Ljg2MyA1LjIwOUwyOC40NTIuODQ0YTIuNDg4IDIuNDg4IDAgMCAxIDEuODQxLS43MjljLjM1MS4wMDkuNjk5LjA5MSAxLjAxOS4yNDVsOC4yMzYgMy45NjFhMi41IDIuNSAwIDAgMSAxLjQxNSAyLjI1M3YuMDk5LS4wNDVWMzMuMzd2LS4wNDUuMDk1YTIuNTAxIDIuNTAxIDAgMCAxLTEuNDE2IDIuMjU3bC04LjIzNSAzLjk2MWEyLjQ5MiAyLjQ5MiAwIDAgMS0xLjA3Ny4yNDZabS43MTYtMjguOTQ3LTExLjk0OCA5LjA2MiAxMS45NTIgOS4wNjUtLjAwNC0xOC4xMjdaIi8+PC9zdmc+)](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22stagehand-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22stagehand-mcp%22%5D%7D) + +> Note: You may need to set environment variables in your MCP client. + ## Documentation The REST API documentation can be found on [docs.stagehand.dev](https://docs.stagehand.dev). The full API of this library can be found in [api.md](api.md). @@ -20,35 +29,62 @@ The REST API documentation can be found on [docs.stagehand.dev](https://docs.sta pip install stagehand-alpha ``` +## Running the Example + +A complete working example is available at [`examples/full_example.py`](examples/full_example.py). + +To run it, first export the required environment variables, then use Python: + +```bash +export BROWSERBASE_API_KEY="your-bb-api-key" +export BROWSERBASE_PROJECT_ID="your-bb-project-uuid" +export MODEL_API_KEY="sk-proj-your-llm-api-key" + +python examples/full_example.py +``` + ## Usage The full API of this library can be found in [api.md](api.md). +## Client configuration + +Configure the client using environment variables: + +```python +from stagehand import Stagehand + +# Configures using the BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, and MODEL_API_KEY environment variables +client = Stagehand() +``` + +Or manually: + ```python -import os from stagehand import Stagehand client = Stagehand( - browserbase_api_key=os.environ.get( - "BROWSERBASE_API_KEY" - ), # This is the default and can be omitted - browserbase_project_id=os.environ.get( - "BROWSERBASE_PROJECT_ID" - ), # This is the default and can be omitted - model_api_key=os.environ.get("MODEL_API_KEY"), # This is the default and can be omitted + browserbase_api_key="My Browserbase API Key", + browserbase_project_id="My Browserbase Project ID", + model_api_key="My Model API Key", ) +``` -response = client.sessions.act( - id="00000000-your-session-id-000000000000", - input="click the first link on the page", +Or using a combination of the two approaches: + +```python +from stagehand import Stagehand + +client = Stagehand( + # Configures using environment variables + browserbase_api_key="My Browserbase API Key", # Override just this one ) -print(response.data) ``` -While you can provide a `browserbase_api_key` keyword argument, +While you can provide API keys as keyword arguments, we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/) to add `BROWSERBASE_API_KEY="My Browserbase API Key"` to your `.env` file -so that your Browserbase API Key is not stored in source control. +so that your API keys are not stored in source control. ## Async usage diff --git a/api.md b/api.md index d5ec2705..dae59737 100644 --- a/api.md +++ b/api.md @@ -20,7 +20,7 @@ from stagehand.types import ( Methods: - client.sessions.act(id, \*\*params) -> SessionActResponse -- client.sessions.end(id) -> SessionEndResponse +- client.sessions.end(id, \*\*params) -> SessionEndResponse - client.sessions.execute(id, \*\*params) -> SessionExecuteResponse - client.sessions.extract(id, \*\*params) -> SessionExtractResponse - client.sessions.navigate(id, \*\*params) -> SessionNavigateResponse diff --git a/examples/agent_execute.py b/examples/agent_execute.py new file mode 100644 index 00000000..643fbb0b --- /dev/null +++ b/examples/agent_execute.py @@ -0,0 +1,70 @@ +""" +Minimal example using the Sessions Agent Execute endpoint. + +Required environment variables: +- BROWSERBASE_API_KEY +- BROWSERBASE_PROJECT_ID +- MODEL_API_KEY + +Optional: +- STAGEHAND_MODEL (defaults to "openai/gpt-5-nano") + +Run from the repo root: + `PYTHONPATH=src .venv/bin/python examples/agent_execute_minimal.py` +""" + +import os +import json + +from stagehand import Stagehand, APIResponseValidationError + + +def main() -> None: + model_name = os.environ.get("STAGEHAND_MODEL", "openai/gpt-5-nano") + + # Enable strict response validation so we fail fast if the API response + # doesn't match the expected schema (instead of silently constructing models + # with missing fields set to None). + with Stagehand(_strict_response_validation=True) as client: + try: + session = client.sessions.start(model_name=model_name) + except APIResponseValidationError as e: + print("Session start response failed schema validation.") + print(f"Base URL: {client.base_url!r}") + print(f"HTTP status: {e.response.status_code}") + print("Raw response text:") + print(e.response.text) + print("Parsed response body:") + print(e.body) + raise + session_id = session.data.session_id + if not session_id: + raise RuntimeError(f"Expected a session ID from /sessions/start but received {session.to_dict()!r}") + + try: + client.sessions.navigate( + id=session_id, + url="https://news.ycombinator.com", + options={"wait_until": "domcontentloaded"}, + ) + + result = client.sessions.execute( + id=session_id, + agent_config={"model": model_name}, + execute_options={ + "instruction": "Go to Hacker News and return the titles of the first 3 articles.", + "max_steps": 5, + }, + ) + + print("Agent message:", result.data.result.message) + print("\nFull result:") + print(json.dumps(result.data.result.to_dict(), indent=2, default=str)) + finally: + # Only attempt cleanup if a valid session ID was created. + if session_id: + client.sessions.end(id=session_id) + + +if __name__ == "__main__": + main() diff --git a/examples/full_example.py b/examples/full_example.py new file mode 100644 index 00000000..16e6e860 --- /dev/null +++ b/examples/full_example.py @@ -0,0 +1,156 @@ +""" +Basic example demonstrating the Stagehand Python SDK. + +This example shows the full flow of: +1. Starting a browser session +2. Navigating to a webpage +3. Observing to find possible actions +4. Acting on an element +5. Extracting structured data +6. Running an autonomous agent +7. Ending the session + +Required environment variables: +- BROWSERBASE_API_KEY: Your Browserbase API key +- BROWSERBASE_PROJECT_ID: Your Browserbase project ID +- MODEL_API_KEY: Your OpenAI API key +""" + +import os + +from stagehand import Stagehand + + +def main() -> None: + # SDK version for API compatibility (matches TypeScript SDK v3) + SDK_VERSION = "3.0.6" + + # Create client using environment variables + # BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, MODEL_API_KEY + client = Stagehand( + browserbase_api_key=os.environ.get("BROWSERBASE_API_KEY"), + browserbase_project_id=os.environ.get("BROWSERBASE_PROJECT_ID"), + model_api_key=os.environ.get("MODEL_API_KEY"), + ) + + # Start a new browser session + start_response = client.sessions.start( + model_name="openai/gpt-5-nano", + x_language="typescript", + x_sdk_version=SDK_VERSION, + ) + + session_id = start_response.data.session_id + print(f"Session started: {session_id}") + + try: + # Navigate to Hacker News + client.sessions.navigate( + id=session_id, + url="https://news.ycombinator.com", + frame_id="", # Empty string for main frame + x_language="typescript", + x_sdk_version=SDK_VERSION, + ) + print("Navigated to Hacker News") + + # Observe to find possible actions - looking for the comments link + observe_response = client.sessions.observe( + id=session_id, + instruction="find the link to view comments for the top post", + x_language="typescript", + x_sdk_version=SDK_VERSION, + ) + + results = observe_response.data.result + print(f"Found {len(results)} possible actions") + + if not results: + print("No actions found") + return + + # Use the first result + result = results[0] + print(f"Acting on: {result.description}") + + # Pass the action to Act + act_response = client.sessions.act( + id=session_id, + input=result, # type: ignore[arg-type] + x_language="typescript", + x_sdk_version=SDK_VERSION, + ) + print(f"Act completed: {act_response.data.result.message}") + + # Extract data from the page + # We're now on the comments page, so extract the top comment text + extract_response = client.sessions.extract( + id=session_id, + instruction="extract the text of the top comment on this page", + schema={ + "type": "object", + "properties": { + "commentText": { + "type": "string", + "description": "The text content of the top comment" + }, + "author": { + "type": "string", + "description": "The username of the comment author" + } + }, + "required": ["commentText"] + }, + x_language="typescript", + x_sdk_version=SDK_VERSION, + ) + + # Get the extracted result + extracted_result = extract_response.data.result + print(f"Extracted data: {extracted_result}") + + # Get the author from the extracted data + author: str = extracted_result.get("author", "unknown") if isinstance(extracted_result, dict) else "unknown" # type: ignore[union-attr] + print(f"Looking up profile for author: {author}") + + # Use the Agent to find the author's profile + # Execute runs an autonomous agent that can navigate and interact with pages + # Use a longer timeout (5 minutes) since agent execution can take a while + execute_response = client.sessions.execute( # pyright: ignore[reportArgumentType] + id=session_id, + execute_options={ + "instruction": ( + f"Find any personal website, GitHub, LinkedIn, or other best profile URL for the Hacker News user '{author}'. " + f"Click on their username to go to their profile page and look for any links they have shared. " + f"Use Google Search with their username or other details from their profile if you dont find any direct links." + ), + "max_steps": 15, + }, + agent_config={ + "model": { + "model_name": "openai/gpt-5-nano", + "api_key": os.environ.get("MODEL_API_KEY"), + }, + "cua": False, + }, + x_language="typescript", + x_sdk_version=SDK_VERSION, + timeout=300.0, # 5 minutes + ) + + print(f"Agent completed: {execute_response.data.result.message}") + print(f"Agent success: {execute_response.data.result.success}") + print(f"Agent actions taken: {len(execute_response.data.result.actions)}") + + finally: + # End the session to clean up resources + client.sessions.end( + id=session_id, + x_language="typescript", + x_sdk_version=SDK_VERSION, + ) + print("Session ended") + + +if __name__ == "__main__": + main() diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 00000000..d96a9596 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +def _infer_platform_tag() -> str: + from packaging.tags import sys_tags + + # Linux tag is after many/musl; skip those to get the generic platform tag. + tag = next(iter(t for t in sys_tags() if "manylinux" not in t.platform and "musllinux" not in t.platform)) + return tag.platform + + +def _has_embedded_sea_binaries() -> bool: + sea_dir = Path(__file__).resolve().parent / "src" / "stagehand" / "_sea" + if not sea_dir.exists(): + return False + + for path in sea_dir.iterdir(): + if not path.is_file(): + continue + if path.name in {".keep"}: + continue + if path.name.startswith("."): + continue + return True + + return False + + +class CustomBuildHook(BuildHookInterface): + def initialize(self, _version: str, build_data: dict) -> None: + if not _has_embedded_sea_binaries(): + return + + # We are bundling a platform-specific executable, so this must not be a + # "pure python" wheel. + build_data["pure_python"] = False + + # CI sets this so we get deterministic wheel tags that match the SEA + # artifact we're embedding (e.g. "py3-none-macosx_11_0_arm64"). + wheel_tag = os.environ.get("STAGEHAND_WHEEL_TAG", "").strip() + if wheel_tag: + if wheel_tag.count("-") != 2: + raise ValueError( + "Invalid STAGEHAND_WHEEL_TAG. Expected a full wheel tag like 'py3-none-macosx_11_0_arm64'." + ) + build_data["tag"] = wheel_tag + build_data["infer_tag"] = False + else: + # For local builds, infer just the platform portion so the wheel + # remains Python-version agnostic (our embedded server binary is not + # tied to a specific Python ABI). + build_data["tag"] = f"py3-none-{_infer_platform_tag()}" + build_data["infer_tag"] = False diff --git a/pyproject.toml b/pyproject.toml index eeb79abe..b16d60f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stagehand-alpha" -version = "0.1.0" +version = "0.2.0" description = "The official Python library for the stagehand API" dynamic = ["readme"] license = "Apache-2.0" @@ -77,7 +77,7 @@ pydantic-v2 = [ ] [build-system] -requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme", "packaging"] build-backend = "hatchling.build" [tool.hatch.build] @@ -85,6 +85,9 @@ include = [ "src/*" ] +[tool.hatch.build.hooks.custom] +path = "hatch_build.py" + [tool.hatch.build.targets.wheel] packages = ["src/stagehand"] @@ -136,6 +139,10 @@ exclude = [ ".venv", ".nox", ".git", + "hatch_build.py", + "examples", + "scripts", + "test_local_mode.py", ] reportImplicitOverride = true @@ -154,7 +161,7 @@ show_error_codes = true # # We also exclude our `tests` as mypy doesn't always infer # types correctly and Pyright will still catch any type errors. -exclude = ['src/stagehand/_files.py', '_dev/.*.py', 'tests/.*'] +exclude = ['src/stagehand/_files.py', '_dev/.*.py', 'tests/.*', 'hatch_build.py', 'examples/.*', 'scripts/.*', 'test_local_mode.py'] strict_equality = true implicit_reexport = true diff --git a/scripts/download-binary.py b/scripts/download-binary.py new file mode 100755 index 00000000..026d037a --- /dev/null +++ b/scripts/download-binary.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Download the stagehand-server binary for local development. + +This script downloads the appropriate binary for your platform from GitHub releases +and places it in bin/sea/ for use during development and testing. + +Usage: + python scripts/download-binary.py [--version VERSION] + +Examples: + python scripts/download-binary.py + python scripts/download-binary.py --version v3.2.0 +""" +from __future__ import annotations + +import sys +import argparse +import platform +import urllib.error +import urllib.request +from pathlib import Path + + +def get_platform_info() -> tuple[str, str]: + """Determine platform and architecture.""" + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "darwin": + plat = "darwin" + elif system == "windows": + plat = "win32" + else: + plat = "linux" + + arch = "arm64" if machine in ("arm64", "aarch64") else "x64" + return plat, arch + + +def get_binary_filename(plat: str, arch: str) -> str: + """Get the expected binary filename for this platform.""" + name = f"stagehand-server-{plat}-{arch}" + return name + (".exe" if plat == "win32" else "") + + +def get_local_filename(plat: str, arch: str) -> str: + """Get the local filename (what the code expects to find).""" + name = f"stagehand-{plat}-{arch}" + return name + (".exe" if plat == "win32" else "") + + +def download_binary(version: str) -> None: + """Download the binary for the current platform.""" + plat, arch = get_platform_info() + binary_filename = get_binary_filename(plat, arch) + local_filename = get_local_filename(plat, arch) + + # GitHub release URL + repo = "browserbase/stagehand" + tag = version if version.startswith("stagehand-server/v") else f"stagehand-server/{version}" + url = f"https://github.com/{repo}/releases/download/{tag}/{binary_filename}" + + # Destination path + repo_root = Path(__file__).parent.parent + dest_dir = repo_root / "bin" / "sea" + dest_dir.mkdir(parents=True, exist_ok=True) + dest_path = dest_dir / local_filename + + if dest_path.exists(): + print(f"āœ“ Binary already exists: {dest_path}") + response = input(" Overwrite? [y/N]: ").strip().lower() + if response != "y": + print(" Skipping download.") + return + + print(f"šŸ“¦ Downloading binary for {plat}-{arch}...") + print(f" From: {url}") + print(f" To: {dest_path}") + + try: + # Download with progress + def reporthook(block_num: int, block_size: int, total_size: int) -> None: + downloaded = block_num * block_size + if total_size > 0: + percent = min(downloaded * 100 / total_size, 100) + mb_downloaded = downloaded / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + print(f"\r Progress: {percent:.1f}% ({mb_downloaded:.1f}/{mb_total:.1f} MB)", end="") + + urllib.request.urlretrieve(url, dest_path, reporthook) # type: ignore[arg-type] + print() # New line after progress + + # Make executable on Unix + if plat != "win32": + import os + os.chmod(dest_path, 0o755) + + size_mb = dest_path.stat().st_size / (1024 * 1024) + print(f"āœ… Downloaded successfully: {dest_path} ({size_mb:.1f} MB)") + print(f"\nšŸ’” You can now run: uv run python test_local_mode.py") + + except urllib.error.HTTPError as e: # type: ignore[misc] + print(f"\nāŒ Error: Failed to download binary (HTTP {e.code})") # type: ignore[union-attr] + print(f" URL: {url}") + print(f"\n Available releases at: https://github.com/{repo}/releases") + sys.exit(1) + except Exception as e: + print(f"\nāŒ Error: {e}") + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Download stagehand-server binary for local development", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python scripts/download-binary.py + python scripts/download-binary.py --version v3.2.0 + python scripts/download-binary.py --version stagehand-server/v3.2.0 + """, + ) + parser.add_argument( + "--version", + default="v3.2.0", + help="Version to download (default: v3.2.0)", + ) + + args = parser.parse_args() + download_binary(args.version) + + +if __name__ == "__main__": + main() diff --git a/src/stagehand/_base_client.py b/src/stagehand/_base_client.py index 921ca2f7..a415befe 100644 --- a/src/stagehand/_base_client.py +++ b/src/stagehand/_base_client.py @@ -1303,7 +1303,7 @@ def __init__(self, **kwargs: Any) -> None: try: - import httpx_aiohttp + import httpx_aiohttp # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] except ImportError: class _DefaultAioHttpClient(httpx.AsyncClient): @@ -1317,7 +1317,7 @@ def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) - super().__init__(**kwargs) + super().__init__(**kwargs) # pyright: ignore[reportUnknownMemberType] if TYPE_CHECKING: diff --git a/src/stagehand/_client.py b/src/stagehand/_client.py index 9f93e242..481c9599 100644 --- a/src/stagehand/_client.py +++ b/src/stagehand/_client.py @@ -4,7 +4,7 @@ import os from typing import TYPE_CHECKING, Any, Mapping -from typing_extensions import Self, override +from typing_extensions import Self, Literal, override import httpx @@ -21,6 +21,7 @@ ) from ._utils import is_given, get_async_library from ._compat import cached_property +from ._models import FinalRequestOptions from ._version import __version__ from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, StagehandError @@ -29,6 +30,7 @@ SyncAPIClient, AsyncAPIClient, ) +from .lib.sea_server import SeaServerConfig, SeaServerManager if TYPE_CHECKING: from .resources import sessions @@ -58,6 +60,14 @@ def __init__( browserbase_api_key: str | None = None, browserbase_project_id: str | None = None, model_api_key: str | None = None, + server: Literal["remote", "local"] = "remote", + local_binary_path: str | os.PathLike[str] | None = None, + local_host: str = "127.0.0.1", + local_port: int = 0, + local_headless: bool = True, + local_ready_timeout_s: float = 10.0, + local_openai_api_key: str | None = None, + local_shutdown_on_close: bool = True, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, @@ -84,6 +94,15 @@ def __init__( - `browserbase_project_id` from `BROWSERBASE_PROJECT_ID` - `model_api_key` from `MODEL_API_KEY` """ + self._server_mode: Literal["remote", "local"] = server + self._local_binary_path = local_binary_path + self._local_host = local_host + self._local_port = local_port + self._local_headless = local_headless + self._local_ready_timeout_s = local_ready_timeout_s + self._local_openai_api_key = local_openai_api_key + self._local_shutdown_on_close = local_shutdown_on_close + if browserbase_api_key is None: browserbase_api_key = os.environ.get("BROWSERBASE_API_KEY") if browserbase_api_key is None: @@ -108,10 +127,29 @@ def __init__( ) self.model_api_key = model_api_key - if base_url is None: - base_url = os.environ.get("STAGEHAND_BASE_URL") - if base_url is None: - base_url = f"https://api.stagehand.browserbase.com/v1" + self._sea_server: SeaServerManager | None = None + if server == "local": + # We'll switch `base_url` to the started server before the first request. + if base_url is None: + base_url = "http://127.0.0.1" + + openai_api_key = local_openai_api_key or os.environ.get("OPENAI_API_KEY") or model_api_key + self._sea_server = SeaServerManager( + config=SeaServerConfig( + host=local_host, + port=local_port, + headless=local_headless, + ready_timeout_s=local_ready_timeout_s, + openai_api_key=openai_api_key, + shutdown_on_close=local_shutdown_on_close, + ), + local_binary_path=local_binary_path, + ) + else: + if base_url is None: + base_url = os.environ.get("STAGEHAND_BASE_URL") + if base_url is None: + base_url = f"https://api.stagehand.browserbase.com/v1" super().__init__( version=__version__, @@ -126,6 +164,20 @@ def __init__( self._default_stream_cls = Stream + @override + def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: + if self._sea_server is not None: + self.base_url = self._sea_server.ensure_running_sync() + return super()._prepare_options(options) + + @override + def close(self) -> None: + try: + super().close() + finally: + if self._sea_server is not None: + self._sea_server.close() + @cached_property def sessions(self) -> SessionsResource: from .resources.sessions import SessionsResource @@ -170,6 +222,7 @@ def _llm_model_api_key_auth(self) -> dict[str, str]: def default_headers(self) -> dict[str, str | Omit]: return { **super().default_headers, + "x-language": "python", "X-Stainless-Async": "false", **self._custom_headers, } @@ -180,6 +233,14 @@ def copy( browserbase_api_key: str | None = None, browserbase_project_id: str | None = None, model_api_key: str | None = None, + server: Literal["remote", "local"] | None = None, + local_binary_path: str | os.PathLike[str] | None = None, + local_host: str | None = None, + local_port: int | None = None, + local_headless: bool | None = None, + local_ready_timeout_s: float | None = None, + local_openai_api_key: str | None = None, + local_shutdown_on_close: bool | None = None, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, @@ -216,6 +277,20 @@ def copy( browserbase_api_key=browserbase_api_key or self.browserbase_api_key, browserbase_project_id=browserbase_project_id or self.browserbase_project_id, model_api_key=model_api_key or self.model_api_key, + server=server or self._server_mode, + local_binary_path=local_binary_path if local_binary_path is not None else self._local_binary_path, + local_host=local_host or self._local_host, + local_port=local_port if local_port is not None else self._local_port, + local_headless=local_headless if local_headless is not None else self._local_headless, + local_ready_timeout_s=local_ready_timeout_s + if local_ready_timeout_s is not None + else self._local_ready_timeout_s, + local_openai_api_key=local_openai_api_key + if local_openai_api_key is not None + else self._local_openai_api_key, + local_shutdown_on_close=local_shutdown_on_close + if local_shutdown_on_close is not None + else self._local_shutdown_on_close, base_url=base_url or self.base_url, timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, http_client=http_client, @@ -275,6 +350,14 @@ def __init__( browserbase_api_key: str | None = None, browserbase_project_id: str | None = None, model_api_key: str | None = None, + server: Literal["remote", "local"] = "remote", + local_binary_path: str | os.PathLike[str] | None = None, + local_host: str = "127.0.0.1", + local_port: int = 0, + local_headless: bool = True, + local_ready_timeout_s: float = 10.0, + local_openai_api_key: str | None = None, + local_shutdown_on_close: bool = True, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, @@ -301,6 +384,15 @@ def __init__( - `browserbase_project_id` from `BROWSERBASE_PROJECT_ID` - `model_api_key` from `MODEL_API_KEY` """ + self._server_mode: Literal["remote", "local"] = server + self._local_binary_path = local_binary_path + self._local_host = local_host + self._local_port = local_port + self._local_headless = local_headless + self._local_ready_timeout_s = local_ready_timeout_s + self._local_openai_api_key = local_openai_api_key + self._local_shutdown_on_close = local_shutdown_on_close + if browserbase_api_key is None: browserbase_api_key = os.environ.get("BROWSERBASE_API_KEY") if browserbase_api_key is None: @@ -325,10 +417,28 @@ def __init__( ) self.model_api_key = model_api_key - if base_url is None: - base_url = os.environ.get("STAGEHAND_BASE_URL") - if base_url is None: - base_url = f"https://api.stagehand.browserbase.com/v1" + self._sea_server: SeaServerManager | None = None + if server == "local": + if base_url is None: + base_url = "http://127.0.0.1" + + openai_api_key = local_openai_api_key or os.environ.get("OPENAI_API_KEY") or model_api_key + self._sea_server = SeaServerManager( + config=SeaServerConfig( + host=local_host, + port=local_port, + headless=local_headless, + ready_timeout_s=local_ready_timeout_s, + openai_api_key=openai_api_key, + shutdown_on_close=local_shutdown_on_close, + ), + local_binary_path=local_binary_path, + ) + else: + if base_url is None: + base_url = os.environ.get("STAGEHAND_BASE_URL") + if base_url is None: + base_url = f"https://api.stagehand.browserbase.com/v1" super().__init__( version=__version__, @@ -343,6 +453,20 @@ def __init__( self._default_stream_cls = AsyncStream + @override + async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: + if self._sea_server is not None: + self.base_url = await self._sea_server.ensure_running_async() + return await super()._prepare_options(options) + + @override + async def close(self) -> None: + try: + await super().close() + finally: + if self._sea_server is not None: + await self._sea_server.aclose() + @cached_property def sessions(self) -> AsyncSessionsResource: from .resources.sessions import AsyncSessionsResource @@ -387,6 +511,7 @@ def _llm_model_api_key_auth(self) -> dict[str, str]: def default_headers(self) -> dict[str, str | Omit]: return { **super().default_headers, + "x-language": "python", "X-Stainless-Async": f"async:{get_async_library()}", **self._custom_headers, } @@ -397,6 +522,14 @@ def copy( browserbase_api_key: str | None = None, browserbase_project_id: str | None = None, model_api_key: str | None = None, + server: Literal["remote", "local"] | None = None, + local_binary_path: str | os.PathLike[str] | None = None, + local_host: str | None = None, + local_port: int | None = None, + local_headless: bool | None = None, + local_ready_timeout_s: float | None = None, + local_openai_api_key: str | None = None, + local_shutdown_on_close: bool | None = None, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, @@ -433,6 +566,20 @@ def copy( browserbase_api_key=browserbase_api_key or self.browserbase_api_key, browserbase_project_id=browserbase_project_id or self.browserbase_project_id, model_api_key=model_api_key or self.model_api_key, + server=server or self._server_mode, + local_binary_path=local_binary_path if local_binary_path is not None else self._local_binary_path, + local_host=local_host or self._local_host, + local_port=local_port if local_port is not None else self._local_port, + local_headless=local_headless if local_headless is not None else self._local_headless, + local_ready_timeout_s=local_ready_timeout_s + if local_ready_timeout_s is not None + else self._local_ready_timeout_s, + local_openai_api_key=local_openai_api_key + if local_openai_api_key is not None + else self._local_openai_api_key, + local_shutdown_on_close=local_shutdown_on_close + if local_shutdown_on_close is not None + else self._local_shutdown_on_close, base_url=base_url or self.base_url, timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, http_client=http_client, diff --git a/src/stagehand/_sea/.keep b/src/stagehand/_sea/.keep new file mode 100644 index 00000000..e69de29b diff --git a/src/stagehand/_utils/_utils.py b/src/stagehand/_utils/_utils.py index eec7f4a1..1c50ff6a 100644 --- a/src/stagehand/_utils/_utils.py +++ b/src/stagehand/_utils/_utils.py @@ -295,7 +295,7 @@ def strip_not_given(obj: None) -> None: ... @overload -def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> dict[_K, _V]: ... +def strip_not_given(obj: Mapping[_K, _V | NotGiven | Omit]) -> dict[_K, _V]: ... @overload @@ -303,14 +303,14 @@ def strip_not_given(obj: object) -> object: ... def strip_not_given(obj: object | None) -> object: - """Remove all top-level keys where their values are instances of `NotGiven`""" + """Remove all top-level keys where their values are `not_given` or `omit`.""" if obj is None: return None if not is_mapping(obj): return obj - return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)} + return {key: value for key, value in obj.items() if is_given(value)} def coerce_integer(val: str) -> int: diff --git a/src/stagehand/_version.py b/src/stagehand/_version.py index a094f3ed..5772616e 100644 --- a/src/stagehand/_version.py +++ b/src/stagehand/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "stagehand" -__version__ = "0.1.0" # x-release-please-version +__version__ = "0.2.0" # x-release-please-version diff --git a/src/stagehand/lib/__init__.py b/src/stagehand/lib/__init__.py new file mode 100644 index 00000000..60fb7e11 --- /dev/null +++ b/src/stagehand/lib/__init__.py @@ -0,0 +1,11 @@ +"""SEA binary and server management.""" + +from .sea_binary import resolve_binary_path, default_binary_filename +from .sea_server import SeaServerConfig, SeaServerManager + +__all__ = [ + "resolve_binary_path", + "default_binary_filename", + "SeaServerConfig", + "SeaServerManager", +] diff --git a/src/stagehand/lib/sea_binary.py b/src/stagehand/lib/sea_binary.py new file mode 100644 index 00000000..fcfd1415 --- /dev/null +++ b/src/stagehand/lib/sea_binary.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import os +import sys +import hashlib +import platform +import importlib.resources as importlib_resources +from pathlib import Path +from contextlib import suppress + + +def _platform_tag() -> tuple[str, str]: + plat = "win32" if sys.platform.startswith("win") else ("darwin" if sys.platform == "darwin" else "linux") + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "x64" + return plat, arch + + +def default_binary_filename() -> str: + plat, arch = _platform_tag() + name = f"stagehand-{plat}-{arch}" + return name + (".exe" if plat == "win32" else "") + + +def _cache_dir() -> Path: + # Avoid extra deps (e.g. platformdirs) for now. + if sys.platform == "darwin": + root = Path.home() / "Library" / "Caches" + elif sys.platform.startswith("win"): + root = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) + else: + root = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) + return root / "stagehand" / "sea" + + +def _ensure_executable(path: Path) -> None: + if sys.platform.startswith("win"): + return + with suppress(OSError): + mode = path.stat().st_mode + path.chmod(mode | 0o100) + + +def _resource_binary_path(filename: str) -> Path | None: + # Expect binaries to live at stagehand/_sea/ inside the installed package. + try: + root = importlib_resources.files("stagehand") + except Exception: + return None + + candidate = root.joinpath("_sea").joinpath(filename) + try: + if not candidate.is_file(): + return None + except Exception: + return None + + with importlib_resources.as_file(candidate) as extracted: + return extracted + + +def _copy_to_cache(*, src: Path, filename: str, version: str) -> Path: + cache_root = _cache_dir() / version + cache_root.mkdir(parents=True, exist_ok=True) + dst = cache_root / filename + + if dst.exists(): + _ensure_executable(dst) + return dst + + data = src.read_bytes() + tmp = cache_root / f".{filename}.{hashlib.sha256(data).hexdigest()}.tmp" + tmp.write_bytes(data) + tmp.replace(dst) + _ensure_executable(dst) + return dst + + +def resolve_binary_path( + *, + local_binary_path: str | os.PathLike[str] | None = None, + version: str | None = None, +) -> Path: + if local_binary_path is not None: + path = Path(local_binary_path) + _ensure_executable(path) + return path + + env = os.environ.get("STAGEHAND_SEA_BINARY") + if env: + path = Path(env) + _ensure_executable(path) + return path + + filename = default_binary_filename() + + # Prefer packaged resources (works for wheel installs). + resource_path = _resource_binary_path(filename) + if resource_path is not None: + # Best-effort versioning to keep cached binaries stable across upgrades. + if version is None: + version = os.environ.get("STAGEHAND_VERSION", "dev") + return _copy_to_cache(src=resource_path, filename=filename, version=version) + + # Fallback: source checkout layout (works for local dev in-repo). + here = Path(__file__).resolve() + repo_root = here.parents[3] # stagehand-python/ + candidate = repo_root / "bin" / "sea" / filename + + if not candidate.exists(): + raise FileNotFoundError( + f"Stagehand SEA binary not found at {candidate}.\n" + f"For local development, download the binary using:\n" + f" uv run python scripts/download-binary.py\n" + f"Or set the STAGEHAND_SEA_BINARY environment variable to point to your binary.\n" + f"For production use, install a platform-specific wheel from PyPI.\n" + f"See: https://github.com/browserbase/stagehand-python#local-development" + ) + + _ensure_executable(candidate) + return candidate diff --git a/src/stagehand/lib/sea_server.py b/src/stagehand/lib/sea_server.py new file mode 100644 index 00000000..ad298a43 --- /dev/null +++ b/src/stagehand/lib/sea_server.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import os +import sys +import time +import atexit +import signal +import socket +import asyncio +import subprocess +from pathlib import Path +from threading import Lock +from dataclasses import dataclass + +import httpx + +from .._version import __version__ +from .sea_binary import resolve_binary_path + + +@dataclass(frozen=True) +class SeaServerConfig: + host: str + port: int + headless: bool + ready_timeout_s: float + openai_api_key: str | None + shutdown_on_close: bool + + +def _pick_free_port(host: str) -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((host, 0)) + return int(sock.getsockname()[1]) + + +def _build_base_url(*, host: str, port: int) -> str: + return f"http://{host}:{port}" + + +def _terminate_process(proc: subprocess.Popen[bytes]) -> None: + if proc.poll() is not None: + return + + try: + if sys.platform != "win32": + os.killpg(proc.pid, signal.SIGTERM) + else: + proc.terminate() + proc.wait(timeout=3) + return + except Exception: + pass + + try: + if sys.platform != "win32": + os.killpg(proc.pid, signal.SIGKILL) + else: + proc.kill() + finally: + try: + proc.wait(timeout=3) + except Exception: + pass + + +def _wait_ready_sync(*, base_url: str, timeout_s: float) -> None: + deadline = time.monotonic() + timeout_s + with httpx.Client(timeout=1.0) as client: + while time.monotonic() < deadline: + try: + # stagehand-binary: /health + # stagehand/packages/server: /readyz and /healthz + for path in ("/readyz", "/healthz", "/health"): + resp = client.get(f"{base_url}{path}") + if resp.status_code == 200: + return + except httpx.HTTPError: + pass + time.sleep(0.1) + raise TimeoutError(f"Stagehand SEA server not ready at {base_url} after {timeout_s}s") + + +async def _wait_ready_async(*, base_url: str, timeout_s: float) -> None: + deadline = time.monotonic() + timeout_s + async with httpx.AsyncClient(timeout=1.0) as client: + while time.monotonic() < deadline: + try: + for path in ("/readyz", "/healthz", "/health"): + resp = await client.get(f"{base_url}{path}") + if resp.status_code == 200: + return + except httpx.HTTPError: + pass + await asyncio.sleep(0.1) + raise TimeoutError(f"Stagehand SEA server not ready at {base_url} after {timeout_s}s") + + +class SeaServerManager: + def __init__( + self, + *, + config: SeaServerConfig, + local_binary_path: str | os.PathLike[str] | None = None, + ) -> None: + self._config = config + self._binary_path: Path = resolve_binary_path(local_binary_path=local_binary_path, version=__version__) + + self._lock = Lock() + self._async_lock = asyncio.Lock() + + self._proc: subprocess.Popen[bytes] | None = None + self._base_url: str | None = None + self._atexit_registered: bool = False + + @property + def base_url(self) -> str | None: + return self._base_url + + def ensure_running_sync(self) -> str: + with self._lock: + if self._proc is not None and self._proc.poll() is None and self._base_url is not None: + return self._base_url + + base_url, proc = self._start_sync() + self._base_url = base_url + self._proc = proc + return base_url + + async def ensure_running_async(self) -> str: + async with self._async_lock: + if self._proc is not None and self._proc.poll() is None and self._base_url is not None: + return self._base_url + + base_url, proc = await self._start_async() + self._base_url = base_url + self._proc = proc + return base_url + + def close(self) -> None: + if not self._config.shutdown_on_close: + return + + with self._lock: + if self._proc is None: + return + _terminate_process(self._proc) + self._proc = None + self._base_url = None + + async def aclose(self) -> None: + if not self._config.shutdown_on_close: + return + + async with self._async_lock: + if self._proc is None: + return + _terminate_process(self._proc) + self._proc = None + self._base_url = None + + def _start_sync(self) -> tuple[str, subprocess.Popen[bytes]]: + if not self._binary_path.exists(): + raise FileNotFoundError( + f"Stagehand SEA binary not found at {self._binary_path}. " + "Pass local_binary_path=... or set STAGEHAND_SEA_BINARY." + ) + + port = _pick_free_port(self._config.host) if self._config.port == 0 else self._config.port + base_url = _build_base_url(host=self._config.host, port=port) + + proc_env = dict(os.environ) + # Defaults that make the server boot under SEA (avoid pino-pretty transport) + proc_env.setdefault("NODE_ENV", "production") + # Server package expects BB_ENV to be set (see packages/server/src/lib/env.ts) + proc_env.setdefault("BB_ENV", "local") + proc_env["HOST"] = self._config.host + proc_env["PORT"] = str(port) + proc_env["HEADLESS"] = "true" if self._config.headless else "false" + if self._config.openai_api_key: + proc_env["OPENAI_API_KEY"] = self._config.openai_api_key + + preexec_fn = None + creationflags = 0 + if sys.platform != "win32": + preexec_fn = os.setsid + else: + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP + + proc = subprocess.Popen( + [str(self._binary_path)], + env=proc_env, + stdout=None, + stderr=None, + preexec_fn=preexec_fn, + creationflags=creationflags, + ) + + if not self._atexit_registered: + atexit.register(_terminate_process, proc) + self._atexit_registered = True + + try: + _wait_ready_sync(base_url=base_url, timeout_s=self._config.ready_timeout_s) + except Exception: + _terminate_process(proc) + raise + + return base_url, proc + + async def _start_async(self) -> tuple[str, subprocess.Popen[bytes]]: + if not self._binary_path.exists(): + raise FileNotFoundError( + f"Stagehand SEA binary not found at {self._binary_path}. " + "Pass local_binary_path=... or set STAGEHAND_SEA_BINARY." + ) + + port = _pick_free_port(self._config.host) if self._config.port == 0 else self._config.port + base_url = _build_base_url(host=self._config.host, port=port) + + proc_env = dict(os.environ) + proc_env.setdefault("NODE_ENV", "production") + proc_env.setdefault("BB_ENV", "local") + proc_env["HOST"] = self._config.host + proc_env["PORT"] = str(port) + proc_env["HEADLESS"] = "true" if self._config.headless else "false" + if self._config.openai_api_key: + proc_env["OPENAI_API_KEY"] = self._config.openai_api_key + + preexec_fn = None + creationflags = 0 + if sys.platform != "win32": + preexec_fn = os.setsid + else: + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP + + proc = subprocess.Popen( + [str(self._binary_path)], + env=proc_env, + stdout=None, + stderr=None, + preexec_fn=preexec_fn, + creationflags=creationflags, + ) + + if not self._atexit_registered: + atexit.register(_terminate_process, proc) + self._atexit_registered = True + + try: + await _wait_ready_async(base_url=base_url, timeout_s=self._config.ready_timeout_s) + except Exception: + _terminate_process(proc) + raise + + return base_url, proc diff --git a/src/stagehand/resources/sessions.py b/src/stagehand/resources/sessions.py index 19fe9256..def9de51 100644 --- a/src/stagehand/resources/sessions.py +++ b/src/stagehand/resources/sessions.py @@ -40,6 +40,14 @@ __all__ = ["SessionsResource", "AsyncSessionsResource"] +def _format_x_sent_at(value: Union[str, datetime] | Omit) -> str | NotGiven: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, Omit): + return not_given + return value + + class SessionsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> SessionsResourceWithRawResponse: @@ -240,7 +248,7 @@ def act( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -311,7 +319,7 @@ def end( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -319,6 +327,7 @@ def end( } return self._post( f"/v1/sessions/{id}/end", + body={}, # Empty object to satisfy Content-Type requirement options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -496,7 +505,7 @@ def execute( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -709,7 +718,7 @@ def extract( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -791,7 +800,7 @@ def navigate( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -993,7 +1002,7 @@ def observe( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1088,7 +1097,7 @@ def start( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1319,7 +1328,7 @@ async def act( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1390,7 +1399,7 @@ async def end( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1398,6 +1407,7 @@ async def end( } return await self._post( f"/v1/sessions/{id}/end", + body={}, # Empty object to satisfy Content-Type requirement options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -1575,7 +1585,7 @@ async def execute( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1788,7 +1798,7 @@ async def extract( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1870,7 +1880,7 @@ async def navigate( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -2072,7 +2082,7 @@ async def observe( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -2167,7 +2177,7 @@ async def start( { "x-language": str(x_language) if is_given(x_language) else not_given, "x-sdk-version": x_sdk_version, - "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, + "x-sent-at": _format_x_sent_at(x_sent_at), "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), diff --git a/src/stagehand/types/__init__.py b/src/stagehand/types/__init__.py index 46ce9d80..145b9be0 100644 --- a/src/stagehand/types/__init__.py +++ b/src/stagehand/types/__init__.py @@ -6,6 +6,7 @@ from .stream_event import StreamEvent as StreamEvent from .model_config_param import ModelConfigParam as ModelConfigParam from .session_act_params import SessionActParams as SessionActParams +from .session_end_params import SessionEndParams as SessionEndParams from .session_act_response import SessionActResponse as SessionActResponse from .session_end_response import SessionEndResponse as SessionEndResponse from .session_start_params import SessionStartParams as SessionStartParams diff --git a/src/stagehand/types/session_end_params.py b/src/stagehand/types/session_end_params.py new file mode 100644 index 00000000..defdf60a --- /dev/null +++ b/src/stagehand/types/session_end_params.py @@ -0,0 +1,27 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import datetime +from typing_extensions import Literal, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SessionEndParams"] + + +class SessionEndParams(TypedDict, total=False): + _force_body: Annotated[object, PropertyInfo(alias="_forceBody")] + + x_language: Annotated[Literal["typescript", "python", "playground"], PropertyInfo(alias="x-language")] + """Client SDK language""" + + x_sdk_version: Annotated[str, PropertyInfo(alias="x-sdk-version")] + """Version of the Stagehand SDK""" + + x_sent_at: Annotated[Union[str, datetime], PropertyInfo(alias="x-sent-at", format="iso8601")] + """ISO timestamp when request was sent""" + + x_stream_response: Annotated[Literal["true", "false"], PropertyInfo(alias="x-stream-response")] + """Whether to stream the response via SSE""" diff --git a/test_local_mode.py b/test_local_mode.py new file mode 100644 index 00000000..5a8de7cc --- /dev/null +++ b/test_local_mode.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Quick test of local server mode with the embedded binary.""" + +import os +import sys + +# Add src to path for local testing +sys.path.insert(0, "src") + +from stagehand import Stagehand + +# Set required API key for LLM operations +if not os.environ.get("OPENAI_API_KEY"): + print("āŒ Error: OPENAI_API_KEY environment variable not set") # noqa: T201 + print(" Set it with: export OPENAI_API_KEY='sk-proj-...'") # noqa: T201 + sys.exit(1) + +print("šŸš€ Testing local server mode...") # noqa: T201 + +try: + # Create client in local mode - will use bundled binary + print("šŸ“¦ Creating Stagehand client in local mode...") # noqa: T201 + client = Stagehand( + server="local", + browserbase_api_key="local", # Dummy value - not used in local mode + browserbase_project_id="local", # Dummy value - not used in local mode + model_api_key=os.environ["OPENAI_API_KEY"], + local_headless=True, + local_port=0, # Auto-pick free port + local_ready_timeout_s=15.0, # Give it time to start + ) + + print("šŸ”§ Starting session (this will start the local server)...") # noqa: T201 + session = client.sessions.start( + model_name="openai/gpt-5-nano", + browser={ # type: ignore[arg-type] + "type": "local", + "launchOptions": {}, # Launch local Playwright browser with defaults + }, + ) + session_id = session.data.session_id + + print(f"āœ… Session started: {session_id}") # noqa: T201 + print(f"🌐 Server running at: {client.base_url}") # noqa: T201 + + print("\nšŸ“ Navigating to example.com...") # noqa: T201 + client.sessions.navigate( + id=session_id, + url="https://example.com", + frame_id="", + ) + print("āœ… Navigation complete") # noqa: T201 + + print("\nšŸ” Extracting page heading...") # noqa: T201 + result = client.sessions.extract( + id=session_id, + instruction="Extract the main heading text from the page", + ) + print(f"šŸ“„ Extracted: {result.data.result}") # noqa: T201 + + print("\nšŸ›‘ Ending session...") # noqa: T201 + client.sessions.end(id=session_id) + print("āœ… Session ended") # noqa: T201 + + print("\nšŸ”Œ Closing client (will shut down server)...") # noqa: T201 + client.close() + print("āœ… Server shut down successfully!") # noqa: T201 + + print("\nšŸŽ‰ All tests passed!") # noqa: T201 + +except Exception as e: + print(f"\nāŒ Error: {e}") # noqa: T201 + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/tests/test_local_server.py b/tests/test_local_server.py new file mode 100644 index 00000000..f7329a4f --- /dev/null +++ b/tests/test_local_server.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import httpx +import pytest +from respx import MockRouter + +from stagehand import Stagehand, AsyncStagehand + + +class _DummySeaServer: + def __init__(self, base_url: str) -> None: + self._base_url = base_url + self.started = 0 + self.closed = 0 + + def ensure_running_sync(self) -> str: + self.started += 1 + return self._base_url + + async def ensure_running_async(self) -> str: + self.started += 1 + return self._base_url + + def close(self) -> None: + self.closed += 1 + + async def aclose(self) -> None: + self.closed += 1 + + +def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BROWSERBASE_API_KEY", "bb_key") + monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "bb_project") + monkeypatch.setenv("MODEL_API_KEY", "model_key") + + +@pytest.mark.respx(base_url="http://127.0.0.1:43123") +def test_sync_local_mode_starts_before_first_request(respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch) -> None: + _set_required_env(monkeypatch) + + dummy = _DummySeaServer("http://127.0.0.1:43123") + + respx_mock.post("/v1/sessions/start").mock( + return_value=httpx.Response( + 200, + json={ + "success": True, + "data": { + "available": True, + "connectUrl": "ws://example", + "sessionId": "00000000-0000-0000-0000-000000000000", + }, + }, + ) + ) + + client = Stagehand(server="local", local_binary_path="/does/not/matter/in/test") + # Swap in a dummy server so we don't spawn a real binary in unit tests. + client._sea_server = dummy # type: ignore[attr-defined] + + resp = client.sessions.start(model_name="openai/gpt-5-nano") + assert resp.success is True + assert dummy.started == 1 + + client.close() + assert dummy.closed == 1 + + +@pytest.mark.respx(base_url="http://127.0.0.1:43124") +@pytest.mark.asyncio +async def test_async_local_mode_starts_before_first_request( + respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + _set_required_env(monkeypatch) + + dummy = _DummySeaServer("http://127.0.0.1:43124") + + respx_mock.post("/v1/sessions/start").mock( + return_value=httpx.Response( + 200, + json={ + "success": True, + "data": { + "available": True, + "connectUrl": "ws://example", + "sessionId": "00000000-0000-0000-0000-000000000000", + }, + }, + ) + ) + + async with AsyncStagehand(server="local", local_binary_path="/does/not/matter/in/test") as client: + client._sea_server = dummy # type: ignore[attr-defined] + resp = await client.sessions.start(model_name="openai/gpt-5-nano") + assert resp.success is True + assert dummy.started == 1 + + assert dummy.closed == 1 diff --git a/uv.lock b/uv.lock index 4e5706cb..b65ca834 100644 --- a/uv.lock +++ b/uv.lock @@ -1339,7 +1339,7 @@ wheels = [ [[package]] name = "stagehand-alpha" -version = "0.0.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "anyio" },