diff --git a/.env.example b/.env.example deleted file mode 100644 index d92fa916..00000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -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 20952126..790bf4c3 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,200 +1,28 @@ # 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-server-linux-x64 - output_path: src/stagehand/_sea/stagehand-linux-x64 - wheel_platform_tag: manylinux2014_x86_64 - - os: macos-latest - binary_name: stagehand-server-darwin-arm64 - output_path: src/stagehand/_sea/stagehand-darwin-arm64 - wheel_platform_tag: macosx_11_0_arm64 - - os: macos-15-intel - binary_name: stagehand-server-darwin-x64 - output_path: src/stagehand/_sea/stagehand-darwin-x64 - wheel_platform_tag: macosx_10_9_x86_64 - - os: windows-latest - binary_name: stagehand-server-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 - with: - version: 9 - - - 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 - rm -f src/stagehand/_sea/.keep || true - git add -f src/stagehand/_sea/* - - - name: Build wheel - env: - STAGEHAND_WHEEL_TAG: py3-none-${{ matrix.wheel_platform_tag }} - run: uv build --wheel - - - name: Log SEA contents - shell: bash - run: | - echo "Contents of src/stagehand/_sea/" - ls -al src/stagehand/_sea || true - python - <<'PY' - import pathlib, zipfile, collections, sys - had_error = False - for wheel in sorted(pathlib.Path("dist").glob("*.whl")): - print(f"Contents of {wheel.name} entries matching stagehand/_sea") - with zipfile.ZipFile(wheel, "r") as zf: - names = [info.filename for info in zf.infolist()] - counts = collections.Counter(names) - dups = sorted([name for name, n in counts.items() if n > 1]) - for info in zf.infolist(): - if "stagehand/_sea/" in info.filename: - print(info.filename) - if dups: - had_error = True - print("ERROR: duplicate zip entries detected:") - for name in dups: - print(f" {counts[name]}x {name}") - if had_error: - sys.exit(1) - PY - - - 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" - - - name: Download build artifacts - uses: actions/download-artifact@v4 - with: - path: dist - - - name: Flatten dist directory - shell: bash - run: | - 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 + version: '0.9.13' - name: Publish to PyPI + run: | + bash ./bin/publish-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 433374d5..95ceb189 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,6 @@ dist .venv .idea -.DS_Store -src/stagehand/_sea/stagehand-* -src/stagehand/_sea/*.exe -bin/sea/ .env .envrc codegen.log diff --git a/.stats.yml b/.stats.yml index cb872bbf..ee5126ee 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-991d1530002115ecec027f98cad357d39ca1ece6784f62d48e6740b8830e1104.yml openapi_spec_hash: 303329893ced56b2c129fb9fd666144e -config_hash: 46336540a7b06c89308171858ad8238c +config_hash: d4df55e4b30aac2d8d0982be97f837c4 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index d296e594..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,176 +0,0 @@ -# Changelog - -## 3.4.7 (2026-01-15) - -Full Changelog: [v3.4.6...v3.4.7](https://github.com/browserbase/stagehand-python/compare/v3.4.6...v3.4.7) - -## 3.4.6 (2026-01-13) - -Full Changelog: [v3.4.5...v3.4.6](https://github.com/browserbase/stagehand-python/compare/v3.4.5...v3.4.6) - -### Chores - -* remove duplicate .keep files for pypi publish step fix ([5235658](https://github.com/browserbase/stagehand-python/commit/5235658b9360362d70d9154a96b53fe69167101d)) - -## 3.4.5 (2026-01-13) - -Full Changelog: [v3.4.4...v3.4.5](https://github.com/browserbase/stagehand-python/compare/v3.4.4...v3.4.5) - -### Chores - -* windows logging/build fix ([5ed0e5f](https://github.com/browserbase/stagehand-python/commit/5ed0e5f633082295b1ab17af9291d6efc863d25d)) - -## 3.4.4 (2026-01-13) - -Full Changelog: [v3.4.3...v3.4.4](https://github.com/browserbase/stagehand-python/compare/v3.4.3...v3.4.4) - -### Chores - -* publish-pypi lint fix ([71abdc6](https://github.com/browserbase/stagehand-python/commit/71abdc6f805c95f42da7c74dde961209a58290e7)) - -## 3.4.3 (2026-01-13) - -Full Changelog: [v3.4.2...v3.4.3](https://github.com/browserbase/stagehand-python/compare/v3.4.2...v3.4.3) - -### Chores - -* force-include SEA binaries in wheel ([301147c](https://github.com/browserbase/stagehand-python/commit/301147ce8f7fde3726e04efaaecfcdc5755b7683)) - -## 3.4.2 (2026-01-13) - -Full Changelog: [v3.4.1...v3.4.2](https://github.com/browserbase/stagehand-python/compare/v3.4.1...v3.4.2) - -### Chores - -* sync repo ([2d4bd0a](https://github.com/browserbase/stagehand-python/commit/2d4bd0aee5a1f03ed09473a43f5607871f05c7ee)) - -## [3.4.1](https://github.com/browserbase/stagehand-python/compare/v0.4.0...v3.4.1) (2026-01-13) - - -### Documentation - -* refresh README for release ([41926c7](https://github.com/browserbase/stagehand-python/commit/41926c77f9f8ffcca32c341a33d50dc731e1d84a)) - -## [0.4.0](https://github.com/browserbase/stagehand-python/compare/v0.3.1...v0.4.0) (2026-01-13) - - -### Features - -* don't close new opened tabs ([#161](https://github.com/browserbase/stagehand-python/issues/161)) ([#169](https://github.com/browserbase/stagehand-python/issues/169)) ([f68e86c](https://github.com/browserbase/stagehand-python/commit/f68e86c90d9e5f30d2f447ada65cc711ac531baa)) - - -### Bug Fixes - -* active page context ([#251](https://github.com/browserbase/stagehand-python/issues/251)) ([d61e118](https://github.com/browserbase/stagehand-python/commit/d61e118ccc8845ac95e4579f6137a91abb004943)) -* set injected Stagehand cursor position to fixed for correct viewport tracking ([#121](https://github.com/browserbase/stagehand-python/issues/121)) ([#122](https://github.com/browserbase/stagehand-python/issues/122)) ([93c16e3](https://github.com/browserbase/stagehand-python/commit/93c16e392d754227f9bec47ee9d9f26046bfb770)) - -## 0.3.1 (2026-01-13) - -Full Changelog: [v0.3.0...v0.3.1](https://github.com/browserbase/stagehand-python/compare/v0.3.0...v0.3.1) - -## 0.3.0 (2026-01-12) - -Full Changelog: [v0.2.4...v0.3.0](https://github.com/browserbase/stagehand-python/compare/v0.2.4...v0.3.0) - -### Features - -* Removed requiring x-language and x-sdk-version from openapi spec ([618266f](https://github.com/browserbase/stagehand-python/commit/618266f3fe397a2d346fc1f3adaad225db443cdf)) -* Using provider/model syntax in modelName examples within openapi spec ([98d8ab9](https://github.com/browserbase/stagehand-python/commit/98d8ab97cb1115b9cff7f6e831b7dfa98e27f15a)) - -## 0.2.4 (2026-01-07) - -Full Changelog: [v0.2.3...v0.2.4](https://github.com/browserbase/stagehand-python/compare/v0.2.3...v0.2.4) - -### Documentation - -* update README with SDK version headers ([f7bd20f](https://github.com/browserbase/stagehand-python/commit/f7bd20f4f44ae2b74a27bd791fa7bed3721b645c)) - -## 0.2.3 (2026-01-07) - -Full Changelog: [v0.2.2...v0.2.3](https://github.com/browserbase/stagehand-python/compare/v0.2.2...v0.2.3) - -### Bug Fixes - -* use macos-15-intel runner for darwin-x64 builds ([8e716fa](https://github.com/browserbase/stagehand-python/commit/8e716faccb3b3cb5a9622a4f524813f9a17b6f2d)) - -## 0.2.2 (2026-01-07) - -Full Changelog: [v0.2.1...v0.2.2](https://github.com/browserbase/stagehand-python/compare/v0.2.1...v0.2.2) - -### Bug Fixes - -* correct binary names and update macOS runner in publish workflow ([c396aa3](https://github.com/browserbase/stagehand-python/commit/c396aa32d85d6b16acaed2bbdadd3e619a87aae6)) - -## 0.2.1 (2026-01-07) - -Full Changelog: [v0.2.0...v0.2.1](https://github.com/browserbase/stagehand-python/compare/v0.2.0...v0.2.1) - -### Bug Fixes - -* specify pnpm version 9 in publish workflow ([f4fdb7a](https://github.com/browserbase/stagehand-python/commit/f4fdb7a36ab4aea1e3c5a3e1604322a92fc5bd3f)) - -## 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) - -### Features - -* [STG-1053] [server] Use fastify-zod-openapi + zod v4 for openapi generation ([405c606](https://github.com/browserbase/stagehand-python/commit/405c6068de29f39d90882b31805cc2785c6b94e0)) -* **api:** manual updates ([dde1e8b](https://github.com/browserbase/stagehand-python/commit/dde1e8b312f72179c416baaa8603c4a5da9ce706)) -* **api:** manual updates ([577cea0](https://github.com/browserbase/stagehand-python/commit/577cea04ec2814b9ec70e5f18119292991e5b635)) -* **api:** manual updates ([0cdb22b](https://github.com/browserbase/stagehand-python/commit/0cdb22be4350e78b49a2c90bb62fbf5fcc0d4a25)) -* **api:** manual updates ([fcf7666](https://github.com/browserbase/stagehand-python/commit/fcf7666829c41b7892d708c430a1a16b3ea6097e)) -* **api:** manual updates ([8590a04](https://github.com/browserbase/stagehand-python/commit/8590a048dbe8a82b8b298b7345b30b71876b6e10)) -* **api:** manual updates ([8d1c5ae](https://github.com/browserbase/stagehand-python/commit/8d1c5ae737a481f22818a4adcaba162d015142ee)) -* **api:** manual updates ([638e928](https://github.com/browserbase/stagehand-python/commit/638e92824408754dadebbffab7be6e5f14c0034c)) -* **api:** manual updates ([13484f8](https://github.com/browserbase/stagehand-python/commit/13484f87d343a9b02d58027ab17114c07fda5220)) -* **api:** manual updates ([722abc9](https://github.com/browserbase/stagehand-python/commit/722abc902c2d7210b6b8c35655b9a8dbd6433ee3)) -* **api:** manual updates ([72aa8b8](https://github.com/browserbase/stagehand-python/commit/72aa8b8476bddf351364a1bf161454206eaea3ba)) -* **api:** manual updates ([54f3289](https://github.com/browserbase/stagehand-python/commit/54f32894104f60ca81cad4797b19a86903f4ef73)) -* **api:** manual updates ([9b9d548](https://github.com/browserbase/stagehand-python/commit/9b9d548fb1a4f8a489d4dd920399d2145f4bd3af)) -* **api:** manual updates ([54fb057](https://github.com/browserbase/stagehand-python/commit/54fb05764ac58ad86e9ef4a96aefdda001839fc7)) -* **api:** manual updates ([5efd001](https://github.com/browserbase/stagehand-python/commit/5efd001ad8e5dbcea9f5aa7dad31584ade9402ae)) -* **api:** manual updates ([19a67fd](https://github.com/browserbase/stagehand-python/commit/19a67fd34a16a0acd72427862bcd0eafd6dab353)) -* **api:** manual updates ([80413be](https://github.com/browserbase/stagehand-python/commit/80413be240dd2cdf8c0c95f3e47c5537fbeed017)) -* **api:** manual updates ([585015c](https://github.com/browserbase/stagehand-python/commit/585015c998f014040086fd927d91949c7d153b86)) -* **api:** manual updates ([f032352](https://github.com/browserbase/stagehand-python/commit/f032352d00c69dd94438500c0ced3a110a7cc521)) -* **api:** manual updates ([2dcbe2d](https://github.com/browserbase/stagehand-python/commit/2dcbe2d88a8a35781d42e5bbdcebb44e0ba830dc)) -* **api:** tweak branding and fix some config fields ([8526eb4](https://github.com/browserbase/stagehand-python/commit/8526eb4417d0f2b69397294b1aa3d4da5892f2d6)) - - -### Bug Fixes - -* use async_to_httpx_files in patch method ([77eb123](https://github.com/browserbase/stagehand-python/commit/77eb1234c04a9aa95cedddb15bef377d644f6c42)) - - -### Chores - -* **internal:** add `--fix` argument to lint script ([f7eefb4](https://github.com/browserbase/stagehand-python/commit/f7eefb45344f354cfbdbfa00505f0225ce1ad396)) -* **internal:** add missing files argument to base client ([5c05e7b](https://github.com/browserbase/stagehand-python/commit/5c05e7b37ae9aff8e259cc3190998d7e259f0cef)) -* speedup initial import ([5aafb83](https://github.com/browserbase/stagehand-python/commit/5aafb83959802f8d2a6d7544f115de28a6495d2e)) -* update SDK settings ([b8d1cd3](https://github.com/browserbase/stagehand-python/commit/b8d1cd34b5ee9608e52ea009ff31b7a429cdec62)) -* update SDK settings ([4c0b2c8](https://github.com/browserbase/stagehand-python/commit/4c0b2c8045935a5790b668e553c114d82550b85e)) - - -### Documentation - -* add more examples ([681e90f](https://github.com/browserbase/stagehand-python/commit/681e90f695f60d3b59ee017da3270bd344cf01f6)) - - -### Refactors - -* **internal:** switch from rye to uv ([0eba9d2](https://github.com/browserbase/stagehand-python/commit/0eba9d2e2ba2ff82a412adf06e80866e3dc4b7cb)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0410f495..d42a6f15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,58 +14,29 @@ Or [install uv manually](https://docs.astral.sh/uv/getting-started/installation/ $ uv sync --all-extras ``` -You can then run scripts using `uv run python script.py`: +You can then run scripts using `uv run python script.py` or by manually activating the virtual environment: ```sh -uv run python script.py -``` - -## Modifying/Adding code - -Most of the SDK is generated code. Modifications to code will be persisted between generations, but may -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) +# manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +$ source .venv/bin/activate -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. +# now you can omit the `uv run` prefix +$ python script.py +``` -### Quick setup +### Without `uv` -Run the download script to automatically download the correct binary: +Alternatively if you don't want to install `uv`, you can stick with the standard `pip` setup by ensuring you have the Python version specified in `.python-version`, create a virtual environment however you desire and then install dependencies using this command: ```sh -$ uv run python scripts/download-binary.py +$ pip install -r requirements-dev.lock ``` -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: +## Modifying/Adding code -```sh -$ export STAGEHAND_SEA_BINARY=/path/to/your/stagehand-binary -$ uv run python test_local_mode.py -``` +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +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. ## Adding and running examples @@ -91,7 +62,7 @@ If you’d like to use the repository from source, you can either install from g To install via git: ```sh -uv run pip install git+ssh://git@github.com/browserbase/stagehand-python#stainless.git +$ pip install git+ssh://git@github.com/browserbase/stagehand-python.git ``` Alternatively, you can build from source and install the wheel file: @@ -103,13 +74,13 @@ To create a distributable version of the library, all you have to do is run this ```sh $ uv build # or -$ uv run python -m build +$ python -m build ``` Then to install: ```sh -uv run pip install ./path-to-wheel-file.whl +$ pip install ./path-to-wheel-file.whl ``` ## Running tests @@ -122,7 +93,7 @@ $ npx prism mock path/to/your/openapi.yml ``` ```sh -$ uv run -- ./scripts/test +$ ./scripts/test ``` ## Linting and formatting @@ -133,13 +104,13 @@ This repository uses [ruff](https://github.com/astral-sh/ruff) and To lint: ```sh -$ uv run -- ./scripts/lint +$ ./scripts/lint ``` To format and fix all ruff issues automatically: ```sh -$ uv run -- ./scripts/format +$ ./scripts/format ``` ## Publishing and releases diff --git a/LICENSE b/LICENSE index d15d0212..a7b82c2f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,7 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Copyright 2026 stagehand - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - 1. Definitions. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - 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. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index ae67fcc6..d80419b1 100644 --- a/README.md +++ b/README.md @@ -1,342 +1,132 @@ -
- -
-

