diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf09c2f..ccb20d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: ./scripts/lint build: - if: github.repository == 'stainless-sdks/browser-use-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork timeout-minutes: 10 name: build permissions: @@ -61,12 +61,14 @@ jobs: run: rye build - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/browser-use-python' id: github-oidc uses: actions/github-script@v6 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball + if: github.repository == 'stainless-sdks/browser-use-python' env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a8f7122..06d6df2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.1" + ".": "1.0.2" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0a227..284fac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.2 (2025-08-22) + +Full Changelog: [v1.0.1...v1.0.2](https://github.com/browser-use/browser-use-python/compare/v1.0.1...v1.0.2) + +### Chores + +* update github action ([655a660](https://github.com/browser-use/browser-use-python/commit/655a6600972aee2cefcf83c73fdfd9e1ae68b852)) + ## 1.0.1 (2025-08-21) Full Changelog: [v1.0.0...v1.0.1](https://github.com/browser-use/browser-use-python/compare/v1.0.0...v1.0.1) diff --git a/README.md b/README.md index e313e6a..79ee251 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Browser Use Python +Browser Use Python [![PyPI version]()](https://pypi.org/project/browser-use-sdk/) @@ -26,65 +26,6 @@ result.done_output > The full API of this library can be found in [api.md](api.md). -## Async usage - -Simply import `AsyncBrowserUse` instead of `BrowserUse` and use `await` with each API call: - -```python -import os -import asyncio -from browser_use_sdk import AsyncBrowserUse - -client = AsyncBrowserUse( - api_key=os.environ.get("BROWSER_USE_API_KEY"), # This is the default and can be omitted -) - - -async def main() -> None: - task = await client.tasks.run( - task="Search for the top 10 Hacker News posts and return the title and url.", - ) - print(task.done_output) - - -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. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. - -You can enable this by installing `aiohttp`: - -```sh -# install from PyPI -pip install browser-use-sdk[aiohttp] -``` - -Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: - -```python -import asyncio -from browser_use_sdk import DefaultAioHttpClient -from browser_use_sdk import AsyncBrowserUse - - -async def main() -> None: - async with AsyncBrowserUse( - api_key="My API Key", - http_client=DefaultAioHttpClient(), - ) as client: - task = await client.tasks.run( - task="Search for the top 10 Hacker News posts and return the title and url.", - ) - print(task.done_output) - - -asyncio.run(main()) -``` - ## Structured Output with Pydantic Browser Use Python SDK provides first class support for Pydantic models. @@ -115,6 +56,10 @@ asyncio.run(main()) ## Streaming Updates with Async Iterators +> When presenting a long running task you might want to show updates as they happen. + +Browser Use SDK exposes a `.stream` method that lets you subscribe to a sync or an async generator that automatically polls Browser Use Cloud servers and emits a new event when an update happens (e.g., live url becomes available, agent takes a new step, or agent completes the task). + ```py class HackerNewsPost(BaseModel): title: str @@ -132,7 +77,7 @@ async def main() -> None: structured_output_json=SearchResult, ) - async for update in client.tasks.stream(structured_task.id, structured_output_json=SearchResult): + async for update in client.tasks.stream(task.id, structured_output_json=SearchResult): if len(update.steps) > 0: last_step = update.steps[-1] print(f"{update.status}: {last_step.url} ({last_step.next_goal})") @@ -152,6 +97,109 @@ async def main() -> None: asyncio.run(main()) ``` +## Verifying Webhook Events + +> You can configure Browser Use Cloud to emit Webhook events and process them easily with Browser Use Python SDK. + +Browser Use SDK lets you easily verify the signature and structure of the payload you receive in the webhook. + +```py +import uvicorn +import os +from browser_use_sdk.lib.webhooks import Webhook, verify_webhook_event_signature + +from fastapi import FastAPI, Request, HTTPException + +app = FastAPI() + +SECRET_KEY = os.environ['SECRET_KEY'] + +@app.post('/webhook') +async def webhook(request: Request): + body = await request.json() + + timestamp = request.headers.get('X-Browser-Use-Timestamp') + signature = request.headers.get('X-Browser-Use-Signature') + + verified_webhook: Webhook = verify_webhook_event_signature( + body=body, + timestamp=timestamp, + secret=SECRET_KEY, + expected_signature=signature, + ) + + if verified_webhook is not None: + print('Webhook received:', verified_webhook) + else: + print('Invalid webhook received') + + return {'status': 'success', 'message': 'Webhook received'} + +if __name__ == '__main__': + uvicorn.run(app, host='0.0.0.0', port=8080) +``` + +## Async usage + +Simply import `AsyncBrowserUse` instead of `BrowserUse` and use `await` with each API call: + +```python +import os +import asyncio +from browser_use_sdk import AsyncBrowserUse + +client = AsyncBrowserUse( + api_key=os.environ.get("BROWSER_USE_API_KEY"), # This is the default and can be omitted +) + + +async def main() -> None: + task = await client.tasks.run( + task="Search for the top 10 Hacker News posts and return the title and url.", + ) + print(task.done_output) + + +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. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend. + +You can enable this by installing `aiohttp`: + +```sh +# install from PyPI +pip install browser-use-sdk[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import asyncio +from browser_use_sdk import DefaultAioHttpClient +from browser_use_sdk import AsyncBrowserUse + + +async def main() -> None: + async with AsyncBrowserUse( + api_key="My API Key", + http_client=DefaultAioHttpClient(), + ) as client: + task = await client.tasks.run( + task="Search for the top 10 Hacker News posts and return the title and url.", + ) + print(task.done_output) + + +asyncio.run(main()) +``` + +## Advanced + ## 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 `browser_use_sdk.APIConnectionError` is raised. @@ -247,8 +295,6 @@ On timeout, an `APITimeoutError` is thrown. Note that requests that time out are [retried twice by default](#retries). -## Advanced - ### Logging We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. @@ -294,58 +340,6 @@ These methods return an [`APIResponse`](https://github.com/browser-use/browser-u The async client returns an [`AsyncAPIResponse`](https://github.com/browser-use/browser-use-python/tree/main/src/browser_use_sdk/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. -#### `.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. - -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.tasks.with_streaming_response.create( - task="Search for the top 10 Hacker News posts and return the title and url.", -) as response: - print(response.headers.get("X-My-Header")) - - for line in response.iter_lines(): - print(line) -``` - -The context manager is required so that the response will reliably be closed. - -### Making custom/undocumented requests - -This library is typed for convenient access to the documented API. - -If you need to access undocumented endpoints, params, or response properties, the library can still be used. - -#### Undocumented endpoints - -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. - -```py -import httpx - -response = client.post( - "/foo", - cast_to=httpx.Response, - body={"my_param": True}, -) - -print(response.headers.get("x-foo")) -``` - -#### Undocumented request params - -If you want to explicitly send an extra param, you can do so with the `extra_query`, `extra_body`, and `extra_headers` request -options. - -#### 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). - ### Configuring the HTTP client You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: @@ -388,29 +382,6 @@ with BrowserUse() as client: # HTTP client is now closed ``` -## 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: - -1. Changes that only affect static types, without breaking runtime behavior. -2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ -3. Changes that we do not expect to impact the vast majority of users in practice. - -We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. - -We are keen for your feedback; please open an [issue](https://www.github.com/browser-use/browser-use-python/issues) with questions, bugs, or suggestions. - -### Determining the installed version - -If you've upgraded to the latest version but aren't seeing any new features you were expecting then your python environment is likely still using an older version. - -You can determine the version that is being used at runtime with: - -```py -import browser_use_sdk -print(browser_use_sdk.__version__) -``` - ## Requirements Python 3.8 or higher. diff --git a/examples/webhooks.py b/examples/webhooks.py new file mode 100755 index 0000000..65cb93b --- /dev/null +++ b/examples/webhooks.py @@ -0,0 +1,65 @@ +#!/usr/bin/env -S rye run python + +from typing import Any, Dict, Tuple +from datetime import datetime + +from browser_use_sdk.lib.webhooks import ( + Webhook, + WebhookAgentTaskStatusUpdate, + WebhookAgentTaskStatusUpdatePayload, + create_webhook_signature, + verify_webhook_event_signature, +) + +SECRET = "your-webhook-secret-key" + + +def mock_webhook_event() -> Tuple[Dict[str, Any], str, str]: + """Mock a webhook event.""" + + timestamp = datetime.fromisoformat("2023-01-01T00:00:00").isoformat() + + payload = WebhookAgentTaskStatusUpdatePayload( + session_id="sess_123", + task_id="task_123", + status="started", + metadata={"progress": 25}, + ) + + signature = create_webhook_signature( + payload=payload.model_dump(), + timestamp=timestamp, + secret=SECRET, + ) + + evt: Webhook = WebhookAgentTaskStatusUpdate( + type="agent.task.status_update", + timestamp=datetime.fromisoformat("2023-01-01T00:00:00"), + payload=payload, + ) + + return evt.model_dump(), signature, timestamp + + +def main() -> None: + """Demonstrate webhook functionality.""" + + # NOTE: You'd get the evt and signature from the webhook request body and headers! + evt, signature, timestamp = mock_webhook_event() + + verified_webhook = verify_webhook_event_signature( + body=evt, + expected_signature=signature, + timestamp=timestamp, + secret=SECRET, + ) + + if verified_webhook is None: + print("✗ Webhook signature verification failed") + else: + print("✓ Webhook signature verified successfully") + print(f" Event type: {verified_webhook.type}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index f335717..cbc8b6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-use-sdk" -version = "1.0.1" +version = "1.0.2" description = "The official Python library for the browser-use API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browser_use_sdk/_version.py b/src/browser_use_sdk/_version.py index 8b431f6..dbca030 100644 --- a/src/browser_use_sdk/_version.py +++ b/src/browser_use_sdk/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browser_use_sdk" -__version__ = "1.0.1" # x-release-please-version +__version__ = "1.0.2" # x-release-please-version diff --git a/src/browser_use_sdk/lib/webhooks.py b/src/browser_use_sdk/lib/webhooks.py new file mode 100644 index 0000000..1a8f1d2 --- /dev/null +++ b/src/browser_use_sdk/lib/webhooks.py @@ -0,0 +1,136 @@ +import hmac +import json +import hashlib +from typing import Any, Dict, Union, Literal, Optional +from datetime import datetime + +from pydantic import BaseModel + +# https://docs.browser-use.com/cloud/webhooks + +# Models --------------------------------------------------------------------- + +# test + + +class WebhookTestPayload(BaseModel): + """Test webhook payload.""" + + test: Literal["ok"] + + +class WebhookTest(BaseModel): + """Test webhook event.""" + + type: Literal["test"] + timestamp: datetime + payload: WebhookTestPayload + + +# agent.task.status_update + + +class WebhookAgentTaskStatusUpdatePayload(BaseModel): + """Agent task status update webhook payload.""" + + session_id: str + task_id: str + status: Literal["initializing", "started", "paused", "stopped", "finished"] + metadata: Optional[Dict[str, Any]] = None + + +class WebhookAgentTaskStatusUpdate(BaseModel): + """Agent task status update webhook event.""" + + type: Literal["agent.task.status_update"] + timestamp: datetime + payload: WebhookAgentTaskStatusUpdatePayload + + +# Union of all webhook types +Webhook = Union[WebhookTest, WebhookAgentTaskStatusUpdate] + +# Methods -------------------------------------------------------------------- + + +def create_webhook_signature(payload: Any, timestamp: str, secret: str) -> str: + """ + Creates a webhook signature for the given payload, timestamp, and secret. + + Args: + payload: The webhook payload to sign + timestamp: The timestamp string + secret: The secret key for signing + + Returns: + The HMAC-SHA256 signature as a hex string + """ + + dump = json.dumps(payload, separators=(",", ":"), sort_keys=True) + message = f"{timestamp}.{dump}" + + # Create HMAC-SHA256 signature + hmac_obj = hmac.new(secret.encode(), message.encode(), hashlib.sha256) + signature = hmac_obj.hexdigest() + + return signature + + +def verify_webhook_event_signature( + body: Union[Dict[str, Any], str], + expected_signature: str, + timestamp: str, + # + secret: str, +) -> Union["Webhook", None]: + """ + Utility function that validates the received webhook event signature. + + Args: + body: Dictionary containing 'body', 'signature', and 'timestamp' + signature: The signature of the webhook event + timestamp: The timestamp of the webhook event + secret: The secret key for signing + + Returns: + None if the signature is invalid, otherwise the parsed webhook event. + """ + try: + if isinstance(body, str): + json_data = json.loads(body) + else: + json_data = body + + # PARSE + + webhook_event: Optional[Webhook] = None + + if webhook_event is None: + try: + webhook_event = WebhookTest(**json_data) + except Exception: + pass + + # Try agent task status update webhook + if webhook_event is None: + try: + webhook_event = WebhookAgentTaskStatusUpdate(**json_data) + except Exception: + pass + + if webhook_event is None: + return None + + # Verify + + calculated_signature = create_webhook_signature( + payload=webhook_event.payload.model_dump(), timestamp=timestamp, secret=secret + ) + + if not hmac.compare_digest(expected_signature, calculated_signature): + return None + + return webhook_event + + except Exception: + return None diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..84f09d7 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,151 @@ +"""Tests for webhook functionality.""" + +from __future__ import annotations + +from typing import Any, Dict +from datetime import datetime, timezone + +import pytest + +from browser_use_sdk._compat import PYDANTIC_V2 +from browser_use_sdk.lib.webhooks import ( + WebhookTest, + WebhookTestPayload, + create_webhook_signature, + verify_webhook_event_signature, +) + +# Signature Creation --------------------------------------------------------- + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") +def test_create_webhook_signature() -> None: + """Test webhook signature creation.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + payload = {"test": "ok"} + + signature = create_webhook_signature(payload, timestamp, secret) + + assert isinstance(signature, str) + assert len(signature) == 64 + + signature2 = create_webhook_signature(payload, timestamp, secret) + assert signature == signature2 + + different_payload = {"test": "different"} + different_signature = create_webhook_signature(different_payload, timestamp, secret) + + assert signature != different_signature + + +# Webhook Verification -------------------------------------------------------- + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") +def test_verify_webhook_event_signature_valid() -> None: + """Test webhook signature verification with valid signature.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + payload = WebhookTestPayload(test="ok") + webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) + signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) + + # Verify signature + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump(), + secret=secret, + timestamp=timestamp, + expected_signature=signature, + ) + + assert verified_webhook is not None + assert isinstance(verified_webhook, WebhookTest) + assert verified_webhook.payload.test == "ok" + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") +def test_verify_webhook_event_signature_invalid_signature() -> None: + """Test webhook signature verification with invalid signature.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Create test webhook + payload = WebhookTestPayload(test="ok") + webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) + + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump(), + secret=secret, + timestamp=timestamp, + expected_signature="random_invalid_signature", + ) + + assert verified_webhook is None + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") +def test_verify_webhook_event_signature_wrong_secret() -> None: + """Test webhook signature verification with wrong secret.""" + + timestamp = "2023-01-01T00:00:00Z" + + # Create test webhook + payload = WebhookTestPayload(test="ok") + webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) + + # Create signature with correct secret + signature = create_webhook_signature( + payload=payload.model_dump(), + timestamp=timestamp, + secret="test-secret-key", + ) + + # Verify with wrong secret + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump(), + secret="wrong-secret-key", + timestamp=timestamp, + expected_signature=signature, + ) + + assert verified_webhook is None + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") +def test_verify_webhook_event_signature_string_body() -> None: + """Test webhook signature verification with string body.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Create test webhook + payload = WebhookTestPayload(test="ok") + webhook = WebhookTest(type="test", timestamp=datetime.now(timezone.utc), payload=payload) + signature = create_webhook_signature(webhook.payload.model_dump(), timestamp, secret) + + verified_webhook = verify_webhook_event_signature( + body=webhook.model_dump_json(), + secret=secret, + timestamp=timestamp, + expected_signature=signature, + ) + + assert verified_webhook is not None + assert isinstance(verified_webhook, WebhookTest) + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="Webhook tests only run in Pydantic V2") +def test_verify_webhook_event_signature_invalid_body() -> None: + """Test webhook signature verification with invalid body.""" + secret = "test-secret-key" + timestamp = "2023-01-01T00:00:00Z" + + # Invalid webhook data + invalid_body: Dict[str, Any] = {"type": "invalid_type", "timestamp": "invalid", "payload": {}} + + verified_webhook = verify_webhook_event_signature( + body=invalid_body, secret=secret, expected_signature="some_signature", timestamp=timestamp + ) + + assert verified_webhook is None