- The AI Browser Automation Framework
- Read the Docs -

- -

- - - - MIT License - - - - - - Discord Community - - -

- -

- browserbase%2Fstagehand | Trendshift -

- -

-If you're looking for other languages, you can find them - here -

- -
- Vibe code - Stagehand with - - Director - - - - Director - -
- -> [!TIP] -> Migrating from the old v2 Python SDK? See our [migration guide here](https://docs.stagehand.dev/v3/migrations/python). - -## What is Stagehand? - -Stagehand is a browser automation framework used to control web browsers with natural language and code. By combining the power of AI with the precision of code, Stagehand makes web automation flexible, maintainable, and actually reliable. - -## Why Stagehand? - -Most existing browser automation tools either require you to write low-level code in a framework like Selenium, Playwright, or Puppeteer, or use high-level agents that can be unpredictable in production. By letting developers choose what to write in code vs. natural language (and bridging the gap between the two) Stagehand is the natural choice for browser automations in production. - -1. **Choose when to write code vs. natural language**: use AI when you want to navigate unfamiliar pages, and use code when you know exactly what you want to do. - -2. **Go from AI-driven to repeatable workflows**: Stagehand lets you preview AI actions before running them, and also helps you easily cache repeatable actions to save time and tokens. - -3. **Write once, run forever**: Stagehand's auto-caching combined with self-healing remembers previous actions, runs without LLM inference, and knows when to involve AI whenever the website changes and your automation breaks. +# Stagehand Python API library -## Installation - -```sh -uv pip install stagehand -``` - -For local development or when working from this repository, sync the dependency lockfile with `uv` (see the Local development section below) before running project scripts. - -## Requirements - -Python 3.9 or higher. - -## 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: + +[![PyPI version](https://img.shields.io/pypi/v/stagehand.svg?label=pypi%20(stable))](https://pypi.org/project/stagehand/) -```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" +The Stagehand Python library provides convenient access to the Stagehand REST API from any Python 3.9+ +application. The library includes type definitions for all request params and response fields, +and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). -uv run python examples/full_example.py -``` - -## Local mode example - -If you want to run Stagehand locally, use the local example (`examples/local_example.py`). It shows how to configure the client for `server="local"`. - -Local mode runs Stagehand’s embedded server and launches a **local Chrome/Chromium** browser (it is **not bundled** with the Python wheel), so you must have Chrome installed on the machine running the example. - -If Chrome is installed but Stagehand can’t find it, set `CHROME_PATH` to your browser executable (or pass `browser.launchOptions.executablePath` when starting the session). - -Common Windows paths: -- `C:\Program Files\Google\Chrome\Application\chrome.exe` -- `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe` +It is generated with [Stainless](https://www.stainless.com/). -PowerShell: - -```powershell -# optional if you don't already have Chrome installed -winget install -e --id Google.Chrome - -# optional if Stagehand can't auto-detect Chrome -$env:CHROME_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe" - -uv run python examples/local_example.py -``` - -```bash -pip install stagehand -uv run python examples/local_example.py -``` +## MCP Server -## Streaming logging example +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. -See [`examples/logging_example.py`](examples/logging_example.py) for a remote-only flow that streams `StreamEvent`s with `verbose=2`, `stream_response=True`, and `x_stream_response="true"` so you can watch the SDK’s logs as they arrive. +[![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) -```bash -uv run python examples/logging_example.py -``` +> Note: You may need to set environment variables in your MCP client. -
-Local development +## Documentation -This repository relies on `uv` to install the sanctioned Python version and dependencies. After cloning, bootstrap the environment with: +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). -```sh -./scripts/bootstrap -``` -Once the environment is ready, execute repo scripts with `uv run`: +## Installation ```sh -uv run python examples/full_example.py +# install from PyPI +pip install stagehand ``` -
## Usage -This example demonstrates the full Stagehand workflow: starting a session, navigating to a page, observing possible actions, acting on elements, extracting data, and running an autonomous agent. - -```python -import asyncio - -from stagehand import AsyncStagehand - - -async def main() -> None: - # Create client using environment variables: - # BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, MODEL_API_KEY - client = AsyncStagehand() - - # Start a new browser session (returns a session helper bound to a session_id) - session = await client.sessions.create(model_name="openai/gpt-5-nano") - - print(f"Session started: {session.id}") - - try: - # Navigate to a webpage - await session.navigate( - url="https://news.ycombinator.com", - ) - print("Navigated to Hacker News") - - # Observe to find possible actions on the page - observe_response = await session.observe( - instruction="find the link to view comments for the top post", - ) - - results = observe_response.data.result - print(f"Found {len(results)} possible actions") - if not results: - return - - # Take the first action returned by Observe and pass it to Act - action = results[0].to_dict(exclude_none=True) - print("Acting on:", action.get("description")) - - act_response = await session.act(input=action) - print("Act completed:", act_response.data.result.message) - - # Extract structured data from the page using a JSON schema - extract_response = await session.extract( - instruction="extract the text of the top comment on this page", - schema={ - "type": "object", - "properties": { - "commentText": {"type": "string"}, - "author": {"type": "string"}, - }, - "required": ["commentText"], - }, - ) - - extracted = extract_response.data.result - author = extracted.get("author", "unknown") if isinstance(extracted, dict) else "unknown" - print("Extracted author:", author) - - # Run an autonomous agent to accomplish a complex task - execute_response = await session.execute( - execute_options={ - "instruction": f"Find any personal website, GitHub, or LinkedIn profile for the Hacker News user '{author}'.", - "max_steps": 10, - }, - agent_config={"model": "openai/gpt-5-nano"}, - timeout=300.0, - ) - - print("Agent completed:", execute_response.data.result.message) - print("Agent success:", execute_response.data.result.success) - finally: - # End the browser session to clean up resources - await session.end() - print("Session ended") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## Client configuration - -Configure the client using environment variables: - -```python -from stagehand import AsyncStagehand - -client = AsyncStagehand() -``` - -Or manually: +The full API of this library can be found in [api.md](api.md). ```python -from stagehand import AsyncStagehand - -client = AsyncStagehand( - browserbase_api_key="My Browserbase API Key", - browserbase_project_id="My Browserbase Project ID", - model_api_key="My Model API Key", +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 ) -``` - -Or using a combination of the two approaches: -```python -from stagehand import AsyncStagehand - -client = AsyncStagehand( - # Configures using environment variables - browserbase_api_key="My Browserbase API Key", # override just this one +response = client.sessions.act( + id="00000000-your-session-id-000000000000", + input="click the first link on the page", ) +print(response.data) ``` -See this table for the available options: - -| Keyword argument | Environment variable | Required | Default value | -| ------------------------ | ------------------------ | -------- | ----------------------------------------- | -| `browserbase_api_key` | `BROWSERBASE_API_KEY` | true | - | -| `browserbase_project_id` | `BROWSERBASE_PROJECT_ID` | true | - | -| `model_api_key` | `MODEL_API_KEY` | true | - | -| `base_url` | `STAGEHAND_BASE_URL` | false | `"https://api.stagehand.browserbase.com"` | - -Keyword arguments take precedence over environment variables. - -> [!TIP] -> Don't create more than one client in the same application. Each client has a connection pool, which is more efficient to share between requests. - -### Modifying configuration - -To temporarily use a modified client configuration while reusing the same connection pool, call `with_options()` on any client: - -```python -client_with_options = client.with_options(model_api_key="sk-your-llm-api-key-here", max_retries=42) -``` - -The `with_options()` method does not affect the original client. - -## Requests and responses - -To send a request to the Stagehand API, call the corresponding client method using keyword arguments. - -Nested request parameters are dictionaries typed using [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods like: - -- Serializing back into JSON: `model.to_json()` -- Converting to a dictionary: `model.to_dict()` - -## Immutability +While you can provide a `browserbase_api_key` keyword argument, +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. -Response objects are Pydantic models. If you want to build a modified copy, prefer `model.model_copy(update={...})` (Pydantic v2) rather than mutating in place. +## Async usage -## Asynchronous execution - -This SDK recommends using `AsyncStagehand` and `await`ing each API call: +Simply import `AsyncStagehand` instead of `Stagehand` and use `await` with each API call: ```python +import os import asyncio from stagehand import AsyncStagehand +client = AsyncStagehand( + 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 +) + async def main() -> None: - client = AsyncStagehand() - session = await client.sessions.create(model_name="openai/gpt-5-nano") - response = await session.act(input="click the first link on the page") + response = await client.sessions.act( + id="00000000-your-session-id-000000000000", + input="click the first link on the page", + ) print(response.data) asyncio.run(main()) ``` +Functionality between the synchronous and asynchronous clients is otherwise identical. + ### With aiohttp -By default, the async client uses `httpx` for HTTP requests. For improved concurrency performance you may also use `aiohttp` as the HTTP backend. +By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. -Install `aiohttp`: +You can enable this by installing `aiohttp`: ```sh -uv run pip install stagehand[aiohttp] +# install from PyPI +pip install stagehand[aiohttp] ``` -Then instantiate the client with `http_client=DefaultAioHttpClient()`: +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio -from stagehand import AsyncStagehand, DefaultAioHttpClient +from stagehand import DefaultAioHttpClient +from stagehand import AsyncStagehand async def main() -> None: - async with AsyncStagehand(http_client=DefaultAioHttpClient()) as client: - session = await client.sessions.create(model_name="openai/gpt-5-nano") - response = await session.act(input="click the first link on the page") + async with AsyncStagehand( + 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 + http_client=DefaultAioHttpClient(), + ) as client: + response = await client.sessions.act( + id="00000000-your-session-id-000000000000", + input="click the first link on the page", + ) print(response.data) @@ -345,115 +135,92 @@ asyncio.run(main()) ## Streaming responses -We provide support for streaming responses using Server-Sent Events (SSE). - -To enable SSE streaming, you must: - -1. Ask the server to stream by setting `x_stream_response="true"` (header), and -2. Tell the client to parse an SSE stream by setting `stream_response=True`. +We provide support for streaming responses using Server Side Events (SSE). ```python -import asyncio - -from stagehand import AsyncStagehand - - -async def main() -> None: - async with AsyncStagehand() as client: - session = await client.sessions.create(model_name="openai/gpt-5-nano") - - stream = await client.sessions.act( - id=session.id, - input="click the first link on the page", - stream_response=True, - x_stream_response="true", - ) - async for event in stream: - # event is a StreamEvent (type: "system" | "log") - print(event.type, event.data) +from stagehand import Stagehand +client = Stagehand() -asyncio.run(main()) +stream = client.sessions.act( + id="00000000-your-session-id-000000000000", + input="click the first link on the page", + stream_response=True, +) +for response in stream: + print(response.data) ``` -## Raw responses - -The SDK defines methods that deserialize responses into Pydantic models. However, these methods don't provide access to response headers, status code, or the raw response body. - -To access this data, prefix any HTTP method call on a client or service with `with_raw_response`: +The async client uses the exact same interface. ```python -import asyncio - from stagehand import AsyncStagehand +client = AsyncStagehand() -async def main() -> None: - async with AsyncStagehand() as client: - response = await client.sessions.with_raw_response.start(model_name="openai/gpt-5-nano") - print(response.headers.get("X-My-Header")) - - session = response.parse() # get the object that `sessions.start()` would have returned - print(session.data) - - -asyncio.run(main()) +stream = await client.sessions.act( + id="00000000-your-session-id-000000000000", + input="click the first link on the page", + stream_response=True, +) +async for response in stream: + print(response.data) ``` -### `.with_streaming_response` +## Using types -The `with_raw_response` interface eagerly reads the full response body when you make the request. +Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typing.html#typing.TypedDict). Responses are [Pydantic models](https://docs.pydantic.dev) which also provide helper methods for things like: -To stream the response body (not SSE), use `with_streaming_response` instead. It requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. +- Serializing back into JSON, `model.to_json()` +- Converting to a dictionary, `model.to_dict()` -```python -import asyncio +Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. -from stagehand import AsyncStagehand +## Nested params +Nested parameters are dictionaries, typed using `TypedDict`, for example: -async def main() -> None: - async with AsyncStagehand() as client: - async with client.sessions.with_streaming_response.start(model_name="openai/gpt-5-nano") as response: - print(response.headers.get("X-My-Header")) - async for line in response.iter_lines(): - print(line) +```python +from stagehand import Stagehand +client = Stagehand() -asyncio.run(main()) +response = client.sessions.act( + id="c4dbf3a9-9a58-4b22-8a1c-9f20f9f9e123", + input="Click the login button", + options={}, +) +print(response.options) ``` -## Error handling +## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `stagehand.APIConnectionError` is raised. -When the API returns a non-success status code (that is, 4xx or 5xx response), a subclass of `stagehand.APIStatusError` is raised, containing `status_code` and `response` properties. +When the API returns a non-success status code (that is, 4xx or 5xx +response), a subclass of `stagehand.APIStatusError` is raised, containing `status_code` and `response` properties. All errors inherit from `stagehand.APIError`. ```python -import asyncio - import stagehand -from stagehand import AsyncStagehand - - -async def main() -> None: - async with AsyncStagehand() as client: - try: - await client.sessions.start(model_name="openai/gpt-5-nano") - except stagehand.APIConnectionError as e: - print("The server could not be reached") - print(e.__cause__) # an underlying Exception, likely raised within httpx. - except stagehand.RateLimitError: - print("A 429 status code was received; we should back off a bit.") - except stagehand.APIStatusError as e: - print("A non-200-range status code was received") - print(e.status_code) - print(e.response) +from stagehand import Stagehand +client = Stagehand() -asyncio.run(main()) +try: + client.sessions.start( + model_name="openai/gpt-5-nano", + ) +except stagehand.APIConnectionError as e: + print("The server could not be reached") + print(e.__cause__) # an underlying Exception, likely raised within httpx. +except stagehand.RateLimitError as e: + print("A 429 status code was received; we should back off a bit.") +except stagehand.APIStatusError as e: + print("Another non-200-range status code was received") + print(e.status_code) + print(e.response) ``` Error codes are as follows: @@ -471,121 +238,198 @@ Error codes are as follows: ### Retries -Certain errors are automatically retried 2 times by default, with a short exponential backoff. Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, 429 Rate Limit, and >=500 Internal errors are all retried by default. +Certain errors are automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors are all retried by default. You can use the `max_retries` option to configure or disable retry settings: ```python -import asyncio +from stagehand import Stagehand -from stagehand import AsyncStagehand +# Configure the default for all requests: +client = Stagehand( + # default is 2 + max_retries=0, +) + +# Or, configure per-request: +client.with_options(max_retries=5).sessions.start( + model_name="openai/gpt-5-nano", +) +``` +### Timeouts -async def main() -> None: - async with AsyncStagehand(max_retries=0) as client: - # Or, configure per-request: - await client.with_options(max_retries=5).sessions.start(model_name="openai/gpt-5-nano") +By default requests time out after 1 minute. You can configure this with a `timeout` option, +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: +```python +from stagehand import Stagehand -asyncio.run(main()) +# Configure the default for all requests: +client = Stagehand( + # 20 seconds (default is 1 minute) + timeout=20.0, +) + +# More granular control: +client = Stagehand( + timeout=httpx.Timeout(60.0, read=5.0, write=10.0, connect=2.0), +) + +# Override per-request: +client.with_options(timeout=5.0).sessions.start( + model_name="openai/gpt-5-nano", +) ``` -### Timeouts +On timeout, an `APITimeoutError` is thrown. -By default requests time out after 1 minute. You can configure this with a `timeout` option, which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object. +Note that requests that time out are [retried twice by default](#retries). -On timeout, an `APITimeoutError` is thrown. Note that requests that time out are [retried twice by default](#retries). +## Advanced -## Logging +### Logging -The SDK uses the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. +We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. -Enable logging by setting the `STAGEHAND_LOG` environment variable to `info`: +You can enable logging by setting the environment variable `STAGEHAND_LOG` to `info`. -```sh -export STAGEHAND_LOG=info +```shell +$ export STAGEHAND_LOG=info ``` -Or to `debug` for more verbose logging: +Or to `debug` for more verbose logging. -```sh -export STAGEHAND_LOG=debug +### How to tell whether `None` means `null` or missing + +In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: + +```py +if response.my_field is None: + if 'my_field' not in response.model_fields_set: + print('Got json like {}, without a "my_field" key present at all.') + else: + print('Got json like {"my_field": null}.') ``` -## Undocumented API functionality +### Accessing raw response data (e.g. headers) -This library is typed for convenient access to the documented API, but you can still access undocumented endpoints, request params, or response properties when needed. +The "raw" Response object can be accessed by prefixing `.with_raw_response.` to any HTTP method call, e.g., -### Undocumented endpoints +```py +from stagehand import Stagehand -To make requests to undocumented endpoints, use `client.get`, `client.post`, and other HTTP verbs. Client options (such as retries) are respected. +client = Stagehand() +response = client.sessions.with_raw_response.start( + model_name="openai/gpt-5-nano", +) +print(response.headers.get('X-My-Header')) -```python -import httpx -from stagehand import AsyncStagehand +session = response.parse() # get the object that `sessions.start()` would have returned +print(session.data) +``` -import asyncio +These methods return an [`APIResponse`](https://github.com/browserbase/stagehand-python/tree/main/src/stagehand/_response.py) object. +The async client returns an [`AsyncAPIResponse`](https://github.com/browserbase/stagehand-python/tree/main/src/stagehand/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. -async def main() -> None: - async with AsyncStagehand() as client: - response = await client.post("/foo", cast_to=httpx.Response, body={"my_param": True}) - print(response.headers.get("x-foo")) +#### `.with_streaming_response` +The above interface eagerly reads the full response body when you make the request, which may not always be what you want. -asyncio.run(main()) +To stream the response body, use `.with_streaming_response` instead, which requires a context manager and only reads the response body once you call `.read()`, `.text()`, `.json()`, `.iter_bytes()`, `.iter_text()`, `.iter_lines()` or `.parse()`. In the async client, these are async methods. + +```python +with client.sessions.with_streaming_response.start( + model_name="openai/gpt-5-nano", +) as response: + print(response.headers.get("X-My-Header")) + + for line in response.iter_lines(): + print(line) ``` -### Undocumented request params +The context manager is required so that the response will reliably be closed. -To send extra params that aren't available as keyword args, use `extra_query`, `extra_body`, and `extra_headers`. +### Making custom/undocumented requests -### Undocumented response properties +This library is typed for convenient access to the documented API. -To access undocumented response properties, you can access extra fields like `response.unknown_prop`. You can also get all extra fields as a dict with [`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). +If you need to access undocumented endpoints, params, or response properties, the library can still be used. -## Response validation +#### Undocumented endpoints -In rare cases, the API may return a response that doesn't match the expected type. +To make requests to undocumented endpoints, you can make requests using `client.get`, `client.post`, and other +http verbs. Options on the client will be respected (such as retries) when making this request. -By default, the SDK is permissive and will only raise an error if you later try to use the invalid data. +```py +import httpx -If you would prefer to validate responses upfront, instantiate the client with `_strict_response_validation=True`. An `APIResponseValidationError` will be raised if the API responds with invalid data for the expected schema. +response = client.post( + "/foo", + cast_to=httpx.Response, + body={"my_param": True}, +) -```python -import asyncio +print(response.headers.get("x-foo")) +``` -from stagehand import APIResponseValidationError, AsyncStagehand +#### Undocumented request params -try: - async def main() -> None: - async with AsyncStagehand(_strict_response_validation=True) as client: - await client.sessions.start(model_name="openai/gpt-5-nano") +If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request +options. - asyncio.run(main()) -except APIResponseValidationError as e: - print("Response failed schema validation:", e) -``` +#### Undocumented response properties + +To access undocumented response properties, you can access the extra fields like `response.unknown_prop`. You +can also get all the extra fields on the Pydantic model as a dict with +[`response.model_extra`](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_extra). -## FAQ +### Configuring the HTTP client -### Why are some values typed as `Literal[...]` instead of Python `Enum`s? +You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: -Using `Literal[...]` types is forwards compatible: the API can introduce new enum values without breaking older SDKs at runtime. +- Support for [proxies](https://www.python-httpx.org/advanced/proxies/) +- Custom [transports](https://www.python-httpx.org/advanced/transports/) +- Additional [advanced](https://www.python-httpx.org/advanced/clients/) functionality -### How can I tell whether `None` means `null` or “missing” in a response? +```python +import httpx +from stagehand import Stagehand, DefaultHttpxClient + +client = Stagehand( + # Or use the `STAGEHAND_BASE_URL` env var + base_url="http://my.test.server.example.com:8083", + http_client=DefaultHttpxClient( + proxy="http://my.test.proxy.example.com", + transport=httpx.HTTPTransport(local_address="0.0.0.0"), + ), +) +``` -In an API response, a field may be explicitly `null`, or missing entirely; in either case its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`: +You can also customize the client on a per-request basis by using `with_options()`: ```python -if response.my_field is None: - if "my_field" not in response.model_fields_set: - print('Got json like {}, without a "my_field" key present at all.') - else: - print('Got json like {"my_field": null}.') +client.with_options(http_client=DefaultHttpxClient(...)) +``` + +### Managing HTTP resources + +By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. + +```py +from stagehand import Stagehand + +with Stagehand() as client: + # make requests here + ... + +# HTTP client is now closed ``` -## Semantic versioning +## Versioning This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: @@ -603,8 +447,15 @@ If you've upgraded to the latest version but aren't seeing any new features you You can determine the version that is being used at runtime with: -```python +```py import stagehand - print(stagehand.__version__) ``` + +## Requirements + +Python 3.9 or higher. + +## Contributing + +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/examples/act_example.py b/examples/act_example.py deleted file mode 100644 index c472983d..00000000 --- a/examples/act_example.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Example demonstrating calling act() with a string instruction. - -This example shows how to use the act() method with a natural language -string instruction instead of an Action object from observe(). - -The act() method accepts either: -1. A string instruction (demonstrated here): input="click the button" -2. An Action object from observe(): input=action_object - -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 AsyncStagehand - - -async def main() -> None: - # Create client using environment variables - # BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, MODEL_API_KEY - async with AsyncStagehand( - 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"), - ) as client: - # Start a new browser session - session = await client.sessions.create( - model_name="openai/gpt-5-nano", - ) - - print(f"Session started: {session.id}") - - try: - # Navigate to example.com - await session.navigate( - url="https://www.example.com", - ) - print("Navigated to example.com") - - # Call act() with a string instruction directly - # This is the key test - passing a string instead of an Action object - print("\nAttempting to call act() with string input...") - act_response = await session.act( - input="click the 'More information' link", # String instruction - ) - - print(f"Act completed successfully!") - print(f"Result: {act_response.data.result.message}") - print(f"Success: {act_response.data.result.success}") - - except Exception as e: - print(f"Error: {e}") - print(f"Error type: {type(e).__name__}") - import traceback - - traceback.print_exc() - - finally: - # End the session to clean up resources - await session.end() - print("\nSession ended") - - -if __name__ == "__main__": - import asyncio - - asyncio.run(main()) diff --git a/examples/agent_execute.py b/examples/agent_execute.py deleted file mode 100644 index 4a4f4eca..00000000 --- a/examples/agent_execute.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -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 AsyncStagehand, APIResponseValidationError - - -async 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). - async with AsyncStagehand(_strict_response_validation=True) as client: - try: - session = await client.sessions.create(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 - if not session.id: - raise RuntimeError(f"Expected a session ID from /sessions/start but received {session!r}") - - try: - await session.navigate( - url="https://news.ycombinator.com", - options={"wait_until": "domcontentloaded"}, - ) - - result = await session.execute( - 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: - await session.end() - - -if __name__ == "__main__": - import asyncio - - asyncio.run(main()) diff --git a/examples/byob_example.py b/examples/byob_example.py deleted file mode 100644 index 1f6cb0e9..00000000 --- a/examples/byob_example.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -""" -Example showing how to bring your own browser driver while still using Stagehand. - -This script runs Playwright locally to drive the browser and uses Stagehand to -plan the interactions (observe → extract) without having Stagehand own the page. - -Required environment variables: -- BROWSERBASE_API_KEY -- BROWSERBASE_PROJECT_ID -- MODEL_API_KEY - -Usage: - -``` -pip install playwright stagehand-alpha -# (if Playwright is new) playwright install chromium -uv run python examples/byob_example.py -``` -""" - -import os -import asyncio - -from playwright.async_api import async_playwright - -from stagehand import AsyncStagehand - - -async def main() -> None: - async with AsyncStagehand( - 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"), - ) as client, async_playwright() as playwright: - browser = await playwright.chromium.launch(headless=True) - page = await browser.new_page() - session = await client.sessions.create(model_name="openai/gpt-5-nano") - - try: - target_url = "https://news.ycombinator.com" - await session.navigate(url=target_url) - await page.goto(target_url, wait_until="networkidle") - - print("🎯 Stagehand already navigated to Hacker News; Playwright now drives that page.") - - # Click the first story's comments link with Playwright. - comments_selector = "tr.athing:first-of-type + tr .subline > a[href^='item?id=']:nth-last-of-type(1)" - await page.click(comments_selector, timeout=15_000) - await page.wait_for_load_state("networkidle") - - print("✅ Playwright clicked the first story link.") - - print("🔄 Syncing Stagehand to Playwright's current URL:", page.url) - await session.navigate(url=page.url) - - extract_response = await session.extract( - instruction="extract the text of the top comment on this page", - schema={ - "type": "object", - "properties": {"comment": {"type": "string"}}, - "required": ["comment"], - }, - ) - - print("🧮 Stagehand extraction result:", extract_response.data.result) - finally: - await session.end() - await browser.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/full_example.py b/examples/full_example.py deleted file mode 100644 index dcba7410..00000000 --- a/examples/full_example.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -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 AsyncStagehand - - -async def main() -> None: - # Create client using environment variables - # BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, MODEL_API_KEY - async with AsyncStagehand( - 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"), - ) as client: - # Start a new browser session (returns a session helper bound to a session_id) - session = await client.sessions.create( - model_name="openai/gpt-5-nano", - ) - - print(f"Session started: {session.id}") - - try: - # Navigate to Hacker News - await session.navigate( - url="https://news.ycombinator.com", - ) - print("Navigated to Hacker News") - - # Observe to find possible actions - looking for the comments link - observe_response = await session.observe( - instruction="find the link to view comments for the top post", - ) - - 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 = await session.act( - input=result, # type: ignore[arg-type] - ) - 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 = await session.extract( - 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"], - }, - ) - - # 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 = await session.execute( # pyright: ignore[reportArgumentType] - 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, - }, - 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 - await session.end() - print("Session ended") - - -if __name__ == "__main__": - import asyncio - - asyncio.run(main()) diff --git a/examples/local_example.py b/examples/local_example.py deleted file mode 100644 index 644e0244..00000000 --- a/examples/local_example.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Example demonstrating how to run Stagehand in local mode using the SEA binary -that ships with the PyPI wheel. - -Required environment variables: -- BROWSERBASE_API_KEY (can be any value in local mode) -- BROWSERBASE_PROJECT_ID (can be any value in local mode) -- MODEL_API_KEY (used for client configuration even in local mode) - -You can also set `OPENAI_API_KEY` if you prefer, but the example defaults to `MODEL_API_KEY`. - -Install the published wheel before running this script: - `pip install stagehand-alpha` -Then execute this example with the same interpreter: - `python examples/local_example.py` -""" - -import os -import sys -from typing import Optional - -from stagehand import Stagehand - - -def main() -> None: - model_key = os.environ.get("MODEL_API_KEY") - openai_key = os.environ.get("OPENAI_API_KEY") - - if not model_key and not openai_key: - sys.exit("Set MODEL_API_KEY to run the local server.") - - client = Stagehand( - server="local", - local_openai_api_key=openai_key or model_key, - local_ready_timeout_s=30.0, - ) - - session_id: Optional[str] = None - - try: - print("⏳ Starting local session (this will start the embedded SEA binary)...") - session = client.sessions.start( - model_name="openai/gpt-5-nano", - browser={ - "type": "local", - "launchOptions": { - "headless": True, - }, - }, - ) - session_id = session.data.session_id - print(f"✅ Session started: {session_id}") - - print("🌐 Navigating to https://www.example.com...") - client.sessions.navigate( - id=session_id, - url="https://www.example.com", - ) - print("✅ Navigation complete") - - print("🔍 Extracting the main heading text...") - extract_response = client.sessions.extract( - id=session_id, - instruction="Extract the text of the top-level heading on this page.", - ) - print(f"📄 Extracted data: {extract_response.data.result}") - - except Exception as exc: - print(f"❌ Encountered an error: {exc}") - raise - finally: - if session_id: - print("🛑 Ending session...") - client.sessions.end(id=session_id) - print("✅ Session ended") - print("🔌 Closing client (shuts down the SEA server)...") - client.close() - print("✅ Local server shut down") - - -if __name__ == "__main__": - main() diff --git a/examples/logging_example.py b/examples/logging_example.py deleted file mode 100644 index 3bc5dc9f..00000000 --- a/examples/logging_example.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -Example demonstrating how to run an extract() call with streaming logs enabled -using the remote Browserbase Stagehand service. - -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 AsyncStagehand - - -async def main() -> None: - # Create client using environment variables - async with AsyncStagehand( - 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"), - ) as client: - # Start a new browser session with verbose logging enabled - session = await client.sessions.create( - model_name="openai/gpt-5-nano", - verbose=2, - ) - - print(f"Session started: {session.id}") - - try: - print("Navigating to https://www.example.com...") - await session.navigate(url="https://www.example.com") - print("Navigation complete.") - - print("\nExtracting the page heading with streaming logs...") - stream = await session.extract( - instruction="Extract the text of the top-level heading on this page.", - schema={ - "type": "object", - "properties": { - "headingText": { - "type": "string", - "description": "The text content of the top-level heading", - }, - "subheadingText": { - "type": "string", - "description": "Optional subheading text below the main heading", - }, - }, - "required": ["headingText"], - }, - stream_response=True, - x_stream_response="true", - ) - - result_payload: object | None = None - async for event in stream: - if event.type == "log": - print(f"[log] {event.data.message}") - continue - - status = event.data.status - print(f"[system] status={status}") - if status == "finished": - result_payload = event.data.result - elif status == "error": - error_message = event.data.error or "unknown error" - raise RuntimeError(f"Stream reported error: {error_message}") - - print("Extract completed successfully!") - print(f"Payload received: {result_payload}") - finally: - await session.end() - print("\nSession ended.") - - -if __name__ == "__main__": - import asyncio - - asyncio.run(main()) diff --git a/examples/playwright_page_example.py b/examples/playwright_page_example.py deleted file mode 100644 index 1140c454..00000000 --- a/examples/playwright_page_example.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Example: use a Playwright Page with the Stagehand Python SDK. - -What this demonstrates: -- Start a Stagehand session (remote Stagehand API / Browserbase browser) -- Attach Playwright to the same browser via CDP (`cdp_url`) -- Pass the Playwright `page` into `session.observe/act/extract` so Stagehand - auto-detects the correct `frame_id` for that page. - -Environment variables required: -- MODEL_API_KEY -- BROWSERBASE_API_KEY -- BROWSERBASE_PROJECT_ID - -Optional: -- STAGEHAND_BASE_URL (defaults to https://api.stagehand.browserbase.com) -""" - -from __future__ import annotations - -import os -import sys -from typing import Optional - -from stagehand import Stagehand - - -def main() -> None: - model_api_key = os.environ.get("MODEL_API_KEY") - if not model_api_key: - sys.exit("Set the MODEL_API_KEY environment variable to run this example.") - - bb_api_key = os.environ.get("BROWSERBASE_API_KEY") - bb_project_id = os.environ.get("BROWSERBASE_PROJECT_ID") - if not bb_api_key or not bb_project_id: - sys.exit( - "Set BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID to run this example." - ) - - try: - from playwright.sync_api import sync_playwright # type: ignore[import-not-found] - except Exception: - sys.exit( - "Playwright is not installed. Install it with:\n" - " uv pip install playwright\n" - "and ensure browsers are installed (e.g. `playwright install chromium`)." - ) - - session_id: Optional[str] = None - - with Stagehand( - server="remote", - browserbase_api_key=bb_api_key, - browserbase_project_id=bb_project_id, - model_api_key=model_api_key, - ) as client: - print("⏳ Starting Stagehand session...") - session = client.sessions.create( - model_name="openai/gpt-5-nano", - browser={"type": "browserbase"}, - ) - session_id = session.id - - cdp_url = session.data.cdp_url - if not cdp_url: - sys.exit( - "No cdp_url returned from the API for this session; cannot attach Playwright." - ) - - print(f"✅ Session started: {session_id}") - print("🔌 Connecting Playwright to the same browser over CDP...") - - with sync_playwright() as p: - # Attach to the same browser session Stagehand is controlling. - browser = p.chromium.connect_over_cdp(cdp_url) - try: - # Reuse an existing context/page if present; otherwise create one. - context = browser.contexts[0] if browser.contexts else browser.new_context() - page = context.pages[0] if context.pages else context.new_page() - - page.goto("https://example.com", wait_until="domcontentloaded") - - print("👀 Stagehand.observe(page=...) ...") - actions = session.observe( - instruction="Find the most relevant click target on this page", - page=page, - ) - print(f"Observed {len(actions.data.result)} actions") - - print("🧠 Stagehand.extract(page=...) ...") - extracted = session.extract( - instruction="Extract the page title and the primary heading (h1) text", - schema={ - "type": "object", - "properties": { - "title": {"type": "string"}, - "h1": {"type": "string"}, - }, - "required": ["title", "h1"], - "additionalProperties": False, - }, - page=page, - ) - print("Extracted:", extracted.data.result) - - print("🖱️ Stagehand.act(page=...) ...") - _ = session.act( - input="Click the 'More information' link", - page=page, - ) - print("Done.") - finally: - browser.close() - - -if __name__ == "__main__": - main() - diff --git a/examples/pydoll_tab_example.py b/examples/pydoll_tab_example.py deleted file mode 100644 index 54ff427a..00000000 --- a/examples/pydoll_tab_example.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Example: use a Pydoll Tab with the Stagehand Python SDK. - -What this demonstrates: -- Start a Stagehand session (remote Stagehand API / Browserbase browser) -- Attach Pydoll to the same browser via CDP (`cdp_url`) -- Use Pydoll to navigate -- Fetch the current page's `frame_id` via CDP `Page.getFrameTree` -- Fetch the current page's `frame_id` via CDP ePage.getFrameTree` -- Pass `frame_id` into `session.observe/act/extract` - -Environment variables required: -- MODEL_API_KEY -- BROWSERBASE_API_KEY -- BROWSERBASE_PROJECT_ID - -Optional: -- STAGEHAND_BASE_URL (defaults to https://api.stagehand.browserbase.com) - -Notes: -- This example requires Python 3.10+ because `pydoll-python` requires Python 3.10+. -- If this repo is pinned to an older Python via `.python-version`, run with: - - `uv run --python 3.12 python examples/pydoll_tab_example.py` -""" - -from __future__ import annotations - -import os -import sys -import asyncio -from typing import Any - -from stagehand import AsyncStagehand - - -def _normalize_ws_address_for_pydoll(cdp_url: str) -> str: - # Pydoll currently validates the address strictly as `ws://...` (not `wss://...`). - if cdp_url.startswith("ws://"): - return cdp_url - if cdp_url.startswith("wss://"): - return "ws://" + cdp_url.removeprefix("wss://") - if cdp_url.startswith("http://"): - return "ws://" + cdp_url.removeprefix("http://") - if cdp_url.startswith("https://"): - return "ws://" + cdp_url.removeprefix("https://") - raise RuntimeError(f"Unsupported CDP URL scheme for Pydoll: {cdp_url!r}") - - -async def _pydoll_attach_to_tab_session(*, chrome: Any, tab: Any) -> tuple[Any, str]: - """ - Attach to the tab target via CDP Target.attachToTarget (flatten mode) and return (handler, session_id). - - For some CDP proxies (including Browserbase connect URLs), the `/devtools/page/` endpoints may not - behave like local Chrome's endpoints. Attaching and sending commands with `sessionId` is the most - compatible approach. - """ - handler = getattr(chrome, "_connection_handler", None) - if handler is None: - raise RuntimeError("Could not find Pydoll browser connection handler on `chrome`.") - - target_id = getattr(tab, "_target_id", None) or getattr(tab, "target_id", None) - if not target_id: - raise RuntimeError("Could not find a target id on the tab (expected `tab._target_id`).") - - attached = await handler.execute_command( - { - "method": "Target.attachToTarget", - "params": {"targetId": target_id, "flatten": True}, - }, - timeout=60, - ) - try: - return handler, attached["result"]["sessionId"] - except Exception as e: # noqa: BLE001 - raise RuntimeError("Failed to attach to target and get sessionId") from e - - -async def _pydoll_execute_on_session(*, handler: Any, session_id: str, command: dict[str, Any]) -> dict[str, Any]: - cmd = dict(command) - cmd["sessionId"] = session_id - return await handler.execute_command(cmd, timeout=60) - - -async def _pydoll_session_to_frame_id(*, handler: Any, session_id: str) -> str: - response = await _pydoll_execute_on_session( - handler=handler, - session_id=session_id, - command={"method": "Page.getFrameTree", "params": {}}, - ) - try: - return response["result"]["frameTree"]["frame"]["id"] - except Exception as e: # noqa: BLE001 - raise RuntimeError("Failed to extract frame id from CDP Page.getFrameTree response") from e - - -async def main() -> None: - model_api_key = os.environ.get("MODEL_API_KEY") - if not model_api_key: - sys.exit("Set the MODEL_API_KEY environment variable to run this example.") - - bb_api_key = os.environ.get("BROWSERBASE_API_KEY") - bb_project_id = os.environ.get("BROWSERBASE_PROJECT_ID") - if not bb_api_key or not bb_project_id: - sys.exit("Set BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID to run this example.") - - try: - from pydoll.browser.chromium import Chrome # type: ignore[import-not-found] - except Exception: - sys.exit( - "Pydoll is not installed. Install it with:\n" - " uv pip install pydoll-python\n" - "or:\n" - " pip install pydoll-python\n" - ) - - async with AsyncStagehand( - server="remote", - browserbase_api_key=bb_api_key, - browserbase_project_id=bb_project_id, - model_api_key=model_api_key, - ) as client: - print("⏳ Starting Stagehand session...") - session = await client.sessions.create( - model_name="openai/gpt-5-nano", - browser={"type": "browserbase"}, - ) - - cdp_url = session.data.cdp_url - if not cdp_url: - sys.exit("No cdp_url returned from the API for this session; cannot attach Pydoll.") - - print(f"✅ Session started: {session.id}") - print("🔌 Connecting Pydoll to the same browser over CDP...") - - chrome = Chrome() - try: - ws_address = _normalize_ws_address_for_pydoll(cdp_url) - if ws_address != cdp_url: - print(f"ℹ️ Normalized cdp_url for Pydoll: {ws_address}") - tab = await chrome.connect(ws_address) - - handler, session_id = await _pydoll_attach_to_tab_session(chrome=chrome, tab=tab) - - await _pydoll_execute_on_session( - handler=handler, - session_id=session_id, - command={"method": "Page.enable", "params": {}}, - ) - await _pydoll_execute_on_session( - handler=handler, - session_id=session_id, - command={"method": "Runtime.enable", "params": {}}, - ) - - # Navigate a bit using CDP (via the attached session). - await _pydoll_execute_on_session( - handler=handler, - session_id=session_id, - command={"method": "Page.navigate", "params": {"url": "https://example.com"}}, - ) - await asyncio.sleep(2) - - await _pydoll_execute_on_session( - handler=handler, - session_id=session_id, - command={ - "method": "Page.navigate", - "params": {"url": "https://www.iana.org/domains/reserved"}, - }, - ) - await asyncio.sleep(2) - - frame_id = await _pydoll_session_to_frame_id(handler=handler, session_id=session_id) - print(f"🧩 frame_id: {frame_id}") - - print("👀 Stagehand.observe(frame_id=...) ...") - actions = await session.observe( - instruction="Find the most relevant click target on this page", - frame_id=frame_id, - ) - print(f"Observed {len(actions.data.result)} actions") - - print("🧠 Stagehand.extract(frame_id=...) ...") - extracted = await session.extract( - instruction="Extract the page title and the primary heading (h1) text", - schema={ - "type": "object", - "properties": { - "title": {"type": "string"}, - "h1": {"type": "string"}, - }, - "required": ["title", "h1"], - "additionalProperties": False, - }, - frame_id=frame_id, - ) - print("Extracted:", extracted.data.result) - - finally: - close = getattr(chrome, "close", None) - if callable(close): - await close() - await session.end() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/hatch_build.py b/hatch_build.py deleted file mode 100644 index d96a9596..00000000 --- a/hatch_build.py +++ /dev/null @@ -1,58 +0,0 @@ -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 b872e67b..f5a15105 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "stagehand" version = "3.4.7" description = "The official Python library for the stagehand API" dynamic = ["readme"] -license = "Apache-2.0" +license = "MIT" authors = [ { name = "Stagehand", email = "" }, ] @@ -33,7 +33,7 @@ classifiers = [ "Operating System :: POSIX :: Linux", "Operating System :: Microsoft :: Windows", "Topic :: Software Development :: Libraries :: Python Modules", - "License :: OSI Approved :: Apache Software License" + "License :: OSI Approved :: MIT License" ] [project.urls] @@ -67,7 +67,6 @@ dev = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "pytest-xdist>=3.6.1", - "dotenv>=0.9.9", ] pydantic-v1 = [ "pydantic>=1.9.0,<2", @@ -78,7 +77,7 @@ pydantic-v2 = [ ] [build-system] -requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme", "packaging"] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" [tool.hatch.build] @@ -86,17 +85,8 @@ include = [ "src/*" ] -[tool.hatch.build.hooks.custom] -path = "hatch_build.py" - [tool.hatch.build.targets.wheel] packages = ["src/stagehand"] -exclude = [ - "src/stagehand/_sea/.keep" -] - -[tool.hatch.build.targets.wheel.force-include] -"src/stagehand/_sea" = "stagehand/_sea" [tool.hatch.build.targets.sdist] # Basically everything except hidden files/directories (such as .github, .devcontainers, .python-version, etc) @@ -122,7 +112,7 @@ path = "README.md" [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] # replace relative links with absolute links pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' -replacement = '[\1](https://github.com/browserbase/stagehand-python/tree/stainless/\g<2>)' +replacement = '[\1](https://github.com/browserbase/stagehand-python/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] @@ -146,10 +136,6 @@ exclude = [ ".venv", ".nox", ".git", - "hatch_build.py", - "examples", - "scripts", - "test_local_mode.py", ] reportImplicitOverride = true @@ -168,7 +154,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/.*', 'hatch_build.py', 'examples/.*', 'scripts/.*', 'test_local_mode.py'] +exclude = ['src/stagehand/_files.py', '_dev/.*.py', 'tests/.*'] strict_equality = true implicit_reexport = true diff --git a/requirements-dev.lock b/requirements-dev.lock index 0bb435b6..db709b17 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -6,7 +6,7 @@ annotated-types==0.7.0 anyio==4.12.0 # via # httpx - # stagehand-alpha + # stagehand backports-asyncio-runner==1.2.0 ; python_full_version < '3.11' # via pytest-asyncio certifi==2025.11.12 @@ -17,7 +17,7 @@ colorama==0.4.6 ; sys_platform == 'win32' # via pytest dirty-equals==0.11 distro==1.9.0 - # via stagehand-alpha + # via stagehand exceptiongroup==1.3.1 ; python_full_version < '3.11' # via # anyio @@ -31,7 +31,7 @@ httpcore==1.0.9 httpx==0.28.1 # via # respx - # stagehand-alpha + # stagehand idna==3.11 # via # anyio @@ -59,7 +59,7 @@ pathspec==0.12.1 pluggy==1.6.0 # via pytest pydantic==2.12.5 - # via stagehand-alpha + # via stagehand pydantic-core==2.41.5 # via pydantic pygments==2.19.2 @@ -86,7 +86,7 @@ ruff==0.14.7 six==1.17.0 ; python_full_version < '3.10' # via python-dateutil sniffio==1.3.1 - # via stagehand-alpha + # via stagehand time-machine==2.19.0 ; python_full_version < '3.10' time-machine==3.1.0 ; python_full_version >= '3.10' tomli==2.3.0 ; python_full_version < '3.11' @@ -102,7 +102,7 @@ typing-extensions==4.15.0 # pydantic-core # pyright # pytest-asyncio - # stagehand-alpha + # stagehand # typing-inspection typing-inspection==0.4.2 # via pydantic diff --git a/scripts/download-binary.py b/scripts/download-binary.py deleted file mode 100755 index 3e895ede..00000000 --- a/scripts/download-binary.py +++ /dev/null @@ -1,209 +0,0 @@ -#!/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 os -import sys -import json -import argparse -import platform -import urllib.error -import urllib.request -from typing import Any -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 _parse_server_tag(tag: str) -> tuple[int, int, int] | None: - # Expected: stagehand-server/vX.Y.Z - if not tag.startswith("stagehand-server/v"): - return None - - ver = tag.removeprefix("stagehand-server/v") - # Drop any pre-release/build metadata (we only expect stable tags here). - ver = ver.split("-", 1)[0].split("+", 1)[0] - parts = ver.split(".") - if len(parts) != 3: - return None - try: - return int(parts[0]), int(parts[1]), int(parts[2]) - except ValueError: - return None - - -def _http_get_json(url: str) -> Any: - headers = { - "Accept": "application/vnd.github+json", - "User-Agent": "stagehand-python/download-binary", - } - # Optional, but helps avoid rate limits in CI. - token = (os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or "").strip() - if token: - headers["Authorization"] = f"Bearer {token}" - - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=30) as resp: - data = resp.read() - return json.loads(data.decode("utf-8")) - - -def resolve_latest_server_tag() -> str: - """Resolve the latest stagehand-server/v* tag from GitHub releases.""" - repo = "browserbase/stagehand" - releases_url = f"https://api.github.com/repos/{repo}/releases?per_page=100" - try: - releases = _http_get_json(releases_url) - except urllib.error.HTTPError as e: # type: ignore[misc] - raise RuntimeError(f"Failed to query GitHub releases (HTTP {e.code}): {releases_url}") from e # type: ignore[union-attr] - except Exception as e: - raise RuntimeError(f"Failed to query GitHub releases: {releases_url}") from e - - if not isinstance(releases, list): - raise RuntimeError(f"Unexpected GitHub API response for releases: {type(releases).__name__}") - - best: tuple[tuple[int, int, int], str] | None = None - for r in releases: - if not isinstance(r, dict): - continue - tag = r.get("tag_name") - if not isinstance(tag, str): - continue - parsed = _parse_server_tag(tag) - if parsed is None: - continue - if best is None or parsed > best[0]: - best = (parsed, tag) - - if best is None: - raise RuntimeError("No stagehand-server/v* GitHub Releases found for browserbase/stagehand") - - return best[1] - - -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=None, - help="Stagehand server release tag/version to download (e.g. v3.2.0 or stagehand-server/v3.2.0). Defaults to latest stagehand-server/* GitHub Release.", - ) - - args = parser.parse_args() - version = str(args.version).strip() if args.version is not None else "" - if not version: - latest_tag = resolve_latest_server_tag() - download_binary(latest_tag) - return - - download_binary(version) - - -if __name__ == "__main__": - main() diff --git a/src/stagehand/_base_client.py b/src/stagehand/_base_client.py index a415befe..00e248e2 100644 --- a/src/stagehand/_base_client.py +++ b/src/stagehand/_base_client.py @@ -9,6 +9,7 @@ import inspect import logging import platform +import warnings import email.utils from types import TracebackType from random import random @@ -51,9 +52,11 @@ ResponseT, AnyMapping, PostParser, + BinaryTypes, RequestFiles, HttpxSendArgs, RequestOptions, + AsyncBinaryTypes, HttpxRequestFiles, ModelBuilderProtocol, not_given, @@ -477,8 +480,19 @@ def _build_request( retries_taken: int = 0, ) -> httpx.Request: if log.isEnabledFor(logging.DEBUG): - log.debug("Request options: %s", model_dump(options, exclude_unset=True)) - + log.debug( + "Request options: %s", + model_dump( + options, + exclude_unset=True, + # Pydantic v1 can't dump every type we support in content, so we exclude it for now. + exclude={ + "content", + } + if PYDANTIC_V1 + else {}, + ), + ) kwargs: dict[str, Any] = {} json_data = options.json_data @@ -532,7 +546,13 @@ def _build_request( is_body_allowed = options.method.lower() != "get" if is_body_allowed: - if isinstance(json_data, bytes): + if options.content is not None and json_data is not None: + raise TypeError("Passing both `content` and `json_data` is not supported") + if options.content is not None and files is not None: + raise TypeError("Passing both `content` and `files` is not supported") + if options.content is not None: + kwargs["content"] = options.content + elif isinstance(json_data, bytes): kwargs["content"] = json_data else: kwargs["json"] = json_data if is_given(json_data) else None @@ -1194,6 +1214,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[False] = False, @@ -1206,6 +1227,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], @@ -1219,6 +1241,7 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, @@ -1231,13 +1254,25 @@ def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @@ -1247,11 +1282,23 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=to_httpx_files(files), **options + method="patch", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1261,11 +1308,23 @@ def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=to_httpx_files(files), **options ) return self.request(cast_to, opts) @@ -1275,9 +1334,19 @@ def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return self.request(cast_to, opts) def get_api_list( @@ -1303,7 +1372,7 @@ def __init__(self, **kwargs: Any) -> None: try: - import httpx_aiohttp # type: ignore[import-not-found] # pyright: ignore[reportMissingImports] + import httpx_aiohttp except ImportError: class _DefaultAioHttpClient(httpx.AsyncClient): @@ -1317,7 +1386,7 @@ def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) kwargs.setdefault("follow_redirects", True) - super().__init__(**kwargs) # pyright: ignore[reportUnknownMemberType] + super().__init__(**kwargs) if TYPE_CHECKING: @@ -1717,6 +1786,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[False] = False, @@ -1729,6 +1799,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], @@ -1742,6 +1813,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1754,13 +1826,25 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, ) -> ResponseT | _AsyncStreamT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="post", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="post", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @@ -1770,11 +1854,28 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="patch", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="patch", + url=path, + json_data=body, + content=content, + files=await async_to_httpx_files(files), + **options, ) return await self.request(cast_to, opts) @@ -1784,11 +1885,23 @@ async def put( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if files is not None and content is not None: + raise TypeError("Passing both `files` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) opts = FinalRequestOptions.construct( - method="put", url=path, json_data=body, files=await async_to_httpx_files(files), **options + method="put", url=path, json_data=body, content=content, files=await async_to_httpx_files(files), **options ) return await self.request(cast_to, opts) @@ -1798,9 +1911,19 @@ async def delete( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, **options) + if body is not None and content is not None: + raise TypeError("Passing both `body` and `content` is not supported") + if isinstance(body, bytes): + warnings.warn( + "Passing raw bytes as `body` is deprecated and will be removed in a future version. " + "Please pass raw bytes via the `content` parameter instead.", + DeprecationWarning, + stacklevel=2, + ) + opts = FinalRequestOptions.construct(method="delete", url=path, json_data=body, content=content, **options) return await self.request(cast_to, opts) def get_api_list( diff --git a/src/stagehand/_client.py b/src/stagehand/_client.py index 4da9963a..f5c0ea12 100644 --- a/src/stagehand/_client.py +++ b/src/stagehand/_client.py @@ -3,9 +3,8 @@ from __future__ import annotations import os -import datetime from typing import TYPE_CHECKING, Any, Mapping -from typing_extensions import Self, Literal, override +from typing_extensions import Self, override import httpx @@ -22,7 +21,6 @@ ) 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 @@ -31,11 +29,10 @@ SyncAPIClient, AsyncAPIClient, ) -from .lib.sea_server import SeaServerConfig, SeaServerManager if TYPE_CHECKING: from .resources import sessions - from .resources.sessions_helpers import SessionsResourceWithHelpers, AsyncSessionsResourceWithHelpers + from .resources.sessions import SessionsResource, AsyncSessionsResource __all__ = [ "Timeout", @@ -61,15 +58,6 @@ 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_stagehand_binary_path: str | os.PathLike[str] | None = None, - local_host: str = "127.0.0.1", - local_port: int = 0, - local_headless: bool = True, - local_chrome_path: str | None = None, - 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, @@ -96,22 +84,20 @@ 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_stagehand_binary_path = _local_stagehand_binary_path - self._local_host = local_host - self._local_port = local_port - self._local_headless = local_headless - self._local_chrome_path = local_chrome_path - 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: + raise StagehandError( + "The browserbase_api_key client option must be set either by passing browserbase_api_key to the client or by setting the BROWSERBASE_API_KEY environment variable" + ) + self.browserbase_api_key = browserbase_api_key + if browserbase_project_id is None: browserbase_project_id = os.environ.get("BROWSERBASE_PROJECT_ID") - - self.browserbase_api_key = browserbase_api_key + if browserbase_project_id is None: + raise StagehandError( + "The browserbase_project_id client option must be set either by passing browserbase_project_id to the client or by setting the BROWSERBASE_PROJECT_ID environment variable" + ) self.browserbase_project_id = browserbase_project_id if model_api_key is None: @@ -122,30 +108,10 @@ def __init__( ) self.model_api_key = model_api_key - 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, - chrome_path=local_chrome_path, - shutdown_on_close=local_shutdown_on_close, - ), - _local_stagehand_binary_path=_local_stagehand_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" + 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" super().__init__( version=__version__, @@ -160,25 +126,11 @@ 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) -> SessionsResourceWithHelpers: - from .resources.sessions_helpers import SessionsResourceWithHelpers + def sessions(self) -> SessionsResource: + from .resources.sessions import SessionsResource - return SessionsResourceWithHelpers(self) + return SessionsResource(self) @cached_property def with_raw_response(self) -> StagehandWithRawResponse: @@ -201,12 +153,12 @@ def auth_headers(self) -> dict[str, str]: @property def _bb_api_key_auth(self) -> dict[str, str]: browserbase_api_key = self.browserbase_api_key - return {"x-bb-api-key": browserbase_api_key} if browserbase_api_key else {} + return {"x-bb-api-key": browserbase_api_key} @property def _bb_project_id_auth(self) -> dict[str, str]: browserbase_project_id = self.browserbase_project_id - return {"x-bb-project-id": browserbase_project_id} if browserbase_project_id else {} + return {"x-bb-project-id": browserbase_project_id} @property def _llm_model_api_key_auth(self) -> dict[str, str]: @@ -218,9 +170,6 @@ 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-sdk-version": __version__, - "x-sent-at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "X-Stainless-Async": "false", **self._custom_headers, } @@ -231,14 +180,6 @@ 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_stagehand_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, @@ -275,20 +216,6 @@ 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_stagehand_binary_path=_local_stagehand_binary_path if _local_stagehand_binary_path is not None else self._local_stagehand_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, @@ -348,15 +275,6 @@ 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_stagehand_binary_path: str | os.PathLike[str] | None = None, - local_host: str = "127.0.0.1", - local_port: int = 0, - local_headless: bool = True, - local_chrome_path: str | None = None, - 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, @@ -383,22 +301,20 @@ 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_stagehand_binary_path = _local_stagehand_binary_path - self._local_host = local_host - self._local_port = local_port - self._local_headless = local_headless - self._local_chrome_path = local_chrome_path - 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: + raise StagehandError( + "The browserbase_api_key client option must be set either by passing browserbase_api_key to the client or by setting the BROWSERBASE_API_KEY environment variable" + ) + self.browserbase_api_key = browserbase_api_key + if browserbase_project_id is None: browserbase_project_id = os.environ.get("BROWSERBASE_PROJECT_ID") - - self.browserbase_api_key = browserbase_api_key + if browserbase_project_id is None: + raise StagehandError( + "The browserbase_project_id client option must be set either by passing browserbase_project_id to the client or by setting the BROWSERBASE_PROJECT_ID environment variable" + ) self.browserbase_project_id = browserbase_project_id if model_api_key is None: @@ -409,29 +325,10 @@ def __init__( ) self.model_api_key = model_api_key - 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, - chrome_path=local_chrome_path, - shutdown_on_close=local_shutdown_on_close, - ), - _local_stagehand_binary_path=_local_stagehand_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" + 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" super().__init__( version=__version__, @@ -446,25 +343,11 @@ 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) -> AsyncSessionsResourceWithHelpers: - from .resources.sessions_helpers import AsyncSessionsResourceWithHelpers + def sessions(self) -> AsyncSessionsResource: + from .resources.sessions import AsyncSessionsResource - return AsyncSessionsResourceWithHelpers(self) + return AsyncSessionsResource(self) @cached_property def with_raw_response(self) -> AsyncStagehandWithRawResponse: @@ -487,12 +370,12 @@ def auth_headers(self) -> dict[str, str]: @property def _bb_api_key_auth(self) -> dict[str, str]: browserbase_api_key = self.browserbase_api_key - return {"x-bb-api-key": browserbase_api_key} if browserbase_api_key else {} + return {"x-bb-api-key": browserbase_api_key} @property def _bb_project_id_auth(self) -> dict[str, str]: browserbase_project_id = self.browserbase_project_id - return {"x-bb-project-id": browserbase_project_id} if browserbase_project_id else {} + return {"x-bb-project-id": browserbase_project_id} @property def _llm_model_api_key_auth(self) -> dict[str, str]: @@ -504,9 +387,6 @@ 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-sdk-version": __version__, - "x-sent-at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "X-Stainless-Async": f"async:{get_async_library()}", **self._custom_headers, } @@ -517,14 +397,6 @@ 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_stagehand_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, @@ -561,20 +433,6 @@ 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_stagehand_binary_path=_local_stagehand_binary_path if _local_stagehand_binary_path is not None else self._local_stagehand_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/_models.py b/src/stagehand/_models.py index ca9500b2..29070e05 100644 --- a/src/stagehand/_models.py +++ b/src/stagehand/_models.py @@ -3,7 +3,20 @@ import os import inspect import weakref -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +from typing import ( + IO, + TYPE_CHECKING, + Any, + Type, + Union, + Generic, + TypeVar, + Callable, + Iterable, + Optional, + AsyncIterable, + cast, +) from datetime import date, datetime from typing_extensions import ( List, @@ -787,6 +800,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): timeout: float | Timeout | None files: HttpxRequestFiles | None idempotency_key: str + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] json_data: Body extra_json: AnyMapping follow_redirects: bool @@ -805,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel): post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() follow_redirects: Union[bool, None] = None + content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None # It should be noted that we cannot use `json` here as that would override # a BaseModel method in an incompatible fashion. json_data: Union[Body, None] = None diff --git a/src/stagehand/_sea/.keep b/src/stagehand/_sea/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/stagehand/_types.py b/src/stagehand/_types.py index c2ce0d7a..d25d0560 100644 --- a/src/stagehand/_types.py +++ b/src/stagehand/_types.py @@ -13,9 +13,11 @@ Mapping, TypeVar, Callable, + Iterable, Iterator, Optional, Sequence, + AsyncIterable, ) from typing_extensions import ( Set, @@ -56,6 +58,13 @@ else: Base64FileInput = Union[IO[bytes], PathLike] FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8. + + +# Used for sending raw binary data / streaming data in request bodies +# e.g. for file uploads without multipart encoding +BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]] +AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]] + FileTypes = Union[ # file (or bytes) FileContent, diff --git a/src/stagehand/_utils/_utils.py b/src/stagehand/_utils/_utils.py index 1c50ff6a..eec7f4a1 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 | Omit]) -> dict[_K, _V]: ... +def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> 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 `not_given` or `omit`.""" + """Remove all top-level keys where their values are instances of `NotGiven`""" if obj is None: return None if not is_mapping(obj): return obj - return {key: value for key, value in obj.items() if is_given(value)} + return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)} def coerce_integer(val: str) -> int: diff --git a/src/stagehand/lib/__init__.py b/src/stagehand/lib/__init__.py deleted file mode 100644 index 60fb7e11..00000000 --- a/src/stagehand/lib/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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 deleted file mode 100644 index f42c737a..00000000 --- a/src/stagehand/lib/sea_binary.py +++ /dev/null @@ -1,121 +0,0 @@ -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_stagehand_binary_path: str | os.PathLike[str] | None = None, - version: str | None = None, -) -> Path: - if _local_stagehand_binary_path is not None: - path = Path(_local_stagehand_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 deleted file mode 100644 index baed6da0..00000000 --- a/src/stagehand/lib/sea_server.py +++ /dev/null @@ -1,263 +0,0 @@ -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 - chrome_path: 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_stagehand_binary_path: str | os.PathLike[str] | None = None, - ) -> None: - self._config = config - self._binary_path: Path = resolve_binary_path(_local_stagehand_binary_path=_local_stagehand_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_stagehand_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 - if self._config.chrome_path: - proc_env["CHROME_PATH"] = self._config.chrome_path - proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path - - 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_stagehand_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 - if self._config.chrome_path: - proc_env["CHROME_PATH"] = self._config.chrome_path - proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path - - 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 b4fdbbe4..5f31c6b9 100644 --- a/src/stagehand/resources/sessions.py +++ b/src/stagehand/resources/sessions.py @@ -2,23 +2,21 @@ from __future__ import annotations -from typing import Dict, TYPE_CHECKING, Union -from datetime import datetime, timezone +from typing import Dict, Union +from datetime import datetime from typing_extensions import Literal, overload import httpx from ..types import ( session_act_params, + session_end_params, session_start_params, session_execute_params, session_extract_params, session_observe_params, session_navigate_params, ) - -if TYPE_CHECKING: - from .._client import Stagehand, AsyncStagehand from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from .._utils import is_given, required_args, maybe_transform, strip_not_given, async_maybe_transform from .._compat import cached_property @@ -39,36 +37,10 @@ from ..types.session_extract_response import SessionExtractResponse from ..types.session_observe_response import SessionObserveResponse from ..types.session_navigate_response import SessionNavigateResponse -from .._exceptions import StagehandError __all__ = ["SessionsResource", "AsyncSessionsResource"] -def _format_x_sent_at(value: Union[str, datetime] | Omit) -> str | NotGiven: - if isinstance(value, datetime): - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - value = value.astimezone(timezone.utc) - return value.isoformat().replace("+00:00", "Z") - if isinstance(value, Omit): - return not_given - return value - - -def _requires_browserbase_credentials( - _: "Stagehand | AsyncStagehand", - browser: session_start_params.Browser | Omit, -) -> bool: - if not is_given(browser): - return True - - browser_type = None - if isinstance(browser, dict): - browser_type = browser.get("type") - - return browser_type != "local" - - class SessionsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> SessionsResourceWithRawResponse: @@ -247,7 +219,7 @@ def act( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -311,7 +283,7 @@ def end( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -319,7 +291,7 @@ def end( } return self._post( f"/v1/sessions/{id}/end", - body={}, # Empty object to satisfy Content-Type requirement + body=maybe_transform({"_force_body": _force_body}, session_end_params.SessionEndParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -475,7 +447,7 @@ def execute( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -666,7 +638,7 @@ def extract( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -740,7 +712,7 @@ def navigate( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -920,7 +892,7 @@ def observe( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1004,20 +976,10 @@ def start( timeout: Override the client-level default timeout for this request, in seconds """ - if _requires_browserbase_credentials(self._client, browser): - missing: list[str] = [] - if not self._client.browserbase_api_key: - missing.append("browserbase_api_key") - if not self._client.browserbase_project_id: - missing.append("browserbase_project_id") - if missing: - raise StagehandError( - f"Browserbase credentials are required when launching a Browserbase browser: missing {', '.join(missing)}." - ) extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1226,7 +1188,7 @@ async def act( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1290,7 +1252,7 @@ async def end( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1298,7 +1260,7 @@ async def end( } return await self._post( f"/v1/sessions/{id}/end", - body={}, # Empty object to satisfy Content-Type requirement + body=await async_maybe_transform({"_force_body": _force_body}, session_end_params.SessionEndParams), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -1454,7 +1416,7 @@ async def execute( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1645,7 +1607,7 @@ async def extract( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1719,7 +1681,7 @@ async def navigate( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1899,7 +1861,7 @@ async def observe( extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), @@ -1983,20 +1945,10 @@ async def start( timeout: Override the client-level default timeout for this request, in seconds """ - if _requires_browserbase_credentials(self._client, browser): - missing: list[str] = [] - if not self._client.browserbase_api_key: - missing.append("browserbase_api_key") - if not self._client.browserbase_project_id: - missing.append("browserbase_project_id") - if missing: - raise StagehandError( - f"Browserbase credentials are required when launching a Browserbase browser: missing {', '.join(missing)}." - ) extra_headers = { **strip_not_given( { - "x-sent-at": _format_x_sent_at(x_sent_at), + "x-sent-at": x_sent_at.isoformat() if is_given(x_sent_at) else not_given, "x-stream-response": str(x_stream_response) if is_given(x_stream_response) else not_given, } ), diff --git a/src/stagehand/resources/sessions_helpers.py b/src/stagehand/resources/sessions_helpers.py deleted file mode 100644 index df8941a7..00000000 --- a/src/stagehand/resources/sessions_helpers.py +++ /dev/null @@ -1,103 +0,0 @@ -# Manually maintained helpers (not generated). - -from __future__ import annotations - -from typing import Union -from datetime import datetime -from typing_extensions import Literal - -import httpx - -from ..types import session_start_params -from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..session import Session, AsyncSession -from .sessions import SessionsResource, AsyncSessionsResource -from ..types.session_start_response import SessionStartResponse - - -class SessionsResourceWithHelpers(SessionsResource): - def create( - self, - *, - model_name: str, - act_timeout_ms: float | Omit = omit, - browser: session_start_params.Browser | Omit = omit, - browserbase_session_create_params: session_start_params.BrowserbaseSessionCreateParams | Omit = omit, - browserbase_session_id: str | Omit = omit, - dom_settle_timeout_ms: float | Omit = omit, - experimental: bool | Omit = omit, - self_heal: bool | Omit = omit, - system_prompt: str | Omit = omit, - verbose: Literal[0, 1, 2] | Omit = omit, - wait_for_captcha_solves: bool | Omit = omit, - x_sent_at: Union[str, datetime] | Omit = omit, - x_stream_response: Literal["true", "false"] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> Session: - start_response = self.start( - model_name=model_name, - act_timeout_ms=act_timeout_ms, - browser=browser, - browserbase_session_create_params=browserbase_session_create_params, - browserbase_session_id=browserbase_session_id, - dom_settle_timeout_ms=dom_settle_timeout_ms, - experimental=experimental, - self_heal=self_heal, - system_prompt=system_prompt, - verbose=verbose, - wait_for_captcha_solves=wait_for_captcha_solves, - x_sent_at=x_sent_at, - x_stream_response=x_stream_response, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - return Session(self._client, start_response.data.session_id, data=start_response.data, success=start_response.success) - - -class AsyncSessionsResourceWithHelpers(AsyncSessionsResource): - async def create( - self, - *, - model_name: str, - act_timeout_ms: float | Omit = omit, - browser: session_start_params.Browser | Omit = omit, - browserbase_session_create_params: session_start_params.BrowserbaseSessionCreateParams | Omit = omit, - browserbase_session_id: str | Omit = omit, - dom_settle_timeout_ms: float | Omit = omit, - experimental: bool | Omit = omit, - self_heal: bool | Omit = omit, - system_prompt: str | Omit = omit, - verbose: Literal[0, 1, 2] | Omit = omit, - wait_for_captcha_solves: bool | Omit = omit, - x_sent_at: Union[str, datetime] | Omit = omit, - x_stream_response: Literal["true", "false"] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncSession: - start_response: SessionStartResponse = await self.start( - model_name=model_name, - act_timeout_ms=act_timeout_ms, - browser=browser, - browserbase_session_create_params=browserbase_session_create_params, - browserbase_session_id=browserbase_session_id, - dom_settle_timeout_ms=dom_settle_timeout_ms, - experimental=experimental, - self_heal=self_heal, - system_prompt=system_prompt, - verbose=verbose, - wait_for_captcha_solves=wait_for_captcha_solves, - x_sent_at=x_sent_at, - x_stream_response=x_stream_response, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - return AsyncSession(self._client, start_response.data.session_id, data=start_response.data, success=start_response.success) diff --git a/src/stagehand/session.py b/src/stagehand/session.py deleted file mode 100644 index d519fef1..00000000 --- a/src/stagehand/session.py +++ /dev/null @@ -1,379 +0,0 @@ -# Manually maintained helpers (not generated). - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Union, cast -from datetime import datetime -import inspect -from typing_extensions import Unpack, Literal, Protocol - -import httpx - -from .types import ( - session_act_params, - session_execute_params, - session_extract_params, - session_observe_params, - session_navigate_params, -) -from ._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .types.session_act_response import SessionActResponse -from .types.session_end_response import SessionEndResponse -from .types.session_start_response import Data as SessionStartResponseData, SessionStartResponse -from .types.session_execute_response import SessionExecuteResponse -from .types.session_extract_response import SessionExtractResponse -from .types.session_observe_response import SessionObserveResponse -from .types.session_navigate_response import SessionNavigateResponse -from ._exceptions import StagehandError - -if TYPE_CHECKING: - from ._client import Stagehand, AsyncStagehand - - -class _PlaywrightCDPSession(Protocol): - def send(self, method: str, params: Any = ...) -> Any: # noqa: ANN401 - ... - - -class _PlaywrightContext(Protocol): - def new_cdp_session(self, page: Any) -> Any: # noqa: ANN401 - ... - - -def _extract_frame_id_from_playwright_page(page: Any) -> str: - context = getattr(page, "context", None) - if context is None: - raise StagehandError("page must be a Playwright Page with a .context attribute") - - if callable(context): - context = context() - - new_cdp_session = getattr(context, "new_cdp_session", None) - if not callable(new_cdp_session): - raise StagehandError( - "page must be a Playwright Page; expected page.context.new_cdp_session(...) to exist" - ) - - pw_context = cast(_PlaywrightContext, context) - cdp = pw_context.new_cdp_session(page) - if inspect.isawaitable(cdp): - raise StagehandError( - "Expected a synchronous Playwright Page, but received an async CDP session; use AsyncSession methods" - ) - - send = getattr(cdp, "send", None) - if not callable(send): - raise StagehandError("Playwright CDP session missing .send(...) method") - - pw_cdp = cast(_PlaywrightCDPSession, cdp) - result = pw_cdp.send("Page.getFrameTree") - if inspect.isawaitable(result): - raise StagehandError( - "Expected a synchronous Playwright Page, but received an async CDP session; use AsyncSession methods" - ) - - try: - return result["frameTree"]["frame"]["id"] - except Exception as e: # noqa: BLE001 - raise StagehandError("Failed to extract frame id from Playwright CDP Page.getFrameTree response") from e - - -async def _extract_frame_id_from_playwright_page_async(page: Any) -> str: - context = getattr(page, "context", None) - if context is None: - raise StagehandError("page must be a Playwright Page with a .context attribute") - - if callable(context): - context = context() - - new_cdp_session = getattr(context, "new_cdp_session", None) - if not callable(new_cdp_session): - raise StagehandError( - "page must be a Playwright Page; expected page.context.new_cdp_session(...) to exist" - ) - - pw_context = cast(_PlaywrightContext, context) - cdp = pw_context.new_cdp_session(page) - if inspect.isawaitable(cdp): - cdp = await cdp - - send = getattr(cdp, "send", None) - if not callable(send): - raise StagehandError("Playwright CDP session missing .send(...) method") - - pw_cdp = cast(_PlaywrightCDPSession, cdp) - result = pw_cdp.send("Page.getFrameTree") - if inspect.isawaitable(result): - result = await result - - try: - return result["frameTree"]["frame"]["id"] - except Exception as e: # noqa: BLE001 - raise StagehandError("Failed to extract frame id from Playwright CDP Page.getFrameTree response") from e - - -def _maybe_inject_frame_id(params: dict[str, Any], page: Any | None) -> dict[str, Any]: - if page is None: - return params - if "frame_id" in params: - return params - return {**params, "frame_id": _extract_frame_id_from_playwright_page(page)} - - -async def _maybe_inject_frame_id_async(params: dict[str, Any], page: Any | None) -> dict[str, Any]: - if page is None: - return params - if "frame_id" in params: - return params - return {**params, "frame_id": await _extract_frame_id_from_playwright_page_async(page)} - - -class Session(SessionStartResponse): - """A Stagehand session bound to a specific `session_id`.""" - - def __init__(self, client: Stagehand, id: str, data: SessionStartResponseData, success: bool) -> None: - # Must call super().__init__() first to initialize Pydantic's __pydantic_extra__ before setting attributes - super().__init__(data=data, success=success) - self._client = client - self.id = id - - - def navigate( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_navigate_params.SessionNavigateParams], - ) -> SessionNavigateResponse: - return self._client.sessions.navigate( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **_maybe_inject_frame_id(dict(params), page), - ) - - def act( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_act_params.SessionActParamsNonStreaming], - ) -> SessionActResponse: - return self._client.sessions.act( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **_maybe_inject_frame_id(dict(params), page), - ) - - def observe( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_observe_params.SessionObserveParamsNonStreaming], - ) -> SessionObserveResponse: - return self._client.sessions.observe( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **_maybe_inject_frame_id(dict(params), page), - ) - - def extract( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_extract_params.SessionExtractParamsNonStreaming], - ) -> SessionExtractResponse: - return self._client.sessions.extract( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **_maybe_inject_frame_id(dict(params), page), - ) - - def execute( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_execute_params.SessionExecuteParamsNonStreaming], - ) -> SessionExecuteResponse: - return self._client.sessions.execute( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **_maybe_inject_frame_id(dict(params), page), - ) - - def end( - self, - *, - x_sent_at: Union[str, datetime] | Omit = omit, - x_stream_response: Literal["true", "false"] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SessionEndResponse: - return self._client.sessions.end( - id=self.id, - x_sent_at=x_sent_at, - x_stream_response=x_stream_response, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class AsyncSession(SessionStartResponse): - """Async variant of `Session`.""" - - def __init__(self, client: AsyncStagehand, id: str, data: SessionStartResponseData, success: bool) -> None: - # Must call super().__init__() first to initialize Pydantic's __pydantic_extra__ before setting attributes - super().__init__(data=data, success=success) - self._client = client - self.id = id - - async def navigate( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_navigate_params.SessionNavigateParams], - ) -> SessionNavigateResponse: - return await self._client.sessions.navigate( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **(await _maybe_inject_frame_id_async(dict(params), page)), - ) - - async def act( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_act_params.SessionActParamsNonStreaming], - ) -> SessionActResponse: - return await self._client.sessions.act( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **(await _maybe_inject_frame_id_async(dict(params), page)), - ) - - async def observe( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_observe_params.SessionObserveParamsNonStreaming], - ) -> SessionObserveResponse: - return await self._client.sessions.observe( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **(await _maybe_inject_frame_id_async(dict(params), page)), - ) - - async def extract( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_extract_params.SessionExtractParamsNonStreaming], - ) -> SessionExtractResponse: - return await self._client.sessions.extract( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **(await _maybe_inject_frame_id_async(dict(params), page)), - ) - - async def execute( - self, - *, - page: Any | None = None, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - **params: Unpack[session_execute_params.SessionExecuteParamsNonStreaming], - ) -> SessionExecuteResponse: - return await self._client.sessions.execute( - id=self.id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - **(await _maybe_inject_frame_id_async(dict(params), page)), - ) - - async def end( - self, - *, - x_sent_at: Union[str, datetime] | Omit = omit, - x_stream_response: Literal["true", "false"] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SessionEndResponse: - return await self._client.sessions.end( - id=self.id, - x_sent_at=x_sent_at, - x_stream_response=x_stream_response, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) diff --git a/test_local_mode.py b/test_local_mode.py deleted file mode 100644 index 12267164..00000000 --- a/test_local_mode.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/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("MODEL_API_KEY") and not os.environ.get("OPENAI_API_KEY"): - print("❌ Error: MODEL_API_KEY or OPENAI_API_KEY environment variable not set") # noqa: T201 - print(" Set it with: export MODEL_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.get("MODEL_API_KEY") or 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", - ) - 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_client.py b/tests/test_client.py index d57cbeac..71a059e9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,10 +8,11 @@ import json import asyncio import inspect +import dataclasses import tracemalloc -from typing import Any, Union, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast from unittest import mock -from typing_extensions import Literal +from typing_extensions import Literal, AsyncIterator, override import httpx import pytest @@ -37,6 +38,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") browserbase_api_key = "My Browserbase API Key" browserbase_project_id = "My Browserbase Project ID" @@ -53,6 +55,57 @@ def _low_retry_timeout(*_args: Any, **_kwargs: Any) -> float: return 0.1 +def mirror_request_content(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=request.content) + + +# note: we can't use the httpx.MockTransport class as it consumes the request +# body itself, which means we can't test that the body is read lazily +class MockTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + def __init__( + self, + handler: Callable[[httpx.Request], httpx.Response] + | Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]], + ) -> None: + self.handler = handler + + @override + def handle_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert not inspect.iscoroutinefunction(self.handler), "handler must not be a coroutine function" + assert inspect.isfunction(self.handler), "handler must be a function" + return self.handler(request) + + @override + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + assert inspect.iscoroutinefunction(self.handler), "handler must be a coroutine function" + return await self.handler(request) + + +@dataclasses.dataclass +class Counter: + value: int = 0 + + +def _make_sync_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> Iterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + +async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] = None) -> AsyncIterator[T]: + for item in iterable: + if counter: + counter.value += 1 + yield item + + def _get_open_connections(client: Stagehand | AsyncStagehand) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -426,7 +479,7 @@ def test_validate_headers(self) -> None: model_api_key=None, _strict_response_validation=True, ) - client2.sessions.start(model_name="openai/gpt-5-nano") + _ = client2 def test_default_query_option(self) -> None: client = Stagehand( @@ -579,6 +632,72 @@ def test_multipart_repeating_array(self, client: Stagehand) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload(self, respx_mock: MockRouter, client: Stagehand) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + def test_binary_content_upload_with_iterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_sync_iterator([file_content], counter=counter) + + def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=request.read()) + + with Stagehand( + base_url=base_url, + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=model_api_key, + _strict_response_validation=True, + http_client=httpx.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + def test_binary_content_upload_with_body_is_deprecated(self, respx_mock: MockRouter, client: Stagehand) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) def test_basic_union_response(self, respx_mock: MockRouter, client: Stagehand) -> None: class Model1(BaseModel): @@ -1553,6 +1672,74 @@ def test_multipart_repeating_array(self, async_client: AsyncStagehand) -> None: b"", ] + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncStagehand) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + response = await async_client.post( + "/upload", + content=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + + async def test_binary_content_upload_with_asynciterator(self) -> None: + file_content = b"Hello, this is a test file." + counter = Counter() + iterator = _make_async_iterator([file_content], counter=counter) + + async def mock_handler(request: httpx.Request) -> httpx.Response: + assert counter.value == 0, "the request body should not have been read" + return httpx.Response(200, content=await request.aread()) + + async with AsyncStagehand( + base_url=base_url, + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=model_api_key, + _strict_response_validation=True, + http_client=httpx.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + response = await client.post( + "/upload", + content=iterator, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + assert counter.value == 1 + + @pytest.mark.respx(base_url=base_url) + async def test_binary_content_upload_with_body_is_deprecated( + self, respx_mock: MockRouter, async_client: AsyncStagehand + ) -> None: + respx_mock.post("/upload").mock(side_effect=mirror_request_content) + + file_content = b"Hello, this is a test file." + + with pytest.deprecated_call( + match="Passing raw bytes as `body` is deprecated and will be removed in a future version. Please pass raw bytes via the `content` parameter instead." + ): + response = await async_client.post( + "/upload", + body=file_content, + cast_to=httpx.Response, + options={"headers": {"Content-Type": "application/octet-stream"}}, + ) + + assert response.status_code == 200 + assert response.request.headers["Content-Type"] == "application/octet-stream" + assert response.content == file_content + @pytest.mark.respx(base_url=base_url) async def test_basic_union_response(self, respx_mock: MockRouter, async_client: AsyncStagehand) -> None: class Model1(BaseModel): diff --git a/tests/test_local_server.py b/tests/test_local_server.py deleted file mode 100644 index dbe6a9cc..00000000 --- a/tests/test_local_server.py +++ /dev/null @@ -1,126 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest -from respx import MockRouter - -from stagehand import Stagehand, AsyncStagehand -from stagehand._exceptions import StagehandError - - -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_stagehand_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_stagehand_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 - - -def test_local_server_requires_browserbase_keys_for_browserbase_sessions( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _set_required_env(monkeypatch) - monkeypatch.delenv("BROWSERBASE_API_KEY", raising=False) - monkeypatch.delenv("BROWSERBASE_PROJECT_ID", raising=False) - client = Stagehand(server="local") - with pytest.raises(StagehandError): - client.sessions.start(model_name="openai/gpt-5-nano") - - -def test_local_server_allows_local_browser_without_browserbase_keys( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _set_required_env(monkeypatch) - monkeypatch.delenv("BROWSERBASE_API_KEY", raising=False) - monkeypatch.delenv("BROWSERBASE_PROJECT_ID", raising=False) - client = Stagehand(server="local") - client._post = lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("post called")) - - with pytest.raises(RuntimeError, match="post called"): - client.sessions.start( - model_name="openai/gpt-5-nano", - browser={"type": "local"}, - ) diff --git a/tests/test_session_page_param.py b/tests/test_session_page_param.py deleted file mode 100644 index 797a7bbe..00000000 --- a/tests/test_session_page_param.py +++ /dev/null @@ -1,155 +0,0 @@ -# Manually maintained tests for Playwright page helpers (non-generated). - -from __future__ import annotations - -import json -import os -from typing import cast, Any - -import httpx -import pytest -from respx import MockRouter -from respx.models import Call - -from stagehand import Stagehand, AsyncStagehand - - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -class _SyncCDP: - def __init__(self, frame_id: str) -> None: - self._frame_id = frame_id - - def send(self, method: str) -> dict[str, Any]: - assert method == "Page.getFrameTree" - return {"frameTree": {"frame": {"id": self._frame_id}}} - - -class _SyncContext: - def __init__(self, frame_id: str) -> None: - self._frame_id = frame_id - - def new_cdp_session(self, _page: Any) -> _SyncCDP: - return _SyncCDP(self._frame_id) - - -class _SyncPage: - def __init__(self, frame_id: str) -> None: - self.context = _SyncContext(frame_id) - - -class _AsyncCDP: - def __init__(self, frame_id: str) -> None: - self._frame_id = frame_id - - async def send(self, method: str) -> dict[str, Any]: - assert method == "Page.getFrameTree" - return {"frameTree": {"frame": {"id": self._frame_id}}} - - -class _AsyncContext: - def __init__(self, frame_id: str) -> None: - self._frame_id = frame_id - - async def new_cdp_session(self, _page: Any) -> _AsyncCDP: - return _AsyncCDP(self._frame_id) - - -class _AsyncPage: - def __init__(self, frame_id: str) -> None: - self.context = _AsyncContext(frame_id) - - -@pytest.mark.respx(base_url=base_url) -def test_session_act_injects_frame_id_from_page(respx_mock: MockRouter, client: Stagehand) -> None: - session_id = "00000000-0000-0000-0000-000000000000" - frame_id = "frame-123" - - respx_mock.post("/v1/sessions/start").mock( - return_value=httpx.Response( - 200, - json={"success": True, "data": {"available": True, "sessionId": session_id}}, - ) - ) - - act_route = respx_mock.post(f"/v1/sessions/{session_id}/act").mock( - return_value=httpx.Response( - 200, - json={"success": True, "data": {"result": {"success": True, "message": "", "actionDescription": "", "actions": []}}}, - ) - ) - - session = client.sessions.create(model_name="openai/gpt-5-nano") - session.act(input="click something", page=_SyncPage(frame_id)) - - assert act_route.called is True - first_call = cast(Call, act_route.calls[0]) - request_body = json.loads(first_call.request.content) - assert request_body["frameId"] == frame_id - - -@pytest.mark.respx(base_url=base_url) -def test_session_act_prefers_explicit_frame_id_over_page(respx_mock: MockRouter, client: Stagehand) -> None: - session_id = "00000000-0000-0000-0000-000000000000" - - respx_mock.post("/v1/sessions/start").mock( - return_value=httpx.Response( - 200, - json={"success": True, "data": {"available": True, "sessionId": session_id}}, - ) - ) - - act_route = respx_mock.post(f"/v1/sessions/{session_id}/act").mock( - return_value=httpx.Response( - 200, - json={"success": True, "data": {"result": {"success": True, "message": "", "actionDescription": "", "actions": []}}}, - ) - ) - - session = client.sessions.create(model_name="openai/gpt-5-nano") - - class _ExplodingContext: - def new_cdp_session(self, _page: Any) -> None: - raise AssertionError("new_cdp_session should not be called when frame_id is provided") - - class _ExplodingPage: - context = _ExplodingContext() - - session.act(input="click something", frame_id="explicit-frame", page=_ExplodingPage()) - - assert act_route.called is True - first_call = cast(Call, act_route.calls[0]) - request_body = json.loads(first_call.request.content) - assert request_body["frameId"] == "explicit-frame" - - -@pytest.mark.respx(base_url=base_url) -async def test_async_session_act_injects_frame_id_from_page( - respx_mock: MockRouter, async_client: AsyncStagehand -) -> None: - session_id = "00000000-0000-0000-0000-000000000000" - frame_id = "frame-async-456" - - respx_mock.post("/v1/sessions/start").mock( - return_value=httpx.Response( - 200, - json={"success": True, "data": {"available": True, "sessionId": session_id}}, - ) - ) - - act_route = respx_mock.post(f"/v1/sessions/{session_id}/act").mock( - return_value=httpx.Response( - 200, - json={"success": True, "data": {"result": {"success": True, "message": "", "actionDescription": "", "actions": []}}}, - ) - ) - - session = await async_client.sessions.create(model_name="openai/gpt-5-nano") - await session.act(input="click something", page=_AsyncPage(frame_id)) - - assert act_route.called is True - first_call = cast(Call, act_route.calls[0]) - request_body = json.loads(first_call.request.content) - assert request_body["frameId"] == frame_id - diff --git a/tests/test_sessions_create_helper.py b/tests/test_sessions_create_helper.py deleted file mode 100644 index 30c0b192..00000000 --- a/tests/test_sessions_create_helper.py +++ /dev/null @@ -1,80 +0,0 @@ -# Manually maintained tests for non-generated helpers. - -from __future__ import annotations - -import os -import json -from typing import cast - -import httpx -import pytest -from respx import MockRouter -from respx.models import Call - -from stagehand import Stagehand, AsyncStagehand - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") - - -@pytest.mark.respx(base_url=base_url) -def test_sessions_create_returns_bound_session(respx_mock: MockRouter, client: Stagehand) -> None: - session_id = "00000000-0000-0000-0000-000000000000" - - respx_mock.post("/v1/sessions/start").mock( - return_value=httpx.Response( - 200, - json={ - "success": True, - "data": {"available": True, "sessionId": session_id}, - }, - ) - ) - - navigate_route = respx_mock.post(f"/v1/sessions/{session_id}/navigate").mock( - return_value=httpx.Response( - 200, - json={"success": True, "data": {"result": None}}, - ) - ) - - session = client.sessions.create(model_name="openai/gpt-5-nano") - assert session.id == session_id - - session.navigate(url="https://example.com") - assert navigate_route.called is True - first_call = cast(Call, navigate_route.calls[0]) - request_body = json.loads(first_call.request.content) - assert "frameId" not in request_body - - -@pytest.mark.respx(base_url=base_url) -async def test_async_sessions_create_returns_bound_session( - respx_mock: MockRouter, async_client: AsyncStagehand -) -> None: - session_id = "00000000-0000-0000-0000-000000000000" - - respx_mock.post("/v1/sessions/start").mock( - return_value=httpx.Response( - 200, - json={ - "success": True, - "data": {"available": True, "sessionId": session_id}, - }, - ) - ) - - navigate_route = respx_mock.post(f"/v1/sessions/{session_id}/navigate").mock( - return_value=httpx.Response( - 200, - json={"success": True, "data": {"result": None}}, - ) - ) - - session = await async_client.sessions.create(model_name="openai/gpt-5-nano") - assert session.id == session_id - - await session.navigate(url="https://example.com") - assert navigate_route.called is True - first_call = cast(Call, navigate_route.calls[0]) - request_body = json.loads(first_call.request.content) - assert "frameId" not in request_body diff --git a/uv.lock b/uv.lock index f7cf7e5b..d8f55f75 100644 --- a/uv.lock +++ b/uv.lock @@ -260,17 +260,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "dotenv" -version = "0.9.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dotenv" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, -] - [[package]] name = "exceptiongroup" version = "1.3.1" @@ -1278,15 +1267,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - [[package]] name = "respx" version = "0.22.0" @@ -1359,7 +1339,7 @@ wheels = [ [[package]] name = "stagehand" -version = "0.4.0" +version = "3.4.7" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -1380,7 +1360,6 @@ aiohttp = [ [package.dev-dependencies] dev = [ { name = "dirty-equals" }, - { name = "dotenv" }, { name = "importlib-metadata" }, { name = "mypy" }, { name = "pyright" }, @@ -1418,7 +1397,6 @@ provides-extras = ["aiohttp"] [package.metadata.requires-dev] dev = [ { name = "dirty-equals", specifier = ">=0.6.0" }, - { name = "dotenv", specifier = ">=0.9.9" }, { name = "importlib-metadata", specifier = ">=6.7.0" }, { name = "mypy", specifier = "==1.17" }, { name = "pyright", specifier = "==1.1.399" },