From db66a278483f9608df47d3e7016a578aa7115bf6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 19 Nov 2024 08:49:13 -0800 Subject: [PATCH 01/22] release: 1.0.1 (#52) * feat(api): api update (#48) * feat(api): api update (#51) * chore: rebuild project due to codegen change (#53) * chore: rebuild project due to codegen change (#54) * chore: rebuild project due to codegen change (#55) * chore: rebuild project due to codegen change (#57) * chore: rebuild project due to codegen change (#58) * release: 1.0.1 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Stainless Bot --- .release-please-manifest.json | 2 +- .stats.yml | 2 +- CHANGELOG.md | 18 ++++++++++++++++ README.md | 4 ++-- pyproject.toml | 7 +++---- src/browserbase/_compat.py | 6 ++++-- src/browserbase/_models.py | 9 +++++--- src/browserbase/_utils/__init__.py | 1 + src/browserbase/_utils/_transform.py | 14 +++++++++++-- src/browserbase/_utils/_utils.py | 17 +++++++++++++++ src/browserbase/_version.py | 2 +- src/browserbase/resources/projects.py | 4 ++-- .../resources/sessions/sessions.py | 4 ++-- .../types/sessions/session_recording.py | 2 -- tests/api_resources/test_sessions.py | 16 +++++++------- tests/test_client.py | 4 ++-- tests/test_models.py | 21 +++++++------------ tests/test_transform.py | 15 +++++++++++++ 18 files changed, 102 insertions(+), 46 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fea34540..a8f71224 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.0" + ".": "1.0.1" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 1a6b2b54..d42b050b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,2 +1,2 @@ configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-b341dd9d5bb77c4f217b94b186763e730fd798fbb773a5e90bb4e2a8d4a2c822.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-7f88912695bab2b98cb73137e6f36125d02fdfaf8eed4532ee1c82385609a259.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index a17bbc56..0df64a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 1.0.1 (2024-11-18) + +Full Changelog: [v1.0.0...v1.0.1](https://github.com/browserbase/sdk-python/compare/v1.0.0...v1.0.1) + +### Features + +* **api:** api update ([#48](https://github.com/browserbase/sdk-python/issues/48)) ([b17a3b8](https://github.com/browserbase/sdk-python/commit/b17a3b8e6984447421a7581ca56c0521cb3b55dd)) +* **api:** api update ([#51](https://github.com/browserbase/sdk-python/issues/51)) ([dc2da25](https://github.com/browserbase/sdk-python/commit/dc2da25d2e33d55e5655cbb8000fd4afdd6bbf62)) + + +### Chores + +* rebuild project due to codegen change ([#53](https://github.com/browserbase/sdk-python/issues/53)) ([b1684fa](https://github.com/browserbase/sdk-python/commit/b1684fa889aecf2fe7965a37ebd9c73977136ef6)) +* rebuild project due to codegen change ([#54](https://github.com/browserbase/sdk-python/issues/54)) ([e6a41da](https://github.com/browserbase/sdk-python/commit/e6a41dab6f0de6894a97067611166b1bc61893a2)) +* rebuild project due to codegen change ([#55](https://github.com/browserbase/sdk-python/issues/55)) ([ff17087](https://github.com/browserbase/sdk-python/commit/ff1708757bdeaa4e6b8d1959d1830105bd7f4b92)) +* rebuild project due to codegen change ([#57](https://github.com/browserbase/sdk-python/issues/57)) ([dfd0e19](https://github.com/browserbase/sdk-python/commit/dfd0e199c2447d4bd1b6704745d22f959a6b6bb1)) +* rebuild project due to codegen change ([#58](https://github.com/browserbase/sdk-python/issues/58)) ([f3be0be](https://github.com/browserbase/sdk-python/commit/f3be0bec13d95c65ab4cc81565b456cb566a62e2)) + ## 1.0.0 (2024-10-29) Full Changelog: [v1.0.0-alpha.0...v1.0.0](https://github.com/browserbase/sdk-python/compare/v1.0.0-alpha.0...v1.0.0) diff --git a/README.md b/README.md index c790a0f3..2ddc05b6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![PyPI version](https://img.shields.io/pypi/v/browserbase.svg)](https://pypi.org/project/browserbase/) -The Browserbase Python library provides convenient access to the Browserbase REST API from any Python 3.7+ +The Browserbase Python library provides convenient access to the Browserbase REST API from any Python 3.8+ 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). @@ -340,7 +340,7 @@ print(browserbase.__version__) ## Requirements -Python 3.7 or higher. +Python 3.8 or higher. ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 9cdf351a..2e4cc0ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.0.0" +version = "1.0.1" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -16,11 +16,10 @@ dependencies = [ "sniffio", "cached-property; python_version < '3.8'", ] -requires-python = ">= 3.7" +requires-python = ">= 3.8" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -148,7 +147,7 @@ filterwarnings = [ # there are a couple of flags that are still disabled by # default in strict mode as they are experimental and niche. typeCheckingMode = "strict" -pythonVersion = "3.7" +pythonVersion = "3.8" exclude = [ "_dev", diff --git a/src/browserbase/_compat.py b/src/browserbase/_compat.py index d89920d9..4794129c 100644 --- a/src/browserbase/_compat.py +++ b/src/browserbase/_compat.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload from datetime import date, datetime -from typing_extensions import Self +from typing_extensions import Self, Literal import pydantic from pydantic.fields import FieldInfo @@ -137,9 +137,11 @@ def model_dump( exclude_unset: bool = False, exclude_defaults: bool = False, warnings: bool = True, + mode: Literal["json", "python"] = "python", ) -> dict[str, Any]: - if PYDANTIC_V2: + if PYDANTIC_V2 or hasattr(model, "model_dump"): return model.model_dump( + mode=mode, exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index 42551b76..6cb469e2 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -37,6 +37,7 @@ PropertyInfo, is_list, is_given, + json_safe, lru_cache, is_mapping, parse_date, @@ -279,8 +280,8 @@ def model_dump( Returns: A dictionary representation of the model. """ - if mode != "python": - raise ValueError("mode is only supported in Pydantic v2") + if mode not in {"json", "python"}: + raise ValueError("mode must be either 'json' or 'python'") if round_trip != False: raise ValueError("round_trip is only supported in Pydantic v2") if warnings != True: @@ -289,7 +290,7 @@ def model_dump( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") - return super().dict( # pyright: ignore[reportDeprecated] + dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, by_alias=by_alias, @@ -298,6 +299,8 @@ def model_dump( exclude_none=exclude_none, ) + return cast(dict[str, Any], json_safe(dumped)) if mode == "json" else dumped + @override def model_dump_json( self, diff --git a/src/browserbase/_utils/__init__.py b/src/browserbase/_utils/__init__.py index 3efe66c8..a7cff3c0 100644 --- a/src/browserbase/_utils/__init__.py +++ b/src/browserbase/_utils/__init__.py @@ -6,6 +6,7 @@ is_list as is_list, is_given as is_given, is_tuple as is_tuple, + json_safe as json_safe, lru_cache as lru_cache, is_mapping as is_mapping, is_tuple_t as is_tuple_t, diff --git a/src/browserbase/_utils/_transform.py b/src/browserbase/_utils/_transform.py index 47e262a5..a6b62cad 100644 --- a/src/browserbase/_utils/_transform.py +++ b/src/browserbase/_utils/_transform.py @@ -173,6 +173,11 @@ def _transform_recursive( # Iterable[T] or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + inner_type = extract_type_arg(stripped_type, 0) return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] @@ -186,7 +191,7 @@ def _transform_recursive( return data if isinstance(data, pydantic.BaseModel): - return model_dump(data, exclude_unset=True) + return model_dump(data, exclude_unset=True, mode="json") annotated_type = _get_annotated_type(annotation) if annotated_type is None: @@ -311,6 +316,11 @@ async def _async_transform_recursive( # Iterable[T] or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) ): + # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually + # intended as an iterable, so we don't transform it. + if isinstance(data, dict): + return cast(object, data) + inner_type = extract_type_arg(stripped_type, 0) return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] @@ -324,7 +334,7 @@ async def _async_transform_recursive( return data if isinstance(data, pydantic.BaseModel): - return model_dump(data, exclude_unset=True) + return model_dump(data, exclude_unset=True, mode="json") annotated_type = _get_annotated_type(annotation) if annotated_type is None: diff --git a/src/browserbase/_utils/_utils.py b/src/browserbase/_utils/_utils.py index 0bba17ca..e5811bba 100644 --- a/src/browserbase/_utils/_utils.py +++ b/src/browserbase/_utils/_utils.py @@ -16,6 +16,7 @@ overload, ) from pathlib import Path +from datetime import date, datetime from typing_extensions import TypeGuard import sniffio @@ -395,3 +396,19 @@ def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]: maxsize=maxsize, ) return cast(Any, wrapper) # type: ignore[no-any-return] + + +def json_safe(data: object) -> object: + """Translates a mapping / sequence recursively in the same fashion + as `pydantic` v2's `model_dump(mode="json")`. + """ + if is_mapping(data): + return {json_safe(key): json_safe(value) for key, value in data.items()} + + if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)): + return [json_safe(item) for item in data] + + if isinstance(data, (datetime, date)): + return data.isoformat() + + return data diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 1f27c648..09428425 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.0.0" # x-release-please-version +__version__ = "1.0.1" # x-release-please-version diff --git a/src/browserbase/resources/projects.py b/src/browserbase/resources/projects.py index f8b1936a..bf4a5df9 100644 --- a/src/browserbase/resources/projects.py +++ b/src/browserbase/resources/projects.py @@ -84,7 +84,7 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ProjectListResponse: - """List all projects""" + """List projects""" return self._get( "/v1/projects", options=make_request_options( @@ -190,7 +190,7 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ProjectListResponse: - """List all projects""" + """List projects""" return await self._get( "/v1/projects", options=make_request_options( diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index fc4cac3c..45ef0820 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -155,7 +155,7 @@ def create( "keep_alive": keep_alive, "proxies": proxies, "region": region, - "timeout": api_timeout, + "api_timeout": api_timeout, }, session_create_params.SessionCreateParams, ), @@ -409,7 +409,7 @@ async def create( "keep_alive": keep_alive, "proxies": proxies, "region": region, - "timeout": api_timeout, + "api_timeout": api_timeout, }, session_create_params.SessionCreateParams, ), diff --git a/src/browserbase/types/sessions/session_recording.py b/src/browserbase/types/sessions/session_recording.py index d3e0325a..c8471371 100644 --- a/src/browserbase/types/sessions/session_recording.py +++ b/src/browserbase/types/sessions/session_recording.py @@ -10,8 +10,6 @@ class SessionRecording(BaseModel): - id: str - data: Dict[str, object] """ See diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 8ebd5daf..7b9fbce1 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -41,11 +41,11 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: }, "extension_id": "extensionId", "fingerprint": { - "browsers": ["chrome", "edge", "firefox"], - "devices": ["desktop", "mobile"], + "browsers": ["chrome"], + "devices": ["desktop"], "http_version": 1, - "locales": ["string", "string", "string"], - "operating_systems": ["android", "ios", "linux"], + "locales": ["string"], + "operating_systems": ["android"], "screen": { "max_height": 0, "max_width": 0, @@ -270,11 +270,11 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas }, "extension_id": "extensionId", "fingerprint": { - "browsers": ["chrome", "edge", "firefox"], - "devices": ["desktop", "mobile"], + "browsers": ["chrome"], + "devices": ["desktop"], "http_version": 1, - "locales": ["string", "string", "string"], - "operating_systems": ["android", "ios", "linux"], + "locales": ["string"], + "operating_systems": ["android"], "screen": { "max_height": 0, "max_width": 0, diff --git a/tests/test_client.py b/tests/test_client.py index c70ef50e..9cf62b33 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -703,7 +703,7 @@ class Model(BaseModel): [3, "", 0.5], [2, "", 0.5 * 2.0], [1, "", 0.5 * 4.0], - [-1100, "", 7.8], # test large number potentially overflowing + [-1100, "", 8], # test large number potentially overflowing ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) @@ -1482,7 +1482,7 @@ class Model(BaseModel): [3, "", 0.5], [2, "", 0.5 * 2.0], [1, "", 0.5 * 4.0], - [-1100, "", 7.8], # test large number potentially overflowing + [-1100, "", 8], # test large number potentially overflowing ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) diff --git a/tests/test_models.py b/tests/test_models.py index 5b8044f0..c199e942 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -520,19 +520,15 @@ class Model(BaseModel): assert m3.to_dict(exclude_none=True) == {} assert m3.to_dict(exclude_defaults=True) == {} - if PYDANTIC_V2: - - class Model2(BaseModel): - created_at: datetime + class Model2(BaseModel): + created_at: datetime - time_str = "2024-03-21T11:39:01.275859" - m4 = Model2.construct(created_at=time_str) - assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} - assert m4.to_dict(mode="json") == {"created_at": time_str} - else: - with pytest.raises(ValueError, match="mode is only supported in Pydantic v2"): - m.to_dict(mode="json") + time_str = "2024-03-21T11:39:01.275859" + m4 = Model2.construct(created_at=time_str) + assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} + assert m4.to_dict(mode="json") == {"created_at": time_str} + if not PYDANTIC_V2: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_dict(warnings=False) @@ -558,9 +554,6 @@ class Model(BaseModel): assert m3.model_dump(exclude_none=True) == {} if not PYDANTIC_V2: - with pytest.raises(ValueError, match="mode is only supported in Pydantic v2"): - m.model_dump(mode="json") - with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump(round_trip=True) diff --git a/tests/test_transform.py b/tests/test_transform.py index 436b8185..03c2ecd4 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -177,17 +177,32 @@ class DateDict(TypedDict, total=False): foo: Annotated[date, PropertyInfo(format="iso8601")] +class DatetimeModel(BaseModel): + foo: datetime + + +class DateModel(BaseModel): + foo: Optional[date] + + @parametrize @pytest.mark.asyncio async def test_iso8601_format(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") + tz = "Z" if PYDANTIC_V2 else "+00:00" assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] dt = dt.replace(tzinfo=None) assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] + assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692"} # type: ignore[comparison-overlap] assert await transform({"foo": None}, DateDict, use_async) == {"foo": None} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=None), Any, use_async) == {"foo": None} # type: ignore assert await transform({"foo": date.fromisoformat("2023-02-23")}, DateDict, use_async) == {"foo": "2023-02-23"} # type: ignore[comparison-overlap] + assert await transform(DateModel(foo=date.fromisoformat("2023-02-23")), DateDict, use_async) == { + "foo": "2023-02-23" + } # type: ignore[comparison-overlap] @parametrize From 41d1427645e5173b72cfd12ee7e8c41ff80e665a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 21 Nov 2024 09:33:48 -0800 Subject: [PATCH 02/22] release: 1.0.2 (#63) * chore: rebuild project due to codegen change (#59) * release: 1.0.2 --------- Co-authored-by: Stainless Bot Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 +++ pyproject.toml | 3 +- requirements-dev.lock | 1 + src/browserbase/_utils/_sync.py | 90 +++++++++++++++------------------ src/browserbase/_version.py | 2 +- tests/test_client.py | 38 ++++++++++++++ 7 files changed, 91 insertions(+), 53 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a8f71224..06d6df24 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 0df64a39..e3e35f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.2 (2024-11-19) + +Full Changelog: [v1.0.1...v1.0.2](https://github.com/browserbase/sdk-python/compare/v1.0.1...v1.0.2) + +### Chores + +* rebuild project due to codegen change ([#59](https://github.com/browserbase/sdk-python/issues/59)) ([bd52098](https://github.com/browserbase/sdk-python/commit/bd520989c50f8353c7184930d0da661bdc8625fa)) + ## 1.0.1 (2024-11-18) Full Changelog: [v1.0.0...v1.0.1](https://github.com/browserbase/sdk-python/compare/v1.0.0...v1.0.1) diff --git a/pyproject.toml b/pyproject.toml index 2e4cc0ec..b7a90900 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.0.1" +version = "1.0.2" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -56,6 +56,7 @@ dev-dependencies = [ "dirty-equals>=0.6.0", "importlib-metadata>=6.7.0", "rich>=13.7.1", + "nest_asyncio==1.6.0", "python-dotenv", "playwright", "selenium", diff --git a/requirements-dev.lock b/requirements-dev.lock index 83ce0203..86a29c66 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -66,6 +66,7 @@ mdurl==0.1.2 mypy==1.13.0 mypy-extensions==1.0.0 # via mypy +nest-asyncio==1.6.0 nodeenv==1.9.1 # via pyright nox==2024.10.9 diff --git a/src/browserbase/_utils/_sync.py b/src/browserbase/_utils/_sync.py index d0d81033..8b3aaf2b 100644 --- a/src/browserbase/_utils/_sync.py +++ b/src/browserbase/_utils/_sync.py @@ -1,56 +1,62 @@ from __future__ import annotations +import sys +import asyncio import functools -from typing import TypeVar, Callable, Awaitable +import contextvars +from typing import Any, TypeVar, Callable, Awaitable from typing_extensions import ParamSpec -import anyio -import anyio.to_thread - -from ._reflection import function_has_argument - T_Retval = TypeVar("T_Retval") T_ParamSpec = ParamSpec("T_ParamSpec") -# copied from `asyncer`, https://github.com/tiangolo/asyncer -def asyncify( - function: Callable[T_ParamSpec, T_Retval], - *, - cancellable: bool = False, - limiter: anyio.CapacityLimiter | None = None, -) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: +if sys.version_info >= (3, 9): + to_thread = asyncio.to_thread +else: + # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread + # for Python 3.8 support + async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs + ) -> Any: + """Asynchronously run function *func* in a separate thread. + + Any *args and **kwargs supplied for this function are directly passed + to *func*. Also, the current :class:`contextvars.Context` is propagated, + allowing context variables from the main thread to be accessed in the + separate thread. + + Returns a coroutine that can be awaited to get the eventual result of *func*. + """ + loop = asyncio.events.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial(ctx.run, func, *args, **kwargs) + return await loop.run_in_executor(None, func_call) + + +# inspired by `asyncer`, https://github.com/tiangolo/asyncer +def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments, and that when called, calls the original function - in a worker thread using `anyio.to_thread.run_sync()`. Internally, - `asyncer.asyncify()` uses the same `anyio.to_thread.run_sync()`, but it supports - keyword arguments additional to positional arguments and it adds better support for - autocompletion and inline errors for the arguments of the function called and the - return value. - - If the `cancellable` option is enabled and the task waiting for its completion is - cancelled, the thread will still run its course but its return value (or any raised - exception) will be ignored. + positional and keyword arguments. For python version 3.9 and above, it uses + asyncio.to_thread to run the function in a separate thread. For python version + 3.8, it uses locally defined copy of the asyncio.to_thread function which was + introduced in python 3.9. - Use it like this: + Usage: - ```Python - def do_work(arg1, arg2, kwarg1="", kwarg2="") -> str: - # Do work - return "Some result" + ```python + def blocking_func(arg1, arg2, kwarg1=None): + # blocking code + return result - result = await to_thread.asyncify(do_work)("spam", "ham", kwarg1="a", kwarg2="b") - print(result) + result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1) ``` ## Arguments `function`: a blocking regular callable (e.g. a function) - `cancellable`: `True` to allow cancellation of the operation - `limiter`: capacity limiter to use to limit the total amount of threads running - (if omitted, the default limiter is used) ## Return @@ -60,22 +66,6 @@ def do_work(arg1, arg2, kwarg1="", kwarg2="") -> str: """ async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: - partial_f = functools.partial(function, *args, **kwargs) - - # In `v4.1.0` anyio added the `abandon_on_cancel` argument and deprecated the old - # `cancellable` argument, so we need to use the new `abandon_on_cancel` to avoid - # surfacing deprecation warnings. - if function_has_argument(anyio.to_thread.run_sync, "abandon_on_cancel"): - return await anyio.to_thread.run_sync( - partial_f, - abandon_on_cancel=cancellable, - limiter=limiter, - ) - - return await anyio.to_thread.run_sync( - partial_f, - cancellable=cancellable, - limiter=limiter, - ) + return await to_thread(function, *args, **kwargs) return wrapper diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 09428425..bf5d1e11 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.0.1" # x-release-please-version +__version__ = "1.0.2" # x-release-please-version diff --git a/tests/test_client.py b/tests/test_client.py index 9cf62b33..aa0043e2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,11 +4,14 @@ import gc import os +import sys import json import asyncio import inspect +import subprocess import tracemalloc from typing import Any, Union, cast +from textwrap import dedent from unittest import mock from typing_extensions import Literal @@ -1608,3 +1611,38 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: ) assert response.http_request.headers.get("x-stainless-retry-count") == "42" + + def test_get_platform(self) -> None: + # A previous implementation of asyncify could leave threads unterminated when + # used with nest_asyncio. + # + # Since nest_asyncio.apply() is global and cannot be un-applied, this + # test is run in a separate process to avoid affecting other tests. + test_code = dedent(""" + import asyncio + import nest_asyncio + import threading + + from browserbase._utils import asyncify + from browserbase._base_client import get_platform + + async def test_main() -> None: + result = await asyncify(get_platform)() + print(result) + for thread in threading.enumerate(): + print(thread.name) + + nest_asyncio.apply() + asyncio.run(test_main()) + """) + with subprocess.Popen( + [sys.executable, "-c", test_code], + text=True, + ) as process: + try: + process.wait(2) + if process.returncode: + raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") + except subprocess.TimeoutExpired as e: + process.kill() + raise AssertionError("calling get_platform using asyncify resulted in a hung process") from e From d515d0406044223a4c402ebbb18f0d70d458dc13 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 10:55:08 -0800 Subject: [PATCH 03/22] release: 1.0.3 (#66) * chore(internal): fix compat model_dump method when warnings are passed (#65) * release: 1.0.3 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/browserbase/_compat.py | 3 ++- src/browserbase/_version.py | 2 +- tests/test_models.py | 8 ++++++++ 6 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 06d6df24..b7634f9b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.2" + ".": "1.0.3" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e35f4d..9b355e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.3 (2024-11-22) + +Full Changelog: [v1.0.2...v1.0.3](https://github.com/browserbase/sdk-python/compare/v1.0.2...v1.0.3) + +### Chores + +* **internal:** fix compat model_dump method when warnings are passed ([#65](https://github.com/browserbase/sdk-python/issues/65)) ([4e999de](https://github.com/browserbase/sdk-python/commit/4e999de99372f6b348e74aa37663dd809c5d0da7)) + ## 1.0.2 (2024-11-19) Full Changelog: [v1.0.1...v1.0.2](https://github.com/browserbase/sdk-python/compare/v1.0.1...v1.0.2) diff --git a/pyproject.toml b/pyproject.toml index b7a90900..25803447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.0.2" +version = "1.0.3" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browserbase/_compat.py b/src/browserbase/_compat.py index 4794129c..df173f85 100644 --- a/src/browserbase/_compat.py +++ b/src/browserbase/_compat.py @@ -145,7 +145,8 @@ def model_dump( exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, - warnings=warnings, + # warnings are not supported in Pydantic v1 + warnings=warnings if PYDANTIC_V2 else True, ) return cast( "dict[str, Any]", diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index bf5d1e11..f1870ed8 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.0.2" # x-release-please-version +__version__ = "1.0.3" # x-release-please-version diff --git a/tests/test_models.py b/tests/test_models.py index c199e942..37671b06 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -561,6 +561,14 @@ class Model(BaseModel): m.model_dump(warnings=False) +def test_compat_method_no_error_for_warnings() -> None: + class Model(BaseModel): + foo: Optional[str] + + m = Model(foo="hello") + assert isinstance(model_dump(m, warnings=False), dict) + + def test_to_json() -> None: class Model(BaseModel): foo: Optional[str] = Field(alias="FOO", default=None) From a4b244fed5d4a4b25200d605ac9f572f4693e2b0 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 29 Nov 2024 09:17:35 -0800 Subject: [PATCH 04/22] release: 1.0.4 (#69) * chore(internal): codegen related update (#68) * chore(internal): exclude mypy from running on tests (#70) * fix(client): compat with new httpx 0.28.0 release (#71) * release: 1.0.4 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ README.md | 6 ++++-- mypy.ini | 5 ++++- pyproject.toml | 3 +-- src/browserbase/_base_client.py | 12 ++++++++---- src/browserbase/_compat.py | 5 +---- src/browserbase/_version.py | 2 +- 8 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b7634f9b..80d368ad 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.3" + ".": "1.0.4" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b355e70..f1f81d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.0.4 (2024-11-29) + +Full Changelog: [v1.0.3...v1.0.4](https://github.com/browserbase/sdk-python/compare/v1.0.3...v1.0.4) + +### Bug Fixes + +* **client:** compat with new httpx 0.28.0 release ([#71](https://github.com/browserbase/sdk-python/issues/71)) ([7b87947](https://github.com/browserbase/sdk-python/commit/7b87947d0cdf555c73a1527b3e396cd40175d0b4)) + + +### Chores + +* **internal:** codegen related update ([#68](https://github.com/browserbase/sdk-python/issues/68)) ([3e4372e](https://github.com/browserbase/sdk-python/commit/3e4372ed8790e32850e1196c402e0023cd8a0f9d)) +* **internal:** exclude mypy from running on tests ([#70](https://github.com/browserbase/sdk-python/issues/70)) ([edd3628](https://github.com/browserbase/sdk-python/commit/edd3628710ed8f863bce5df336385dd6d380041e)) + ## 1.0.3 (2024-11-22) Full Changelog: [v1.0.2...v1.0.3](https://github.com/browserbase/sdk-python/compare/v1.0.2...v1.0.3) diff --git a/README.md b/README.md index 2ddc05b6..63a38365 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,14 @@ Note that requests that time out are [retried twice by default](#retries). We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module. -You can enable logging by setting the environment variable `BROWSERBASE_LOG` to `debug`. +You can enable logging by setting the environment variable `BROWSERBASE_LOG` to `info`. ```shell -$ export BROWSERBASE_LOG=debug +$ export BROWSERBASE_LOG=info ``` +Or to `debug` for more verbose logging. + ### 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`: diff --git a/mypy.ini b/mypy.ini index 94f60e5e..9e79a7c7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5,7 +5,10 @@ show_error_codes = True # Exclude _files.py because mypy isn't smart enough to apply # the correct type narrowing and as this is an internal module # it's fine to just use Pyright. -exclude = ^(src/browserbase/_files\.py|_dev/.*\.py)$ +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ^(src/browserbase/_files\.py|_dev/.*\.py|tests/.*)$ strict_equality = True implicit_reexport = True diff --git a/pyproject.toml b/pyproject.toml index 25803447..2bfcc94c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.0.3" +version = "1.0.4" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -14,7 +14,6 @@ dependencies = [ "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", - "cached-property; python_version < '3.8'", ] requires-python = ">= 3.8" classifiers = [ diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index f17e8d2b..d8b28d9a 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_base_client.py @@ -792,6 +792,7 @@ def __init__( custom_query: Mapping[str, object] | None = None, _strict_response_validation: bool, ) -> None: + kwargs: dict[str, Any] = {} if limits is not None: warnings.warn( "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", @@ -804,6 +805,7 @@ def __init__( limits = DEFAULT_CONNECTION_LIMITS if transport is not None: + kwargs["transport"] = transport warnings.warn( "The `transport` argument is deprecated. The `http_client` argument should be passed instead", category=DeprecationWarning, @@ -813,6 +815,7 @@ def __init__( raise ValueError("The `http_client` argument is mutually exclusive with `transport`") if proxies is not None: + kwargs["proxies"] = proxies warnings.warn( "The `proxies` argument is deprecated. The `http_client` argument should be passed instead", category=DeprecationWarning, @@ -856,10 +859,9 @@ def __init__( base_url=base_url, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - proxies=proxies, - transport=transport, limits=limits, follow_redirects=True, + **kwargs, # type: ignore ) def is_closed(self) -> bool: @@ -1358,6 +1360,7 @@ def __init__( custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, ) -> None: + kwargs: dict[str, Any] = {} if limits is not None: warnings.warn( "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", @@ -1370,6 +1373,7 @@ def __init__( limits = DEFAULT_CONNECTION_LIMITS if transport is not None: + kwargs["transport"] = transport warnings.warn( "The `transport` argument is deprecated. The `http_client` argument should be passed instead", category=DeprecationWarning, @@ -1379,6 +1383,7 @@ def __init__( raise ValueError("The `http_client` argument is mutually exclusive with `transport`") if proxies is not None: + kwargs["proxies"] = proxies warnings.warn( "The `proxies` argument is deprecated. The `http_client` argument should be passed instead", category=DeprecationWarning, @@ -1422,10 +1427,9 @@ def __init__( base_url=base_url, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - proxies=proxies, - transport=transport, limits=limits, follow_redirects=True, + **kwargs, # type: ignore ) def is_closed(self) -> bool: diff --git a/src/browserbase/_compat.py b/src/browserbase/_compat.py index df173f85..92d9ee61 100644 --- a/src/browserbase/_compat.py +++ b/src/browserbase/_compat.py @@ -214,9 +214,6 @@ def __set_name__(self, owner: type[Any], name: str) -> None: ... # __set__ is not defined at runtime, but @cached_property is designed to be settable def __set__(self, instance: object, value: _T) -> None: ... else: - try: - from functools import cached_property as cached_property - except ImportError: - from cached_property import cached_property as cached_property + from functools import cached_property as cached_property typed_cached_property = cached_property diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index f1870ed8..6e25ae84 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.0.3" # x-release-please-version +__version__ = "1.0.4" # x-release-please-version From 0558b2bc9f9068a248b1493c7d1801c1fe118361 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Dec 2024 09:01:44 -0800 Subject: [PATCH 05/22] release: 1.0.5 (#75) * chore(internal): bump pyright (#73) * release: 1.0.5 --------- Co-authored-by: Stainless Bot Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- requirements-dev.lock | 4 ++-- src/browserbase/_version.py | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 80d368ad..1214610a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.4" + ".": "1.0.5" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f81d4b..6bb29660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.0.5 (2024-12-03) + +Full Changelog: [v1.0.4...v1.0.5](https://github.com/browserbase/sdk-python/compare/v1.0.4...v1.0.5) + +### Chores + +* **internal:** bump pyright ([#73](https://github.com/browserbase/sdk-python/issues/73)) ([d5f9711](https://github.com/browserbase/sdk-python/commit/d5f97119b2ec2334f47029541173e78ca846abae)) + ## 1.0.4 (2024-11-29) Full Changelog: [v1.0.3...v1.0.4](https://github.com/browserbase/sdk-python/compare/v1.0.3...v1.0.4) diff --git a/pyproject.toml b/pyproject.toml index 2bfcc94c..82209c4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.0.4" +version = "1.0.5" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/requirements-dev.lock b/requirements-dev.lock index 86a29c66..48e714f0 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -75,7 +75,7 @@ outcome==1.3.0.post0 packaging==24.1 # via nox # via pytest -platformdirs==4.3.6 +platformdirs==3.11.0 # via virtualenv playwright==1.48.0 # via pytest-playwright @@ -145,7 +145,7 @@ typing-extensions==4.12.2 urllib3==2.2.3 # via requests # via selenium -virtualenv==20.27.1 +virtualenv==20.24.5 # via nox websocket-client==1.8.0 # via selenium diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 6e25ae84..c871d01e 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.0.4" # x-release-please-version +__version__ = "1.0.5" # x-release-please-version From 9b267008906d151aa5dfdabc3cb91126eb30e027 Mon Sep 17 00:00:00 2001 From: Anirudh Kamath Date: Wed, 4 Dec 2024 09:21:59 -0800 Subject: [PATCH 06/22] Rename bb to client in README (#80) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 63a38365..1f1c0f99 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ from browserbase import Browserbase BROWSERBASE_API_KEY = os.environ.get("BROWSERBASE_API_KEY") BROWSERBASE_PROJECT_ID = os.environ.get("BROWSERBASE_PROJECT_ID") -bb = Browserbase( +client = Browserbase( # This is the default and can be omitted api_key=BROWSERBASE_API_KEY, ) From 0a3c371efad36b42f7d02faf241bce7a2253330a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:34:51 -0800 Subject: [PATCH 07/22] release: 1.1.0 (#79) * chore: make the `Omit` type public (#78) * chore(internal): bump pydantic dependency (#81) * docs(readme): fix http client proxies example (#82) * chore(internal): bump pyright (#83) * chore(internal): add support for TypeAliasType (#85) * chore(internal): codegen related update (#86) * chore(internal): codegen related update (#87) * chore(internal): codegen related update (#88) * chore(internal): remove some duplicated imports (#89) * chore(internal): updated imports (#90) * docs(readme): example snippet for client context manager (#91) * chore(internal): fix some typos (#92) * chore(internal): codegen related update (#93) * chore: add missing isclass check (#94) * chore(internal): bump httpx dependency (#95) * fix(client): only call .close() when needed (#97) * docs: fix typos (#98) * chore(internal): codegen related update (#99) * fix: correctly handle deserialising `cls` fields (#100) * feat(api): api update (#101) * chore(internal): codegen related update (#102) * feat(api): api update (#104) * feat(api): api update (#105) * chore(internal): codegen related update (#106) * chore(internal): codegen related update (#107) * feat(api): api update (#109) * chore(internal): minor formatting changes (#110) * feat(api): api update (#111) * release: 1.1.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +- .release-please-manifest.json | 2 +- .stats.yml | 2 +- CHANGELOG.md | 47 +++++++++++++ LICENSE | 2 +- README.md | 29 +++++--- examples/e2e/test_playwright.py | 1 + examples/playwright_basic.py | 4 +- examples/playwright_captcha.py | 4 +- examples/playwright_contexts.py | 16 ++--- examples/playwright_extensions.py | 20 ++---- examples/playwright_proxy.py | 4 +- examples/playwright_upload.py | 8 +-- examples/selenium_basic.py | 3 +- mypy.ini | 2 +- pyproject.toml | 5 +- requirements-dev.lock | 15 ++-- requirements.lock | 7 +- scripts/bootstrap | 2 +- scripts/lint | 1 - src/browserbase/__init__.py | 3 +- src/browserbase/_base_client.py | 6 ++ src/browserbase/_client.py | 69 ++++++++++--------- src/browserbase/_models.py | 17 +++-- src/browserbase/_response.py | 32 +++++---- src/browserbase/_types.py | 6 +- src/browserbase/_utils/__init__.py | 1 + src/browserbase/_utils/_typing.py | 31 ++++++++- src/browserbase/_version.py | 2 +- src/browserbase/resources/contexts.py | 4 +- src/browserbase/resources/extensions.py | 4 +- src/browserbase/resources/projects.py | 4 +- .../resources/sessions/downloads.py | 4 +- src/browserbase/resources/sessions/logs.py | 4 +- .../resources/sessions/recording.py | 4 +- .../resources/sessions/sessions.py | 54 ++++++++++++--- src/browserbase/resources/sessions/uploads.py | 4 +- src/browserbase/types/session.py | 9 ++- .../types/session_create_params.py | 12 +++- .../types/session_create_response.py | 9 ++- src/browserbase/types/session_list_params.py | 8 +++ src/browserbase/types/sessions/session_log.py | 12 ++-- tests/api_resources/test_sessions.py | 6 ++ tests/test_client.py | 33 ++++++--- tests/test_models.py | 28 +++++++- tests/utils.py | 4 ++ 46 files changed, 367 insertions(+), 180 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40293964..c8a8a4f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ jobs: lint: name: lint runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 @@ -30,6 +29,7 @@ jobs: - name: Run lints run: ./scripts/lint + test: name: test runs-on: ubuntu-latest @@ -50,4 +50,3 @@ jobs: - name: Run tests run: ./scripts/test - diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1214610a..2601677b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.0.5" + ".": "1.1.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index d42b050b..be077766 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,2 +1,2 @@ configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-7f88912695bab2b98cb73137e6f36125d02fdfaf8eed4532ee1c82385609a259.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-396a2b9092f645c5a9e46a1f3be8c2e45ca9ae079e1d39761eb0a73f56e24b15.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb29660..e9586e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## 1.1.0 (2025-01-28) + +Full Changelog: [v1.0.5...v1.1.0](https://github.com/browserbase/sdk-python/compare/v1.0.5...v1.1.0) + +### Features + +* **api:** api update ([#101](https://github.com/browserbase/sdk-python/issues/101)) ([5be14e9](https://github.com/browserbase/sdk-python/commit/5be14e9b49b95daa2bc043ed8c33b2d4527a7361)) +* **api:** api update ([#104](https://github.com/browserbase/sdk-python/issues/104)) ([c13b2f9](https://github.com/browserbase/sdk-python/commit/c13b2f95924c940deece1f6e3b1e4ca2dfbd9fe7)) +* **api:** api update ([#105](https://github.com/browserbase/sdk-python/issues/105)) ([fc3b82f](https://github.com/browserbase/sdk-python/commit/fc3b82f224e92e273d484f8b0f52eb433210e38b)) +* **api:** api update ([#109](https://github.com/browserbase/sdk-python/issues/109)) ([faca7e9](https://github.com/browserbase/sdk-python/commit/faca7e94c6086d461b81f2806868af2e1506e035)) +* **api:** api update ([#111](https://github.com/browserbase/sdk-python/issues/111)) ([42ae774](https://github.com/browserbase/sdk-python/commit/42ae77474c2fbe9eefd9929e15d8d51cbf40bc00)) + + +### Bug Fixes + +* **client:** only call .close() when needed ([#97](https://github.com/browserbase/sdk-python/issues/97)) ([01d5bd5](https://github.com/browserbase/sdk-python/commit/01d5bd5eb7675fc069fe01e7651d769df182270a)) +* correctly handle deserialising `cls` fields ([#100](https://github.com/browserbase/sdk-python/issues/100)) ([b617b85](https://github.com/browserbase/sdk-python/commit/b617b85ef3cce3c16e38125bec483c72bc3d43c0)) + + +### Chores + +* add missing isclass check ([#94](https://github.com/browserbase/sdk-python/issues/94)) ([de5856d](https://github.com/browserbase/sdk-python/commit/de5856dac77567813f681615bef7d147e505a6a0)) +* **internal:** add support for TypeAliasType ([#85](https://github.com/browserbase/sdk-python/issues/85)) ([64448c6](https://github.com/browserbase/sdk-python/commit/64448c6e020aaeb4b39b7ec8f1b28a6b8f0c746a)) +* **internal:** bump httpx dependency ([#95](https://github.com/browserbase/sdk-python/issues/95)) ([d592266](https://github.com/browserbase/sdk-python/commit/d592266e85c40d14e4929089f8ae4db814d04ce7)) +* **internal:** bump pydantic dependency ([#81](https://github.com/browserbase/sdk-python/issues/81)) ([e35a0d8](https://github.com/browserbase/sdk-python/commit/e35a0d85ef0e45aed1a5f58757427bf7c16a76f5)) +* **internal:** bump pyright ([#83](https://github.com/browserbase/sdk-python/issues/83)) ([894b4c4](https://github.com/browserbase/sdk-python/commit/894b4c45b0c36963822923535391aa34dbfec766)) +* **internal:** codegen related update ([#102](https://github.com/browserbase/sdk-python/issues/102)) ([f648bbb](https://github.com/browserbase/sdk-python/commit/f648bbbae4520a1003ecaf5cbd299da9aabfb90f)) +* **internal:** codegen related update ([#106](https://github.com/browserbase/sdk-python/issues/106)) ([3fc9cde](https://github.com/browserbase/sdk-python/commit/3fc9cde212c1ea7f1010c9e688bd75841d828ace)) +* **internal:** codegen related update ([#107](https://github.com/browserbase/sdk-python/issues/107)) ([c97e138](https://github.com/browserbase/sdk-python/commit/c97e1383ac673d05861653c0818c1d1c5b0fa5c8)) +* **internal:** codegen related update ([#86](https://github.com/browserbase/sdk-python/issues/86)) ([ab76578](https://github.com/browserbase/sdk-python/commit/ab76578bdce5eba2410b09f497758fbf0e0d8cf0)) +* **internal:** codegen related update ([#87](https://github.com/browserbase/sdk-python/issues/87)) ([f7f189e](https://github.com/browserbase/sdk-python/commit/f7f189ec317394f2fc532b8f95c3d15304298027)) +* **internal:** codegen related update ([#88](https://github.com/browserbase/sdk-python/issues/88)) ([85f1492](https://github.com/browserbase/sdk-python/commit/85f1492efc58d86ebc34511ca1269a0db2a4d223)) +* **internal:** codegen related update ([#93](https://github.com/browserbase/sdk-python/issues/93)) ([57f0977](https://github.com/browserbase/sdk-python/commit/57f0977c8e050b85b2c2de91202f6775299f80bf)) +* **internal:** codegen related update ([#99](https://github.com/browserbase/sdk-python/issues/99)) ([f817bcb](https://github.com/browserbase/sdk-python/commit/f817bcb67c2080a954c476c15dc048c2c628243a)) +* **internal:** fix some typos ([#92](https://github.com/browserbase/sdk-python/issues/92)) ([51d9f42](https://github.com/browserbase/sdk-python/commit/51d9f42a32d17d2d2277eb8a7b8f35a980c7c485)) +* **internal:** minor formatting changes ([#110](https://github.com/browserbase/sdk-python/issues/110)) ([195c595](https://github.com/browserbase/sdk-python/commit/195c595bfbe2ed97ae4b551658618f4a99a255f0)) +* **internal:** remove some duplicated imports ([#89](https://github.com/browserbase/sdk-python/issues/89)) ([a82ae7d](https://github.com/browserbase/sdk-python/commit/a82ae7d418b1daf68c85e70dea61e628eb785b79)) +* **internal:** updated imports ([#90](https://github.com/browserbase/sdk-python/issues/90)) ([dc6e187](https://github.com/browserbase/sdk-python/commit/dc6e187bfe9585692b2de1b67fc83f027a52c43c)) +* make the `Omit` type public ([#78](https://github.com/browserbase/sdk-python/issues/78)) ([a7bdc57](https://github.com/browserbase/sdk-python/commit/a7bdc57ab7f327da61121986ba7b006238d0e5b5)) + + +### Documentation + +* fix typos ([#98](https://github.com/browserbase/sdk-python/issues/98)) ([d4f4bae](https://github.com/browserbase/sdk-python/commit/d4f4bae46341e91ac537e121bba38e511c7026bc)) +* **readme:** example snippet for client context manager ([#91](https://github.com/browserbase/sdk-python/issues/91)) ([950c8af](https://github.com/browserbase/sdk-python/commit/950c8af19db4581fabd5b965ca4f0af3cc5cd6dc)) +* **readme:** fix http client proxies example ([#82](https://github.com/browserbase/sdk-python/issues/82)) ([cc67c77](https://github.com/browserbase/sdk-python/commit/cc67c773b11b42b406b677f466c7c0ef090b254e)) + ## 1.0.5 (2024-12-03) Full Changelog: [v1.0.4...v1.0.5](https://github.com/browserbase/sdk-python/compare/v1.0.4...v1.0.5) diff --git a/LICENSE b/LICENSE index 915e6f84..2cec9d4b 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2024 Browserbase + Copyright 2025 Browserbase Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 1f1c0f99..1a508e68 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ session = client.sessions.create( ) print(session.id) + def run(playwright: Playwright) -> None: # Connect to the remote session chromium = playwright.chromium @@ -51,9 +52,7 @@ def run(playwright: Playwright) -> None: # Execute Playwright actions on the remote browser tab page.goto("https://news.ycombinator.com/") page_title = page.title() - assert ( - page_title == "Hacker News" - ), f"Page title is not 'Hacker News', it is '{page_title}'" + assert page_title == "Hacker News", f"Page title is not 'Hacker News', it is '{page_title}'" page.screenshot(path="screenshot.png") page.close() @@ -121,7 +120,7 @@ except browserbase.APIStatusError as e: print(e.response) ``` -Error codes are as followed: +Error codes are as follows: | Status Code | Error Type | | ----------- | -------------------------- | @@ -260,8 +259,7 @@ If you need to access undocumented endpoints, params, or response properties, th #### 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) will be respected when making this -request. +http verbs. Options on the client will be respected (such as retries) when making this request. ```py import httpx @@ -290,18 +288,19 @@ can also get all the extra fields on the Pydantic model as a dict with You can directly override the [httpx client](https://www.python-httpx.org/api/#client) to customize it for your use case, including: -- Support for proxies -- Custom transports +- 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 ```python +import httpx from browserbase import Browserbase, DefaultHttpxClient client = Browserbase( # Or use the `BROWSERBASE_BASE_URL` env var base_url="http://my.test.server.example.com:8083", http_client=DefaultHttpxClient( - proxies="http://my.test.proxy.example.com", + proxy="http://my.test.proxy.example.com", transport=httpx.HTTPTransport(local_address="0.0.0.0"), ), ) @@ -317,12 +316,22 @@ client.with_options(http_client=DefaultHttpxClient(...)) 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 browserbase import Browserbase + +with Browserbase() as client: + # make requests here + ... + +# 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)_. +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. diff --git a/examples/e2e/test_playwright.py b/examples/e2e/test_playwright.py index 2e58c70a..afd94f13 100644 --- a/examples/e2e/test_playwright.py +++ b/examples/e2e/test_playwright.py @@ -29,6 +29,7 @@ def playwright() -> Generator[Playwright, None, None]: with sync_playwright() as p: yield p + def test_playwright_basic(playwright: Playwright) -> None: playwright_basic.run(playwright) diff --git a/examples/playwright_basic.py b/examples/playwright_basic.py index 06fc93ca..33bf88a9 100644 --- a/examples/playwright_basic.py +++ b/examples/playwright_basic.py @@ -19,9 +19,7 @@ def run(playwright: Playwright) -> None: # Execute Playwright actions on the remote browser tab page.goto("https://news.ycombinator.com/") page_title = page.title() - assert ( - page_title == "Hacker News" - ), f"Page title is not 'Hacker News', it is '{page_title}'" + assert page_title == "Hacker News", f"Page title is not 'Hacker News', it is '{page_title}'" page.screenshot(path="screenshot.png") page.close() diff --git a/examples/playwright_captcha.py b/examples/playwright_captcha.py index 7bc2ff42..980504f3 100644 --- a/examples/playwright_captcha.py +++ b/examples/playwright_captcha.py @@ -34,9 +34,7 @@ def handle_console(msg: ConsoleMessage) -> None: page.on("console", handle_console) page.goto(DEFAULT_CAPTCHA_URL, wait_until="networkidle") - page.wait_for_function( - "() => window.captchaSolvingFinished === true", timeout=OVERRIDE_TIMEOUT - ) + page.wait_for_function("() => window.captchaSolvingFinished === true", timeout=OVERRIDE_TIMEOUT) assert captcha_solving_started, "Captcha solving did not start" assert captcha_solving_finished, "Captcha solving did not finish" diff --git a/examples/playwright_contexts.py b/examples/playwright_contexts.py index 610636ff..acf46338 100644 --- a/examples/playwright_contexts.py +++ b/examples/playwright_contexts.py @@ -41,15 +41,11 @@ def run(playwright: Playwright) -> None: # Step 2: Creates a session with the context session = bb.sessions.create( project_id=BROWSERBASE_PROJECT_ID, - browser_settings=TypeAdapter(BrowserSettings).validate_python( - {"context": {"id": context_id, "persist": True}} - ), + browser_settings=TypeAdapter(BrowserSettings).validate_python({"context": {"id": context_id, "persist": True}}), ) print(session) - assert ( - session.context_id == context_id - ), f"Session context_id is {session.context_id}, expected {context_id}" + assert session.context_id == context_id, f"Session context_id is {session.context_id}, expected {context_id}" session_id = session.id # Step 3: Populates and persists the context @@ -90,13 +86,9 @@ def run(playwright: Playwright) -> None: # Step 4: Creates another session with the same context session = bb.sessions.create( project_id=BROWSERBASE_PROJECT_ID, - browser_settings=BrowserSettings( - context=BrowserSettingsContext(id=context_id, persist=True) - ), + browser_settings=BrowserSettings(context=BrowserSettingsContext(id=context_id, persist=True)), ) - assert ( - session.context_id == context_id - ), f"Session context_id is {session.context_id}, expected {context_id}" + assert session.context_id == context_id, f"Session context_id is {session.context_id}, expected {context_id}" session_id = session.id # Step 5: Uses context to find previous state diff --git a/examples/playwright_extensions.py b/examples/playwright_extensions.py index 7bdb9426..6ef1f985 100644 --- a/examples/playwright_extensions.py +++ b/examples/playwright_extensions.py @@ -12,9 +12,7 @@ ) from browserbase.types import Extension, SessionCreateResponse -PATH_TO_EXTENSION = ( - Path.cwd() / "examples" / "packages" / "extensions" / "browserbase-test" -) +PATH_TO_EXTENSION = Path.cwd() / "examples" / "packages" / "extensions" / "browserbase-test" def zip_extension(path: Path = PATH_TO_EXTENSION, save_local: bool = False) -> BytesIO: @@ -23,9 +21,7 @@ def zip_extension(path: Path = PATH_TO_EXTENSION, save_local: bool = False) -> B Mark save_local=True to save the zip file to a local file. """ # Ensure we're looking at an extension - assert "manifest.json" in os.listdir( - path - ), "No manifest.json found in the extension folder." + assert "manifest.json" in os.listdir(path), "No manifest.json found in the extension folder." # Create a BytesIO object to hold the zip file in memory memory_zip = BytesIO() @@ -51,9 +47,7 @@ def zip_extension(path: Path = PATH_TO_EXTENSION, save_local: bool = False) -> B def create_extension() -> str: zip_data = zip_extension(save_local=True) - extension: Extension = bb.extensions.create( - file=("extension.zip", zip_data.getvalue()) - ) + extension: Extension = bb.extensions.create(file=("extension.zip", zip_data.getvalue())) return extension.id @@ -75,9 +69,7 @@ def check_for_message(page: Page, message: str) -> None: while time.time() - start < 10: if message in console_messages: break - assert ( - message in console_messages - ), f"Expected message not found in console logs. Messages: {console_messages}" + assert message in console_messages, f"Expected message not found in console logs. Messages: {console_messages}" def run(playwright: Playwright) -> None: @@ -141,9 +133,7 @@ def run(playwright: Playwright) -> None: project_id=BROWSERBASE_PROJECT_ID, extension_id=extension_id, ) - raise AssertionError( - "Expected to fail when creating session with deleted extension" - ) + raise AssertionError("Expected to fail when creating session with deleted extension") except Exception as e: print(f"Failed to create session with deleted extension as expected: {str(e)}") diff --git a/examples/playwright_proxy.py b/examples/playwright_proxy.py index 8378e290..47c706b2 100644 --- a/examples/playwright_proxy.py +++ b/examples/playwright_proxy.py @@ -11,9 +11,7 @@ def check_proxy_bytes(session_id: str) -> None: - bb.sessions.update( - id=session_id, project_id=BROWSERBASE_PROJECT_ID, status="REQUEST_RELEASE" - ) + bb.sessions.update(id=session_id, project_id=BROWSERBASE_PROJECT_ID, status="REQUEST_RELEASE") time.sleep(GRACEFUL_SHUTDOWN_TIMEOUT / 1000) updated_session = bb.sessions.retrieve(id=session_id) assert ( diff --git a/examples/playwright_upload.py b/examples/playwright_upload.py index da1e32c0..6bba6e0c 100644 --- a/examples/playwright_upload.py +++ b/examples/playwright_upload.py @@ -33,12 +33,8 @@ def run(playwright: Playwright) -> None: file_size = int(file_size_span.inner_text()) # Assert the file name and size - assert ( - file_name == "logo.png" - ), f"Expected file name to be 'logo.png', but got '{file_name}'" - assert ( - file_size > 0 - ), f"Expected file size to be greater than 0, but got {file_size}" + assert file_name == "logo.png", f"Expected file name to be 'logo.png', but got '{file_name}'" + assert file_size > 0, f"Expected file size to be greater than 0, but got {file_size}" print("File upload test passed successfully!") diff --git a/examples/selenium_basic.py b/examples/selenium_basic.py index 83b73078..91c7c32c 100644 --- a/examples/selenium_basic.py +++ b/examples/selenium_basic.py @@ -34,7 +34,8 @@ def run() -> None: session = bb.sessions.create(project_id=BROWSERBASE_PROJECT_ID) connection = BrowserbaseConnection(session.id, session.selenium_remote_url) driver = webdriver.Remote( - command_executor=connection, options=webdriver.ChromeOptions() # type: ignore + command_executor=connection, + options=webdriver.ChromeOptions(), # type: ignore ) # Print a bit of info about the browser we've connected to diff --git a/mypy.ini b/mypy.ini index 9e79a7c7..811af717 100644 --- a/mypy.ini +++ b/mypy.ini @@ -41,7 +41,7 @@ cache_fine_grained = True # ``` # Changing this codegen to make mypy happy would increase complexity # and would not be worth it. -disable_error_code = func-returns-value +disable_error_code = func-returns-value,overload-cannot-match # https://github.com/python/mypy/issues/12162 [mypy.overrides] diff --git a/pyproject.toml b/pyproject.toml index 82209c4c..7e1b0e91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.0.5" +version = "1.1.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -10,7 +10,7 @@ authors = [ dependencies = [ "httpx>=0.23.0, <1", "pydantic>=1.9.0, <3", - "typing-extensions>=4.7, <5", + "typing-extensions>=4.10, <5", "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", @@ -138,6 +138,7 @@ testpaths = ["tests"] addopts = "--tb=short" xfail_strict = true asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" filterwarnings = [ "error" ] diff --git a/requirements-dev.lock b/requirements-dev.lock index 48e714f0..30dd44de 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -4,7 +4,7 @@ # last locked with the following flags: # pre: false # features: [] -# all-features: false +# all-features: true # with-sources: false # generate-hashes: false # universal: false @@ -48,7 +48,7 @@ h11==0.14.0 # via wsproto httpcore==1.0.6 # via httpx -httpx==0.27.2 +httpx==0.28.1 # via browserbase # via respx idna==3.10 @@ -63,7 +63,7 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -mypy==1.13.0 +mypy==1.14.1 mypy-extensions==1.0.0 # via mypy nest-asyncio==1.6.0 @@ -81,15 +81,15 @@ playwright==1.48.0 # via pytest-playwright pluggy==1.5.0 # via pytest -pydantic==2.9.2 +pydantic==2.10.3 # via browserbase -pydantic-core==2.23.4 +pydantic-core==2.27.1 # via pydantic pyee==12.0.0 # via playwright pygments==2.18.0 # via rich -pyright==1.1.386 +pyright==1.1.392.post0 pysocks==1.7.1 # via urllib3 pytest==8.3.3 @@ -107,7 +107,7 @@ python-slugify==8.0.4 # via pytest-playwright requests==2.32.3 # via pytest-base-url -respx==0.21.1 +respx==0.22.0 rich==13.9.3 ruff==0.7.1 selenium==4.25.0 @@ -116,7 +116,6 @@ six==1.16.0 sniffio==1.3.1 # via anyio # via browserbase - # via httpx # via trio sortedcontainers==2.4.0 # via trio diff --git a/requirements.lock b/requirements.lock index 4bff3dd0..9efa54d7 100644 --- a/requirements.lock +++ b/requirements.lock @@ -26,19 +26,18 @@ h11==0.14.0 # via httpcore httpcore==1.0.2 # via httpx -httpx==0.25.2 +httpx==0.28.1 # via browserbase idna==3.4 # via anyio # via httpx -pydantic==2.9.2 +pydantic==2.10.3 # via browserbase -pydantic-core==2.23.4 +pydantic-core==2.27.1 # via pydantic sniffio==1.3.0 # via anyio # via browserbase - # via httpx typing-extensions==4.12.2 # via anyio # via browserbase diff --git a/scripts/bootstrap b/scripts/bootstrap index 8c5c60eb..e84fe62c 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then +if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then brew bundle check >/dev/null 2>&1 || { echo "==> Installing Homebrew dependencies…" brew bundle diff --git a/scripts/lint b/scripts/lint index a74a1988..feccbdde 100755 --- a/scripts/lint +++ b/scripts/lint @@ -9,4 +9,3 @@ rye run lint echo "==> Making sure it imports" rye run python -c 'import browserbase' - diff --git a/src/browserbase/__init__.py b/src/browserbase/__init__.py index 4b1d2804..ce50c4ea 100644 --- a/src/browserbase/__init__.py +++ b/src/browserbase/__init__.py @@ -1,7 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from . import types -from ._types import NOT_GIVEN, NoneType, NotGiven, Transport, ProxiesTypes +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes from ._utils import file_from_path from ._client import ( Client, @@ -46,6 +46,7 @@ "ProxiesTypes", "NotGiven", "NOT_GIVEN", + "Omit", "BrowserbaseError", "APIError", "APIStatusError", diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index d8b28d9a..b21f4797 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_base_client.py @@ -767,6 +767,9 @@ def __init__(self, **kwargs: Any) -> None: class SyncHttpxClientWrapper(DefaultHttpxClient): def __del__(self) -> None: + if self.is_closed: + return + try: self.close() except Exception: @@ -1334,6 +1337,9 @@ def __init__(self, **kwargs: Any) -> None: class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): def __del__(self) -> None: + if self.is_closed: + return + try: # TODO(someday): support non asyncio runtimes here asyncio.get_running_loop().create_task(self.aclose()) diff --git a/src/browserbase/_client.py b/src/browserbase/_client.py index b3ed32f6..b845ea86 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -8,7 +8,7 @@ import httpx -from . import resources, _exceptions +from . import _exceptions from ._qs import Querystring from ._types import ( NOT_GIVEN, @@ -24,6 +24,7 @@ get_async_library, ) from ._version import __version__ +from .resources import contexts, projects, extensions from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, BrowserbaseError from ._base_client import ( @@ -31,13 +32,13 @@ SyncAPIClient, AsyncAPIClient, ) +from .resources.sessions import sessions __all__ = [ "Timeout", "Transport", "ProxiesTypes", "RequestOptions", - "resources", "Browserbase", "AsyncBrowserbase", "Client", @@ -46,10 +47,10 @@ class Browserbase(SyncAPIClient): - contexts: resources.ContextsResource - extensions: resources.ExtensionsResource - projects: resources.ProjectsResource - sessions: resources.SessionsResource + contexts: contexts.ContextsResource + extensions: extensions.ExtensionsResource + projects: projects.ProjectsResource + sessions: sessions.SessionsResource with_raw_response: BrowserbaseWithRawResponse with_streaming_response: BrowserbaseWithStreamedResponse @@ -107,10 +108,10 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.contexts = resources.ContextsResource(self) - self.extensions = resources.ExtensionsResource(self) - self.projects = resources.ProjectsResource(self) - self.sessions = resources.SessionsResource(self) + self.contexts = contexts.ContextsResource(self) + self.extensions = extensions.ExtensionsResource(self) + self.projects = projects.ProjectsResource(self) + self.sessions = sessions.SessionsResource(self) self.with_raw_response = BrowserbaseWithRawResponse(self) self.with_streaming_response = BrowserbaseWithStreamedResponse(self) @@ -220,10 +221,10 @@ def _make_status_error( class AsyncBrowserbase(AsyncAPIClient): - contexts: resources.AsyncContextsResource - extensions: resources.AsyncExtensionsResource - projects: resources.AsyncProjectsResource - sessions: resources.AsyncSessionsResource + contexts: contexts.AsyncContextsResource + extensions: extensions.AsyncExtensionsResource + projects: projects.AsyncProjectsResource + sessions: sessions.AsyncSessionsResource with_raw_response: AsyncBrowserbaseWithRawResponse with_streaming_response: AsyncBrowserbaseWithStreamedResponse @@ -281,10 +282,10 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.contexts = resources.AsyncContextsResource(self) - self.extensions = resources.AsyncExtensionsResource(self) - self.projects = resources.AsyncProjectsResource(self) - self.sessions = resources.AsyncSessionsResource(self) + self.contexts = contexts.AsyncContextsResource(self) + self.extensions = extensions.AsyncExtensionsResource(self) + self.projects = projects.AsyncProjectsResource(self) + self.sessions = sessions.AsyncSessionsResource(self) self.with_raw_response = AsyncBrowserbaseWithRawResponse(self) self.with_streaming_response = AsyncBrowserbaseWithStreamedResponse(self) @@ -395,34 +396,34 @@ def _make_status_error( class BrowserbaseWithRawResponse: def __init__(self, client: Browserbase) -> None: - self.contexts = resources.ContextsResourceWithRawResponse(client.contexts) - self.extensions = resources.ExtensionsResourceWithRawResponse(client.extensions) - self.projects = resources.ProjectsResourceWithRawResponse(client.projects) - self.sessions = resources.SessionsResourceWithRawResponse(client.sessions) + self.contexts = contexts.ContextsResourceWithRawResponse(client.contexts) + self.extensions = extensions.ExtensionsResourceWithRawResponse(client.extensions) + self.projects = projects.ProjectsResourceWithRawResponse(client.projects) + self.sessions = sessions.SessionsResourceWithRawResponse(client.sessions) class AsyncBrowserbaseWithRawResponse: def __init__(self, client: AsyncBrowserbase) -> None: - self.contexts = resources.AsyncContextsResourceWithRawResponse(client.contexts) - self.extensions = resources.AsyncExtensionsResourceWithRawResponse(client.extensions) - self.projects = resources.AsyncProjectsResourceWithRawResponse(client.projects) - self.sessions = resources.AsyncSessionsResourceWithRawResponse(client.sessions) + self.contexts = contexts.AsyncContextsResourceWithRawResponse(client.contexts) + self.extensions = extensions.AsyncExtensionsResourceWithRawResponse(client.extensions) + self.projects = projects.AsyncProjectsResourceWithRawResponse(client.projects) + self.sessions = sessions.AsyncSessionsResourceWithRawResponse(client.sessions) class BrowserbaseWithStreamedResponse: def __init__(self, client: Browserbase) -> None: - self.contexts = resources.ContextsResourceWithStreamingResponse(client.contexts) - self.extensions = resources.ExtensionsResourceWithStreamingResponse(client.extensions) - self.projects = resources.ProjectsResourceWithStreamingResponse(client.projects) - self.sessions = resources.SessionsResourceWithStreamingResponse(client.sessions) + self.contexts = contexts.ContextsResourceWithStreamingResponse(client.contexts) + self.extensions = extensions.ExtensionsResourceWithStreamingResponse(client.extensions) + self.projects = projects.ProjectsResourceWithStreamingResponse(client.projects) + self.sessions = sessions.SessionsResourceWithStreamingResponse(client.sessions) class AsyncBrowserbaseWithStreamedResponse: def __init__(self, client: AsyncBrowserbase) -> None: - self.contexts = resources.AsyncContextsResourceWithStreamingResponse(client.contexts) - self.extensions = resources.AsyncExtensionsResourceWithStreamingResponse(client.extensions) - self.projects = resources.AsyncProjectsResourceWithStreamingResponse(client.projects) - self.sessions = resources.AsyncSessionsResourceWithStreamingResponse(client.sessions) + self.contexts = contexts.AsyncContextsResourceWithStreamingResponse(client.contexts) + self.extensions = extensions.AsyncExtensionsResourceWithStreamingResponse(client.extensions) + self.projects = projects.AsyncProjectsResourceWithStreamingResponse(client.projects) + self.sessions = sessions.AsyncSessionsResourceWithStreamingResponse(client.sessions) Client = Browserbase diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index 6cb469e2..9a918aab 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -46,6 +46,7 @@ strip_not_given, extract_type_arg, is_annotated_type, + is_type_alias_type, strip_annotated_type, ) from ._compat import ( @@ -178,14 +179,14 @@ def __str__(self) -> str: @classmethod @override def construct( # pyright: ignore[reportIncompatibleMethodOverride] - cls: Type[ModelT], + __cls: Type[ModelT], _fields_set: set[str] | None = None, **values: object, ) -> ModelT: - m = cls.__new__(cls) + m = __cls.__new__(__cls) fields_values: dict[str, object] = {} - config = get_model_config(cls) + config = get_model_config(__cls) populate_by_name = ( config.allow_population_by_field_name if isinstance(config, _ConfigProtocol) @@ -195,7 +196,7 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] if _fields_set is None: _fields_set = set() - model_fields = get_model_fields(cls) + model_fields = get_model_fields(__cls) for name, field in model_fields.items(): key = field.alias if key is None or (key not in values and populate_by_name): @@ -428,6 +429,8 @@ def construct_type(*, value: object, type_: object) -> object: # we allow `object` as the input type because otherwise, passing things like # `Literal['value']` will be reported as a type error by type checkers type_ = cast("type[object]", type_) + if is_type_alias_type(type_): + type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` if is_annotated_type(type_): @@ -485,7 +488,11 @@ def construct_type(*, value: object, type_: object) -> object: _, items_type = get_args(type_) # Dict[_, items_type] return {key: construct_type(value=item, type_=items_type) for key, item in value.items()} - if not is_literal_type(type_) and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel)): + if ( + not is_literal_type(type_) + and inspect.isclass(origin) + and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel)) + ): if is_list(value): return [cast(Any, type_).construct(**entry) if is_mapping(entry) else entry for entry in value] diff --git a/src/browserbase/_response.py b/src/browserbase/_response.py index 81ae0828..e79cb15f 100644 --- a/src/browserbase/_response.py +++ b/src/browserbase/_response.py @@ -25,7 +25,7 @@ import pydantic from ._types import NoneType -from ._utils import is_given, extract_type_arg, is_annotated_type, extract_type_var_from_base +from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base from ._models import BaseModel, is_basemodel from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type @@ -126,9 +126,17 @@ def __repr__(self) -> str: ) def _parse(self, *, to: type[_T] | None = None) -> R | _T: + cast_to = to if to is not None else self._cast_to + + # unwrap `TypeAlias('Name', T)` -> `T` + if is_type_alias_type(cast_to): + cast_to = cast_to.__value__ # type: ignore[unreachable] + # unwrap `Annotated[T, ...]` -> `T` - if to and is_annotated_type(to): - to = extract_type_arg(to, 0) + if cast_to and is_annotated_type(cast_to): + cast_to = extract_type_arg(cast_to, 0) + + origin = get_origin(cast_to) or cast_to if self._is_sse_stream: if to: @@ -164,18 +172,12 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: return cast( R, stream_cls( - cast_to=self._cast_to, + cast_to=cast_to, response=self.http_response, client=cast(Any, self._client), ), ) - cast_to = to if to is not None else self._cast_to - - # unwrap `Annotated[T, ...]` -> `T` - if is_annotated_type(cast_to): - cast_to = extract_type_arg(cast_to, 0) - if cast_to is NoneType: return cast(R, None) @@ -195,8 +197,6 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: if cast_to == bool: return cast(R, response.text.lower() == "true") - origin = get_origin(cast_to) or cast_to - if origin == APIResponse: raise RuntimeError("Unexpected state - cast_to is `APIResponse`") @@ -210,7 +210,13 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") return cast(R, response) - if inspect.isclass(origin) and not issubclass(origin, BaseModel) and issubclass(origin, pydantic.BaseModel): + if ( + inspect.isclass( + origin # pyright: ignore[reportUnknownArgumentType] + ) + and not issubclass(origin, BaseModel) + and issubclass(origin, pydantic.BaseModel) + ): raise TypeError( "Pydantic models must subclass our base model type, e.g. `from browserbase import BaseModel`" ) diff --git a/src/browserbase/_types.py b/src/browserbase/_types.py index 1691090d..a8833dce 100644 --- a/src/browserbase/_types.py +++ b/src/browserbase/_types.py @@ -192,10 +192,8 @@ def get(self, __key: str) -> str | None: ... StrBytesIntFloat = Union[str, bytes, int, float] # Note: copied from Pydantic -# https://github.com/pydantic/pydantic/blob/32ea570bf96e84234d2992e1ddf40ab8a565925a/pydantic/main.py#L49 -IncEx: TypeAlias = Union[ - Set[int], Set[str], Mapping[int, Union["IncEx", Literal[True]]], Mapping[str, Union["IncEx", Literal[True]]] -] +# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79 +IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]] PostParser = Callable[[Any], Any] diff --git a/src/browserbase/_utils/__init__.py b/src/browserbase/_utils/__init__.py index a7cff3c0..d4fda26f 100644 --- a/src/browserbase/_utils/__init__.py +++ b/src/browserbase/_utils/__init__.py @@ -39,6 +39,7 @@ is_iterable_type as is_iterable_type, is_required_type as is_required_type, is_annotated_type as is_annotated_type, + is_type_alias_type as is_type_alias_type, strip_annotated_type as strip_annotated_type, extract_type_var_from_base as extract_type_var_from_base, ) diff --git a/src/browserbase/_utils/_typing.py b/src/browserbase/_utils/_typing.py index c036991f..278749b1 100644 --- a/src/browserbase/_utils/_typing.py +++ b/src/browserbase/_utils/_typing.py @@ -1,8 +1,17 @@ from __future__ import annotations +import sys +import typing +import typing_extensions from typing import Any, TypeVar, Iterable, cast from collections import abc as _c_abc -from typing_extensions import Required, Annotated, get_args, get_origin +from typing_extensions import ( + TypeIs, + Required, + Annotated, + get_args, + get_origin, +) from .._types import InheritsGeneric from .._compat import is_union as _is_union @@ -36,6 +45,26 @@ def is_typevar(typ: type) -> bool: return type(typ) == TypeVar # type: ignore +_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,) +if sys.version_info >= (3, 12): + _TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType) + + +def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: + """Return whether the provided argument is an instance of `TypeAliasType`. + + ```python + type Int = int + is_type_alias_type(Int) + # > True + Str = TypeAliasType("Str", str) + is_type_alias_type(Str) + # > True + ``` + """ + return isinstance(tp, _TYPE_ALIAS_TYPES) + + # Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] def strip_annotated_type(typ: type) -> type: if is_required_type(typ) or is_annotated_type(typ): diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index c871d01e..9621169e 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.0.5" # x-release-please-version +__version__ = "1.1.0" # x-release-please-version diff --git a/src/browserbase/resources/contexts.py b/src/browserbase/resources/contexts.py index 806cb012..486cd5ff 100644 --- a/src/browserbase/resources/contexts.py +++ b/src/browserbase/resources/contexts.py @@ -30,7 +30,7 @@ class ContextsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> ContextsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -153,7 +153,7 @@ class AsyncContextsResource(AsyncAPIResource): @cached_property def with_raw_response(self) -> AsyncContextsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers diff --git a/src/browserbase/resources/extensions.py b/src/browserbase/resources/extensions.py index dc6c0ac7..a98685a8 100644 --- a/src/browserbase/resources/extensions.py +++ b/src/browserbase/resources/extensions.py @@ -32,7 +32,7 @@ class ExtensionsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> ExtensionsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -159,7 +159,7 @@ class AsyncExtensionsResource(AsyncAPIResource): @cached_property def with_raw_response(self) -> AsyncExtensionsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers diff --git a/src/browserbase/resources/projects.py b/src/browserbase/resources/projects.py index bf4a5df9..fb337a02 100644 --- a/src/browserbase/resources/projects.py +++ b/src/browserbase/resources/projects.py @@ -25,7 +25,7 @@ class ProjectsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> ProjectsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -131,7 +131,7 @@ class AsyncProjectsResource(AsyncAPIResource): @cached_property def with_raw_response(self) -> AsyncProjectsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers diff --git a/src/browserbase/resources/sessions/downloads.py b/src/browserbase/resources/sessions/downloads.py index 461163b0..9ee49759 100644 --- a/src/browserbase/resources/sessions/downloads.py +++ b/src/browserbase/resources/sessions/downloads.py @@ -26,7 +26,7 @@ class DownloadsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> DownloadsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -81,7 +81,7 @@ class AsyncDownloadsResource(AsyncAPIResource): @cached_property def with_raw_response(self) -> AsyncDownloadsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers diff --git a/src/browserbase/resources/sessions/logs.py b/src/browserbase/resources/sessions/logs.py index 07fb5818..2a42c9dc 100644 --- a/src/browserbase/resources/sessions/logs.py +++ b/src/browserbase/resources/sessions/logs.py @@ -23,7 +23,7 @@ class LogsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> LogsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -77,7 +77,7 @@ class AsyncLogsResource(AsyncAPIResource): @cached_property def with_raw_response(self) -> AsyncLogsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers diff --git a/src/browserbase/resources/sessions/recording.py b/src/browserbase/resources/sessions/recording.py index b216fd9b..856b2927 100644 --- a/src/browserbase/resources/sessions/recording.py +++ b/src/browserbase/resources/sessions/recording.py @@ -23,7 +23,7 @@ class RecordingResource(SyncAPIResource): @cached_property def with_raw_response(self) -> RecordingResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -77,7 +77,7 @@ class AsyncRecordingResource(AsyncAPIResource): @cached_property def with_raw_response(self) -> AsyncRecordingResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index 45ef0820..0572d913 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Union, Iterable +from typing import Dict, Union, Iterable from typing_extensions import Literal import httpx @@ -82,7 +82,7 @@ def uploads(self) -> UploadsResource: @cached_property def with_raw_response(self) -> SessionsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -108,6 +108,7 @@ def create( proxies: Union[bool, Iterable[session_create_params.ProxiesUnionMember1]] | NotGiven = NOT_GIVEN, region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | NotGiven = NOT_GIVEN, api_timeout: int | NotGiven = NOT_GIVEN, + user_metadata: Dict[str, object] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -137,6 +138,9 @@ def create( api_timeout: Duration in seconds after which the session will automatically end. Defaults to the Project's `defaultTimeout`. + user_metadata: Arbitrary user metadata to attach to the session. To learn more about user + metadata, see [User Metadata](/features/sessions#user-metadata). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -156,6 +160,7 @@ def create( "proxies": proxies, "region": region, "api_timeout": api_timeout, + "user_metadata": user_metadata, }, session_create_params.SessionCreateParams, ), @@ -250,6 +255,7 @@ def update( def list( self, *, + q: str | NotGiven = NOT_GIVEN, status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -258,10 +264,15 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> SessionListResponse: - """ - List Sessions + """List Sessions Args: + q: Query sessions by user metadata. + + See + [Querying Sessions by User Metadata](/features/sessions#querying-sessions-by-user-metadata) + for the schema of this query. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -277,7 +288,13 @@ def list( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform({"status": status}, session_list_params.SessionListParams), + query=maybe_transform( + { + "q": q, + "status": status, + }, + session_list_params.SessionListParams, + ), ), cast_to=SessionListResponse, ) @@ -336,7 +353,7 @@ def uploads(self) -> AsyncUploadsResource: @cached_property def with_raw_response(self) -> AsyncSessionsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -362,6 +379,7 @@ async def create( proxies: Union[bool, Iterable[session_create_params.ProxiesUnionMember1]] | NotGiven = NOT_GIVEN, region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | NotGiven = NOT_GIVEN, api_timeout: int | NotGiven = NOT_GIVEN, + user_metadata: Dict[str, object] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -391,6 +409,9 @@ async def create( api_timeout: Duration in seconds after which the session will automatically end. Defaults to the Project's `defaultTimeout`. + user_metadata: Arbitrary user metadata to attach to the session. To learn more about user + metadata, see [User Metadata](/features/sessions#user-metadata). + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -410,6 +431,7 @@ async def create( "proxies": proxies, "region": region, "api_timeout": api_timeout, + "user_metadata": user_metadata, }, session_create_params.SessionCreateParams, ), @@ -504,6 +526,7 @@ async def update( async def list( self, *, + q: str | NotGiven = NOT_GIVEN, status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | NotGiven = NOT_GIVEN, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -512,10 +535,15 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> SessionListResponse: - """ - List Sessions + """List Sessions Args: + q: Query sessions by user metadata. + + See + [Querying Sessions by User Metadata](/features/sessions#querying-sessions-by-user-metadata) + for the schema of this query. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -531,7 +559,13 @@ async def list( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=await async_maybe_transform({"status": status}, session_list_params.SessionListParams), + query=await async_maybe_transform( + { + "q": q, + "status": status, + }, + session_list_params.SessionListParams, + ), ), cast_to=SessionListResponse, ) @@ -715,4 +749,4 @@ def recording(self) -> AsyncRecordingResourceWithStreamingResponse: @cached_property def uploads(self) -> AsyncUploadsResourceWithStreamingResponse: - return AsyncUploadsResourceWithStreamingResponse(self._sessions.uploads) \ No newline at end of file + return AsyncUploadsResourceWithStreamingResponse(self._sessions.uploads) diff --git a/src/browserbase/resources/sessions/uploads.py b/src/browserbase/resources/sessions/uploads.py index e985e4d9..eed93499 100644 --- a/src/browserbase/resources/sessions/uploads.py +++ b/src/browserbase/resources/sessions/uploads.py @@ -32,7 +32,7 @@ class UploadsResource(SyncAPIResource): @cached_property def with_raw_response(self) -> UploadsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers @@ -95,7 +95,7 @@ class AsyncUploadsResource(AsyncAPIResource): @cached_property def with_raw_response(self) -> AsyncUploadsResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers diff --git a/src/browserbase/types/session.py b/src/browserbase/types/session.py index 8bd47f93..16450e29 100644 --- a/src/browserbase/types/session.py +++ b/src/browserbase/types/session.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional +from typing import Dict, Optional from datetime import datetime from typing_extensions import Literal @@ -46,3 +46,10 @@ class Session(BaseModel): memory_usage: Optional[int] = FieldInfo(alias="memoryUsage", default=None) """Memory used by the Session""" + + user_metadata: Optional[Dict[str, object]] = FieldInfo(alias="userMetadata", default=None) + """Arbitrary user metadata to attach to the session. + + To learn more about user metadata, see + [User Metadata](/features/sessions#user-metadata). + """ diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index bd643b38..5e76037d 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Union, Iterable +from typing import Dict, List, Union, Iterable from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict from .._utils import PropertyInfo @@ -57,6 +57,13 @@ class SessionCreateParams(TypedDict, total=False): Defaults to the Project's `defaultTimeout`. """ + user_metadata: Annotated[Dict[str, object], PropertyInfo(alias="userMetadata")] + """Arbitrary user metadata to attach to the session. + + To learn more about user metadata, see + [User Metadata](/features/sessions#user-metadata). + """ + class BrowserSettingsContext(TypedDict, total=False): id: Required[str] @@ -107,6 +114,9 @@ class BrowserSettingsViewport(TypedDict, total=False): class BrowserSettings(TypedDict, total=False): + advanced_stealth: Annotated[bool, PropertyInfo(alias="advancedStealth")] + """Advanced Browser Stealth Mode""" + block_ads: Annotated[bool, PropertyInfo(alias="blockAds")] """Enable or disable ad blocking in the browser. Defaults to `false`.""" diff --git a/src/browserbase/types/session_create_response.py b/src/browserbase/types/session_create_response.py index 8c9ae097..b548d50f 100644 --- a/src/browserbase/types/session_create_response.py +++ b/src/browserbase/types/session_create_response.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional +from typing import Dict, Optional from datetime import datetime from typing_extensions import Literal @@ -55,3 +55,10 @@ class SessionCreateResponse(BaseModel): memory_usage: Optional[int] = FieldInfo(alias="memoryUsage", default=None) """Memory used by the Session""" + + user_metadata: Optional[Dict[str, object]] = FieldInfo(alias="userMetadata", default=None) + """Arbitrary user metadata to attach to the session. + + To learn more about user metadata, see + [User Metadata](/features/sessions#user-metadata). + """ diff --git a/src/browserbase/types/session_list_params.py b/src/browserbase/types/session_list_params.py index 7ba4798c..54b0a05c 100644 --- a/src/browserbase/types/session_list_params.py +++ b/src/browserbase/types/session_list_params.py @@ -8,4 +8,12 @@ class SessionListParams(TypedDict, total=False): + q: str + """Query sessions by user metadata. + + See + [Querying Sessions by User Metadata](/features/sessions#querying-sessions-by-user-metadata) + for the schema of this query. + """ + status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] diff --git a/src/browserbase/types/sessions/session_log.py b/src/browserbase/types/sessions/session_log.py index d15eb831..428f518a 100644 --- a/src/browserbase/types/sessions/session_log.py +++ b/src/browserbase/types/sessions/session_log.py @@ -14,7 +14,7 @@ class Request(BaseModel): raw_body: str = FieldInfo(alias="rawBody") - timestamp: int + timestamp: Optional[int] = None """milliseconds that have elapsed since the UNIX epoch""" @@ -23,22 +23,17 @@ class Response(BaseModel): result: Dict[str, object] - timestamp: int + timestamp: Optional[int] = None """milliseconds that have elapsed since the UNIX epoch""" class SessionLog(BaseModel): - event_id: str = FieldInfo(alias="eventId") - method: str page_id: int = FieldInfo(alias="pageId") session_id: str = FieldInfo(alias="sessionId") - timestamp: int - """milliseconds that have elapsed since the UNIX epoch""" - frame_id: Optional[str] = FieldInfo(alias="frameId", default=None) loader_id: Optional[str] = FieldInfo(alias="loaderId", default=None) @@ -46,3 +41,6 @@ class SessionLog(BaseModel): request: Optional[Request] = None response: Optional[Response] = None + + timestamp: Optional[int] = None + """milliseconds that have elapsed since the UNIX epoch""" diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 7b9fbce1..482624f8 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -34,6 +34,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: session = client.sessions.create( project_id="projectId", browser_settings={ + "advanced_stealth": True, "block_ads": True, "context": { "id": "id", @@ -66,6 +67,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: proxies=True, region="us-west-2", api_timeout=60, + user_metadata={"foo": "bar"}, ) assert_matches_type(SessionCreateResponse, session, path=["response"]) @@ -185,6 +187,7 @@ def test_method_list(self, client: Browserbase) -> None: @parametrize def test_method_list_with_all_params(self, client: Browserbase) -> None: session = client.sessions.list( + q="q", status="RUNNING", ) assert_matches_type(SessionListResponse, session, path=["response"]) @@ -263,6 +266,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas session = await async_client.sessions.create( project_id="projectId", browser_settings={ + "advanced_stealth": True, "block_ads": True, "context": { "id": "id", @@ -295,6 +299,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas proxies=True, region="us-west-2", api_timeout=60, + user_metadata={"foo": "bar"}, ) assert_matches_type(SessionCreateResponse, session, path=["response"]) @@ -414,6 +419,7 @@ async def test_method_list(self, async_client: AsyncBrowserbase) -> None: @parametrize async def test_method_list_with_all_params(self, async_client: AsyncBrowserbase) -> None: session = await async_client.sessions.list( + q="q", status="RUNNING", ) assert_matches_type(SessionListResponse, session, path=["response"]) diff --git a/tests/test_client.py b/tests/test_client.py index aa0043e2..20ac94b0 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,6 +6,7 @@ import os import sys import json +import time import asyncio import inspect import subprocess @@ -355,11 +356,11 @@ def test_default_query_option(self) -> None: FinalRequestOptions( method="get", url="/foo", - params={"foo": "baz", "query_param": "overriden"}, + params={"foo": "baz", "query_param": "overridden"}, ) ) url = httpx.URL(request.url) - assert dict(url.params) == {"foo": "baz", "query_param": "overriden"} + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} def test_request_extra_json(self) -> None: request = self.client._build_request( @@ -1131,11 +1132,11 @@ def test_default_query_option(self) -> None: FinalRequestOptions( method="get", url="/foo", - params={"foo": "baz", "query_param": "overriden"}, + params={"foo": "baz", "query_param": "overridden"}, ) ) url = httpx.URL(request.url) - assert dict(url.params) == {"foo": "baz", "query_param": "overriden"} + assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} def test_request_extra_json(self) -> None: request = self.client._build_request( @@ -1639,10 +1640,20 @@ async def test_main() -> None: [sys.executable, "-c", test_code], text=True, ) as process: - try: - process.wait(2) - if process.returncode: - raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") - except subprocess.TimeoutExpired as e: - process.kill() - raise AssertionError("calling get_platform using asyncify resulted in a hung process") from e + timeout = 10 # seconds + + start_time = time.monotonic() + while True: + return_code = process.poll() + if return_code is not None: + if return_code != 0: + raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") + + # success + break + + if time.monotonic() - start_time > timeout: + process.kill() + raise AssertionError("calling get_platform using asyncify resulted in a hung process") + + time.sleep(0.1) diff --git a/tests/test_models.py b/tests/test_models.py index 37671b06..669d2190 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,7 @@ import json from typing import Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone -from typing_extensions import Literal, Annotated +from typing_extensions import Literal, Annotated, TypeAliasType import pytest import pydantic @@ -828,3 +828,29 @@ class B(BaseModel): # if the discriminator details object stays the same between invocations then # we hit the cache assert UnionType.__discriminator__ is discriminator + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +def test_type_alias_type() -> None: + Alias = TypeAliasType("Alias", str) + + class Model(BaseModel): + alias: Alias + union: Union[int, Alias] + + m = construct_type(value={"alias": "foo", "union": "bar"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.alias, str) + assert m.alias == "foo" + assert isinstance(m.union, str) + assert m.union == "bar" + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +def test_field_named_cls() -> None: + class Model(BaseModel): + cls: str + + m = construct_type(value={"cls": "foo"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.cls, str) diff --git a/tests/utils.py b/tests/utils.py index ad9be375..ac183a7e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -16,6 +16,7 @@ is_union_type, extract_type_arg, is_annotated_type, + is_type_alias_type, ) from browserbase._compat import PYDANTIC_V2, field_outer_type, get_model_fields from browserbase._models import BaseModel @@ -51,6 +52,9 @@ def assert_matches_type( path: list[str], allow_none: bool = False, ) -> None: + if is_type_alias_type(type_): + type_ = type_.__value__ + # unwrap `Annotated[T, ...]` -> `T` if is_annotated_type(type_): type_ = extract_type_arg(type_, 0) From 797daee99c588564939c0292da3075f65baf23ee Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:56:35 -0800 Subject: [PATCH 08/22] release: 1.2.0 (#114) * chore(internal): change default timeout to an int (#113) * chore(internal): bummp ruff dependency (#115) * feat(client): send `X-Stainless-Read-Timeout` header (#117) * chore(internal): fix type traversing dictionary params (#118) * chore(internal): minor type handling changes (#119) --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 4 ++-- scripts/utils/ruffen-docs.py | 4 ++-- src/browserbase/_base_client.py | 11 +++++++++-- src/browserbase/_constants.py | 2 +- src/browserbase/_models.py | 10 ++++++++-- src/browserbase/_utils/_transform.py | 12 +++++++++++- src/browserbase/_version.py | 2 +- tests/test_transform.py | 11 ++++++++++- 10 files changed, 61 insertions(+), 13 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2601677b..d0ab6645 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e9586e96..4091999b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.2.0 (2025-02-11) + +Full Changelog: [v1.1.0...v1.2.0](https://github.com/browserbase/sdk-python/compare/v1.1.0...v1.2.0) + +### Features + +* **client:** send `X-Stainless-Read-Timeout` header ([#117](https://github.com/browserbase/sdk-python/issues/117)) ([e53c47a](https://github.com/browserbase/sdk-python/commit/e53c47ae14f4dca507cc146b37b81d5e59845806)) + + +### Chores + +* **internal:** bummp ruff dependency ([#115](https://github.com/browserbase/sdk-python/issues/115)) ([f687590](https://github.com/browserbase/sdk-python/commit/f68759062445e8336ca0f6c9b0bde3b0d2ca1e62)) +* **internal:** change default timeout to an int ([#113](https://github.com/browserbase/sdk-python/issues/113)) ([081bb21](https://github.com/browserbase/sdk-python/commit/081bb216f4b9a4df0dfdd51bcbcacef0154fe636)) +* **internal:** fix type traversing dictionary params ([#118](https://github.com/browserbase/sdk-python/issues/118)) ([cc59fe8](https://github.com/browserbase/sdk-python/commit/cc59fe8950fa4e66ee5efd598b69da9c0c8f08a0)) +* **internal:** minor type handling changes ([#119](https://github.com/browserbase/sdk-python/issues/119)) ([7be3940](https://github.com/browserbase/sdk-python/commit/7be3940cfb0bb947a6774ec225b5eb450a951e88)) + ## 1.1.0 (2025-01-28) Full Changelog: [v1.0.5...v1.1.0](https://github.com/browserbase/sdk-python/compare/v1.0.5...v1.1.0) diff --git a/pyproject.toml b/pyproject.toml index 7e1b0e91..f55d5c3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.1.0" +version = "1.2.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -186,7 +186,7 @@ select = [ "T201", "T203", # misuse of typing.TYPE_CHECKING - "TCH004", + "TC004", # import rules "TID251", ] diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py index 37b3d94f..0cf2bd2f 100644 --- a/scripts/utils/ruffen-docs.py +++ b/scripts/utils/ruffen-docs.py @@ -47,7 +47,7 @@ def _md_match(match: Match[str]) -> str: with _collect_error(match): code = format_code_block(code) code = textwrap.indent(code, match["indent"]) - return f'{match["before"]}{code}{match["after"]}' + return f"{match['before']}{code}{match['after']}" def _pycon_match(match: Match[str]) -> str: code = "" @@ -97,7 +97,7 @@ def finish_fragment() -> None: def _md_pycon_match(match: Match[str]) -> str: code = _pycon_match(match) code = textwrap.indent(code, match["indent"]) - return f'{match["before"]}{code}{match["after"]}' + return f"{match['before']}{code}{match['after']}" src = MD_RE.sub(_md_match, src) src = MD_PYCON_RE.sub(_md_pycon_match, src) diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index b21f4797..0e3d4ae3 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_base_client.py @@ -418,10 +418,17 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0 if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers: headers[idempotency_header] = options.idempotency_key or self._idempotency_key() - # Don't set the retry count header if it was already set or removed by the caller. We check + # Don't set these headers if they were already set or removed by the caller. We check # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. - if "x-stainless-retry-count" not in (header.lower() for header in custom_headers): + lower_custom_headers = [header.lower() for header in custom_headers] + if "x-stainless-retry-count" not in lower_custom_headers: headers["x-stainless-retry-count"] = str(retries_taken) + if "x-stainless-read-timeout" not in lower_custom_headers: + timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + if isinstance(timeout, Timeout): + timeout = timeout.read + if timeout is not None: + headers["x-stainless-read-timeout"] = str(timeout) return headers diff --git a/src/browserbase/_constants.py b/src/browserbase/_constants.py index a2ac3b6f..6ddf2c71 100644 --- a/src/browserbase/_constants.py +++ b/src/browserbase/_constants.py @@ -6,7 +6,7 @@ OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" # default timeout is 1 minute -DEFAULT_TIMEOUT = httpx.Timeout(timeout=60.0, connect=5.0) +DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0) DEFAULT_MAX_RETRIES = 2 DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index 9a918aab..c4401ff8 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -172,7 +172,7 @@ def to_json( @override def __str__(self) -> str: # mypy complains about an invalid self arg - return f'{self.__repr_name__()}({self.__repr_str__(", ")})' # type: ignore[misc] + return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc] # Override the 'construct' method in a way that supports recursive parsing without validation. # Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836. @@ -426,10 +426,16 @@ def construct_type(*, value: object, type_: object) -> object: If the given value does not match the expected type then it is returned as-is. """ + + # store a reference to the original type we were given before we extract any inner + # types so that we can properly resolve forward references in `TypeAliasType` annotations + original_type = None + # we allow `object` as the input type because otherwise, passing things like # `Literal['value']` will be reported as a type error by type checkers type_ = cast("type[object]", type_) if is_type_alias_type(type_): + original_type = type_ # type: ignore[unreachable] type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` @@ -446,7 +452,7 @@ def construct_type(*, value: object, type_: object) -> object: if is_union(origin): try: - return validate_type(type_=cast("type[object]", type_), value=value) + return validate_type(type_=cast("type[object]", original_type or type_), value=value) except Exception: pass diff --git a/src/browserbase/_utils/_transform.py b/src/browserbase/_utils/_transform.py index a6b62cad..18afd9d8 100644 --- a/src/browserbase/_utils/_transform.py +++ b/src/browserbase/_utils/_transform.py @@ -25,7 +25,7 @@ is_annotated_type, strip_annotated_type, ) -from .._compat import model_dump, is_typeddict +from .._compat import get_origin, model_dump, is_typeddict _T = TypeVar("_T") @@ -164,9 +164,14 @@ def _transform_recursive( inner_type = annotation stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type if is_typeddict(stripped_type) and is_mapping(data): return _transform_typeddict(data, stripped_type) + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + if ( # List[T] (is_list_type(stripped_type) and is_list(data)) @@ -307,9 +312,14 @@ async def _async_transform_recursive( inner_type = annotation stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type if is_typeddict(stripped_type) and is_mapping(data): return await _async_transform_typeddict(data, stripped_type) + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + if ( # List[T] (is_list_type(stripped_type) and is_list(data)) diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 9621169e..4207df36 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.1.0" # x-release-please-version +__version__ = "1.2.0" # x-release-please-version diff --git a/tests/test_transform.py b/tests/test_transform.py index 03c2ecd4..32c44ae7 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -2,7 +2,7 @@ import io import pathlib -from typing import Any, List, Union, TypeVar, Iterable, Optional, cast +from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast from datetime import date, datetime from typing_extensions import Required, Annotated, TypedDict @@ -388,6 +388,15 @@ def my_iter() -> Iterable[Baz8]: } +@parametrize +@pytest.mark.asyncio +async def test_dictionary_items(use_async: bool) -> None: + class DictItems(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} + + class TypedDictIterableUnionStr(TypedDict): foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")] From 7d044017aa92498fa381020485b4e91c67cc2201 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 8 May 2025 11:23:07 -0700 Subject: [PATCH 09/22] release: 1.3.0 (#122) * chore(internal): update client tests (#121) * fix: asyncify on non-asyncio runtimes (#123) * chore(internal): codegen related update (#124) * feat(client): allow passing `NotGiven` for body (#125) fix(client): mark some request bodies as optional * chore(internal): fix devcontainers setup (#126) * chore(internal): properly set __pydantic_private__ (#127) * docs: update URLs from stainlessapi.com to stainless.com (#128) More details at https://www.stainless.com/changelog/stainless-com * chore(docs): update client docstring (#129) * chore(internal): remove unused http client options forwarding (#130) * feat(api): api update (#131) * chore(internal): codegen related update (#132) * feat(api): api update (#133) * chore(internal): remove extra empty newlines (#134) * chore(internal): bump rye to 0.44.0 (#136) * fix(types): handle more discriminated union shapes (#137) * fix(ci): ensure pip is always available (#138) * fix(ci): remove publishing patch (#139) * codegen metadata * feat(api): api update (#140) * feat(api): api update (#141) * chore: fix typos (#142) * codegen metadata * feat(api): api update (#143) * chore(internal): remove trailing character (#145) * chore(internal): slight transform perf improvement (#147) * chore: slight wording improvement in README (#148) * chore(internal): expand CI branch coverage * chore(internal): reduce CI branch coverage * fix(perf): skip traversing types for NotGiven values * fix(perf): optimize some hot paths * chore(internal): update pyright settings * chore(client): minor internal fixes * release: 1.3.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .devcontainer/Dockerfile | 4 +- .devcontainer/devcontainer.json | 3 + .github/workflows/ci.yml | 6 +- .github/workflows/publish-pypi.yml | 2 +- .release-please-manifest.json | 2 +- .stats.yml | 4 +- CHANGELOG.md | 50 ++++++++ README.md | 63 +++++++++- SECURITY.md | 4 +- api.md | 10 +- bin/publish-pypi | 3 - pyproject.toml | 7 +- scripts/test | 2 + src/browserbase/_base_client.py | 118 +++--------------- src/browserbase/_client.py | 2 +- src/browserbase/_files.py | 2 +- src/browserbase/_models.py | 9 +- src/browserbase/_utils/_sync.py | 19 ++- src/browserbase/_utils/_transform.py | 49 +++++++- src/browserbase/_utils/_typing.py | 2 + src/browserbase/_version.py | 2 +- .../resources/sessions/sessions.py | 17 +-- src/browserbase/types/__init__.py | 1 + src/browserbase/types/project.py | 3 + .../types/session_create_params.py | 4 +- .../types/session_retrieve_response.py | 64 ++++++++++ tests/api_resources/test_sessions.py | 17 +-- tests/test_client.py | 12 +- tests/test_models.py | 32 +++++ tests/test_transform.py | 21 +++- 30 files changed, 380 insertions(+), 154 deletions(-) create mode 100644 src/browserbase/types/session_retrieve_response.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ac9a2e75..ff261bad 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} USER vscode -RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.35.0" RYE_INSTALL_OPTION="--yes" bash +RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash ENV PATH=/home/vscode/.rye/shims:$PATH -RUN echo "[[ -d .venv ]] && source .venv/bin/activate" >> /home/vscode/.bashrc +RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bbeb30b1..c17fdc16 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,6 +24,9 @@ } } } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": {} } // Features to add to the dev container. More info: https://containers.dev/features. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8a8a4f7..81f6dc20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ jobs: lint: name: lint runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 @@ -21,7 +20,7 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: '0.35.0' + RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - name: Install dependencies @@ -33,7 +32,6 @@ jobs: test: name: test runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 @@ -42,7 +40,7 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: '0.35.0' + RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - name: Bootstrap diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index db8cf944..b3c832c7 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -21,7 +21,7 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: '0.35.0' + RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - name: Publish to PyPI diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d0ab6645..2a8f4ffd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.2.0" + ".": "1.3.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index be077766..92128df9 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,2 +1,4 @@ configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-396a2b9092f645c5a9e46a1f3be8c2e45ca9ae079e1d39761eb0a73f56e24b15.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-45328621800082e652c9b2f34b176b89ebba3af423ea9f4fed91a359cf4e0ae4.yml +openapi_spec_hash: c20658b49312b14d158ce5c88f34ee34 +config_hash: 74882e23a455dece33e43a27e67f0fbb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4091999b..f7ab3da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## 1.3.0 (2025-04-15) + +Full Changelog: [v1.2.0...v1.3.0](https://github.com/browserbase/sdk-python/compare/v1.2.0...v1.3.0) + +### Features + +* **api:** api update ([#131](https://github.com/browserbase/sdk-python/issues/131)) ([1be828d](https://github.com/browserbase/sdk-python/commit/1be828d5c83e48af8740886303f73620bc71b1ba)) +* **api:** api update ([#133](https://github.com/browserbase/sdk-python/issues/133)) ([2a08d98](https://github.com/browserbase/sdk-python/commit/2a08d98914d26cdc36d080bccebd66786b5247ff)) +* **api:** api update ([#140](https://github.com/browserbase/sdk-python/issues/140)) ([134049e](https://github.com/browserbase/sdk-python/commit/134049e29ba480a2238a08c327070bda96b05109)) +* **api:** api update ([#141](https://github.com/browserbase/sdk-python/issues/141)) ([145e5cb](https://github.com/browserbase/sdk-python/commit/145e5cbfc76ac2731b1d6eb3c069cba59a9fbcd9)) +* **api:** api update ([#143](https://github.com/browserbase/sdk-python/issues/143)) ([d55e411](https://github.com/browserbase/sdk-python/commit/d55e4118972d7badbe09a2dd46257d2e66822b85)) +* **client:** allow passing `NotGiven` for body ([#125](https://github.com/browserbase/sdk-python/issues/125)) ([6cdee1b](https://github.com/browserbase/sdk-python/commit/6cdee1ba5775d3c72e0cbd9fe757a1b7452780bd)) + + +### Bug Fixes + +* asyncify on non-asyncio runtimes ([#123](https://github.com/browserbase/sdk-python/issues/123)) ([c8b2cd7](https://github.com/browserbase/sdk-python/commit/c8b2cd77f4cb07d06a00a09cac3eaa55cf6c6925)) +* **ci:** ensure pip is always available ([#138](https://github.com/browserbase/sdk-python/issues/138)) ([173fdde](https://github.com/browserbase/sdk-python/commit/173fddeea8867f93428bddc5ab1d9e1fcd5a925e)) +* **ci:** remove publishing patch ([#139](https://github.com/browserbase/sdk-python/issues/139)) ([bd66d56](https://github.com/browserbase/sdk-python/commit/bd66d56eec53a7778ca624a7ccd00fcf8a9f69af)) +* **client:** mark some request bodies as optional ([6cdee1b](https://github.com/browserbase/sdk-python/commit/6cdee1ba5775d3c72e0cbd9fe757a1b7452780bd)) +* **perf:** optimize some hot paths ([042f048](https://github.com/browserbase/sdk-python/commit/042f048847634ed606d475a0aaeedc5fd129ddbd)) +* **perf:** skip traversing types for NotGiven values ([5cc6c58](https://github.com/browserbase/sdk-python/commit/5cc6c58561556e2b50fccbeed5e123adf3aba72d)) +* **types:** handle more discriminated union shapes ([#137](https://github.com/browserbase/sdk-python/issues/137)) ([d9e09e3](https://github.com/browserbase/sdk-python/commit/d9e09e3d2428a92c29a4411533564637ce5b3121)) + + +### Chores + +* **client:** minor internal fixes ([47df6f5](https://github.com/browserbase/sdk-python/commit/47df6f5956507649f684df46bf2b5bb18aa7bc93)) +* **docs:** update client docstring ([#129](https://github.com/browserbase/sdk-python/issues/129)) ([b2201f1](https://github.com/browserbase/sdk-python/commit/b2201f1d9f99f67a3b8fa21ba19560e72a245611)) +* fix typos ([#142](https://github.com/browserbase/sdk-python/issues/142)) ([0157632](https://github.com/browserbase/sdk-python/commit/015763281689247799dd97e46884ba3be520c2f5)) +* **internal:** bump rye to 0.44.0 ([#136](https://github.com/browserbase/sdk-python/issues/136)) ([9aeac01](https://github.com/browserbase/sdk-python/commit/9aeac01a20df8303f806e22b274bdd10adaeea49)) +* **internal:** codegen related update ([#124](https://github.com/browserbase/sdk-python/issues/124)) ([0678102](https://github.com/browserbase/sdk-python/commit/0678102eee40182b0fc2c2a2b2e3f965a2885a50)) +* **internal:** codegen related update ([#132](https://github.com/browserbase/sdk-python/issues/132)) ([3248d7e](https://github.com/browserbase/sdk-python/commit/3248d7e6242808bcb74427cb1b78ac52dee0948c)) +* **internal:** expand CI branch coverage ([4494839](https://github.com/browserbase/sdk-python/commit/449483977d4af8b56b916d555bea966f25304ac7)) +* **internal:** fix devcontainers setup ([#126](https://github.com/browserbase/sdk-python/issues/126)) ([eaf577b](https://github.com/browserbase/sdk-python/commit/eaf577b05bd72e2bb40105131a65e7c13172c3bb)) +* **internal:** properly set __pydantic_private__ ([#127](https://github.com/browserbase/sdk-python/issues/127)) ([5236106](https://github.com/browserbase/sdk-python/commit/52361065d4547b06c44a07396e0679f588181053)) +* **internal:** reduce CI branch coverage ([1bd4d8b](https://github.com/browserbase/sdk-python/commit/1bd4d8bf088ac47c01a12048cb7b3c963d18eb4a)) +* **internal:** remove extra empty newlines ([#134](https://github.com/browserbase/sdk-python/issues/134)) ([2206050](https://github.com/browserbase/sdk-python/commit/22060504e0f57402decfff129778a472717e29e1)) +* **internal:** remove trailing character ([#145](https://github.com/browserbase/sdk-python/issues/145)) ([2b055d7](https://github.com/browserbase/sdk-python/commit/2b055d730b2313227a0193cfc2b95056d4731464)) +* **internal:** remove unused http client options forwarding ([#130](https://github.com/browserbase/sdk-python/issues/130)) ([c63a3bd](https://github.com/browserbase/sdk-python/commit/c63a3bdad3f35658d87d48bbd5e746a36228a8ab)) +* **internal:** slight transform perf improvement ([#147](https://github.com/browserbase/sdk-python/issues/147)) ([2d46582](https://github.com/browserbase/sdk-python/commit/2d46582e5bb55d3ca74c2a4191144743d5f0058b)) +* **internal:** update client tests ([#121](https://github.com/browserbase/sdk-python/issues/121)) ([862cd7e](https://github.com/browserbase/sdk-python/commit/862cd7efb4c694866ab385c5a70fd450b917f057)) +* **internal:** update pyright settings ([0f0e110](https://github.com/browserbase/sdk-python/commit/0f0e110388f893b86881aa67badc30af8e271b8a)) +* slight wording improvement in README ([#148](https://github.com/browserbase/sdk-python/issues/148)) ([c40603c](https://github.com/browserbase/sdk-python/commit/c40603cafa809128edeff23eca37db97dda8de54)) + + +### Documentation + +* update URLs from stainlessapi.com to stainless.com ([#128](https://github.com/browserbase/sdk-python/issues/128)) ([5e2932f](https://github.com/browserbase/sdk-python/commit/5e2932f5c13c19eb454116ffdce38863556feaf1)) + ## 1.2.0 (2025-02-11) Full Changelog: [v1.1.0...v1.2.0](https://github.com/browserbase/sdk-python/compare/v1.1.0...v1.2.0) diff --git a/README.md b/README.md index 1a508e68..d0dfb403 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The Browserbase Python library provides convenient access to the Browserbase RES 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). -It is generated with [Stainless](https://www.stainlessapi.com/). +It is generated with [Stainless](https://www.stainless.com/). ## Documentation @@ -90,6 +90,67 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ 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`. +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from browserbase import Browserbase + +client = Browserbase() + +session = client.sessions.create( + project_id="projectId", + browser_settings={ + "advanced_stealth": True, + "block_ads": True, + "context": { + "id": "id", + "persist": True, + }, + "extension_id": "extensionId", + "fingerprint": { + "browsers": ["chrome"], + "devices": ["desktop"], + "http_version": "1", + "locales": ["string"], + "operating_systems": ["android"], + "screen": { + "max_height": 0, + "max_width": 0, + "min_height": 0, + "min_width": 0, + }, + }, + "log_session": True, + "record_session": True, + "solve_captchas": True, + "viewport": { + "height": 0, + "width": 0, + }, + }, +) +print(session.browser_settings) +``` + +## File uploads + +Request parameters that correspond to file uploads can be passed as `bytes`, or a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance or a tuple of `(filename, contents, media type)`. + +```python +from pathlib import Path +from browserbase import Browserbase + +client = Browserbase() + +client.extensions.create( + file=Path("/path/to/file"), +) +``` + +The async client uses the exact same interface. If you pass a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance, the file contents will be read asynchronously automatically. + ## 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 `browserbase.APIConnectionError` is raised. diff --git a/SECURITY.md b/SECURITY.md index 4fdede87..e10eb19a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,9 @@ ## Reporting Security Issues -This SDK is generated by [Stainless Software Inc](http://stainlessapi.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. -To report a security issue, please contact the Stainless team at security@stainlessapi.com. +To report a security issue, please contact the Stainless team at security@stainless.com. ## Responsible Disclosure diff --git a/api.md b/api.md index 3f21eb29..dbb776f6 100644 --- a/api.md +++ b/api.md @@ -45,13 +45,19 @@ Methods: Types: ```python -from browserbase.types import Session, SessionLiveURLs, SessionCreateResponse, SessionListResponse +from browserbase.types import ( + Session, + SessionLiveURLs, + SessionCreateResponse, + SessionRetrieveResponse, + SessionListResponse, +) ``` Methods: - client.sessions.create(\*\*params) -> SessionCreateResponse -- client.sessions.retrieve(id) -> Session +- client.sessions.retrieve(id) -> SessionRetrieveResponse - client.sessions.update(id, \*\*params) -> Session - client.sessions.list(\*\*params) -> SessionListResponse - client.sessions.debug(id) -> SessionLiveURLs diff --git a/bin/publish-pypi b/bin/publish-pypi index 05bfccbb..826054e9 100644 --- a/bin/publish-pypi +++ b/bin/publish-pypi @@ -3,7 +3,4 @@ set -eux mkdir -p dist rye build --clean -# Patching importlib-metadata version until upstream library version is updated -# https://github.com/pypa/twine/issues/977#issuecomment-2189800841 -"$HOME/.rye/self/bin/python3" -m pip install 'importlib-metadata==7.2.1' rye publish --yes --token=$PYPI_TOKEN diff --git a/pyproject.toml b/pyproject.toml index f55d5c3e..26da0624 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.2.0" +version = "1.3.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -38,7 +38,6 @@ Homepage = "https://github.com/browserbase/sdk-python" Repository = "https://github.com/browserbase/sdk-python" - [tool.rye] managed = true # version pins are in requirements-dev.lock @@ -96,7 +95,7 @@ typecheck = { chain = [ "typecheck:mypy" = "mypy ." [build-system] -requires = ["hatchling", "hatch-fancy-pypi-readme"] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" [tool.hatch.build] @@ -157,11 +156,11 @@ exclude = [ ] reportImplicitOverride = true +reportOverlappingOverload = false reportImportCycles = false reportPrivateUsage = false - [tool.ruff] line-length = 120 output-format = "grouped" diff --git a/scripts/test b/scripts/test index 4fa5698b..2b878456 100755 --- a/scripts/test +++ b/scripts/test @@ -52,6 +52,8 @@ else echo fi +export DEFER_PYDANTIC_BUILD=false + echo "==> Running tests" rye run pytest "$@" diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index 0e3d4ae3..e30de649 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_base_client.py @@ -9,7 +9,6 @@ import inspect import logging import platform -import warnings import email.utils from types import TracebackType from random import random @@ -36,7 +35,7 @@ import httpx import distro import pydantic -from httpx import URL, Limits +from httpx import URL from pydantic import PrivateAttr from . import _exceptions @@ -51,19 +50,16 @@ Timeout, NotGiven, ResponseT, - Transport, AnyMapping, PostParser, - ProxiesTypes, RequestFiles, HttpxSendArgs, - AsyncTransport, RequestOptions, HttpxRequestFiles, ModelBuilderProtocol, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping -from ._compat import model_copy, model_dump +from ._compat import PYDANTIC_V2, model_copy, model_dump from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, @@ -207,6 +203,9 @@ def _set_private_attributes( model: Type[_T], options: FinalRequestOptions, ) -> None: + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + self._model = model self._client = client self._options = options @@ -292,6 +291,9 @@ def _set_private_attributes( client: AsyncAPIClient, options: FinalRequestOptions, ) -> None: + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + self._model = model self._client = client self._options = options @@ -331,9 +333,6 @@ class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): _base_url: URL max_retries: int timeout: Union[float, Timeout, None] - _limits: httpx.Limits - _proxies: ProxiesTypes | None - _transport: Transport | AsyncTransport | None _strict_response_validation: bool _idempotency_header: str | None _default_stream_cls: type[_DefaultStreamT] | None = None @@ -346,9 +345,6 @@ def __init__( _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, timeout: float | Timeout | None = DEFAULT_TIMEOUT, - limits: httpx.Limits, - transport: Transport | AsyncTransport | None, - proxies: ProxiesTypes | None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, ) -> None: @@ -356,9 +352,6 @@ def __init__( self._base_url = self._enforce_trailing_slash(URL(base_url)) self.max_retries = max_retries self.timeout = timeout - self._limits = limits - self._proxies = proxies - self._transport = transport self._custom_headers = custom_headers or {} self._custom_query = custom_query or {} self._strict_response_validation = _strict_response_validation @@ -416,7 +409,8 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0 idempotency_header = self._idempotency_header if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers: - headers[idempotency_header] = options.idempotency_key or self._idempotency_key() + options.idempotency_key = options.idempotency_key or self._idempotency_key() + headers[idempotency_header] = options.idempotency_key # Don't set these headers if they were already set or removed by the caller. We check # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. @@ -518,7 +512,7 @@ def _build_request( # so that passing a `TypedDict` doesn't cause an error. # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, - json=json_data, + json=json_data if is_given(json_data) else None, files=files, **kwargs, ) @@ -794,46 +788,11 @@ def __init__( base_url: str | URL, max_retries: int = DEFAULT_MAX_RETRIES, timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - transport: Transport | None = None, - proxies: ProxiesTypes | None = None, - limits: Limits | None = None, http_client: httpx.Client | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, _strict_response_validation: bool, ) -> None: - kwargs: dict[str, Any] = {} - if limits is not None: - warnings.warn( - "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `connection_pool_limits`") - else: - limits = DEFAULT_CONNECTION_LIMITS - - if transport is not None: - kwargs["transport"] = transport - warnings.warn( - "The `transport` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `transport`") - - if proxies is not None: - kwargs["proxies"] = proxies - warnings.warn( - "The `proxies` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `proxies`") - if not is_given(timeout): # if the user passed in a custom http client with a non-default # timeout set then we use that timeout. @@ -854,12 +813,9 @@ def __init__( super().__init__( version=version, - limits=limits, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - proxies=proxies, base_url=base_url, - transport=transport, max_retries=max_retries, custom_query=custom_query, custom_headers=custom_headers, @@ -869,9 +825,6 @@ def __init__( base_url=base_url, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - limits=limits, - follow_redirects=True, - **kwargs, # type: ignore ) def is_closed(self) -> bool: @@ -991,6 +944,10 @@ def _request( request = self._build_request(options, retries_taken=retries_taken) self._prepare_request(request) + if options.idempotency_key: + # ensure the idempotency key is reused between requests + input_options.idempotency_key = options.idempotency_key + kwargs: HttpxSendArgs = {} if self.custom_auth is not None: kwargs["auth"] = self.custom_auth @@ -1366,45 +1323,10 @@ def __init__( _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - transport: AsyncTransport | None = None, - proxies: ProxiesTypes | None = None, - limits: Limits | None = None, http_client: httpx.AsyncClient | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, ) -> None: - kwargs: dict[str, Any] = {} - if limits is not None: - warnings.warn( - "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `connection_pool_limits`") - else: - limits = DEFAULT_CONNECTION_LIMITS - - if transport is not None: - kwargs["transport"] = transport - warnings.warn( - "The `transport` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `transport`") - - if proxies is not None: - kwargs["proxies"] = proxies - warnings.warn( - "The `proxies` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `proxies`") - if not is_given(timeout): # if the user passed in a custom http client with a non-default # timeout set then we use that timeout. @@ -1426,11 +1348,8 @@ def __init__( super().__init__( version=version, base_url=base_url, - limits=limits, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - proxies=proxies, - transport=transport, max_retries=max_retries, custom_query=custom_query, custom_headers=custom_headers, @@ -1440,9 +1359,6 @@ def __init__( base_url=base_url, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - limits=limits, - follow_redirects=True, - **kwargs, # type: ignore ) def is_closed(self) -> bool: @@ -1564,6 +1480,10 @@ async def _request( request = self._build_request(options, retries_taken=retries_taken) await self._prepare_request(request) + if options.idempotency_key: + # ensure the idempotency key is reused between requests + input_options.idempotency_key = options.idempotency_key + kwargs: HttpxSendArgs = {} if self.custom_auth is not None: kwargs["auth"] = self.custom_auth diff --git a/src/browserbase/_client.py b/src/browserbase/_client.py index b845ea86..15fde864 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -254,7 +254,7 @@ def __init__( # part of our public interface in the future. _strict_response_validation: bool = False, ) -> None: - """Construct a new async Browserbase client instance. + """Construct a new async AsyncBrowserbase client instance. This automatically infers the `api_key` argument from the `BROWSERBASE_API_KEY` environment variable if it is not provided. """ diff --git a/src/browserbase/_files.py b/src/browserbase/_files.py index 715cc207..c690226c 100644 --- a/src/browserbase/_files.py +++ b/src/browserbase/_files.py @@ -34,7 +34,7 @@ def assert_is_file_content(obj: object, *, key: str | None = None) -> None: if not is_file_content(obj): prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" raise RuntimeError( - f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/browserbase/sdk-python/tree/main#file-uploads" ) from None diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index c4401ff8..34935716 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -65,7 +65,7 @@ from ._constants import RAW_RESPONSE_HEADER if TYPE_CHECKING: - from pydantic_core.core_schema import ModelField, LiteralSchema, ModelFieldsSchema + from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema __all__ = ["BaseModel", "GenericModel"] @@ -646,15 +646,18 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None: schema = model.__pydantic_core_schema__ + if schema["type"] == "definitions": + schema = schema["schema"] + if schema["type"] != "model": return None + schema = cast("ModelSchema", schema) fields_schema = schema["schema"] if fields_schema["type"] != "model-fields": return None fields_schema = cast("ModelFieldsSchema", fields_schema) - field = fields_schema["fields"].get(field_name) if not field: return None @@ -678,7 +681,7 @@ def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: setattr(typ, "__pydantic_config__", config) # noqa: B010 -# our use of subclasssing here causes weirdness for type checkers, +# our use of subclassing here causes weirdness for type checkers, # so we just pretend that we don't subclass if TYPE_CHECKING: GenericModel = BaseModel diff --git a/src/browserbase/_utils/_sync.py b/src/browserbase/_utils/_sync.py index 8b3aaf2b..ad7ec71b 100644 --- a/src/browserbase/_utils/_sync.py +++ b/src/browserbase/_utils/_sync.py @@ -7,16 +7,20 @@ from typing import Any, TypeVar, Callable, Awaitable from typing_extensions import ParamSpec +import anyio +import sniffio +import anyio.to_thread + T_Retval = TypeVar("T_Retval") T_ParamSpec = ParamSpec("T_ParamSpec") if sys.version_info >= (3, 9): - to_thread = asyncio.to_thread + _asyncio_to_thread = asyncio.to_thread else: # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread # for Python 3.8 support - async def to_thread( + async def _asyncio_to_thread( func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs ) -> Any: """Asynchronously run function *func* in a separate thread. @@ -34,6 +38,17 @@ async def to_thread( return await loop.run_in_executor(None, func_call) +async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs +) -> T_Retval: + if sniffio.current_async_library() == "asyncio": + return await _asyncio_to_thread(func, *args, **kwargs) + + return await anyio.to_thread.run_sync( + functools.partial(func, *args, **kwargs), + ) + + # inspired by `asyncer`, https://github.com/tiangolo/asyncer def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ diff --git a/src/browserbase/_utils/_transform.py b/src/browserbase/_utils/_transform.py index 18afd9d8..b0cc20a7 100644 --- a/src/browserbase/_utils/_transform.py +++ b/src/browserbase/_utils/_transform.py @@ -5,13 +5,15 @@ import pathlib from typing import Any, Mapping, TypeVar, cast from datetime import date, datetime -from typing_extensions import Literal, get_args, override, get_type_hints +from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints import anyio import pydantic from ._utils import ( is_list, + is_given, + lru_cache, is_mapping, is_iterable, ) @@ -108,6 +110,7 @@ class Params(TypedDict, total=False): return cast(_T, transformed) +@lru_cache(maxsize=8096) def _get_annotated_type(type_: type) -> type | None: """If the given type is an `Annotated` type then it is returned, if not `None` is returned. @@ -126,7 +129,7 @@ def _get_annotated_type(type_: type) -> type | None: def _maybe_transform_key(key: str, type_: type) -> str: """Transform the given `data` based on the annotations provided in `type_`. - Note: this function only looks at `Annotated` types that contain `PropertInfo` metadata. + Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata. """ annotated_type = _get_annotated_type(type_) if annotated_type is None: @@ -142,6 +145,10 @@ def _maybe_transform_key(key: str, type_: type) -> str: return key +def _no_transform_needed(annotation: type) -> bool: + return annotation == float or annotation == int + + def _transform_recursive( data: object, *, @@ -184,6 +191,15 @@ def _transform_recursive( return cast(object, data) inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] if is_union_type(stripped_type): @@ -245,6 +261,11 @@ def _transform_typeddict( result: dict[str, object] = {} annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): + if not is_given(value): + # we don't need to include `NotGiven` values here as they'll + # be stripped out before the request is sent anyway + continue + type_ = annotations.get(key) if type_ is None: # we do not have a type annotation for this field, leave it as is @@ -332,6 +353,15 @@ async def _async_transform_recursive( return cast(object, data) inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] if is_union_type(stripped_type): @@ -393,6 +423,11 @@ async def _async_transform_typeddict( result: dict[str, object] = {} annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): + if not is_given(value): + # we don't need to include `NotGiven` values here as they'll + # be stripped out before the request is sent anyway + continue + type_ = annotations.get(key) if type_ is None: # we do not have a type annotation for this field, leave it as is @@ -400,3 +435,13 @@ async def _async_transform_typeddict( else: result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) return result + + +@lru_cache(maxsize=8096) +def get_type_hints( + obj: Any, + globalns: dict[str, Any] | None = None, + localns: Mapping[str, Any] | None = None, + include_extras: bool = False, +) -> dict[str, Any]: + return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras) diff --git a/src/browserbase/_utils/_typing.py b/src/browserbase/_utils/_typing.py index 278749b1..1958820f 100644 --- a/src/browserbase/_utils/_typing.py +++ b/src/browserbase/_utils/_typing.py @@ -13,6 +13,7 @@ get_origin, ) +from ._utils import lru_cache from .._types import InheritsGeneric from .._compat import is_union as _is_union @@ -66,6 +67,7 @@ def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: # Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] +@lru_cache(maxsize=8096) def strip_annotated_type(typ: type) -> type: if is_required_type(typ) or is_annotated_type(typ): return strip_annotated_type(cast(type, get_args(typ)[0])) diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 4207df36..9c23d1bb 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.2.0" # x-release-please-version +__version__ = "1.3.0" # x-release-please-version diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index 0572d913..44814380 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -58,6 +58,7 @@ from ...types.session_live_urls import SessionLiveURLs from ...types.session_list_response import SessionListResponse from ...types.session_create_response import SessionCreateResponse +from ...types.session_retrieve_response import SessionRetrieveResponse __all__ = ["SessionsResource", "AsyncSessionsResource"] @@ -127,8 +128,8 @@ def create( extension_id: The uploaded Extension ID. See [Upload Extension](/reference/api/upload-an-extension). - keep_alive: Set to true to keep the session alive even after disconnections. This is - available on the Startup plan only. + keep_alive: Set to true to keep the session alive even after disconnections. Available on + the Hobby Plan and above. proxies: Proxy configuration. Can be true for default proxy, or an array of proxy configurations. @@ -180,7 +181,7 @@ def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: + ) -> SessionRetrieveResponse: """ Session @@ -200,7 +201,7 @@ def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Session, + cast_to=SessionRetrieveResponse, ) def update( @@ -398,8 +399,8 @@ async def create( extension_id: The uploaded Extension ID. See [Upload Extension](/reference/api/upload-an-extension). - keep_alive: Set to true to keep the session alive even after disconnections. This is - available on the Startup plan only. + keep_alive: Set to true to keep the session alive even after disconnections. Available on + the Hobby Plan and above. proxies: Proxy configuration. Can be true for default proxy, or an array of proxy configurations. @@ -451,7 +452,7 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: + ) -> SessionRetrieveResponse: """ Session @@ -471,7 +472,7 @@ async def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Session, + cast_to=SessionRetrieveResponse, ) async def update( diff --git a/src/browserbase/types/__init__.py b/src/browserbase/types/__init__.py index ebc243db..4dd85ddb 100644 --- a/src/browserbase/types/__init__.py +++ b/src/browserbase/types/__init__.py @@ -18,3 +18,4 @@ from .context_update_response import ContextUpdateResponse as ContextUpdateResponse from .extension_create_params import ExtensionCreateParams as ExtensionCreateParams from .session_create_response import SessionCreateResponse as SessionCreateResponse +from .session_retrieve_response import SessionRetrieveResponse as SessionRetrieveResponse diff --git a/src/browserbase/types/project.py b/src/browserbase/types/project.py index afbcef63..dc3cf335 100644 --- a/src/browserbase/types/project.py +++ b/src/browserbase/types/project.py @@ -12,6 +12,9 @@ class Project(BaseModel): id: str + concurrency: int + """The maximum number of sessions that this project can run concurrently.""" + created_at: datetime = FieldInfo(alias="createdAt") default_timeout: int = FieldInfo(alias="defaultTimeout") diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index 5e76037d..f3b9606d 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -39,7 +39,7 @@ class SessionCreateParams(TypedDict, total=False): keep_alive: Annotated[bool, PropertyInfo(alias="keepAlive")] """Set to true to keep the session alive even after disconnections. - This is available on the Startup plan only. + Available on the Hobby Plan and above. """ proxies: Union[bool, Iterable[ProxiesUnionMember1]] @@ -88,7 +88,7 @@ class BrowserSettingsFingerprint(TypedDict, total=False): devices: List[Literal["desktop", "mobile"]] - http_version: Annotated[Literal[1, 2], PropertyInfo(alias="httpVersion")] + http_version: Annotated[Literal["1", "2"], PropertyInfo(alias="httpVersion")] locales: List[str] """ diff --git a/src/browserbase/types/session_retrieve_response.py b/src/browserbase/types/session_retrieve_response.py new file mode 100644 index 00000000..a9a4ff28 --- /dev/null +++ b/src/browserbase/types/session_retrieve_response.py @@ -0,0 +1,64 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional +from datetime import datetime +from typing_extensions import Literal + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["SessionRetrieveResponse"] + + +class SessionRetrieveResponse(BaseModel): + id: str + + created_at: datetime = FieldInfo(alias="createdAt") + + expires_at: datetime = FieldInfo(alias="expiresAt") + + keep_alive: bool = FieldInfo(alias="keepAlive") + """Indicates if the Session was created to be kept alive upon disconnections""" + + project_id: str = FieldInfo(alias="projectId") + """The Project ID linked to the Session.""" + + proxy_bytes: int = FieldInfo(alias="proxyBytes") + """Bytes used via the [Proxy](/features/stealth-mode#proxies-and-residential-ips)""" + + region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] + """The region where the Session is running.""" + + started_at: datetime = FieldInfo(alias="startedAt") + + status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] + + updated_at: datetime = FieldInfo(alias="updatedAt") + + avg_cpu_usage: Optional[int] = FieldInfo(alias="avgCpuUsage", default=None) + """CPU used by the Session""" + + connect_url: Optional[str] = FieldInfo(alias="connectUrl", default=None) + """WebSocket URL to connect to the Session.""" + + context_id: Optional[str] = FieldInfo(alias="contextId", default=None) + """Optional. The Context linked to the Session.""" + + ended_at: Optional[datetime] = FieldInfo(alias="endedAt", default=None) + + memory_usage: Optional[int] = FieldInfo(alias="memoryUsage", default=None) + """Memory used by the Session""" + + selenium_remote_url: Optional[str] = FieldInfo(alias="seleniumRemoteUrl", default=None) + """HTTP URL to connect to the Session.""" + + signing_key: Optional[str] = FieldInfo(alias="signingKey", default=None) + """Signing key to use when connecting to the Session via HTTP.""" + + user_metadata: Optional[Dict[str, object]] = FieldInfo(alias="userMetadata", default=None) + """Arbitrary user metadata to attach to the session. + + To learn more about user metadata, see + [User Metadata](/features/sessions#user-metadata). + """ diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 482624f8..0581655c 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -14,6 +14,7 @@ SessionLiveURLs, SessionListResponse, SessionCreateResponse, + SessionRetrieveResponse, ) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -44,7 +45,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: "fingerprint": { "browsers": ["chrome"], "devices": ["desktop"], - "http_version": 1, + "http_version": "1", "locales": ["string"], "operating_systems": ["android"], "screen": { @@ -100,7 +101,7 @@ def test_method_retrieve(self, client: Browserbase) -> None: session = client.sessions.retrieve( "id", ) - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionRetrieveResponse, session, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Browserbase) -> None: @@ -111,7 +112,7 @@ def test_raw_response_retrieve(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionRetrieveResponse, session, path=["response"]) @parametrize def test_streaming_response_retrieve(self, client: Browserbase) -> None: @@ -122,7 +123,7 @@ def test_streaming_response_retrieve(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionRetrieveResponse, session, path=["response"]) assert cast(Any, response.is_closed) is True @@ -276,7 +277,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas "fingerprint": { "browsers": ["chrome"], "devices": ["desktop"], - "http_version": 1, + "http_version": "1", "locales": ["string"], "operating_systems": ["android"], "screen": { @@ -332,7 +333,7 @@ async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: session = await async_client.sessions.retrieve( "id", ) - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionRetrieveResponse, session, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -343,7 +344,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionRetrieveResponse, session, path=["response"]) @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -354,7 +355,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionRetrieveResponse, session, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/test_client.py b/tests/test_client.py index 20ac94b0..d03654df 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,6 +23,7 @@ from browserbase import Browserbase, AsyncBrowserbase, APIResponseValidationError from browserbase._types import Omit +from browserbase._utils import maybe_transform from browserbase._models import BaseModel, FinalRequestOptions from browserbase._constants import RAW_RESPONSE_HEADER from browserbase._exceptions import APIStatusError, APITimeoutError, BrowserbaseError, APIResponseValidationError @@ -32,6 +33,7 @@ BaseClient, make_request_options, ) +from browserbase.types.session_create_params import SessionCreateParams from .utils import update_env @@ -727,7 +729,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> No with pytest.raises(APITimeoutError): self.client.post( "/v1/sessions", - body=cast(object, dict(project_id="your_project_id")), + body=cast(object, maybe_transform(dict(project_id="your_project_id"), SessionCreateParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -742,7 +744,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> Non with pytest.raises(APIStatusError): self.client.post( "/v1/sessions", - body=cast(object, dict(project_id="your_project_id")), + body=cast(object, maybe_transform(dict(project_id="your_project_id"), SessionCreateParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -1507,7 +1509,7 @@ async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) with pytest.raises(APITimeoutError): await self.client.post( "/v1/sessions", - body=cast(object, dict(project_id="your_project_id")), + body=cast(object, maybe_transform(dict(project_id="your_project_id"), SessionCreateParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -1522,7 +1524,7 @@ async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) with pytest.raises(APIStatusError): await self.client.post( "/v1/sessions", - body=cast(object, dict(project_id="your_project_id")), + body=cast(object, maybe_transform(dict(project_id="your_project_id"), SessionCreateParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -1625,7 +1627,7 @@ def test_get_platform(self) -> None: import threading from browserbase._utils import asyncify - from browserbase._base_client import get_platform + from browserbase._base_client import get_platform async def test_main() -> None: result = await asyncify(get_platform)() diff --git a/tests/test_models.py b/tests/test_models.py index 669d2190..21043abd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -854,3 +854,35 @@ class Model(BaseModel): m = construct_type(value={"cls": "foo"}, type_=Model) assert isinstance(m, Model) assert isinstance(m.cls, str) + + +def test_discriminated_union_case() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["b"] + + data: List[Union[A, object]] + + class ModelA(BaseModel): + type: Literal["modelA"] + + data: int + + class ModelB(BaseModel): + type: Literal["modelB"] + + required: str + + data: Union[A, B] + + # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required` + m = construct_type( + value={"type": "modelB", "data": {"type": "a", "data": True}}, + type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]), + ) + + assert isinstance(m, ModelB) diff --git a/tests/test_transform.py b/tests/test_transform.py index 32c44ae7..cba80b21 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -8,7 +8,7 @@ import pytest -from browserbase._types import Base64FileInput +from browserbase._types import NOT_GIVEN, Base64FileInput from browserbase._utils import ( PropertyInfo, transform as _transform, @@ -432,3 +432,22 @@ async def test_base64_file_input(use_async: bool) -> None: assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { "foo": "SGVsbG8sIHdvcmxkIQ==" } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_transform_skipping(use_async: bool) -> None: + # lists of ints are left as-is + data = [1, 2, 3] + assert await transform(data, List[int], use_async) is data + + # iterables of ints are converted to a list + data = iter([1, 2, 3]) + assert await transform(data, Iterable[int], use_async) == [1, 2, 3] + + +@parametrize +@pytest.mark.asyncio +async def test_strips_notgiven(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {} From 89e7f0d91a73920cdac66f06bee2d8105302fe46 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 16 May 2025 13:50:19 -0700 Subject: [PATCH 10/22] release: 1.4.0 (#150) * chore(internal): bump pyright version * chore(internal): base client updates * chore(internal): update models test * chore(ci): add timeout thresholds for CI jobs * chore(internal): import reformatting * chore(internal): fix list file params * chore(internal): refactor retries to not use recursion * fix(pydantic v1): more robust ModelField.annotation check * chore(internal): minor formatting changes * chore(internal): codegen related update * chore(ci): only use depot for staging repos * chore: broadly detect json family of content-type headers * chore(internal): avoid errors for isinstance checks on proxies * fix(package): support direct resource imports * chore(ci): upload sdks to package manager * chore(ci): fix installation instructions * feat(api): api update * release: 1.4.0 * Remove rule that fails lint --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: ajmcquilkin <46639306+ajmcquilkin@users.noreply.github.com> --- .github/workflows/ci.yml | 42 +- .release-please-manifest.json | 2 +- .stats.yml | 4 +- CHANGELOG.md | 32 ++ README.md | 2 + pyproject.toml | 6 +- requirements-dev.lock | 2 +- scripts/utils/upload-artifact.sh | 25 + src/browserbase/__init__.py | 5 + src/browserbase/_base_client.py | 445 ++++++++---------- src/browserbase/_client.py | 5 +- src/browserbase/_models.py | 5 +- src/browserbase/_response.py | 2 +- src/browserbase/_utils/_proxy.py | 5 +- src/browserbase/_utils/_resources_proxy.py | 24 + src/browserbase/_utils/_typing.py | 2 +- src/browserbase/_utils/_utils.py | 10 +- src/browserbase/_version.py | 2 +- src/browserbase/resources/contexts.py | 5 +- src/browserbase/resources/extensions.py | 7 +- .../resources/sessions/sessions.py | 5 +- src/browserbase/resources/sessions/uploads.py | 7 +- .../types/context_create_response.py | 1 - .../types/context_update_response.py | 1 - src/browserbase/types/project_usage.py | 1 - .../types/session_create_params.py | 12 + .../types/sessions/upload_create_response.py | 1 - tests/api_resources/test_sessions.py | 4 + tests/conftest.py | 2 +- tests/test_models.py | 5 +- tests/test_utils/test_proxy.py | 11 + 31 files changed, 389 insertions(+), 293 deletions(-) create mode 100755 scripts/utils/upload-artifact.sh create mode 100644 src/browserbase/_utils/_resources_proxy.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81f6dc20..1f415886 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,17 +1,18 @@ name: CI on: push: - branches: - - main - pull_request: - branches: - - main - - next + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: + timeout-minutes: 10 name: lint - runs-on: ubuntu-latest + runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 @@ -29,9 +30,34 @@ jobs: - name: Run lints run: ./scripts/lint + upload: + if: github.repository == 'stainless-sdks/browserbase-python' + timeout-minutes: 10 + name: upload + permissions: + contents: read + id-token: write + runs-on: depot-ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Get GitHub OIDC Token + id: github-oidc + uses: actions/github-script@v6 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + test: + timeout-minutes: 10 name: test - runs-on: ubuntu-latest + runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2a8f4ffd..3e9af1b3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.3.0" + ".": "1.4.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 92128df9..38c95a82 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-45328621800082e652c9b2f34b176b89ebba3af423ea9f4fed91a359cf4e0ae4.yml -openapi_spec_hash: c20658b49312b14d158ce5c88f34ee34 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-e2ed1b5267eeff92982918505349017b9155da2c7ab948787ab11cf9068af1b8.yml +openapi_spec_hash: 6639c21dccb52ca610cae833227a9791 config_hash: 74882e23a455dece33e43a27e67f0fbb diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ab3da5..fb205985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 1.4.0 (2025-05-16) + +Full Changelog: [v1.3.0...v1.4.0](https://github.com/browserbase/sdk-python/compare/v1.3.0...v1.4.0) + +### Features + +* **api:** api update ([d3b2ee1](https://github.com/browserbase/sdk-python/commit/d3b2ee1e3c69efbdcb2f0e53b4625e2c8a2a7430)) + + +### Bug Fixes + +* **package:** support direct resource imports ([8feb502](https://github.com/browserbase/sdk-python/commit/8feb502c7e73e8abed43afae5a0526282c4f0dfe)) +* **pydantic v1:** more robust ModelField.annotation check ([5292730](https://github.com/browserbase/sdk-python/commit/5292730dd7b1585210d7ab8e640ed78a5dd9740a)) + + +### Chores + +* broadly detect json family of content-type headers ([ffe29f8](https://github.com/browserbase/sdk-python/commit/ffe29f8dc99d5e7462a0a9bbd488c368e836acdc)) +* **ci:** add timeout thresholds for CI jobs ([3ca4458](https://github.com/browserbase/sdk-python/commit/3ca4458cf31650ce8749c56bee4549811cadec1f)) +* **ci:** fix installation instructions ([99a7328](https://github.com/browserbase/sdk-python/commit/99a7328f22f6da3cd96d70b00c2a6fa0d4c82b37)) +* **ci:** only use depot for staging repos ([646f7d8](https://github.com/browserbase/sdk-python/commit/646f7d832f269d383a0da5fe5732a52ec10787b2)) +* **ci:** upload sdks to package manager ([ff18efd](https://github.com/browserbase/sdk-python/commit/ff18efdf051eabcb52863739942b652d86ed2231)) +* **internal:** avoid errors for isinstance checks on proxies ([b33d222](https://github.com/browserbase/sdk-python/commit/b33d222fd5fdd6eaaca62fb6eb6d9f878a01d31d)) +* **internal:** base client updates ([44f575e](https://github.com/browserbase/sdk-python/commit/44f575efd621315d9bd28e7921554980045af6ed)) +* **internal:** bump pyright version ([bb6bbd3](https://github.com/browserbase/sdk-python/commit/bb6bbd36b3b0fb7595bcc6bd9b25c0aafd6a08af)) +* **internal:** codegen related update ([9f4f8d1](https://github.com/browserbase/sdk-python/commit/9f4f8d1172d5c4b9fa36c8c97ab6e10958ff2959)) +* **internal:** fix list file params ([74b3df7](https://github.com/browserbase/sdk-python/commit/74b3df7160585d981ff5390b6f354926188aaa2a)) +* **internal:** import reformatting ([bba19e4](https://github.com/browserbase/sdk-python/commit/bba19e44eb67116740b27e1fea04abe06a97e4cd)) +* **internal:** minor formatting changes ([0c58843](https://github.com/browserbase/sdk-python/commit/0c58843c75075e3803c9a5a9790f48558a78e712)) +* **internal:** refactor retries to not use recursion ([4161fdb](https://github.com/browserbase/sdk-python/commit/4161fdbcf76a18deee8b790944369225fb4331ff)) +* **internal:** update models test ([5e5dc11](https://github.com/browserbase/sdk-python/commit/5e5dc11c53c60164829b145762818545cfe36f52)) + ## 1.3.0 (2025-04-15) Full Changelog: [v1.2.0...v1.3.0](https://github.com/browserbase/sdk-python/compare/v1.2.0...v1.3.0) diff --git a/README.md b/README.md index d0dfb403..43a407e6 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ session = client.sessions.create( browser_settings={ "advanced_stealth": True, "block_ads": True, + "captcha_image_selector": "captchaImageSelector", + "captcha_input_selector": "captchaInputSelector", "context": { "id": "id", "persist": True, diff --git a/pyproject.toml b/pyproject.toml index 26da0624..e5e2f6c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.3.0" +version = "1.4.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -42,7 +42,7 @@ Repository = "https://github.com/browserbase/sdk-python" managed = true # version pins are in requirements-dev.lock dev-dependencies = [ - "pyright>=1.1.359", + "pyright==1.1.399", "mypy", "respx", "pytest", @@ -185,7 +185,7 @@ select = [ "T201", "T203", # misuse of typing.TYPE_CHECKING - "TC004", + # "TC004", # fails lint # import rules "TID251", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index 30dd44de..b0045a68 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -89,9 +89,9 @@ pyee==12.0.0 # via playwright pygments==2.18.0 # via rich -pyright==1.1.392.post0 pysocks==1.7.1 # via urllib3 +pyright==1.1.399 pytest==8.3.3 # via pytest-asyncio # via pytest-base-url diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 00000000..7c3d028a --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -exuo pipefail + +RESPONSE=$(curl -X POST "$URL" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ + -H "Content-Type: application/gzip" \ + --data-binary @- "$SIGNED_URL" 2>&1) + +if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then + echo -e "\033[32mUploaded build to Stainless storage.\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/browserbase-python/$SHA'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/browserbase/__init__.py b/src/browserbase/__init__.py index ce50c4ea..e954b0ee 100644 --- a/src/browserbase/__init__.py +++ b/src/browserbase/__init__.py @@ -1,5 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +import typing as _t + from . import types from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes from ._utils import file_from_path @@ -78,6 +80,9 @@ "DefaultAsyncHttpxClient", ] +if not _t.TYPE_CHECKING: + from ._utils._resources_proxy import resources as resources + _setup_logging() # Update the __module__ attribute for exported symbols so that diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index e30de649..82e76c9d 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_base_client.py @@ -98,7 +98,11 @@ _AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) if TYPE_CHECKING: - from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT + from httpx._config import ( + DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage] + ) + + HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG else: try: from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT @@ -115,6 +119,7 @@ class PageInfo: url: URL | NotGiven params: Query | NotGiven + json: Body | NotGiven @overload def __init__( @@ -130,19 +135,30 @@ def __init__( params: Query, ) -> None: ... + @overload + def __init__( + self, + *, + json: Body, + ) -> None: ... + def __init__( self, *, url: URL | NotGiven = NOT_GIVEN, + json: Body | NotGiven = NOT_GIVEN, params: Query | NotGiven = NOT_GIVEN, ) -> None: self.url = url + self.json = json self.params = params @override def __repr__(self) -> str: if self.url: return f"{self.__class__.__name__}(url={self.url})" + if self.json: + return f"{self.__class__.__name__}(json={self.json})" return f"{self.__class__.__name__}(params={self.params})" @@ -191,6 +207,19 @@ def _info_to_options(self, info: PageInfo) -> FinalRequestOptions: options.url = str(url) return options + if not isinstance(info.json, NotGiven): + if not is_mapping(info.json): + raise TypeError("Pagination is only supported with mappings") + + if not options.json_data: + options.json_data = {**info.json} + else: + if not is_mapping(options.json_data): + raise TypeError("Pagination is only supported with mappings") + + options.json_data = {**options.json_data, **info.json} + return options + raise ValueError("Unexpected PageInfo state") @@ -408,8 +437,7 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0 headers = httpx.Headers(headers_dict) idempotency_header = self._idempotency_header - if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers: - options.idempotency_key = options.idempotency_key or self._idempotency_key() + if idempotency_header and options.idempotency_key and idempotency_header not in headers: headers[idempotency_header] = options.idempotency_key # Don't set these headers if they were already set or removed by the caller. We check @@ -874,7 +902,6 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: Literal[True], stream_cls: Type[_StreamT], @@ -885,7 +912,6 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: Literal[False] = False, ) -> ResponseT: ... @@ -895,7 +921,6 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: bool = False, stream_cls: Type[_StreamT] | None = None, @@ -905,125 +930,109 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: - if remaining_retries is not None: - retries_taken = options.get_max_retries(self.max_retries) - remaining_retries - else: - retries_taken = 0 - - return self._request( - cast_to=cast_to, - options=options, - stream=stream, - stream_cls=stream_cls, - retries_taken=retries_taken, - ) + cast_to = self._maybe_override_cast_to(cast_to, options) - def _request( - self, - *, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - retries_taken: int, - stream: bool, - stream_cls: type[_StreamT] | None, - ) -> ResponseT | _StreamT: # create a copy of the options we were given so that if the # options are mutated later & we then retry, the retries are # given the original options input_options = model_copy(options) - - cast_to = self._maybe_override_cast_to(cast_to, options) - options = self._prepare_options(options) - - remaining_retries = options.get_max_retries(self.max_retries) - retries_taken - request = self._build_request(options, retries_taken=retries_taken) - self._prepare_request(request) - - if options.idempotency_key: + if input_options.idempotency_key is None and input_options.method.lower() != "get": # ensure the idempotency key is reused between requests - input_options.idempotency_key = options.idempotency_key + input_options.idempotency_key = self._idempotency_key() - kwargs: HttpxSendArgs = {} - if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) - log.debug("Sending HTTP Request: %s %s", request.method, request.url) + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = self._prepare_options(options) - try: - response = self._client.send( - request, - stream=stream or self._should_stream_response_body(request=request), - **kwargs, - ) - except httpx.TimeoutException as err: - log.debug("Encountered httpx.TimeoutException", exc_info=True) + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + self._prepare_request(request) - if remaining_retries > 0: - return self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - stream=stream, - stream_cls=stream_cls, - response_headers=None, - ) + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth - log.debug("Raising timeout error") - raise APITimeoutError(request=request) from err - except Exception as err: - log.debug("Encountered Exception", exc_info=True) + log.debug("Sending HTTP Request: %s %s", request.method, request.url) - if remaining_retries > 0: - return self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - stream=stream, - stream_cls=stream_cls, - response_headers=None, + response = None + try: + response = self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) - log.debug("Raising connection error") - raise APIConnectionError(request=request) from err - - log.debug( - 'HTTP Response: %s %s "%i %s" %s', - request.method, - request.url, - response.status_code, - response.reason_phrase, - response.headers, - ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + err.response.close() + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code - log.debug("Encountered httpx.HTTPStatusError", exc_info=True) - - if remaining_retries > 0 and self._should_retry(err.response): - err.response.close() - return self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - response_headers=err.response.headers, - stream=stream, - stream_cls=stream_cls, - ) + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + err.response.read() - # If the response is streamed then we need to explicitly read the response - # to completion before attempting to access the response text. - if not err.response.is_closed: - err.response.read() + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None - log.debug("Re-raising status error") - raise self._make_status_error_from_response(err.response) from None + break + assert response is not None, "could not resolve response (should never happen)" return self._process_response( cast_to=cast_to, options=options, @@ -1033,37 +1042,20 @@ def _request( retries_taken=retries_taken, ) - def _retry_request( - self, - options: FinalRequestOptions, - cast_to: Type[ResponseT], - *, - retries_taken: int, - response_headers: httpx.Headers | None, - stream: bool, - stream_cls: type[_StreamT] | None, - ) -> ResponseT | _StreamT: - remaining_retries = options.get_max_retries(self.max_retries) - retries_taken + def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken if remaining_retries == 1: log.debug("1 retry left") else: log.debug("%i retries left", remaining_retries) - timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers) + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) log.info("Retrying request to %s in %f seconds", options.url, timeout) - # In a synchronous context we are blocking the entire thread. Up to the library user to run the client in a - # different thread if necessary. time.sleep(timeout) - return self._request( - options=options, - cast_to=cast_to, - retries_taken=retries_taken + 1, - stream=stream, - stream_cls=stream_cls, - ) - def _process_response( self, *, @@ -1407,7 +1399,6 @@ async def request( options: FinalRequestOptions, *, stream: Literal[False] = False, - remaining_retries: Optional[int] = None, ) -> ResponseT: ... @overload @@ -1418,7 +1409,6 @@ async def request( *, stream: Literal[True], stream_cls: type[_AsyncStreamT], - remaining_retries: Optional[int] = None, ) -> _AsyncStreamT: ... @overload @@ -1429,7 +1419,6 @@ async def request( *, stream: bool, stream_cls: type[_AsyncStreamT] | None = None, - remaining_retries: Optional[int] = None, ) -> ResponseT | _AsyncStreamT: ... async def request( @@ -1439,120 +1428,111 @@ async def request( *, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, - remaining_retries: Optional[int] = None, - ) -> ResponseT | _AsyncStreamT: - if remaining_retries is not None: - retries_taken = options.get_max_retries(self.max_retries) - remaining_retries - else: - retries_taken = 0 - - return await self._request( - cast_to=cast_to, - options=options, - stream=stream, - stream_cls=stream_cls, - retries_taken=retries_taken, - ) - - async def _request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: bool, - stream_cls: type[_AsyncStreamT] | None, - retries_taken: int, ) -> ResponseT | _AsyncStreamT: if self._platform is None: # `get_platform` can make blocking IO calls so we # execute it earlier while we are in an async context self._platform = await asyncify(get_platform)() + cast_to = self._maybe_override_cast_to(cast_to, options) + # create a copy of the options we were given so that if the # options are mutated later & we then retry, the retries are # given the original options input_options = model_copy(options) - - cast_to = self._maybe_override_cast_to(cast_to, options) - options = await self._prepare_options(options) - - remaining_retries = options.get_max_retries(self.max_retries) - retries_taken - request = self._build_request(options, retries_taken=retries_taken) - await self._prepare_request(request) - - if options.idempotency_key: + if input_options.idempotency_key is None and input_options.method.lower() != "get": # ensure the idempotency key is reused between requests - input_options.idempotency_key = options.idempotency_key + input_options.idempotency_key = self._idempotency_key() - kwargs: HttpxSendArgs = {} - if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) - try: - response = await self._client.send( - request, - stream=stream or self._should_stream_response_body(request=request), - **kwargs, - ) - except httpx.TimeoutException as err: - log.debug("Encountered httpx.TimeoutException", exc_info=True) + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = await self._prepare_options(options) - if remaining_retries > 0: - return await self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - stream=stream, - stream_cls=stream_cls, - response_headers=None, - ) + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + await self._prepare_request(request) - log.debug("Raising timeout error") - raise APITimeoutError(request=request) from err - except Exception as err: - log.debug("Encountered Exception", exc_info=True) + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth - if remaining_retries > 0: - return await self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - stream=stream, - stream_cls=stream_cls, - response_headers=None, - ) + log.debug("Sending HTTP Request: %s %s", request.method, request.url) - log.debug("Raising connection error") - raise APIConnectionError(request=request) from err + response = None + try: + response = await self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, + ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) - log.debug( - 'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase - ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + await err.response.aclose() + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code - log.debug("Encountered httpx.HTTPStatusError", exc_info=True) - - if remaining_retries > 0 and self._should_retry(err.response): - await err.response.aclose() - return await self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - response_headers=err.response.headers, - stream=stream, - stream_cls=stream_cls, - ) + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + await err.response.aread() - # If the response is streamed then we need to explicitly read the response - # to completion before attempting to access the response text. - if not err.response.is_closed: - await err.response.aread() + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None - log.debug("Re-raising status error") - raise self._make_status_error_from_response(err.response) from None + break + assert response is not None, "could not resolve response (should never happen)" return await self._process_response( cast_to=cast_to, options=options, @@ -1562,35 +1542,20 @@ async def _request( retries_taken=retries_taken, ) - async def _retry_request( - self, - options: FinalRequestOptions, - cast_to: Type[ResponseT], - *, - retries_taken: int, - response_headers: httpx.Headers | None, - stream: bool, - stream_cls: type[_AsyncStreamT] | None, - ) -> ResponseT | _AsyncStreamT: - remaining_retries = options.get_max_retries(self.max_retries) - retries_taken + async def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken if remaining_retries == 1: log.debug("1 retry left") else: log.debug("%i retries left", remaining_retries) - timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers) + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) log.info("Retrying request to %s in %f seconds", options.url, timeout) await anyio.sleep(timeout) - return await self._request( - options=options, - cast_to=cast_to, - retries_taken=retries_taken + 1, - stream=stream, - stream_cls=stream_cls, - ) - async def _process_response( self, *, diff --git a/src/browserbase/_client.py b/src/browserbase/_client.py index 15fde864..a7039a2a 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -19,10 +19,7 @@ ProxiesTypes, RequestOptions, ) -from ._utils import ( - is_given, - get_async_library, -) +from ._utils import is_given, get_async_library from ._version import __version__ from .resources import contexts, projects, extensions from ._streaming import Stream as Stream, AsyncStream as AsyncStream diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index 34935716..798956f1 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -19,7 +19,6 @@ ) import pydantic -import pydantic.generics from pydantic.fields import FieldInfo from ._types import ( @@ -627,8 +626,8 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, # Note: if one variant defines an alias then they all should discriminator_alias = field_info.alias - if field_info.annotation and is_literal_type(field_info.annotation): - for entry in get_args(field_info.annotation): + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): if isinstance(entry, str): mapping[entry] = variant diff --git a/src/browserbase/_response.py b/src/browserbase/_response.py index e79cb15f..5f8d0f48 100644 --- a/src/browserbase/_response.py +++ b/src/browserbase/_response.py @@ -235,7 +235,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: # split is required to handle cases where additional information is included # in the response, e.g. application/json; charset=utf-8 content_type, *_ = response.headers.get("content-type", "*").split(";") - if content_type != "application/json": + if not content_type.endswith("json"): if is_basemodel(cast_to): try: data = response.json() diff --git a/src/browserbase/_utils/_proxy.py b/src/browserbase/_utils/_proxy.py index ffd883e9..0f239a33 100644 --- a/src/browserbase/_utils/_proxy.py +++ b/src/browserbase/_utils/_proxy.py @@ -46,7 +46,10 @@ def __dir__(self) -> Iterable[str]: @property # type: ignore @override def __class__(self) -> type: # pyright: ignore - proxied = self.__get_proxied__() + try: + proxied = self.__get_proxied__() + except Exception: + return type(self) if issubclass(type(proxied), LazyProxy): return type(proxied) return proxied.__class__ diff --git a/src/browserbase/_utils/_resources_proxy.py b/src/browserbase/_utils/_resources_proxy.py new file mode 100644 index 00000000..3901271d --- /dev/null +++ b/src/browserbase/_utils/_resources_proxy.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any +from typing_extensions import override + +from ._proxy import LazyProxy + + +class ResourcesProxy(LazyProxy[Any]): + """A proxy for the `browserbase.resources` module. + + This is used so that we can lazily import `browserbase.resources` only when + needed *and* so that users can just import `browserbase` and reference `browserbase.resources` + """ + + @override + def __load__(self) -> Any: + import importlib + + mod = importlib.import_module("browserbase.resources") + return mod + + +resources = ResourcesProxy().__as_proxied__() diff --git a/src/browserbase/_utils/_typing.py b/src/browserbase/_utils/_typing.py index 1958820f..1bac9542 100644 --- a/src/browserbase/_utils/_typing.py +++ b/src/browserbase/_utils/_typing.py @@ -110,7 +110,7 @@ class MyResponse(Foo[_T]): ``` """ cls = cast(object, get_origin(typ) or typ) - if cls in generic_bases: + if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] # we're given the class directly return extract_type_arg(typ, index) diff --git a/src/browserbase/_utils/_utils.py b/src/browserbase/_utils/_utils.py index e5811bba..ea3cf3f2 100644 --- a/src/browserbase/_utils/_utils.py +++ b/src/browserbase/_utils/_utils.py @@ -72,8 +72,16 @@ def _extract_items( from .._files import assert_is_file_content # We have exhausted the path, return the entry we found. - assert_is_file_content(obj, key=flattened_key) assert flattened_key is not None + + if is_list(obj): + files: list[tuple[str, FileTypes]] = [] + for entry in obj: + assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") + files.append((flattened_key + "[]", cast(FileTypes, entry))) + return files + + assert_is_file_content(obj, key=flattened_key) return [(flattened_key, cast(FileTypes, obj))] index += 1 diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 9c23d1bb..3c0492ea 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.3.0" # x-release-please-version +__version__ = "1.4.0" # x-release-please-version diff --git a/src/browserbase/resources/contexts.py b/src/browserbase/resources/contexts.py index 486cd5ff..0a438eda 100644 --- a/src/browserbase/resources/contexts.py +++ b/src/browserbase/resources/contexts.py @@ -6,10 +6,7 @@ from ..types import context_create_params from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import ( - maybe_transform, - async_maybe_transform, -) +from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( diff --git a/src/browserbase/resources/extensions.py b/src/browserbase/resources/extensions.py index a98685a8..c7b0fae7 100644 --- a/src/browserbase/resources/extensions.py +++ b/src/browserbase/resources/extensions.py @@ -8,12 +8,7 @@ from ..types import extension_create_params from .._types import NOT_GIVEN, Body, Query, Headers, NoneType, NotGiven, FileTypes -from .._utils import ( - extract_files, - maybe_transform, - deepcopy_minimal, - async_maybe_transform, -) +from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index 44814380..bf3314ad 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -25,10 +25,7 @@ AsyncUploadsResourceWithStreamingResponse, ) from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import ( - maybe_transform, - async_maybe_transform, -) +from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from .downloads import ( DownloadsResource, diff --git a/src/browserbase/resources/sessions/uploads.py b/src/browserbase/resources/sessions/uploads.py index eed93499..69b6ccbe 100644 --- a/src/browserbase/resources/sessions/uploads.py +++ b/src/browserbase/resources/sessions/uploads.py @@ -7,12 +7,7 @@ import httpx from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven, FileTypes -from ..._utils import ( - extract_files, - maybe_transform, - deepcopy_minimal, - async_maybe_transform, -) +from ..._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( diff --git a/src/browserbase/types/context_create_response.py b/src/browserbase/types/context_create_response.py index c168596e..8e2f7aa3 100644 --- a/src/browserbase/types/context_create_response.py +++ b/src/browserbase/types/context_create_response.py @@ -1,6 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - from pydantic import Field as FieldInfo from .._models import BaseModel diff --git a/src/browserbase/types/context_update_response.py b/src/browserbase/types/context_update_response.py index d07e50e7..7e16c624 100644 --- a/src/browserbase/types/context_update_response.py +++ b/src/browserbase/types/context_update_response.py @@ -1,6 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - from pydantic import Field as FieldInfo from .._models import BaseModel diff --git a/src/browserbase/types/project_usage.py b/src/browserbase/types/project_usage.py index f68cc2da..c8a03f5b 100644 --- a/src/browserbase/types/project_usage.py +++ b/src/browserbase/types/project_usage.py @@ -1,6 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - from pydantic import Field as FieldInfo from .._models import BaseModel diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index f3b9606d..1f5324f8 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -120,6 +120,18 @@ class BrowserSettings(TypedDict, total=False): block_ads: Annotated[bool, PropertyInfo(alias="blockAds")] """Enable or disable ad blocking in the browser. Defaults to `false`.""" + captcha_image_selector: Annotated[str, PropertyInfo(alias="captchaImageSelector")] + """Custom selector for captcha image. + + See [Custom Captcha Solving](/features/stealth-mode#custom-captcha-solving) + """ + + captcha_input_selector: Annotated[str, PropertyInfo(alias="captchaInputSelector")] + """Custom selector for captcha input. + + See [Custom Captcha Solving](/features/stealth-mode#custom-captcha-solving) + """ + context: BrowserSettingsContext extension_id: Annotated[str, PropertyInfo(alias="extensionId")] diff --git a/src/browserbase/types/sessions/upload_create_response.py b/src/browserbase/types/sessions/upload_create_response.py index ceece2cd..abeed017 100644 --- a/src/browserbase/types/sessions/upload_create_response.py +++ b/src/browserbase/types/sessions/upload_create_response.py @@ -1,6 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - from ..._models import BaseModel __all__ = ["UploadCreateResponse"] diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 0581655c..4a17c4f6 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -37,6 +37,8 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: browser_settings={ "advanced_stealth": True, "block_ads": True, + "captcha_image_selector": "captchaImageSelector", + "captcha_input_selector": "captchaInputSelector", "context": { "id": "id", "persist": True, @@ -269,6 +271,8 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas browser_settings={ "advanced_stealth": True, "block_ads": True, + "captcha_image_selector": "captchaImageSelector", + "captcha_input_selector": "captchaInputSelector", "context": { "id": "id", "persist": True, diff --git a/tests/conftest.py b/tests/conftest.py index 15ddbcad..94b8e723 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from browserbase import Browserbase, AsyncBrowserbase if TYPE_CHECKING: - from _pytest.fixtures import FixtureRequest + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] pytest.register_assert_rewrite("tests.utils") diff --git a/tests/test_models.py b/tests/test_models.py index 21043abd..b5335f94 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -492,12 +492,15 @@ class Model(BaseModel): resource_id: Optional[str] = None m = Model.construct() + assert m.resource_id is None assert "resource_id" not in m.model_fields_set m = Model.construct(resource_id=None) + assert m.resource_id is None assert "resource_id" in m.model_fields_set m = Model.construct(resource_id="foo") + assert m.resource_id == "foo" assert "resource_id" in m.model_fields_set @@ -832,7 +835,7 @@ class B(BaseModel): @pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") def test_type_alias_type() -> None: - Alias = TypeAliasType("Alias", str) + Alias = TypeAliasType("Alias", str) # pyright: ignore class Model(BaseModel): alias: Alias diff --git a/tests/test_utils/test_proxy.py b/tests/test_utils/test_proxy.py index 986bef9d..d4e2f311 100644 --- a/tests/test_utils/test_proxy.py +++ b/tests/test_utils/test_proxy.py @@ -21,3 +21,14 @@ def test_recursive_proxy() -> None: assert dir(proxy) == [] assert type(proxy).__name__ == "RecursiveLazyProxy" assert type(operator.attrgetter("name.foo.bar.baz")(proxy)).__name__ == "RecursiveLazyProxy" + + +def test_isinstance_does_not_error() -> None: + class AlwaysErrorProxy(LazyProxy[Any]): + @override + def __load__(self) -> Any: + raise RuntimeError("Mocking missing dependency") + + proxy = AlwaysErrorProxy() + assert not isinstance(proxy, dict) + assert isinstance(proxy, LazyProxy) From 99c8f407f7d89d8bb6c0a1787a4e67797610e880 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 01:06:20 +0000 Subject: [PATCH 11/22] release: 1.5.0-alpha.0 (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(docs): grammar improvements * chore(docs): remove reference to rye shell * chore(docs): remove unnecessary param examples * feat(client): add follow_redirects request option * codegen metadata * chore(tests): run tests in parallel * fix(client): correctly parse binary response | stream * chore(tests): add tests for httpx client instantiation & proxies * chore(internal): update conftest.py * chore(ci): enable for pull requests * chore(readme): update badges * fix(tests): fix: tests which call HTTP endpoints directly with the example parameters * docs(client): fix httpx.Timeout documentation reference * feat(client): add support for aiohttp * chore(tests): skip some failing tests on the latest python versions * fix(ci): release-doctor — report correct token name * chore(ci): only run for pushes and fork pull requests * fix(ci): correct conditional * codegen metadata * chore(ci): change upload type * feat(api): api update * chore(internal): codegen related update * codegen metadata * feat(api): api update * chore(internal): bump pinned h11 dep * chore(package): mark python 3.13 as supported * fix(parsing): correctly handle nested discriminated unions * feat(api): api update * chore(internal): codegen related update * feat(api): api update * chore(internal): codegen related update * feat(api): api update * chore(internal): codegen related update * chore(readme): fix version rendering on pypi * feat(api): api update * fix(client): don't send Content-Type header on GET requests * feat: clean up environment call outs * feat(api): api update * feat(api): api update * fix(parsing): ignore empty metadata * fix(parsing): parse extra field types * chore(project): add settings file for vscode * feat(client): support file upload requests * chore(internal): fix ruff target version * chore: update @stainless-api/prism-cli to v5.15.0 * chore(internal): update comment in script * chore: update github action * chore(internal): change ci workflow machines * codegen metadata * fix: avoid newer type syntax * chore(internal): update pyright exclude list * chore(internal): add Sequence related utils * feat(types): replace List[str] with SequenceNotStr in params * feat: improve future compat with pydantic v3 * feat(api): manual updates * feat(api): api update * feat(api): api update * codegen metadata * fix: fix extension types in playwright_extensions * codegen metadata * feat(api): manual updates * release: 1.5.0-alpha.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Dominic Saadi --- .github/workflows/ci.yml | 30 +++- .gitignore | 1 - .release-please-manifest.json | 2 +- .stats.yml | 6 +- .vscode/settings.json | 3 + CHANGELOG.md | 74 +++++++++ CONTRIBUTING.md | 3 +- README.md | 72 +++++---- SECURITY.md | 4 +- api.md | 28 ++-- bin/check-release-environment | 2 +- examples/playwright_extensions.py | 6 +- pyproject.toml | 11 +- requirements-dev.lock | 33 +++- requirements.lock | 31 +++- scripts/mock | 4 +- scripts/test | 2 +- scripts/utils/upload-artifact.sh | 12 +- src/browserbase/__init__.py | 3 +- src/browserbase/_base_client.py | 66 +++++++- src/browserbase/_compat.py | 96 +++++------ src/browserbase/_files.py | 8 +- src/browserbase/_models.py | 118 ++++++++------ src/browserbase/_types.py | 38 ++++- src/browserbase/_utils/__init__.py | 11 +- src/browserbase/_utils/_compat.py | 45 ++++++ src/browserbase/_utils/_datetime_parse.py | 136 ++++++++++++++++ src/browserbase/_utils/_transform.py | 12 +- src/browserbase/_utils/_typing.py | 7 +- src/browserbase/_utils/_utils.py | 1 - src/browserbase/_version.py | 2 +- src/browserbase/resources/contexts.py | 18 +-- src/browserbase/resources/extensions.py | 27 ++-- src/browserbase/resources/projects.py | 32 ++-- .../resources/sessions/sessions.py | 32 ++-- src/browserbase/types/__init__.py | 13 +- ...ontext.py => context_retrieve_response.py} | 4 +- ...ension.py => extension_create_response.py} | 4 +- .../types/extension_retrieve_response.py | 22 +++ .../types/project_list_response.py | 27 +++- ...roject.py => project_retrieve_response.py} | 4 +- ...ect_usage.py => project_usage_response.py} | 4 +- .../types/session_create_params.py | 48 +++--- ...live_urls.py => session_debug_response.py} | 4 +- .../types/session_list_response.py | 58 ++++++- ...{session.py => session_update_response.py} | 4 +- src/browserbase/types/sessions/__init__.py | 2 - .../types/sessions/log_list_response.py | 48 +++++- .../sessions/recording_retrieve_response.py | 26 ++- src/browserbase/types/sessions/session_log.py | 46 ------ .../types/sessions/session_recording.py | 24 --- .../api_resources/sessions/test_downloads.py | 4 +- tests/api_resources/sessions/test_logs.py | 4 +- .../api_resources/sessions/test_recording.py | 4 +- tests/api_resources/sessions/test_uploads.py | 4 +- tests/api_resources/test_contexts.py | 22 ++- tests/api_resources/test_extensions.py | 30 ++-- tests/api_resources/test_projects.py | 30 ++-- tests/api_resources/test_sessions.py | 60 +++++-- tests/conftest.py | 45 +++++- tests/test_client.py | 151 ++++++++++++++---- tests/test_models.py | 120 +++++++++++--- tests/test_transform.py | 16 +- tests/test_utils/test_datetime_parse.py | 110 +++++++++++++ tests/utils.py | 18 ++- 65 files changed, 1422 insertions(+), 510 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/browserbase/_utils/_compat.py create mode 100644 src/browserbase/_utils/_datetime_parse.py rename src/browserbase/types/{context.py => context_retrieve_response.py} (84%) rename src/browserbase/types/{extension.py => extension_create_response.py} (85%) create mode 100644 src/browserbase/types/extension_retrieve_response.py rename src/browserbase/types/{project.py => project_retrieve_response.py} (87%) rename src/browserbase/types/{project_usage.py => project_usage_response.py} (78%) rename src/browserbase/types/{session_live_urls.py => session_debug_response.py} (88%) rename src/browserbase/types/{session.py => session_update_response.py} (95%) delete mode 100644 src/browserbase/types/sessions/session_log.py delete mode 100644 src/browserbase/types/sessions/session_recording.py create mode 100644 tests/test_utils/test_datetime_parse.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f415886..8edf5a60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,17 @@ on: - 'integrated/**' - 'stl-preview-head/**' - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -30,24 +35,40 @@ jobs: - name: Run lints run: ./scripts/lint - upload: - if: github.repository == 'stainless-sdks/browserbase-python' + build: + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork timeout-minutes: 10 - name: upload + name: build permissions: contents: read id-token: write - runs-on: depot-ubuntu-24.04 + runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run build + run: rye build + - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/browserbase-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/browserbase-python' env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} @@ -58,6 +79,7 @@ jobs: timeout-minutes: 10 name: test runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 46152338..117701e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .prism.log -.vscode _dev __pycache__ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3e9af1b3..7a22c4a0 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.4.0" + ".": "1.5.0-alpha.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 38c95a82..a50ccc0a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-e2ed1b5267eeff92982918505349017b9155da2c7ab948787ab11cf9068af1b8.yml -openapi_spec_hash: 6639c21dccb52ca610cae833227a9791 -config_hash: 74882e23a455dece33e43a27e67f0fbb +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-a9ab6f9017f7645722d220eb8172516a7a5400e86542c28fc7e121adcd1f344f.yml +openapi_spec_hash: e29347aba2697d4efa3dce7794810dbd +config_hash: ec077c0d8cde29588ca4ff30d49575a4 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..5b010307 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} diff --git a/CHANGELOG.md b/CHANGELOG.md index fb205985..b1b9474e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,79 @@ # Changelog +## 1.5.0-alpha.0 (2025-09-05) + +Full Changelog: [v1.4.0...v1.5.0-alpha.0](https://github.com/browserbase/sdk-python/compare/v1.4.0...v1.5.0-alpha.0) + +### Features + +* **api:** api update ([e94ddbd](https://github.com/browserbase/sdk-python/commit/e94ddbd8777b97d4e8ab193e1bf3eaad983ecec9)) +* **api:** api update ([28115fb](https://github.com/browserbase/sdk-python/commit/28115fb584336dbf5b08043ad8f9cf1d911240ea)) +* **api:** api update ([3209287](https://github.com/browserbase/sdk-python/commit/32092872a3d4d48824b4d77d517ffdb06470ad95)) +* **api:** api update ([f38e029](https://github.com/browserbase/sdk-python/commit/f38e02981ae0777cb3d922845902b2673dc832fa)) +* **api:** api update ([1d9f769](https://github.com/browserbase/sdk-python/commit/1d9f7694bc0d465ce758ddcec41359e9cd1a08ad)) +* **api:** api update ([d72f39f](https://github.com/browserbase/sdk-python/commit/d72f39fbe29342cfc77e9b224f2ad0a5a77aaae4)) +* **api:** api update ([6d449b3](https://github.com/browserbase/sdk-python/commit/6d449b3deb284a72528877a8729f4cf7a418275d)) +* **api:** api update ([8bd5f8b](https://github.com/browserbase/sdk-python/commit/8bd5f8bcca3a2e5baadfc06009546692e63eb744)) +* **api:** api update ([1ce99ef](https://github.com/browserbase/sdk-python/commit/1ce99efe89c1d0757ca3100cca8619faa4082f74)) +* **api:** api update ([1cbb849](https://github.com/browserbase/sdk-python/commit/1cbb8498bf70c15c001f620b821519216cbadd97)) +* **api:** manual updates ([5893fc6](https://github.com/browserbase/sdk-python/commit/5893fc6165cfd88378d6725317e30c7cb6faf8df)) +* **api:** manual updates ([074f06d](https://github.com/browserbase/sdk-python/commit/074f06d0dfb08554229348828afd2cc1defe94ee)) +* clean up environment call outs ([82c38c4](https://github.com/browserbase/sdk-python/commit/82c38c494a175c1b6b38bab3615916c30ba25d14)) +* **client:** add follow_redirects request option ([a8b0b5e](https://github.com/browserbase/sdk-python/commit/a8b0b5e4c6445e0e8c0d3673a090aabab09a50fd)) +* **client:** add support for aiohttp ([3516092](https://github.com/browserbase/sdk-python/commit/35160921e262f147cc723a754f14cfd9875603f5)) +* **client:** support file upload requests ([2f338f0](https://github.com/browserbase/sdk-python/commit/2f338f009e556ef9be05f49816b17cef138bda17)) +* improve future compat with pydantic v3 ([8b5256c](https://github.com/browserbase/sdk-python/commit/8b5256c801e1423a4daf6bf49de7509a32ebfde2)) +* **types:** replace List[str] with SequenceNotStr in params ([55083f6](https://github.com/browserbase/sdk-python/commit/55083f678b68020fae835af5cd58e0e5deea2888)) + + +### Bug Fixes + +* avoid newer type syntax ([85f597b](https://github.com/browserbase/sdk-python/commit/85f597b34d149138f1b5afdc52062cb131e3a30a)) +* **ci:** correct conditional ([a36b873](https://github.com/browserbase/sdk-python/commit/a36b87379b404613673720dd9f498ed76dfe5c3a)) +* **ci:** release-doctor — report correct token name ([61b97ff](https://github.com/browserbase/sdk-python/commit/61b97fff5ea92bade293c5f5f4a84b0d991375e7)) +* **client:** correctly parse binary response | stream ([9614c4c](https://github.com/browserbase/sdk-python/commit/9614c4c05bc57ea60100aec9a194aee7a39e701b)) +* **client:** don't send Content-Type header on GET requests ([c4c4185](https://github.com/browserbase/sdk-python/commit/c4c4185de32b28c09565b6fe84efd65fd411abb9)) +* fix extension types in playwright_extensions ([8b652e7](https://github.com/browserbase/sdk-python/commit/8b652e78be1493d03e13d2a116cbc6969a880e58)) +* **parsing:** correctly handle nested discriminated unions ([d020678](https://github.com/browserbase/sdk-python/commit/d0206786894ecfb22e0924edb8a227414b17788d)) +* **parsing:** ignore empty metadata ([118c4d4](https://github.com/browserbase/sdk-python/commit/118c4d41bda811d2d942793d8ab029b272c7a5c6)) +* **parsing:** parse extra field types ([c7ef875](https://github.com/browserbase/sdk-python/commit/c7ef87549e324fb06fab945e1754ef7b56b30031)) +* **tests:** fix: tests which call HTTP endpoints directly with the example parameters ([e298407](https://github.com/browserbase/sdk-python/commit/e2984077537fd6dee0191329a083ad0ccf9fd76f)) + + +### Chores + +* **ci:** change upload type ([e42da7c](https://github.com/browserbase/sdk-python/commit/e42da7c1fed216ff2b15223c49f1111bc0ef16e5)) +* **ci:** enable for pull requests ([03a6db7](https://github.com/browserbase/sdk-python/commit/03a6db72e98bf1606bf68928b2ac5029cba088df)) +* **ci:** only run for pushes and fork pull requests ([c8cb51f](https://github.com/browserbase/sdk-python/commit/c8cb51f311f4d39863127fab189c95d84a186bc6)) +* **docs:** grammar improvements ([f32a9e2](https://github.com/browserbase/sdk-python/commit/f32a9e258a9b0b4d29c24137d5a7207907f00f9b)) +* **docs:** remove reference to rye shell ([07d129a](https://github.com/browserbase/sdk-python/commit/07d129a04211037d123b06d36347741960e75323)) +* **docs:** remove unnecessary param examples ([62209dc](https://github.com/browserbase/sdk-python/commit/62209dcac034f40ac8b3b8a119e532201a227680)) +* **internal:** add Sequence related utils ([34b0dd6](https://github.com/browserbase/sdk-python/commit/34b0dd6b4297fafc2bcb9e8243c8d3c2e2e435fc)) +* **internal:** bump pinned h11 dep ([5e3270d](https://github.com/browserbase/sdk-python/commit/5e3270da2e4f41efdd345d073a42d6791eb22a84)) +* **internal:** change ci workflow machines ([14c0ac4](https://github.com/browserbase/sdk-python/commit/14c0ac49a6d9d42f5401a5c24ddb8586b3998fb2)) +* **internal:** codegen related update ([f979aff](https://github.com/browserbase/sdk-python/commit/f979aff605c0d74efb561e0b169ad39b486ab5a0)) +* **internal:** codegen related update ([12de9f3](https://github.com/browserbase/sdk-python/commit/12de9f324fbb40bec91cd7c6b16af1440c4f7373)) +* **internal:** codegen related update ([c4157cb](https://github.com/browserbase/sdk-python/commit/c4157cb8470b1d0ca67e6757f4fe9146a630cc82)) +* **internal:** codegen related update ([ccb2c95](https://github.com/browserbase/sdk-python/commit/ccb2c95002bb6a38e1eb8b9a84e4a335d5ee1a13)) +* **internal:** fix ruff target version ([e6a3df4](https://github.com/browserbase/sdk-python/commit/e6a3df40564b4ba3d23514e0b42221010d465bf6)) +* **internal:** update comment in script ([a7aec17](https://github.com/browserbase/sdk-python/commit/a7aec17c02632684dfeb7759dd6a5322efe092ce)) +* **internal:** update conftest.py ([5d3a2b1](https://github.com/browserbase/sdk-python/commit/5d3a2b1906ca5fca5c84c6d6684a8a62b6700479)) +* **internal:** update pyright exclude list ([33ba4b4](https://github.com/browserbase/sdk-python/commit/33ba4b47ddeb8c0aa19a11f35a7cea9aa9a0966d)) +* **package:** mark python 3.13 as supported ([2450b8e](https://github.com/browserbase/sdk-python/commit/2450b8eb2349adde689febd09269915d41e7a590)) +* **project:** add settings file for vscode ([a406241](https://github.com/browserbase/sdk-python/commit/a4062413b2fce397d59ea9ceaec7ed0565880fe2)) +* **readme:** fix version rendering on pypi ([a8afe1a](https://github.com/browserbase/sdk-python/commit/a8afe1a67c48080ef202cac88da9b5d59534799a)) +* **readme:** update badges ([869a3f4](https://github.com/browserbase/sdk-python/commit/869a3f4dd7e6f19225b697aeee89ce98a2174c0a)) +* **tests:** add tests for httpx client instantiation & proxies ([9c5d88c](https://github.com/browserbase/sdk-python/commit/9c5d88cb4cbbda5aa618cba2f5217bacd4a228cc)) +* **tests:** run tests in parallel ([94308de](https://github.com/browserbase/sdk-python/commit/94308dea065f54268145b175a13e0dbfd2a9cc81)) +* **tests:** skip some failing tests on the latest python versions ([7bc40f0](https://github.com/browserbase/sdk-python/commit/7bc40f068d290a479a0d4070ef54e8f8c4ef598d)) +* update @stainless-api/prism-cli to v5.15.0 ([b48933b](https://github.com/browserbase/sdk-python/commit/b48933b2f68eafaa554662eb7f41bf960a74d8b6)) +* update github action ([d57dc03](https://github.com/browserbase/sdk-python/commit/d57dc0398b083556ed7ceee265efcf282062005d)) + + +### Documentation + +* **client:** fix httpx.Timeout documentation reference ([4bbda56](https://github.com/browserbase/sdk-python/commit/4bbda56cdb4adf677f67011f42f5c3e324a5f60e)) + ## 1.4.0 (2025-05-16) Full Changelog: [v1.3.0...v1.4.0](https://github.com/browserbase/sdk-python/compare/v1.3.0...v1.4.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45a7298e..5f8bfea6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,7 @@ $ rye sync --all-features You can then run scripts using `rye run python script.py` or by activating the virtual environment: ```sh -$ rye shell -# or manually activate - https://docs.python.org/3/library/venv.html#how-venvs-work +# Activate the virtual environment - https://docs.python.org/3/library/venv.html#how-venvs-work $ source .venv/bin/activate # now you can omit the `rye run` prefix diff --git a/README.md b/README.md index 43a407e6..5c1155d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Browserbase Python API library -[![PyPI version](https://img.shields.io/pypi/v/browserbase.svg)](https://pypi.org/project/browserbase/) + +[![PyPI version](https://img.shields.io/pypi/v/browserbase.svg?label=pypi%20(stable))](https://pypi.org/project/browserbase/) The Browserbase Python library provides convenient access to the Browserbase REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, @@ -16,7 +17,7 @@ The REST API documentation can be found on [docs.browserbase.com](https://docs.b ```sh # install from PyPI -pip install browserbase +pip install --pre browserbase ``` ## Usage @@ -81,6 +82,39 @@ rye run example playwright_basic # replace with the example you want to run > [!NOTE] > Make sure you have a `.env` file that matches the [.env.example](.env.example) file in the root of this repository. +### 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 --pre browserbase[aiohttp] +``` + +Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: + +```python +import asyncio +from browserbase import DefaultAioHttpClient +from browserbase import AsyncBrowserbase + + +async def main() -> None: + async with AsyncBrowserbase( + api_key="My API Key", + http_client=DefaultAioHttpClient(), + ) as client: + session = await client.sessions.create( + project_id="your_project_id", + ) + print(session.id) + + +asyncio.run(main()) +``` + ## Using types 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: @@ -101,37 +135,7 @@ client = Browserbase() session = client.sessions.create( project_id="projectId", - browser_settings={ - "advanced_stealth": True, - "block_ads": True, - "captcha_image_selector": "captchaImageSelector", - "captcha_input_selector": "captchaInputSelector", - "context": { - "id": "id", - "persist": True, - }, - "extension_id": "extensionId", - "fingerprint": { - "browsers": ["chrome"], - "devices": ["desktop"], - "http_version": "1", - "locales": ["string"], - "operating_systems": ["android"], - "screen": { - "max_height": 0, - "max_width": 0, - "min_height": 0, - "min_width": 0, - }, - }, - "log_session": True, - "record_session": True, - "solve_captchas": True, - "viewport": { - "height": 0, - "width": 0, - }, - }, + browser_settings={}, ) print(session.browser_settings) ``` @@ -222,7 +226,7 @@ client.with_options(max_retries=5).sessions.create( ### Timeouts 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/#fine-tuning-the-configuration) object: +which accepts a float or an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/#fine-tuning-the-configuration) object: ```python from browserbase import Browserbase diff --git a/SECURITY.md b/SECURITY.md index e10eb19a..ad64e4b9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,11 +16,11 @@ before making any information public. ## Reporting Non-SDK Related Security Issues If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by Browserbase please follow the respective company's security reporting guidelines. +or products provided by Browserbase, please follow the respective company's security reporting guidelines. ### Browserbase Terms and Policies -Please contact support@browserbase.com for any questions or concerns regarding security of our services. +Please contact support@browserbase.com for any questions or concerns regarding the security of our services. --- diff --git a/api.md b/api.md index dbb776f6..01454851 100644 --- a/api.md +++ b/api.md @@ -3,13 +3,13 @@ Types: ```python -from browserbase.types import Context, ContextCreateResponse, ContextUpdateResponse +from browserbase.types import ContextCreateResponse, ContextRetrieveResponse, ContextUpdateResponse ``` Methods: - client.contexts.create(\*\*params) -> ContextCreateResponse -- client.contexts.retrieve(id) -> Context +- client.contexts.retrieve(id) -> ContextRetrieveResponse - client.contexts.update(id) -> ContextUpdateResponse # Extensions @@ -17,13 +17,13 @@ Methods: Types: ```python -from browserbase.types import Extension +from browserbase.types import ExtensionCreateResponse, ExtensionRetrieveResponse ``` Methods: -- client.extensions.create(\*\*params) -> Extension -- client.extensions.retrieve(id) -> Extension +- client.extensions.create(\*\*params) -> ExtensionCreateResponse +- client.extensions.retrieve(id) -> ExtensionRetrieveResponse - client.extensions.delete(id) -> None # Projects @@ -31,14 +31,14 @@ Methods: Types: ```python -from browserbase.types import Project, ProjectUsage, ProjectListResponse +from browserbase.types import ProjectRetrieveResponse, ProjectListResponse, ProjectUsageResponse ``` Methods: -- client.projects.retrieve(id) -> Project +- client.projects.retrieve(id) -> ProjectRetrieveResponse - client.projects.list() -> ProjectListResponse -- client.projects.usage(id) -> ProjectUsage +- client.projects.usage(id) -> ProjectUsageResponse # Sessions @@ -46,11 +46,11 @@ Types: ```python from browserbase.types import ( - Session, - SessionLiveURLs, SessionCreateResponse, SessionRetrieveResponse, + SessionUpdateResponse, SessionListResponse, + SessionDebugResponse, ) ``` @@ -58,9 +58,9 @@ Methods: - client.sessions.create(\*\*params) -> SessionCreateResponse - client.sessions.retrieve(id) -> SessionRetrieveResponse -- client.sessions.update(id, \*\*params) -> Session +- client.sessions.update(id, \*\*params) -> SessionUpdateResponse - client.sessions.list(\*\*params) -> SessionListResponse -- client.sessions.debug(id) -> SessionLiveURLs +- client.sessions.debug(id) -> SessionDebugResponse ## Downloads @@ -73,7 +73,7 @@ Methods: Types: ```python -from browserbase.types.sessions import SessionLog, LogListResponse +from browserbase.types.sessions import LogListResponse ``` Methods: @@ -85,7 +85,7 @@ Methods: Types: ```python -from browserbase.types.sessions import SessionRecording, RecordingRetrieveResponse +from browserbase.types.sessions import RecordingRetrieveResponse ``` Methods: diff --git a/bin/check-release-environment b/bin/check-release-environment index 6ad04d35..b845b0f4 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -3,7 +3,7 @@ errors=() if [ -z "${PYPI_TOKEN}" ]; then - errors+=("The BROWSERBASE_PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") + errors+=("The PYPI_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets.") fi lenErrors=${#errors[@]} diff --git a/examples/playwright_extensions.py b/examples/playwright_extensions.py index 6ef1f985..f2c2f7f9 100644 --- a/examples/playwright_extensions.py +++ b/examples/playwright_extensions.py @@ -10,7 +10,7 @@ BROWSERBASE_PROJECT_ID, bb, ) -from browserbase.types import Extension, SessionCreateResponse +from browserbase.types import SessionCreateResponse, ExtensionRetrieveResponse PATH_TO_EXTENSION = Path.cwd() / "examples" / "packages" / "extensions" / "browserbase-test" @@ -47,11 +47,11 @@ def zip_extension(path: Path = PATH_TO_EXTENSION, save_local: bool = False) -> B def create_extension() -> str: zip_data = zip_extension(save_local=True) - extension: Extension = bb.extensions.create(file=("extension.zip", zip_data.getvalue())) + extension = bb.extensions.create(file=("extension.zip", zip_data.getvalue())) return extension.id -def get_extension(id: str) -> Extension: +def get_extension(id: str) -> ExtensionRetrieveResponse: return bb.extensions.retrieve(id) diff --git a/pyproject.toml b/pyproject.toml index e5e2f6c4..d42ea806 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.4.0" +version = "1.5.0-alpha.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -37,6 +38,8 @@ classifiers = [ Homepage = "https://github.com/browserbase/sdk-python" Repository = "https://github.com/browserbase/sdk-python" +[project.optional-dependencies] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] [tool.rye] managed = true @@ -55,6 +58,7 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "nest_asyncio==1.6.0", + "pytest-xdist>=3.6.1", "python-dotenv", "playwright", "selenium", @@ -134,7 +138,7 @@ replacement = '[\1](https://github.com/browserbase/sdk-python/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--tb=short" +addopts = "--tb=short -n auto" xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" @@ -153,6 +157,7 @@ exclude = [ "_dev", ".venv", ".nox", + ".git", ] reportImplicitOverride = true @@ -164,7 +169,7 @@ reportPrivateUsage = false [tool.ruff] line-length = 120 output-format = "grouped" -target-version = "py37" +target-version = "py38" [tool.ruff.format] docstring-code-format = true diff --git a/requirements-dev.lock b/requirements-dev.lock index b0045a68..23d3fedf 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -10,6 +10,13 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.15 + # via browserbase + # via httpx-aiohttp +aiosignal==1.4.0 + # via aiohttp annotated-types==0.7.0 # via pydantic anyio==4.6.2.post1 @@ -17,7 +24,10 @@ anyio==4.6.2.post1 # via httpx argcomplete==3.5.1 # via nox +async-timeout==5.0.1 + # via aiohttp attrs==24.2.0 + # via aiohttp # via outcome # via trio certifi==2024.8.30 @@ -39,8 +49,13 @@ exceptiongroup==1.2.2 # via pytest # via trio # via trio-websocket +execnet==2.1.1 + # via pytest-xdist filelock==3.16.1 # via virtualenv +frozenlist==1.7.0 + # via aiohttp + # via aiosignal greenlet==3.1.1 # via playwright h11==0.14.0 @@ -50,12 +65,16 @@ httpcore==1.0.6 # via httpx httpx==0.28.1 # via browserbase + # via httpx-aiohttp # via respx +httpx-aiohttp==0.1.8 + # via browserbase idna==3.10 # via anyio # via httpx # via requests # via trio + # via yarl importlib-metadata==8.5.0 iniconfig==2.0.0 # via pytest @@ -63,6 +82,9 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py +multidict==6.6.4 + # via aiohttp + # via yarl mypy==1.14.1 mypy-extensions==1.0.0 # via mypy @@ -81,6 +103,9 @@ playwright==1.48.0 # via pytest-playwright pluggy==1.5.0 # via pytest +propcache==0.3.2 + # via aiohttp + # via yarl pydantic==2.10.3 # via browserbase pydantic-core==2.27.1 @@ -89,17 +114,19 @@ pyee==12.0.0 # via playwright pygments==2.18.0 # via rich +pyright==1.1.399 pysocks==1.7.1 # via urllib3 -pyright==1.1.399 pytest==8.3.3 # via pytest-asyncio # via pytest-base-url # via pytest-playwright + # via pytest-xdist pytest-asyncio==0.24.0 pytest-base-url==2.1.0 # via pytest-playwright pytest-playwright==0.5.2 +pytest-xdist==3.8.0 python-dateutil==2.9.0.post0 # via time-machine python-dotenv==1.0.1 @@ -132,8 +159,10 @@ trio==0.27.0 trio-websocket==0.11.1 # via selenium typing-extensions==4.12.2 + # via aiosignal # via anyio # via browserbase + # via multidict # via mypy # via pydantic # via pydantic-core @@ -150,5 +179,7 @@ websocket-client==1.8.0 # via selenium wsproto==1.2.0 # via trio-websocket +yarl==1.20.1 + # via aiohttp zipp==3.20.2 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 9efa54d7..6f4c4c9e 100644 --- a/requirements.lock +++ b/requirements.lock @@ -10,11 +10,22 @@ # universal: false -e file:. +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.8 + # via browserbase + # via httpx-aiohttp +aiosignal==1.3.2 + # via aiohttp annotated-types==0.6.0 # via pydantic anyio==4.4.0 # via browserbase # via httpx +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp certifi==2023.7.22 # via httpcore # via httpx @@ -22,15 +33,28 @@ distro==1.8.0 # via browserbase exceptiongroup==1.2.2 # via anyio -h11==0.14.0 +frozenlist==1.6.2 + # via aiohttp + # via aiosignal +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via browserbase + # via httpx-aiohttp +httpx-aiohttp==0.1.8 + # via browserbase idna==3.4 # via anyio # via httpx + # via yarl +multidict==6.4.4 + # via aiohttp + # via yarl +propcache==0.3.1 + # via aiohttp + # via yarl pydantic==2.10.3 # via browserbase pydantic-core==2.27.1 @@ -41,5 +65,8 @@ sniffio==1.3.0 typing-extensions==4.12.2 # via anyio # via browserbase + # via multidict # via pydantic # via pydantic-core +yarl==1.20.0 + # via aiohttp diff --git a/scripts/mock b/scripts/mock index d2814ae6..0b28f6ea 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,7 +21,7 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then - npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" &> .prism.log & + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & # Wait for server to come online echo -n "Waiting for server" @@ -37,5 +37,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" fi diff --git a/scripts/test b/scripts/test index 2b878456..dbeda2d2 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! prism_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the prism command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stoplight/prism-cli@~5.3.2 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" echo exit 1 diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index 7c3d028a..4fa57664 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -exuo pipefail -RESPONSE=$(curl -X POST "$URL" \ +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ -H "Authorization: Bearer $AUTH" \ -H "Content-Type: application/json") @@ -12,13 +14,13 @@ if [[ "$SIGNED_URL" == "null" ]]; then exit 1 fi -UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ - -H "Content-Type: application/gzip" \ - --data-binary @- "$SIGNED_URL" 2>&1) +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/browserbase-python/$SHA'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/browserbase-python/$SHA/$FILENAME'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 diff --git a/src/browserbase/__init__.py b/src/browserbase/__init__.py index e954b0ee..8e128845 100644 --- a/src/browserbase/__init__.py +++ b/src/browserbase/__init__.py @@ -36,7 +36,7 @@ UnprocessableEntityError, APIResponseValidationError, ) -from ._base_client import DefaultHttpxClient, DefaultAsyncHttpxClient +from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging __all__ = [ @@ -78,6 +78,7 @@ "DEFAULT_CONNECTION_LIMITS", "DefaultHttpxClient", "DefaultAsyncHttpxClient", + "DefaultAioHttpClient", ] if not _t.TYPE_CHECKING: diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index 82e76c9d..89549337 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_base_client.py @@ -59,7 +59,7 @@ ModelBuilderProtocol, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping -from ._compat import PYDANTIC_V2, model_copy, model_dump +from ._compat import PYDANTIC_V1, model_copy, model_dump from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, @@ -232,7 +232,7 @@ def _set_private_attributes( model: Type[_T], options: FinalRequestOptions, ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: self.__pydantic_private__ = {} self._model = model @@ -320,7 +320,7 @@ def _set_private_attributes( client: AsyncAPIClient, options: FinalRequestOptions, ) -> None: - if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + if (not PYDANTIC_V1) and getattr(self, "__pydantic_private__", None) is None: self.__pydantic_private__ = {} self._model = model @@ -529,6 +529,18 @@ def _build_request( # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + if isinstance(json_data, bytes): + kwargs["content"] = json_data + else: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + # TODO: report this error to httpx return self._client.build_request( # pyright: ignore[reportUnknownMemberType] headers=headers, @@ -540,8 +552,6 @@ def _build_request( # so that passing a `TypedDict` doesn't cause an error. # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, - json=json_data if is_given(json_data) else None, - files=files, **kwargs, ) @@ -960,6 +970,9 @@ def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1068,7 +1081,14 @@ def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, APIResponse): raise TypeError(f"API Response types must subclass {APIResponse}; Received {origin}") @@ -1279,6 +1299,24 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) +try: + import httpx_aiohttp +except ImportError: + + class _DefaultAioHttpClient(httpx.AsyncClient): + def __init__(self, **_kwargs: Any) -> None: + raise RuntimeError("To use the aiohttp client you must have installed the package with the `aiohttp` extra") +else: + + class _DefaultAioHttpClient(httpx_aiohttp.HttpxAiohttpClient): # type: ignore + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("timeout", DEFAULT_TIMEOUT) + kwargs.setdefault("limits", DEFAULT_CONNECTION_LIMITS) + kwargs.setdefault("follow_redirects", True) + + super().__init__(**kwargs) + + if TYPE_CHECKING: DefaultAsyncHttpxClient = httpx.AsyncClient """An alias to `httpx.AsyncClient` that provides the same defaults that this SDK @@ -1287,8 +1325,12 @@ def __init__(self, **kwargs: Any) -> None: This is useful because overriding the `http_client` with your own instance of `httpx.AsyncClient` will result in httpx's defaults being used, not ours. """ + + DefaultAioHttpClient = httpx.AsyncClient + """An alias to `httpx.AsyncClient` that changes the default HTTP transport to `aiohttp`.""" else: DefaultAsyncHttpxClient = _DefaultAsyncHttpxClient + DefaultAioHttpClient = _DefaultAioHttpClient class AsyncHttpxClientWrapper(DefaultAsyncHttpxClient): @@ -1460,6 +1502,9 @@ async def request( if self.custom_auth is not None: kwargs["auth"] = self.custom_auth + if options.follow_redirects is not None: + kwargs["follow_redirects"] = options.follow_redirects + log.debug("Sending HTTP Request: %s %s", request.method, request.url) response = None @@ -1568,7 +1613,14 @@ async def _process_response( ) -> ResponseT: origin = get_origin(cast_to) or cast_to - if inspect.isclass(origin) and issubclass(origin, BaseAPIResponse): + if ( + inspect.isclass(origin) + and issubclass(origin, BaseAPIResponse) + # we only want to actually return the custom BaseAPIResponse class if we're + # returning the raw response, or if we're not streaming SSE, as if we're streaming + # SSE then `cast_to` doesn't actively reflect the type we need to parse into + and (not stream or bool(response.request.headers.get(RAW_RESPONSE_HEADER))) + ): if not issubclass(origin, AsyncAPIResponse): raise TypeError(f"API Response types must subclass {AsyncAPIResponse}; Received {origin}") diff --git a/src/browserbase/_compat.py b/src/browserbase/_compat.py index 92d9ee61..bdef67f0 100644 --- a/src/browserbase/_compat.py +++ b/src/browserbase/_compat.py @@ -12,14 +12,13 @@ _T = TypeVar("_T") _ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel) -# --------------- Pydantic v2 compatibility --------------- +# --------------- Pydantic v2, v3 compatibility --------------- # Pyright incorrectly reports some of our functions as overriding a method when they don't # pyright: reportIncompatibleMethodOverride=false -PYDANTIC_V2 = pydantic.VERSION.startswith("2.") +PYDANTIC_V1 = pydantic.VERSION.startswith("1.") -# v1 re-exports if TYPE_CHECKING: def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001 @@ -44,90 +43,92 @@ def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001 ... else: - if PYDANTIC_V2: - from pydantic.v1.typing import ( + # v1 re-exports + if PYDANTIC_V1: + from pydantic.typing import ( get_args as get_args, is_union as is_union, get_origin as get_origin, is_typeddict as is_typeddict, is_literal_type as is_literal_type, ) - from pydantic.v1.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime + from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime else: - from pydantic.typing import ( + from ._utils import ( get_args as get_args, is_union as is_union, get_origin as get_origin, + parse_date as parse_date, is_typeddict as is_typeddict, + parse_datetime as parse_datetime, is_literal_type as is_literal_type, ) - from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime # refactored config if TYPE_CHECKING: from pydantic import ConfigDict as ConfigDict else: - if PYDANTIC_V2: - from pydantic import ConfigDict - else: + if PYDANTIC_V1: # TODO: provide an error message here? ConfigDict = None + else: + from pydantic import ConfigDict as ConfigDict # renamed methods / properties def parse_obj(model: type[_ModelT], value: object) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(value) - else: + if PYDANTIC_V1: return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + else: + return model.model_validate(value) def field_is_required(field: FieldInfo) -> bool: - if PYDANTIC_V2: - return field.is_required() - return field.required # type: ignore + if PYDANTIC_V1: + return field.required # type: ignore + return field.is_required() def field_get_default(field: FieldInfo) -> Any: value = field.get_default() - if PYDANTIC_V2: - from pydantic_core import PydanticUndefined - - if value == PydanticUndefined: - return None + if PYDANTIC_V1: return value + from pydantic_core import PydanticUndefined + + if value == PydanticUndefined: + return None return value def field_outer_type(field: FieldInfo) -> Any: - if PYDANTIC_V2: - return field.annotation - return field.outer_type_ # type: ignore + if PYDANTIC_V1: + return field.outer_type_ # type: ignore + return field.annotation def get_model_config(model: type[pydantic.BaseModel]) -> Any: - if PYDANTIC_V2: - return model.model_config - return model.__config__ # type: ignore + if PYDANTIC_V1: + return model.__config__ # type: ignore + return model.model_config def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]: - if PYDANTIC_V2: - return model.model_fields - return model.__fields__ # type: ignore + if PYDANTIC_V1: + return model.__fields__ # type: ignore + return model.model_fields def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT: - if PYDANTIC_V2: - return model.model_copy(deep=deep) - return model.copy(deep=deep) # type: ignore + if PYDANTIC_V1: + return model.copy(deep=deep) # type: ignore + return model.model_copy(deep=deep) def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: - if PYDANTIC_V2: - return model.model_dump_json(indent=indent) - return model.json(indent=indent) # type: ignore + if PYDANTIC_V1: + return model.json(indent=indent) # type: ignore + return model.model_dump_json(indent=indent) def model_dump( @@ -139,14 +140,14 @@ def model_dump( warnings: bool = True, mode: Literal["json", "python"] = "python", ) -> dict[str, Any]: - if PYDANTIC_V2 or hasattr(model, "model_dump"): + if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( mode=mode, exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 - warnings=warnings if PYDANTIC_V2 else True, + warnings=True if PYDANTIC_V1 else warnings, ) return cast( "dict[str, Any]", @@ -159,9 +160,9 @@ def model_dump( def model_parse(model: type[_ModelT], data: Any) -> _ModelT: - if PYDANTIC_V2: - return model.model_validate(data) - return model.parse_obj(data) # pyright: ignore[reportDeprecated] + if PYDANTIC_V1: + return model.parse_obj(data) # pyright: ignore[reportDeprecated] + return model.model_validate(data) # generic models @@ -170,17 +171,16 @@ def model_parse(model: type[_ModelT], data: Any) -> _ModelT: class GenericModel(pydantic.BaseModel): ... else: - if PYDANTIC_V2: + if PYDANTIC_V1: + import pydantic.generics + + class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... + else: # there no longer needs to be a distinction in v2 but # we still have to create our own subclass to avoid # inconsistent MRO ordering errors class GenericModel(pydantic.BaseModel): ... - else: - import pydantic.generics - - class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ... - # cached properties if TYPE_CHECKING: diff --git a/src/browserbase/_files.py b/src/browserbase/_files.py index c690226c..ff951be7 100644 --- a/src/browserbase/_files.py +++ b/src/browserbase/_files.py @@ -69,12 +69,12 @@ def _transform_file(file: FileTypes) -> HttpxFileTypes: return file if is_tuple_t(file): - return (file[0], _read_file_content(file[1]), *file[2:]) + return (file[0], read_file_content(file[1]), *file[2:]) raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") -def _read_file_content(file: FileContent) -> HttpxFileContent: +def read_file_content(file: FileContent) -> HttpxFileContent: if isinstance(file, os.PathLike): return pathlib.Path(file).read_bytes() return file @@ -111,12 +111,12 @@ async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: return file if is_tuple_t(file): - return (file[0], await _async_read_file_content(file[1]), *file[2:]) + return (file[0], await async_read_file_content(file[1]), *file[2:]) raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") -async def _async_read_file_content(file: FileContent) -> HttpxFileContent: +async def async_read_file_content(file: FileContent) -> HttpxFileContent: if isinstance(file, os.PathLike): return await anyio.Path(file).read_bytes() diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index 798956f1..3a6017ef 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -2,9 +2,10 @@ import os import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( + List, Unpack, Literal, ClassVar, @@ -49,7 +50,7 @@ strip_annotated_type, ) from ._compat import ( - PYDANTIC_V2, + PYDANTIC_V1, ConfigDict, GenericModel as BaseGenericModel, get_args, @@ -80,11 +81,7 @@ class _ConfigProtocol(Protocol): class BaseModel(pydantic.BaseModel): - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) - ) - else: + if PYDANTIC_V1: @property @override @@ -94,6 +91,10 @@ def model_fields_set(self) -> set[str]: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] extra: Any = pydantic.Extra.allow # type: ignore + else: + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true")) + ) def to_dict( self, @@ -207,28 +208,32 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] else: fields_values[name] = field_get_default(field) + extra_field_type = _get_extra_fields_type(__cls) + _extra = {} for key, value in values.items(): if key not in model_fields: - if PYDANTIC_V2: - _extra[key] = value - else: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + + if PYDANTIC_V1: _fields_set.add(key) - fields_values[key] = value + fields_values[key] = parsed + else: + _extra[key] = parsed object.__setattr__(m, "__dict__", fields_values) - if PYDANTIC_V2: - # these properties are copied from Pydantic's `model_construct()` method - object.__setattr__(m, "__pydantic_private__", None) - object.__setattr__(m, "__pydantic_extra__", _extra) - object.__setattr__(m, "__pydantic_fields_set__", _fields_set) - else: + if PYDANTIC_V1: # init_private_attributes() does not exist in v2 m._init_private_attributes() # type: ignore # copied from Pydantic v1's `construct()` method object.__setattr__(m, "__fields_set__", _fields_set) + else: + # these properties are copied from Pydantic's `model_construct()` method + object.__setattr__(m, "__pydantic_private__", None) + object.__setattr__(m, "__pydantic_extra__", _extra) + object.__setattr__(m, "__pydantic_fields_set__", _fields_set) return m @@ -238,7 +243,7 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] # although not in practice model_construct = construct - if not PYDANTIC_V2: + if PYDANTIC_V1: # we define aliases for some of the new pydantic v2 methods so # that we can just document these methods without having to specify # a specific pydantic version as some users may not know which @@ -299,7 +304,7 @@ def model_dump( exclude_none=exclude_none, ) - return cast(dict[str, Any], json_safe(dumped)) if mode == "json" else dumped + return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped @override def model_dump_json( @@ -358,15 +363,32 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: return field_get_default(field) - if PYDANTIC_V2: - type_ = field.annotation - else: + if PYDANTIC_V1: type_ = cast(type, field.outer_type_) # type: ignore + else: + type_ = field.annotation # type: ignore if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") - return construct_type(value=value, type_=type_) + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) + + +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if PYDANTIC_V1: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None def is_basemodel(type_: type) -> bool: @@ -420,7 +442,7 @@ def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: return cast(_T, construct_type(value=value, type_=type_)) -def construct_type(*, value: object, type_: object) -> object: +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: """Loose coercion to the expected type with construction of nested values. If the given value does not match the expected type then it is returned as-is. @@ -438,8 +460,10 @@ def construct_type(*, value: object, type_: object) -> object: type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if is_annotated_type(type_): - meta: tuple[Any, ...] = get_args(type_)[1:] + if metadata is not None and len(metadata) > 0: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] type_ = extract_type_arg(type_, 0) else: meta = tuple() @@ -604,30 +628,30 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, for variant in get_args(union): variant = strip_annotated_type(variant) if is_basemodel_type(variant): - if PYDANTIC_V2: - field = _extract_field_schema_pv2(variant, discriminator_field_name) - if not field: + if PYDANTIC_V1: + field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] + if not field_info: continue # Note: if one variant defines an alias then they all should - discriminator_alias = field.get("serialization_alias") - - field_schema = field["schema"] + discriminator_alias = field_info.alias - if field_schema["type"] == "literal": - for entry in cast("LiteralSchema", field_schema)["expected"]: + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): if isinstance(entry, str): mapping[entry] = variant else: - field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - if not field_info: + field = _extract_field_schema_pv2(variant, discriminator_field_name) + if not field: continue # Note: if one variant defines an alias then they all should - discriminator_alias = field_info.alias + discriminator_alias = field.get("serialization_alias") - if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): - for entry in get_args(annotation): + field_schema = field["schema"] + + if field_schema["type"] == "literal": + for entry in cast("LiteralSchema", field_schema)["expected"]: if isinstance(entry, str): mapping[entry] = variant @@ -690,7 +714,7 @@ class GenericModel(BaseGenericModel, BaseModel): pass -if PYDANTIC_V2: +if not PYDANTIC_V1: from pydantic import TypeAdapter as _TypeAdapter _CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter)) @@ -737,6 +761,7 @@ class FinalRequestOptionsInput(TypedDict, total=False): idempotency_key: str json_data: Body extra_json: AnyMapping + follow_redirects: bool @final @@ -750,18 +775,19 @@ class FinalRequestOptions(pydantic.BaseModel): files: Union[HttpxRequestFiles, None] = None idempotency_key: Union[str, None] = None post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven() + follow_redirects: Union[bool, 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 extra_json: Union[AnyMapping, None] = None - if PYDANTIC_V2: - model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) - else: + if PYDANTIC_V1: class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] arbitrary_types_allowed: bool = True + else: + model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True) def get_max_retries(self, max_retries: int) -> int: if isinstance(self.max_retries, NotGiven): @@ -794,9 +820,9 @@ def construct( # type: ignore key: strip_not_given(value) for key, value in values.items() } - if PYDANTIC_V2: - return super().model_construct(_fields_set, **kwargs) - return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + if PYDANTIC_V1: + return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] + return super().model_construct(_fields_set, **kwargs) if not TYPE_CHECKING: # type checkers incorrectly complain about this assignment diff --git a/src/browserbase/_types.py b/src/browserbase/_types.py index a8833dce..b954306a 100644 --- a/src/browserbase/_types.py +++ b/src/browserbase/_types.py @@ -13,10 +13,21 @@ Mapping, TypeVar, Callable, + Iterator, Optional, Sequence, ) -from typing_extensions import Set, Literal, Protocol, TypeAlias, TypedDict, override, runtime_checkable +from typing_extensions import ( + Set, + Literal, + Protocol, + TypeAlias, + TypedDict, + SupportsIndex, + overload, + override, + runtime_checkable, +) import httpx import pydantic @@ -100,6 +111,7 @@ class RequestOptions(TypedDict, total=False): params: Query extra_json: AnyMapping idempotency_key: str + follow_redirects: bool # Sentinel class used until PEP 0661 is accepted @@ -215,3 +227,27 @@ class _GenericAlias(Protocol): class HttpxSendArgs(TypedDict, total=False): auth: httpx.Auth + follow_redirects: bool + + +_T_co = TypeVar("_T_co", covariant=True) + + +if TYPE_CHECKING: + # This works because str.__contains__ does not accept object (either in typeshed or at runtime) + # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + class SequenceNotStr(Protocol[_T_co]): + @overload + def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... + @overload + def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... + def __contains__(self, value: object, /) -> bool: ... + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[_T_co]: ... + def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... + def count(self, value: Any, /) -> int: ... + def __reversed__(self) -> Iterator[_T_co]: ... +else: + # just point this to a normal `Sequence` at runtime to avoid having to special case + # deserializing our custom sequence type + SequenceNotStr = Sequence diff --git a/src/browserbase/_utils/__init__.py b/src/browserbase/_utils/__init__.py index d4fda26f..dc64e29a 100644 --- a/src/browserbase/_utils/__init__.py +++ b/src/browserbase/_utils/__init__.py @@ -10,7 +10,6 @@ lru_cache as lru_cache, is_mapping as is_mapping, is_tuple_t as is_tuple_t, - parse_date as parse_date, is_iterable as is_iterable, is_sequence as is_sequence, coerce_float as coerce_float, @@ -23,7 +22,6 @@ coerce_boolean as coerce_boolean, coerce_integer as coerce_integer, file_from_path as file_from_path, - parse_datetime as parse_datetime, strip_not_given as strip_not_given, deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, @@ -32,12 +30,20 @@ maybe_coerce_boolean as maybe_coerce_boolean, maybe_coerce_integer as maybe_coerce_integer, ) +from ._compat import ( + get_args as get_args, + is_union as is_union, + get_origin as get_origin, + is_typeddict as is_typeddict, + is_literal_type as is_literal_type, +) from ._typing import ( is_list_type as is_list_type, is_union_type as is_union_type, extract_type_arg as extract_type_arg, is_iterable_type as is_iterable_type, is_required_type as is_required_type, + is_sequence_type as is_sequence_type, is_annotated_type as is_annotated_type, is_type_alias_type as is_type_alias_type, strip_annotated_type as strip_annotated_type, @@ -55,3 +61,4 @@ function_has_argument as function_has_argument, assert_signatures_in_sync as assert_signatures_in_sync, ) +from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime diff --git a/src/browserbase/_utils/_compat.py b/src/browserbase/_utils/_compat.py new file mode 100644 index 00000000..dd703233 --- /dev/null +++ b/src/browserbase/_utils/_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import typing_extensions +from typing import Any, Type, Union, Literal, Optional +from datetime import date, datetime +from typing_extensions import get_args as _get_args, get_origin as _get_origin + +from .._types import StrBytesIntFloat +from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime + +_LITERAL_TYPES = {Literal, typing_extensions.Literal} + + +def get_args(tp: type[Any]) -> tuple[Any, ...]: + return _get_args(tp) + + +def get_origin(tp: type[Any]) -> type[Any] | None: + return _get_origin(tp) + + +def is_union(tp: Optional[Type[Any]]) -> bool: + if sys.version_info < (3, 10): + return tp is Union # type: ignore[comparison-overlap] + else: + import types + + return tp is Union or tp is types.UnionType + + +def is_typeddict(tp: Type[Any]) -> bool: + return typing_extensions.is_typeddict(tp) + + +def is_literal_type(tp: Type[Any]) -> bool: + return get_origin(tp) in _LITERAL_TYPES + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + return _parse_date(value) + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + return _parse_datetime(value) diff --git a/src/browserbase/_utils/_datetime_parse.py b/src/browserbase/_utils/_datetime_parse.py new file mode 100644 index 00000000..7cb9d9e6 --- /dev/null +++ b/src/browserbase/_utils/_datetime_parse.py @@ -0,0 +1,136 @@ +""" +This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py +without the Pydantic v1 specific errors. +""" + +from __future__ import annotations + +import re +from typing import Dict, Union, Optional +from datetime import date, datetime, timezone, timedelta + +from .._types import StrBytesIntFloat + +date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" +time_expr = ( + r"(?P\d{1,2}):(?P\d{1,2})" + r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" + r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" +) + +date_re = re.compile(f"{date_expr}$") +datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") + + +EPOCH = datetime(1970, 1, 1) +# if greater than this, the number is in ms, if less than or equal it's in seconds +# (in seconds this is 11th October 2603, in ms it's 20th August 1970) +MS_WATERSHED = int(2e10) +# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 +MAX_NUMBER = int(3e20) + + +def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]: + if isinstance(value, (int, float)): + return value + try: + return float(value) + except ValueError: + return None + except TypeError: + raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None + + +def _from_unix_seconds(seconds: Union[int, float]) -> datetime: + if seconds > MAX_NUMBER: + return datetime.max + elif seconds < -MAX_NUMBER: + return datetime.min + + while abs(seconds) > MS_WATERSHED: + seconds /= 1000 + dt = EPOCH + timedelta(seconds=seconds) + return dt.replace(tzinfo=timezone.utc) + + +def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]: + if value == "Z": + return timezone.utc + elif value is not None: + offset_mins = int(value[-2:]) if len(value) > 3 else 0 + offset = 60 * int(value[1:3]) + offset_mins + if value[0] == "-": + offset = -offset + return timezone(timedelta(minutes=offset)) + else: + return None + + +def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: + """ + Parse a datetime/int/float/string and return a datetime.datetime. + + This function supports time zone offsets. When the input contains one, + the output uses a timezone with a fixed offset from UTC. + + Raise ValueError if the input is well formatted but not a valid datetime. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, datetime): + return value + + number = _get_numeric(value, "datetime") + if number is not None: + return _from_unix_seconds(number) + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + + match = datetime_re.match(value) + if match is None: + raise ValueError("invalid datetime format") + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo")) + kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + return datetime(**kw_) # type: ignore + + +def parse_date(value: Union[date, StrBytesIntFloat]) -> date: + """ + Parse a date/int/float/string and return a datetime.date. + + Raise ValueError if the input is well formatted but not a valid date. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, date): + if isinstance(value, datetime): + return value.date() + else: + return value + + number = _get_numeric(value, "date") + if number is not None: + return _from_unix_seconds(number).date() + + if isinstance(value, bytes): + value = value.decode() + + assert not isinstance(value, (float, int)) + match = date_re.match(value) + if match is None: + raise ValueError("invalid date format") + + kw = {k: int(v) for k, v in match.groupdict().items()} + + try: + return date(**kw) + except ValueError: + raise ValueError("invalid date format") from None diff --git a/src/browserbase/_utils/_transform.py b/src/browserbase/_utils/_transform.py index b0cc20a7..c19124f0 100644 --- a/src/browserbase/_utils/_transform.py +++ b/src/browserbase/_utils/_transform.py @@ -16,18 +16,20 @@ lru_cache, is_mapping, is_iterable, + is_sequence, ) from .._files import is_base64_file_input +from ._compat import get_origin, is_typeddict from ._typing import ( is_list_type, is_union_type, extract_type_arg, is_iterable_type, is_required_type, + is_sequence_type, is_annotated_type, strip_annotated_type, ) -from .._compat import get_origin, model_dump, is_typeddict _T = TypeVar("_T") @@ -167,6 +169,8 @@ def _transform_recursive( Defaults to the same value as the `annotation` argument. """ + from .._compat import model_dump + if inner_type is None: inner_type = annotation @@ -184,6 +188,8 @@ def _transform_recursive( (is_list_type(stripped_type) and is_list(data)) # Iterable[T] or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) ): # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually # intended as an iterable, so we don't transform it. @@ -329,6 +335,8 @@ async def _async_transform_recursive( Defaults to the same value as the `annotation` argument. """ + from .._compat import model_dump + if inner_type is None: inner_type = annotation @@ -346,6 +354,8 @@ async def _async_transform_recursive( (is_list_type(stripped_type) and is_list(data)) # Iterable[T] or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str)) + # Sequence[T] + or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str)) ): # dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually # intended as an iterable, so we don't transform it. diff --git a/src/browserbase/_utils/_typing.py b/src/browserbase/_utils/_typing.py index 1bac9542..193109f3 100644 --- a/src/browserbase/_utils/_typing.py +++ b/src/browserbase/_utils/_typing.py @@ -15,7 +15,7 @@ from ._utils import lru_cache from .._types import InheritsGeneric -from .._compat import is_union as _is_union +from ._compat import is_union as _is_union def is_annotated_type(typ: type) -> bool: @@ -26,6 +26,11 @@ def is_list_type(typ: type) -> bool: return (get_origin(typ) or typ) == list +def is_sequence_type(typ: type) -> bool: + origin = get_origin(typ) or typ + return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence + + def is_iterable_type(typ: type) -> bool: """If the given type is `typing.Iterable[T]`""" origin = get_origin(typ) or typ diff --git a/src/browserbase/_utils/_utils.py b/src/browserbase/_utils/_utils.py index ea3cf3f2..f0818595 100644 --- a/src/browserbase/_utils/_utils.py +++ b/src/browserbase/_utils/_utils.py @@ -22,7 +22,6 @@ import sniffio from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike -from .._compat import parse_date as parse_date, parse_datetime as parse_datetime _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 3c0492ea..25f19565 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.4.0" # x-release-please-version +__version__ = "1.5.0-alpha.0" # x-release-please-version diff --git a/src/browserbase/resources/contexts.py b/src/browserbase/resources/contexts.py index 0a438eda..bc4d1cc8 100644 --- a/src/browserbase/resources/contexts.py +++ b/src/browserbase/resources/contexts.py @@ -16,9 +16,9 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options -from ..types.context import Context from ..types.context_create_response import ContextCreateResponse from ..types.context_update_response import ContextUpdateResponse +from ..types.context_retrieve_response import ContextRetrieveResponse __all__ = ["ContextsResource", "AsyncContextsResource"] @@ -89,9 +89,9 @@ def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Context: + ) -> ContextRetrieveResponse: """ - Context + Get a Context Args: extra_headers: Send extra headers @@ -109,7 +109,7 @@ def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Context, + cast_to=ContextRetrieveResponse, ) def update( @@ -124,7 +124,7 @@ def update( timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ContextUpdateResponse: """ - Update Context + Update a Context Args: extra_headers: Send extra headers @@ -212,9 +212,9 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Context: + ) -> ContextRetrieveResponse: """ - Context + Get a Context Args: extra_headers: Send extra headers @@ -232,7 +232,7 @@ async def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Context, + cast_to=ContextRetrieveResponse, ) async def update( @@ -247,7 +247,7 @@ async def update( timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ContextUpdateResponse: """ - Update Context + Update a Context Args: extra_headers: Send extra headers diff --git a/src/browserbase/resources/extensions.py b/src/browserbase/resources/extensions.py index c7b0fae7..4dcd248f 100644 --- a/src/browserbase/resources/extensions.py +++ b/src/browserbase/resources/extensions.py @@ -18,7 +18,8 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options -from ..types.extension import Extension +from ..types.extension_create_response import ExtensionCreateResponse +from ..types.extension_retrieve_response import ExtensionRetrieveResponse __all__ = ["ExtensionsResource", "AsyncExtensionsResource"] @@ -53,7 +54,7 @@ def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Extension: + ) -> ExtensionCreateResponse: """ Upload an Extension @@ -79,7 +80,7 @@ def create( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Extension, + cast_to=ExtensionCreateResponse, ) def retrieve( @@ -92,9 +93,9 @@ def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Extension: + ) -> ExtensionRetrieveResponse: """ - Extension + Get an Extension Args: extra_headers: Send extra headers @@ -112,7 +113,7 @@ def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Extension, + cast_to=ExtensionRetrieveResponse, ) def delete( @@ -127,7 +128,7 @@ def delete( timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> None: """ - Delete Extension + Delete an Extension Args: extra_headers: Send extra headers @@ -180,7 +181,7 @@ async def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Extension: + ) -> ExtensionCreateResponse: """ Upload an Extension @@ -206,7 +207,7 @@ async def create( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Extension, + cast_to=ExtensionCreateResponse, ) async def retrieve( @@ -219,9 +220,9 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Extension: + ) -> ExtensionRetrieveResponse: """ - Extension + Get an Extension Args: extra_headers: Send extra headers @@ -239,7 +240,7 @@ async def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Extension, + cast_to=ExtensionRetrieveResponse, ) async def delete( @@ -254,7 +255,7 @@ async def delete( timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> None: """ - Delete Extension + Delete an Extension Args: extra_headers: Send extra headers diff --git a/src/browserbase/resources/projects.py b/src/browserbase/resources/projects.py index fb337a02..e0e73b40 100644 --- a/src/browserbase/resources/projects.py +++ b/src/browserbase/resources/projects.py @@ -14,9 +14,9 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options -from ..types.project import Project -from ..types.project_usage import ProjectUsage from ..types.project_list_response import ProjectListResponse +from ..types.project_usage_response import ProjectUsageResponse +from ..types.project_retrieve_response import ProjectRetrieveResponse __all__ = ["ProjectsResource", "AsyncProjectsResource"] @@ -51,9 +51,9 @@ def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Project: + ) -> ProjectRetrieveResponse: """ - Project + Get a Project Args: extra_headers: Send extra headers @@ -71,7 +71,7 @@ def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Project, + cast_to=ProjectRetrieveResponse, ) def list( @@ -84,7 +84,7 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ProjectListResponse: - """List projects""" + """List Projects""" return self._get( "/v1/projects", options=make_request_options( @@ -103,9 +103,9 @@ def usage( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> ProjectUsage: + ) -> ProjectUsageResponse: """ - Project Usage + Get Project Usage Args: extra_headers: Send extra headers @@ -123,7 +123,7 @@ def usage( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ProjectUsage, + cast_to=ProjectUsageResponse, ) @@ -157,9 +157,9 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Project: + ) -> ProjectRetrieveResponse: """ - Project + Get a Project Args: extra_headers: Send extra headers @@ -177,7 +177,7 @@ async def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Project, + cast_to=ProjectRetrieveResponse, ) async def list( @@ -190,7 +190,7 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> ProjectListResponse: - """List projects""" + """List Projects""" return await self._get( "/v1/projects", options=make_request_options( @@ -209,9 +209,9 @@ async def usage( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> ProjectUsage: + ) -> ProjectUsageResponse: """ - Project Usage + Get Project Usage Args: extra_headers: Send extra headers @@ -229,7 +229,7 @@ async def usage( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ProjectUsage, + cast_to=ProjectUsageResponse, ) diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index bf3314ad..01a4943a 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -51,10 +51,10 @@ async_to_streamed_response_wrapper, ) from ..._base_client import make_request_options -from ...types.session import Session -from ...types.session_live_urls import SessionLiveURLs from ...types.session_list_response import SessionListResponse +from ...types.session_debug_response import SessionDebugResponse from ...types.session_create_response import SessionCreateResponse +from ...types.session_update_response import SessionUpdateResponse from ...types.session_retrieve_response import SessionRetrieveResponse __all__ = ["SessionsResource", "AsyncSessionsResource"] @@ -103,7 +103,7 @@ def create( browser_settings: session_create_params.BrowserSettings | NotGiven = NOT_GIVEN, extension_id: str | NotGiven = NOT_GIVEN, keep_alive: bool | NotGiven = NOT_GIVEN, - proxies: Union[bool, Iterable[session_create_params.ProxiesUnionMember1]] | NotGiven = NOT_GIVEN, + proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | NotGiven = NOT_GIVEN, region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | NotGiven = NOT_GIVEN, api_timeout: int | NotGiven = NOT_GIVEN, user_metadata: Dict[str, object] | NotGiven = NOT_GIVEN, @@ -180,7 +180,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> SessionRetrieveResponse: """ - Session + Get a Session Args: extra_headers: Send extra headers @@ -213,8 +213,8 @@ def update( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """Update Session + ) -> SessionUpdateResponse: + """Update a Session Args: project_id: The Project ID. @@ -247,7 +247,7 @@ def update( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Session, + cast_to=SessionUpdateResponse, ) def list( @@ -307,7 +307,7 @@ def debug( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionLiveURLs: + ) -> SessionDebugResponse: """ Session Live URLs @@ -327,7 +327,7 @@ def debug( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=SessionLiveURLs, + cast_to=SessionDebugResponse, ) @@ -374,7 +374,7 @@ async def create( browser_settings: session_create_params.BrowserSettings | NotGiven = NOT_GIVEN, extension_id: str | NotGiven = NOT_GIVEN, keep_alive: bool | NotGiven = NOT_GIVEN, - proxies: Union[bool, Iterable[session_create_params.ProxiesUnionMember1]] | NotGiven = NOT_GIVEN, + proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | NotGiven = NOT_GIVEN, region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | NotGiven = NOT_GIVEN, api_timeout: int | NotGiven = NOT_GIVEN, user_metadata: Dict[str, object] | NotGiven = NOT_GIVEN, @@ -451,7 +451,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> SessionRetrieveResponse: """ - Session + Get a Session Args: extra_headers: Send extra headers @@ -484,8 +484,8 @@ async def update( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> Session: - """Update Session + ) -> SessionUpdateResponse: + """Update a Session Args: project_id: The Project ID. @@ -518,7 +518,7 @@ async def update( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=Session, + cast_to=SessionUpdateResponse, ) async def list( @@ -578,7 +578,7 @@ async def debug( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - ) -> SessionLiveURLs: + ) -> SessionDebugResponse: """ Session Live URLs @@ -598,7 +598,7 @@ async def debug( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=SessionLiveURLs, + cast_to=SessionDebugResponse, ) diff --git a/src/browserbase/types/__init__.py b/src/browserbase/types/__init__.py index 4dd85ddb..20e2f905 100644 --- a/src/browserbase/types/__init__.py +++ b/src/browserbase/types/__init__.py @@ -2,20 +2,21 @@ from __future__ import annotations -from .context import Context as Context -from .project import Project as Project -from .session import Session as Session -from .extension import Extension as Extension -from .project_usage import ProjectUsage as ProjectUsage -from .session_live_urls import SessionLiveURLs as SessionLiveURLs from .session_list_params import SessionListParams as SessionListParams from .context_create_params import ContextCreateParams as ContextCreateParams from .project_list_response import ProjectListResponse as ProjectListResponse from .session_create_params import SessionCreateParams as SessionCreateParams from .session_list_response import SessionListResponse as SessionListResponse from .session_update_params import SessionUpdateParams as SessionUpdateParams +from .project_usage_response import ProjectUsageResponse as ProjectUsageResponse +from .session_debug_response import SessionDebugResponse as SessionDebugResponse from .context_create_response import ContextCreateResponse as ContextCreateResponse from .context_update_response import ContextUpdateResponse as ContextUpdateResponse from .extension_create_params import ExtensionCreateParams as ExtensionCreateParams from .session_create_response import SessionCreateResponse as SessionCreateResponse +from .session_update_response import SessionUpdateResponse as SessionUpdateResponse +from .context_retrieve_response import ContextRetrieveResponse as ContextRetrieveResponse +from .extension_create_response import ExtensionCreateResponse as ExtensionCreateResponse +from .project_retrieve_response import ProjectRetrieveResponse as ProjectRetrieveResponse from .session_retrieve_response import SessionRetrieveResponse as SessionRetrieveResponse +from .extension_retrieve_response import ExtensionRetrieveResponse as ExtensionRetrieveResponse diff --git a/src/browserbase/types/context.py b/src/browserbase/types/context_retrieve_response.py similarity index 84% rename from src/browserbase/types/context.py rename to src/browserbase/types/context_retrieve_response.py index cb5c32fd..c2cd6925 100644 --- a/src/browserbase/types/context.py +++ b/src/browserbase/types/context_retrieve_response.py @@ -6,10 +6,10 @@ from .._models import BaseModel -__all__ = ["Context"] +__all__ = ["ContextRetrieveResponse"] -class Context(BaseModel): +class ContextRetrieveResponse(BaseModel): id: str created_at: datetime = FieldInfo(alias="createdAt") diff --git a/src/browserbase/types/extension.py b/src/browserbase/types/extension_create_response.py similarity index 85% rename from src/browserbase/types/extension.py rename to src/browserbase/types/extension_create_response.py index 94582c34..d2b74f41 100644 --- a/src/browserbase/types/extension.py +++ b/src/browserbase/types/extension_create_response.py @@ -6,10 +6,10 @@ from .._models import BaseModel -__all__ = ["Extension"] +__all__ = ["ExtensionCreateResponse"] -class Extension(BaseModel): +class ExtensionCreateResponse(BaseModel): id: str created_at: datetime = FieldInfo(alias="createdAt") diff --git a/src/browserbase/types/extension_retrieve_response.py b/src/browserbase/types/extension_retrieve_response.py new file mode 100644 index 00000000..c786348e --- /dev/null +++ b/src/browserbase/types/extension_retrieve_response.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["ExtensionRetrieveResponse"] + + +class ExtensionRetrieveResponse(BaseModel): + id: str + + created_at: datetime = FieldInfo(alias="createdAt") + + file_name: str = FieldInfo(alias="fileName") + + project_id: str = FieldInfo(alias="projectId") + """The Project ID linked to the uploaded Extension.""" + + updated_at: datetime = FieldInfo(alias="updatedAt") diff --git a/src/browserbase/types/project_list_response.py b/src/browserbase/types/project_list_response.py index 2d05a236..e364b520 100644 --- a/src/browserbase/types/project_list_response.py +++ b/src/browserbase/types/project_list_response.py @@ -1,10 +1,31 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from typing import List +from datetime import datetime from typing_extensions import TypeAlias -from .project import Project +from pydantic import Field as FieldInfo -__all__ = ["ProjectListResponse"] +from .._models import BaseModel -ProjectListResponse: TypeAlias = List[Project] +__all__ = ["ProjectListResponse", "ProjectListResponseItem"] + + +class ProjectListResponseItem(BaseModel): + id: str + + concurrency: int + """The maximum number of sessions that this project can run concurrently.""" + + created_at: datetime = FieldInfo(alias="createdAt") + + default_timeout: int = FieldInfo(alias="defaultTimeout") + + name: str + + owner_id: str = FieldInfo(alias="ownerId") + + updated_at: datetime = FieldInfo(alias="updatedAt") + + +ProjectListResponse: TypeAlias = List[ProjectListResponseItem] diff --git a/src/browserbase/types/project.py b/src/browserbase/types/project_retrieve_response.py similarity index 87% rename from src/browserbase/types/project.py rename to src/browserbase/types/project_retrieve_response.py index dc3cf335..78126679 100644 --- a/src/browserbase/types/project.py +++ b/src/browserbase/types/project_retrieve_response.py @@ -6,10 +6,10 @@ from .._models import BaseModel -__all__ = ["Project"] +__all__ = ["ProjectRetrieveResponse"] -class Project(BaseModel): +class ProjectRetrieveResponse(BaseModel): id: str concurrency: int diff --git a/src/browserbase/types/project_usage.py b/src/browserbase/types/project_usage_response.py similarity index 78% rename from src/browserbase/types/project_usage.py rename to src/browserbase/types/project_usage_response.py index c8a03f5b..b52fccfe 100644 --- a/src/browserbase/types/project_usage.py +++ b/src/browserbase/types/project_usage_response.py @@ -4,10 +4,10 @@ from .._models import BaseModel -__all__ = ["ProjectUsage"] +__all__ = ["ProjectUsageResponse"] -class ProjectUsage(BaseModel): +class ProjectUsageResponse(BaseModel): browser_minutes: int = FieldInfo(alias="browserMinutes") proxy_bytes: int = FieldInfo(alias="proxyBytes") diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index 1f5324f8..31a08ceb 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -5,6 +5,7 @@ from typing import Dict, List, Union, Iterable from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict +from .._types import SequenceNotStr from .._utils import PropertyInfo __all__ = [ @@ -14,10 +15,10 @@ "BrowserSettingsFingerprint", "BrowserSettingsFingerprintScreen", "BrowserSettingsViewport", - "ProxiesUnionMember1", - "ProxiesUnionMember1BrowserbaseProxyConfig", - "ProxiesUnionMember1BrowserbaseProxyConfigGeolocation", - "ProxiesUnionMember1ExternalProxyConfig", + "ProxiesUnionMember0", + "ProxiesUnionMember0UnionMember0", + "ProxiesUnionMember0UnionMember0Geolocation", + "ProxiesUnionMember0UnionMember1", ] @@ -42,7 +43,7 @@ class SessionCreateParams(TypedDict, total=False): Available on the Hobby Plan and above. """ - proxies: Union[bool, Iterable[ProxiesUnionMember1]] + proxies: Union[Iterable[ProxiesUnionMember0], bool] """Proxy configuration. Can be true for default proxy, or an array of proxy configurations. @@ -90,27 +91,21 @@ class BrowserSettingsFingerprint(TypedDict, total=False): http_version: Annotated[Literal["1", "2"], PropertyInfo(alias="httpVersion")] - locales: List[str] - """ - Full list of locales is available - [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language). - """ + locales: SequenceNotStr[str] operating_systems: Annotated[ List[Literal["android", "ios", "linux", "macos", "windows"]], PropertyInfo(alias="operatingSystems") ] - """ - Note: `operatingSystems` set to `ios` or `android` requires `devices` to include - `"mobile"`. - """ screen: BrowserSettingsFingerprintScreen class BrowserSettingsViewport(TypedDict, total=False): height: int + """The height of the browser.""" width: int + """The width of the browser.""" class BrowserSettings(TypedDict, total=False): @@ -143,12 +138,21 @@ class BrowserSettings(TypedDict, total=False): fingerprint: BrowserSettingsFingerprint """ See usage examples - [in the Stealth Mode page](/features/stealth-mode#fingerprinting). + [on the Stealth Mode page](/features/stealth-mode#fingerprinting) """ + headful: bool + """[NOT IN DOCS] Enable or disable headful mode. Defaults to `false`.""" + log_session: Annotated[bool, PropertyInfo(alias="logSession")] """Enable or disable session logging. Defaults to `true`.""" + os: Literal["windows", "mac", "linux", "mobile", "tablet"] + """Operating system for stealth mode. + + Valid values: windows, mac, linux, mobile, tablet + """ + record_session: Annotated[bool, PropertyInfo(alias="recordSession")] """Enable or disable session recording. Defaults to `true`.""" @@ -158,7 +162,7 @@ class BrowserSettings(TypedDict, total=False): viewport: BrowserSettingsViewport -class ProxiesUnionMember1BrowserbaseProxyConfigGeolocation(TypedDict, total=False): +class ProxiesUnionMember0UnionMember0Geolocation(TypedDict, total=False): country: Required[str] """Country code in ISO 3166-1 alpha-2 format""" @@ -169,7 +173,7 @@ class ProxiesUnionMember1BrowserbaseProxyConfigGeolocation(TypedDict, total=Fals """US state code (2 characters). Must also specify US as the country. Optional.""" -class ProxiesUnionMember1BrowserbaseProxyConfig(TypedDict, total=False): +class ProxiesUnionMember0UnionMember0(TypedDict, total=False): type: Required[Literal["browserbase"]] """Type of proxy. @@ -182,11 +186,11 @@ class ProxiesUnionMember1BrowserbaseProxyConfig(TypedDict, total=False): If omitted, defaults to all domains. Optional. """ - geolocation: ProxiesUnionMember1BrowserbaseProxyConfigGeolocation - """Configuration for geolocation""" + geolocation: ProxiesUnionMember0UnionMember0Geolocation + """Geographic location for the proxy. Optional.""" -class ProxiesUnionMember1ExternalProxyConfig(TypedDict, total=False): +class ProxiesUnionMember0UnionMember1(TypedDict, total=False): server: Required[str] """Server URL for external proxy. Required.""" @@ -206,6 +210,4 @@ class ProxiesUnionMember1ExternalProxyConfig(TypedDict, total=False): """Username for external proxy authentication. Optional.""" -ProxiesUnionMember1: TypeAlias = Union[ - ProxiesUnionMember1BrowserbaseProxyConfig, ProxiesUnionMember1ExternalProxyConfig -] +ProxiesUnionMember0: TypeAlias = Union[ProxiesUnionMember0UnionMember0, ProxiesUnionMember0UnionMember1] diff --git a/src/browserbase/types/session_live_urls.py b/src/browserbase/types/session_debug_response.py similarity index 88% rename from src/browserbase/types/session_live_urls.py rename to src/browserbase/types/session_debug_response.py index 3c7ba320..9cee7a77 100644 --- a/src/browserbase/types/session_live_urls.py +++ b/src/browserbase/types/session_debug_response.py @@ -6,7 +6,7 @@ from .._models import BaseModel -__all__ = ["SessionLiveURLs", "Page"] +__all__ = ["SessionDebugResponse", "Page"] class Page(BaseModel): @@ -23,7 +23,7 @@ class Page(BaseModel): url: str -class SessionLiveURLs(BaseModel): +class SessionDebugResponse(BaseModel): debugger_fullscreen_url: str = FieldInfo(alias="debuggerFullscreenUrl") debugger_url: str = FieldInfo(alias="debuggerUrl") diff --git a/src/browserbase/types/session_list_response.py b/src/browserbase/types/session_list_response.py index ca162ddb..4c1bd885 100644 --- a/src/browserbase/types/session_list_response.py +++ b/src/browserbase/types/session_list_response.py @@ -1,10 +1,58 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List -from typing_extensions import TypeAlias +from typing import Dict, List, Optional +from datetime import datetime +from typing_extensions import Literal, TypeAlias -from .session import Session +from pydantic import Field as FieldInfo -__all__ = ["SessionListResponse"] +from .._models import BaseModel -SessionListResponse: TypeAlias = List[Session] +__all__ = ["SessionListResponse", "SessionListResponseItem"] + + +class SessionListResponseItem(BaseModel): + id: str + + created_at: datetime = FieldInfo(alias="createdAt") + + expires_at: datetime = FieldInfo(alias="expiresAt") + + keep_alive: bool = FieldInfo(alias="keepAlive") + """Indicates if the Session was created to be kept alive upon disconnections""" + + project_id: str = FieldInfo(alias="projectId") + """The Project ID linked to the Session.""" + + proxy_bytes: int = FieldInfo(alias="proxyBytes") + """Bytes used via the [Proxy](/features/stealth-mode#proxies-and-residential-ips)""" + + region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] + """The region where the Session is running.""" + + started_at: datetime = FieldInfo(alias="startedAt") + + status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] + + updated_at: datetime = FieldInfo(alias="updatedAt") + + avg_cpu_usage: Optional[int] = FieldInfo(alias="avgCpuUsage", default=None) + """CPU used by the Session""" + + context_id: Optional[str] = FieldInfo(alias="contextId", default=None) + """Optional. The Context linked to the Session.""" + + ended_at: Optional[datetime] = FieldInfo(alias="endedAt", default=None) + + memory_usage: Optional[int] = FieldInfo(alias="memoryUsage", default=None) + """Memory used by the Session""" + + user_metadata: Optional[Dict[str, object]] = FieldInfo(alias="userMetadata", default=None) + """Arbitrary user metadata to attach to the session. + + To learn more about user metadata, see + [User Metadata](/features/sessions#user-metadata). + """ + + +SessionListResponse: TypeAlias = List[SessionListResponseItem] diff --git a/src/browserbase/types/session.py b/src/browserbase/types/session_update_response.py similarity index 95% rename from src/browserbase/types/session.py rename to src/browserbase/types/session_update_response.py index 16450e29..67a13711 100644 --- a/src/browserbase/types/session.py +++ b/src/browserbase/types/session_update_response.py @@ -8,10 +8,10 @@ from .._models import BaseModel -__all__ = ["Session"] +__all__ = ["SessionUpdateResponse"] -class Session(BaseModel): +class SessionUpdateResponse(BaseModel): id: str created_at: datetime = FieldInfo(alias="createdAt") diff --git a/src/browserbase/types/sessions/__init__.py b/src/browserbase/types/sessions/__init__.py index 0cef6b19..69d54703 100644 --- a/src/browserbase/types/sessions/__init__.py +++ b/src/browserbase/types/sessions/__init__.py @@ -2,9 +2,7 @@ from __future__ import annotations -from .session_log import SessionLog as SessionLog from .log_list_response import LogListResponse as LogListResponse -from .session_recording import SessionRecording as SessionRecording from .upload_create_params import UploadCreateParams as UploadCreateParams from .upload_create_response import UploadCreateResponse as UploadCreateResponse from .recording_retrieve_response import RecordingRetrieveResponse as RecordingRetrieveResponse diff --git a/src/browserbase/types/sessions/log_list_response.py b/src/browserbase/types/sessions/log_list_response.py index 2b325a8c..efd848ab 100644 --- a/src/browserbase/types/sessions/log_list_response.py +++ b/src/browserbase/types/sessions/log_list_response.py @@ -1,10 +1,50 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List +from typing import Dict, List, Optional from typing_extensions import TypeAlias -from .session_log import SessionLog +from pydantic import Field as FieldInfo -__all__ = ["LogListResponse"] +from ..._models import BaseModel -LogListResponse: TypeAlias = List[SessionLog] +__all__ = ["LogListResponse", "LogListResponseItem", "LogListResponseItemRequest", "LogListResponseItemResponse"] + + +class LogListResponseItemRequest(BaseModel): + params: Dict[str, object] + + raw_body: str = FieldInfo(alias="rawBody") + + timestamp: Optional[int] = None + """milliseconds that have elapsed since the UNIX epoch""" + + +class LogListResponseItemResponse(BaseModel): + raw_body: str = FieldInfo(alias="rawBody") + + result: Dict[str, object] + + timestamp: Optional[int] = None + """milliseconds that have elapsed since the UNIX epoch""" + + +class LogListResponseItem(BaseModel): + method: str + + page_id: int = FieldInfo(alias="pageId") + + session_id: str = FieldInfo(alias="sessionId") + + frame_id: Optional[str] = FieldInfo(alias="frameId", default=None) + + loader_id: Optional[str] = FieldInfo(alias="loaderId", default=None) + + request: Optional[LogListResponseItemRequest] = None + + response: Optional[LogListResponseItemResponse] = None + + timestamp: Optional[int] = None + """milliseconds that have elapsed since the UNIX epoch""" + + +LogListResponse: TypeAlias = List[LogListResponseItem] diff --git a/src/browserbase/types/sessions/recording_retrieve_response.py b/src/browserbase/types/sessions/recording_retrieve_response.py index 951969bb..d3613b8c 100644 --- a/src/browserbase/types/sessions/recording_retrieve_response.py +++ b/src/browserbase/types/sessions/recording_retrieve_response.py @@ -1,10 +1,28 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List +from typing import Dict, List from typing_extensions import TypeAlias -from .session_recording import SessionRecording +from pydantic import Field as FieldInfo -__all__ = ["RecordingRetrieveResponse"] +from ..._models import BaseModel -RecordingRetrieveResponse: TypeAlias = List[SessionRecording] +__all__ = ["RecordingRetrieveResponse", "RecordingRetrieveResponseItem"] + + +class RecordingRetrieveResponseItem(BaseModel): + data: Dict[str, object] + """ + See + [rrweb documentation](https://github.com/rrweb-io/rrweb/blob/master/docs/recipes/dive-into-event.md). + """ + + session_id: str = FieldInfo(alias="sessionId") + + timestamp: int + """milliseconds that have elapsed since the UNIX epoch""" + + type: int + + +RecordingRetrieveResponse: TypeAlias = List[RecordingRetrieveResponseItem] diff --git a/src/browserbase/types/sessions/session_log.py b/src/browserbase/types/sessions/session_log.py deleted file mode 100644 index 428f518a..00000000 --- a/src/browserbase/types/sessions/session_log.py +++ /dev/null @@ -1,46 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict, Optional - -from pydantic import Field as FieldInfo - -from ..._models import BaseModel - -__all__ = ["SessionLog", "Request", "Response"] - - -class Request(BaseModel): - params: Dict[str, object] - - raw_body: str = FieldInfo(alias="rawBody") - - timestamp: Optional[int] = None - """milliseconds that have elapsed since the UNIX epoch""" - - -class Response(BaseModel): - raw_body: str = FieldInfo(alias="rawBody") - - result: Dict[str, object] - - timestamp: Optional[int] = None - """milliseconds that have elapsed since the UNIX epoch""" - - -class SessionLog(BaseModel): - method: str - - page_id: int = FieldInfo(alias="pageId") - - session_id: str = FieldInfo(alias="sessionId") - - frame_id: Optional[str] = FieldInfo(alias="frameId", default=None) - - loader_id: Optional[str] = FieldInfo(alias="loaderId", default=None) - - request: Optional[Request] = None - - response: Optional[Response] = None - - timestamp: Optional[int] = None - """milliseconds that have elapsed since the UNIX epoch""" diff --git a/src/browserbase/types/sessions/session_recording.py b/src/browserbase/types/sessions/session_recording.py deleted file mode 100644 index c8471371..00000000 --- a/src/browserbase/types/sessions/session_recording.py +++ /dev/null @@ -1,24 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Dict - -from pydantic import Field as FieldInfo - -from ..._models import BaseModel - -__all__ = ["SessionRecording"] - - -class SessionRecording(BaseModel): - data: Dict[str, object] - """ - See - [rrweb documentation](https://github.com/rrweb-io/rrweb/blob/master/docs/recipes/dive-into-event.md). - """ - - session_id: str = FieldInfo(alias="sessionId") - - timestamp: int - """milliseconds that have elapsed since the UNIX epoch""" - - type: int diff --git a/tests/api_resources/sessions/test_downloads.py b/tests/api_resources/sessions/test_downloads.py index 825ff786..10e84fdb 100644 --- a/tests/api_resources/sessions/test_downloads.py +++ b/tests/api_resources/sessions/test_downloads.py @@ -75,7 +75,9 @@ def test_path_params_list(self, client: Browserbase) -> None: class TestAsyncDownloads: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize @pytest.mark.respx(base_url=base_url) diff --git a/tests/api_resources/sessions/test_logs.py b/tests/api_resources/sessions/test_logs.py index c72002b3..eadde723 100644 --- a/tests/api_resources/sessions/test_logs.py +++ b/tests/api_resources/sessions/test_logs.py @@ -57,7 +57,9 @@ def test_path_params_list(self, client: Browserbase) -> None: class TestAsyncLogs: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_list(self, async_client: AsyncBrowserbase) -> None: diff --git a/tests/api_resources/sessions/test_recording.py b/tests/api_resources/sessions/test_recording.py index 0d7a542e..f1e97d07 100644 --- a/tests/api_resources/sessions/test_recording.py +++ b/tests/api_resources/sessions/test_recording.py @@ -57,7 +57,9 @@ def test_path_params_retrieve(self, client: Browserbase) -> None: class TestAsyncRecording: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: diff --git a/tests/api_resources/sessions/test_uploads.py b/tests/api_resources/sessions/test_uploads.py index f193256c..748b92e7 100644 --- a/tests/api_resources/sessions/test_uploads.py +++ b/tests/api_resources/sessions/test_uploads.py @@ -61,7 +61,9 @@ def test_path_params_create(self, client: Browserbase) -> None: class TestAsyncUploads: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncBrowserbase) -> None: diff --git a/tests/api_resources/test_contexts.py b/tests/api_resources/test_contexts.py index e53b7e11..4ad27733 100644 --- a/tests/api_resources/test_contexts.py +++ b/tests/api_resources/test_contexts.py @@ -9,7 +9,11 @@ from browserbase import Browserbase, AsyncBrowserbase from tests.utils import assert_matches_type -from browserbase.types import Context, ContextCreateResponse, ContextUpdateResponse +from browserbase.types import ( + ContextCreateResponse, + ContextUpdateResponse, + ContextRetrieveResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -53,7 +57,7 @@ def test_method_retrieve(self, client: Browserbase) -> None: context = client.contexts.retrieve( "id", ) - assert_matches_type(Context, context, path=["response"]) + assert_matches_type(ContextRetrieveResponse, context, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Browserbase) -> None: @@ -64,7 +68,7 @@ def test_raw_response_retrieve(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" context = response.parse() - assert_matches_type(Context, context, path=["response"]) + assert_matches_type(ContextRetrieveResponse, context, path=["response"]) @parametrize def test_streaming_response_retrieve(self, client: Browserbase) -> None: @@ -75,7 +79,7 @@ def test_streaming_response_retrieve(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" context = response.parse() - assert_matches_type(Context, context, path=["response"]) + assert_matches_type(ContextRetrieveResponse, context, path=["response"]) assert cast(Any, response.is_closed) is True @@ -126,7 +130,9 @@ def test_path_params_update(self, client: Browserbase) -> None: class TestAsyncContexts: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncBrowserbase) -> None: @@ -164,7 +170,7 @@ async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: context = await async_client.contexts.retrieve( "id", ) - assert_matches_type(Context, context, path=["response"]) + assert_matches_type(ContextRetrieveResponse, context, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -175,7 +181,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" context = await response.parse() - assert_matches_type(Context, context, path=["response"]) + assert_matches_type(ContextRetrieveResponse, context, path=["response"]) @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -186,7 +192,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) assert response.http_request.headers.get("X-Stainless-Lang") == "python" context = await response.parse() - assert_matches_type(Context, context, path=["response"]) + assert_matches_type(ContextRetrieveResponse, context, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_extensions.py b/tests/api_resources/test_extensions.py index b7fec7a5..e32ae9b0 100644 --- a/tests/api_resources/test_extensions.py +++ b/tests/api_resources/test_extensions.py @@ -9,7 +9,7 @@ from browserbase import Browserbase, AsyncBrowserbase from tests.utils import assert_matches_type -from browserbase.types import Extension +from browserbase.types import ExtensionCreateResponse, ExtensionRetrieveResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -22,7 +22,7 @@ def test_method_create(self, client: Browserbase) -> None: extension = client.extensions.create( file=b"raw file contents", ) - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) @parametrize def test_raw_response_create(self, client: Browserbase) -> None: @@ -33,7 +33,7 @@ def test_raw_response_create(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = response.parse() - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) @parametrize def test_streaming_response_create(self, client: Browserbase) -> None: @@ -44,7 +44,7 @@ def test_streaming_response_create(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = response.parse() - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) assert cast(Any, response.is_closed) is True @@ -53,7 +53,7 @@ def test_method_retrieve(self, client: Browserbase) -> None: extension = client.extensions.retrieve( "id", ) - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Browserbase) -> None: @@ -64,7 +64,7 @@ def test_raw_response_retrieve(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = response.parse() - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) @parametrize def test_streaming_response_retrieve(self, client: Browserbase) -> None: @@ -75,7 +75,7 @@ def test_streaming_response_retrieve(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = response.parse() - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) assert cast(Any, response.is_closed) is True @@ -126,14 +126,16 @@ def test_path_params_delete(self, client: Browserbase) -> None: class TestAsyncExtensions: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncBrowserbase) -> None: extension = await async_client.extensions.create( file=b"raw file contents", ) - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None: @@ -144,7 +146,7 @@ async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = await response.parse() - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) @parametrize async def test_streaming_response_create(self, async_client: AsyncBrowserbase) -> None: @@ -155,7 +157,7 @@ async def test_streaming_response_create(self, async_client: AsyncBrowserbase) - assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = await response.parse() - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) assert cast(Any, response.is_closed) is True @@ -164,7 +166,7 @@ async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: extension = await async_client.extensions.retrieve( "id", ) - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -175,7 +177,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = await response.parse() - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -186,7 +188,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = await response.parse() - assert_matches_type(Extension, extension, path=["response"]) + assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_projects.py b/tests/api_resources/test_projects.py index 9e70d034..0d8e3c94 100644 --- a/tests/api_resources/test_projects.py +++ b/tests/api_resources/test_projects.py @@ -9,7 +9,7 @@ from browserbase import Browserbase, AsyncBrowserbase from tests.utils import assert_matches_type -from browserbase.types import Project, ProjectUsage, ProjectListResponse +from browserbase.types import ProjectListResponse, ProjectUsageResponse, ProjectRetrieveResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -22,7 +22,7 @@ def test_method_retrieve(self, client: Browserbase) -> None: project = client.projects.retrieve( "id", ) - assert_matches_type(Project, project, path=["response"]) + assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Browserbase) -> None: @@ -33,7 +33,7 @@ def test_raw_response_retrieve(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = response.parse() - assert_matches_type(Project, project, path=["response"]) + assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) @parametrize def test_streaming_response_retrieve(self, client: Browserbase) -> None: @@ -44,7 +44,7 @@ def test_streaming_response_retrieve(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = response.parse() - assert_matches_type(Project, project, path=["response"]) + assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) assert cast(Any, response.is_closed) is True @@ -85,7 +85,7 @@ def test_method_usage(self, client: Browserbase) -> None: project = client.projects.usage( "id", ) - assert_matches_type(ProjectUsage, project, path=["response"]) + assert_matches_type(ProjectUsageResponse, project, path=["response"]) @parametrize def test_raw_response_usage(self, client: Browserbase) -> None: @@ -96,7 +96,7 @@ def test_raw_response_usage(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = response.parse() - assert_matches_type(ProjectUsage, project, path=["response"]) + assert_matches_type(ProjectUsageResponse, project, path=["response"]) @parametrize def test_streaming_response_usage(self, client: Browserbase) -> None: @@ -107,7 +107,7 @@ def test_streaming_response_usage(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = response.parse() - assert_matches_type(ProjectUsage, project, path=["response"]) + assert_matches_type(ProjectUsageResponse, project, path=["response"]) assert cast(Any, response.is_closed) is True @@ -120,14 +120,16 @@ def test_path_params_usage(self, client: Browserbase) -> None: class TestAsyncProjects: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: project = await async_client.projects.retrieve( "id", ) - assert_matches_type(Project, project, path=["response"]) + assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -138,7 +140,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = await response.parse() - assert_matches_type(Project, project, path=["response"]) + assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -149,7 +151,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = await response.parse() - assert_matches_type(Project, project, path=["response"]) + assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) assert cast(Any, response.is_closed) is True @@ -190,7 +192,7 @@ async def test_method_usage(self, async_client: AsyncBrowserbase) -> None: project = await async_client.projects.usage( "id", ) - assert_matches_type(ProjectUsage, project, path=["response"]) + assert_matches_type(ProjectUsageResponse, project, path=["response"]) @parametrize async def test_raw_response_usage(self, async_client: AsyncBrowserbase) -> None: @@ -201,7 +203,7 @@ async def test_raw_response_usage(self, async_client: AsyncBrowserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = await response.parse() - assert_matches_type(ProjectUsage, project, path=["response"]) + assert_matches_type(ProjectUsageResponse, project, path=["response"]) @parametrize async def test_streaming_response_usage(self, async_client: AsyncBrowserbase) -> None: @@ -212,7 +214,7 @@ async def test_streaming_response_usage(self, async_client: AsyncBrowserbase) -> assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = await response.parse() - assert_matches_type(ProjectUsage, project, path=["response"]) + assert_matches_type(ProjectUsageResponse, project, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 4a17c4f6..d7d6a903 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -10,10 +10,10 @@ from browserbase import Browserbase, AsyncBrowserbase from tests.utils import assert_matches_type from browserbase.types import ( - Session, - SessionLiveURLs, SessionListResponse, + SessionDebugResponse, SessionCreateResponse, + SessionUpdateResponse, SessionRetrieveResponse, ) @@ -57,7 +57,9 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: "min_width": 0, }, }, + "headful": True, "log_session": True, + "os": "windows", "record_session": True, "solve_captchas": True, "viewport": { @@ -67,7 +69,17 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: }, extension_id="extensionId", keep_alive=True, - proxies=True, + proxies=[ + { + "type": "browserbase", + "domain_pattern": "domainPattern", + "geolocation": { + "country": "xx", + "city": "city", + "state": "xx", + }, + } + ], region="us-west-2", api_timeout=60, user_metadata={"foo": "bar"}, @@ -143,7 +155,7 @@ def test_method_update(self, client: Browserbase) -> None: project_id="projectId", status="REQUEST_RELEASE", ) - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionUpdateResponse, session, path=["response"]) @parametrize def test_raw_response_update(self, client: Browserbase) -> None: @@ -156,7 +168,7 @@ def test_raw_response_update(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionUpdateResponse, session, path=["response"]) @parametrize def test_streaming_response_update(self, client: Browserbase) -> None: @@ -169,7 +181,7 @@ def test_streaming_response_update(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionUpdateResponse, session, path=["response"]) assert cast(Any, response.is_closed) is True @@ -220,7 +232,7 @@ def test_method_debug(self, client: Browserbase) -> None: session = client.sessions.debug( "id", ) - assert_matches_type(SessionLiveURLs, session, path=["response"]) + assert_matches_type(SessionDebugResponse, session, path=["response"]) @parametrize def test_raw_response_debug(self, client: Browserbase) -> None: @@ -231,7 +243,7 @@ def test_raw_response_debug(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(SessionLiveURLs, session, path=["response"]) + assert_matches_type(SessionDebugResponse, session, path=["response"]) @parametrize def test_streaming_response_debug(self, client: Browserbase) -> None: @@ -242,7 +254,7 @@ def test_streaming_response_debug(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(SessionLiveURLs, session, path=["response"]) + assert_matches_type(SessionDebugResponse, session, path=["response"]) assert cast(Any, response.is_closed) is True @@ -255,7 +267,9 @@ def test_path_params_debug(self, client: Browserbase) -> None: class TestAsyncSessions: - parametrize = pytest.mark.parametrize("async_client", [False, True], indirect=True, ids=["loose", "strict"]) + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) @parametrize async def test_method_create(self, async_client: AsyncBrowserbase) -> None: @@ -291,7 +305,9 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas "min_width": 0, }, }, + "headful": True, "log_session": True, + "os": "windows", "record_session": True, "solve_captchas": True, "viewport": { @@ -301,7 +317,17 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas }, extension_id="extensionId", keep_alive=True, - proxies=True, + proxies=[ + { + "type": "browserbase", + "domain_pattern": "domainPattern", + "geolocation": { + "country": "xx", + "city": "city", + "state": "xx", + }, + } + ], region="us-west-2", api_timeout=60, user_metadata={"foo": "bar"}, @@ -377,7 +403,7 @@ async def test_method_update(self, async_client: AsyncBrowserbase) -> None: project_id="projectId", status="REQUEST_RELEASE", ) - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionUpdateResponse, session, path=["response"]) @parametrize async def test_raw_response_update(self, async_client: AsyncBrowserbase) -> None: @@ -390,7 +416,7 @@ async def test_raw_response_update(self, async_client: AsyncBrowserbase) -> None assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionUpdateResponse, session, path=["response"]) @parametrize async def test_streaming_response_update(self, async_client: AsyncBrowserbase) -> None: @@ -403,7 +429,7 @@ async def test_streaming_response_update(self, async_client: AsyncBrowserbase) - assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(Session, session, path=["response"]) + assert_matches_type(SessionUpdateResponse, session, path=["response"]) assert cast(Any, response.is_closed) is True @@ -454,7 +480,7 @@ async def test_method_debug(self, async_client: AsyncBrowserbase) -> None: session = await async_client.sessions.debug( "id", ) - assert_matches_type(SessionLiveURLs, session, path=["response"]) + assert_matches_type(SessionDebugResponse, session, path=["response"]) @parametrize async def test_raw_response_debug(self, async_client: AsyncBrowserbase) -> None: @@ -465,7 +491,7 @@ async def test_raw_response_debug(self, async_client: AsyncBrowserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(SessionLiveURLs, session, path=["response"]) + assert_matches_type(SessionDebugResponse, session, path=["response"]) @parametrize async def test_streaming_response_debug(self, async_client: AsyncBrowserbase) -> None: @@ -476,7 +502,7 @@ async def test_streaming_response_debug(self, async_client: AsyncBrowserbase) -> assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(SessionLiveURLs, session, path=["response"]) + assert_matches_type(SessionDebugResponse, session, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/conftest.py b/tests/conftest.py index 94b8e723..7fc31c49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + from __future__ import annotations import os import logging from typing import TYPE_CHECKING, Iterator, AsyncIterator +import httpx import pytest from pytest_asyncio import is_async_test -from browserbase import Browserbase, AsyncBrowserbase +from browserbase import Browserbase, AsyncBrowserbase, DefaultAioHttpClient +from browserbase._utils import is_dict if TYPE_CHECKING: from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] @@ -25,6 +29,19 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: for async_test in pytest_asyncio_tests: async_test.add_marker(session_scope_marker, append=False) + # We skip tests that use both the aiohttp client and respx_mock as respx_mock + # doesn't support custom transports. + for item in items: + if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + continue + + if not hasattr(item, "callspec"): + continue + + async_client_param = item.callspec.params.get("async_client") + if is_dict(async_client_param) and async_client_param.get("http_client") == "aiohttp": + item.add_marker(pytest.mark.skip(reason="aiohttp client is not compatible with respx_mock")) + base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -43,9 +60,25 @@ def client(request: FixtureRequest) -> Iterator[Browserbase]: @pytest.fixture(scope="session") async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncBrowserbase]: - strict = getattr(request, "param", True) - if not isinstance(strict, bool): - raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") - - async with AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=strict) as client: + param = getattr(request, "param", True) + + # defaults + strict = True + http_client: None | httpx.AsyncClient = None + + if isinstance(param, bool): + strict = param + elif is_dict(param): + strict = param.get("strict", True) + assert isinstance(strict, bool) + + http_client_type = param.get("http_client", "httpx") + if http_client_type == "aiohttp": + http_client = DefaultAioHttpClient() + else: + raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + + async with AsyncBrowserbase( + base_url=base_url, api_key=api_key, _strict_response_validation=strict, http_client=http_client + ) as client: yield client diff --git a/tests/test_client.py b/tests/test_client.py index d03654df..bf058259 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,17 +23,16 @@ from browserbase import Browserbase, AsyncBrowserbase, APIResponseValidationError from browserbase._types import Omit -from browserbase._utils import maybe_transform from browserbase._models import BaseModel, FinalRequestOptions -from browserbase._constants import RAW_RESPONSE_HEADER from browserbase._exceptions import APIStatusError, APITimeoutError, BrowserbaseError, APIResponseValidationError from browserbase._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + DefaultHttpxClient, + DefaultAsyncHttpxClient, make_request_options, ) -from browserbase.types.session_create_params import SessionCreateParams from .utils import update_env @@ -192,6 +191,7 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") def test_copy_build_request(self) -> None: options = FinalRequestOptions(method="get", url="/foo") @@ -464,7 +464,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, client: Browserbase) -> None: request = client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, @@ -723,32 +723,21 @@ def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, client: Browserbase) -> None: respx_mock.post("/v1/sessions").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - self.client.post( - "/v1/sessions", - body=cast(object, maybe_transform(dict(project_id="your_project_id"), SessionCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + client.sessions.with_streaming_response.create(project_id="projectId").__enter__() assert _get_open_connections(self.client) == 0 @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client: Browserbase) -> None: respx_mock.post("/v1/sessions").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - self.client.post( - "/v1/sessions", - body=cast(object, maybe_transform(dict(project_id="your_project_id"), SessionCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + client.sessions.with_streaming_response.create(project_id="projectId").__enter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -832,6 +821,55 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" + def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" + class TestAsyncBrowserbase: client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) @@ -968,6 +1006,7 @@ def test_copy_signature(self) -> None: copy_param = copy_signature.parameters.get(name) assert copy_param is not None, f"copy() signature is missing the {name} param" + @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") def test_copy_build_request(self) -> None: options = FinalRequestOptions(method="get", url="/foo") @@ -1240,7 +1279,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, async_client: AsyncBrowserbase) -> None: request = async_client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, @@ -1503,32 +1542,25 @@ async def test_parse_retry_after_header(self, remaining_retries: int, retry_afte @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_timeout_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncBrowserbase + ) -> None: respx_mock.post("/v1/sessions").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await self.client.post( - "/v1/sessions", - body=cast(object, maybe_transform(dict(project_id="your_project_id"), SessionCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) + await async_client.sessions.with_streaming_response.create(project_id="projectId").__aenter__() assert _get_open_connections(self.client) == 0 @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> None: + async def test_retrying_status_errors_doesnt_leak( + self, respx_mock: MockRouter, async_client: AsyncBrowserbase + ) -> None: respx_mock.post("/v1/sessions").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await self.client.post( - "/v1/sessions", - body=cast(object, maybe_transform(dict(project_id="your_project_id"), SessionCreateParams)), - cast_to=httpx.Response, - options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, - ) - + await async_client.sessions.with_streaming_response.create(project_id="projectId").__aenter__() assert _get_open_connections(self.client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @@ -1659,3 +1691,52 @@ async def test_main() -> None: raise AssertionError("calling get_platform using asyncify resulted in a hung process") time.sleep(0.1) + + async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Test that the proxy environment variables are set correctly + monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + + client = DefaultAsyncHttpxClient() + + mounts = tuple(client._mounts.items()) + assert len(mounts) == 1 + assert mounts[0][0].pattern == "https://" + + @pytest.mark.filterwarnings("ignore:.*deprecated.*:DeprecationWarning") + async def test_default_client_creation(self) -> None: + # Ensure that the client can be initialized without any exceptions + DefaultAsyncHttpxClient( + verify=True, + cert=None, + trust_env=True, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), + ) + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + # Test that the default follow_redirects=True allows following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) + + response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + @pytest.mark.respx(base_url=base_url) + async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + # Test that follow_redirects=False prevents following redirects + respx_mock.post("/redirect").mock( + return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) + ) + + with pytest.raises(APIStatusError) as exc_info: + await self.client.post( + "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response + ) + + assert exc_info.value.response.status_code == 302 + assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" diff --git a/tests/test_models.py b/tests/test_models.py index b5335f94..34f87334 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone from typing_extensions import Literal, Annotated, TypeAliasType @@ -8,7 +8,7 @@ from pydantic import Field from browserbase._utils import PropertyInfo -from browserbase._compat import PYDANTIC_V2, parse_obj, model_dump, model_json +from browserbase._compat import PYDANTIC_V1, parse_obj, model_dump, model_json from browserbase._models import BaseModel, construct_type @@ -294,12 +294,12 @@ class Model(BaseModel): assert cast(bool, m.foo) is True m = Model.construct(foo={"name": 3}) - if PYDANTIC_V2: - assert isinstance(m.foo, Submodel1) - assert m.foo.name == 3 # type: ignore - else: + if PYDANTIC_V1: assert isinstance(m.foo, Submodel2) assert m.foo.name == "3" + else: + assert isinstance(m.foo, Submodel1) + assert m.foo.name == 3 # type: ignore def test_list_of_unions() -> None: @@ -426,10 +426,10 @@ class Model(BaseModel): expected = datetime(2019, 12, 27, 18, 11, 19, 117000, tzinfo=timezone.utc) - if PYDANTIC_V2: - expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' - else: + if PYDANTIC_V1: expected_json = '{"created_at": "2019-12-27T18:11:19.117000+00:00"}' + else: + expected_json = '{"created_at":"2019-12-27T18:11:19.117000Z"}' model = Model.construct(created_at="2019-12-27T18:11:19.117Z") assert model.created_at == expected @@ -531,7 +531,7 @@ class Model2(BaseModel): assert m4.to_dict(mode="python") == {"created_at": datetime.fromisoformat(time_str)} assert m4.to_dict(mode="json") == {"created_at": time_str} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_dict(warnings=False) @@ -556,7 +556,7 @@ class Model(BaseModel): assert m3.model_dump() == {"foo": None} assert m3.model_dump(exclude_none=True) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump(round_trip=True) @@ -580,10 +580,10 @@ class Model(BaseModel): assert json.loads(m.to_json()) == {"FOO": "hello"} assert json.loads(m.to_json(use_api_names=False)) == {"foo": "hello"} - if PYDANTIC_V2: - assert m.to_json(indent=None) == '{"FOO":"hello"}' - else: + if PYDANTIC_V1: assert m.to_json(indent=None) == '{"FOO": "hello"}' + else: + assert m.to_json(indent=None) == '{"FOO":"hello"}' m2 = Model() assert json.loads(m2.to_json()) == {} @@ -595,7 +595,7 @@ class Model(BaseModel): assert json.loads(m3.to_json()) == {"FOO": None} assert json.loads(m3.to_json(exclude_none=True)) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="warnings is only supported in Pydantic v2"): m.to_json(warnings=False) @@ -622,7 +622,7 @@ class Model(BaseModel): assert json.loads(m3.model_dump_json()) == {"foo": None} assert json.loads(m3.model_dump_json(exclude_none=True)) == {} - if not PYDANTIC_V2: + if PYDANTIC_V1: with pytest.raises(ValueError, match="round_trip is only supported in Pydantic v2"): m.model_dump_json(round_trip=True) @@ -679,12 +679,12 @@ class B(BaseModel): ) assert isinstance(m, A) assert m.type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: + if PYDANTIC_V1: # pydantic v1 automatically converts inputs to strings # if the expected type is a str assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] def test_discriminated_unions_unknown_variant() -> None: @@ -768,12 +768,12 @@ class B(BaseModel): ) assert isinstance(m, A) assert m.foo_type == "a" - if PYDANTIC_V2: - assert m.data == 100 # type: ignore[comparison-overlap] - else: + if PYDANTIC_V1: # pydantic v1 automatically converts inputs to strings # if the expected type is a str assert m.data == "100" + else: + assert m.data == 100 # type: ignore[comparison-overlap] def test_discriminated_unions_overlapping_discriminators_invalid_data() -> None: @@ -833,7 +833,7 @@ class B(BaseModel): assert UnionType.__discriminator__ is discriminator -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") def test_type_alias_type() -> None: Alias = TypeAliasType("Alias", str) # pyright: ignore @@ -849,7 +849,7 @@ class Model(BaseModel): assert m.union == "bar" -@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +@pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") def test_field_named_cls() -> None: class Model(BaseModel): cls: str @@ -889,3 +889,75 @@ class ModelB(BaseModel): ) assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo" diff --git a/tests/test_transform.py b/tests/test_transform.py index cba80b21..498d0d93 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -15,7 +15,7 @@ parse_datetime, async_transform as _async_transform, ) -from browserbase._compat import PYDANTIC_V2 +from browserbase._compat import PYDANTIC_V1 from browserbase._models import BaseModel _T = TypeVar("_T") @@ -189,7 +189,7 @@ class DateModel(BaseModel): @pytest.mark.asyncio async def test_iso8601_format(use_async: bool) -> None: dt = datetime.fromisoformat("2023-02-23T14:16:36.337692+00:00") - tz = "Z" if PYDANTIC_V2 else "+00:00" + tz = "+00:00" if PYDANTIC_V1 else "Z" assert await transform({"foo": dt}, DatetimeDict, use_async) == {"foo": "2023-02-23T14:16:36.337692+00:00"} # type: ignore[comparison-overlap] assert await transform(DatetimeModel(foo=dt), Any, use_async) == {"foo": "2023-02-23T14:16:36.337692" + tz} # type: ignore[comparison-overlap] @@ -297,11 +297,11 @@ async def test_pydantic_unknown_field(use_async: bool) -> None: @pytest.mark.asyncio async def test_pydantic_mismatched_types(use_async: bool) -> None: model = MyModel.construct(foo=True) - if PYDANTIC_V2: + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: with pytest.warns(UserWarning): params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) assert cast(Any, params) == {"foo": True} @@ -309,11 +309,11 @@ async def test_pydantic_mismatched_types(use_async: bool) -> None: @pytest.mark.asyncio async def test_pydantic_mismatched_object_type(use_async: bool) -> None: model = MyModel.construct(foo=MyModel.construct(hello="world")) - if PYDANTIC_V2: + if PYDANTIC_V1: + params = await transform(model, Any, use_async) + else: with pytest.warns(UserWarning): params = await transform(model, Any, use_async) - else: - params = await transform(model, Any, use_async) assert cast(Any, params) == {"foo": {"hello": "world"}} diff --git a/tests/test_utils/test_datetime_parse.py b/tests/test_utils/test_datetime_parse.py new file mode 100644 index 00000000..2834c471 --- /dev/null +++ b/tests/test_utils/test_datetime_parse.py @@ -0,0 +1,110 @@ +""" +Copied from https://github.com/pydantic/pydantic/blob/v1.10.22/tests/test_datetime_parse.py +with modifications so it works without pydantic v1 imports. +""" + +from typing import Type, Union +from datetime import date, datetime, timezone, timedelta + +import pytest + +from browserbase._utils import parse_date, parse_datetime + + +def create_tz(minutes: int) -> timezone: + return timezone(timedelta(minutes=minutes)) + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + ("1494012444.883309", date(2017, 5, 5)), + (b"1494012444.883309", date(2017, 5, 5)), + (1_494_012_444.883_309, date(2017, 5, 5)), + ("1494012444", date(2017, 5, 5)), + (1_494_012_444, date(2017, 5, 5)), + (0, date(1970, 1, 1)), + ("2012-04-23", date(2012, 4, 23)), + (b"2012-04-23", date(2012, 4, 23)), + ("2012-4-9", date(2012, 4, 9)), + (date(2012, 4, 9), date(2012, 4, 9)), + (datetime(2012, 4, 9, 12, 15), date(2012, 4, 9)), + # Invalid inputs + ("x20120423", ValueError), + ("2012-04-56", ValueError), + (19_999_999_999, date(2603, 10, 11)), # just before watershed + (20_000_000_001, date(1970, 8, 20)), # just after watershed + (1_549_316_052, date(2019, 2, 4)), # nowish in s + (1_549_316_052_104, date(2019, 2, 4)), # nowish in ms + (1_549_316_052_104_324, date(2019, 2, 4)), # nowish in μs + (1_549_316_052_104_324_096, date(2019, 2, 4)), # nowish in ns + ("infinity", date(9999, 12, 31)), + ("inf", date(9999, 12, 31)), + (float("inf"), date(9999, 12, 31)), + ("infinity ", date(9999, 12, 31)), + (int("1" + "0" * 100), date(9999, 12, 31)), + (1e1000, date(9999, 12, 31)), + ("-infinity", date(1, 1, 1)), + ("-inf", date(1, 1, 1)), + ("nan", ValueError), + ], +) +def test_date_parsing(value: Union[str, bytes, int, float], result: Union[date, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_date(value) + else: + assert parse_date(value) == result + + +@pytest.mark.parametrize( + "value,result", + [ + # Valid inputs + # values in seconds + ("1494012444.883309", datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + (1_494_012_444.883_309, datetime(2017, 5, 5, 19, 27, 24, 883_309, tzinfo=timezone.utc)), + ("1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (b"1494012444", datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (1_494_012_444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + # values in ms + ("1494012444000.883309", datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), + ("-1494012444000.883309", datetime(1922, 8, 29, 4, 32, 35, 999117, tzinfo=timezone.utc)), + (1_494_012_444_000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ("2012-04-23T09:15:00", datetime(2012, 4, 23, 9, 15)), + ("2012-4-9 4:8:16", datetime(2012, 4, 9, 4, 8, 16)), + ("2012-04-23T09:15:00Z", datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), + ("2012-4-9 4:8:16-0320", datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), + ("2012-04-23T10:20:30.400+02:30", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(150))), + ("2012-04-23T10:20:30.400+02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(120))), + ("2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (b"2012-04-23T10:20:30.400-02", datetime(2012, 4, 23, 10, 20, 30, 400_000, create_tz(-120))), + (datetime(2017, 5, 5), datetime(2017, 5, 5)), + (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), + # Invalid inputs + ("x20120423091500", ValueError), + ("2012-04-56T09:15:90", ValueError), + ("2012-04-23T11:05:00-25:00", ValueError), + (19_999_999_999, datetime(2603, 10, 11, 11, 33, 19, tzinfo=timezone.utc)), # just before watershed + (20_000_000_001, datetime(1970, 8, 20, 11, 33, 20, 1000, tzinfo=timezone.utc)), # just after watershed + (1_549_316_052, datetime(2019, 2, 4, 21, 34, 12, 0, tzinfo=timezone.utc)), # nowish in s + (1_549_316_052_104, datetime(2019, 2, 4, 21, 34, 12, 104_000, tzinfo=timezone.utc)), # nowish in ms + (1_549_316_052_104_324, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in μs + (1_549_316_052_104_324_096, datetime(2019, 2, 4, 21, 34, 12, 104_324, tzinfo=timezone.utc)), # nowish in ns + ("infinity", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf", datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("inf ", datetime(9999, 12, 31, 23, 59, 59, 999999)), + (1e50, datetime(9999, 12, 31, 23, 59, 59, 999999)), + (float("inf"), datetime(9999, 12, 31, 23, 59, 59, 999999)), + ("-infinity", datetime(1, 1, 1, 0, 0)), + ("-inf", datetime(1, 1, 1, 0, 0)), + ("nan", ValueError), + ], +) +def test_datetime_parsing(value: Union[str, bytes, int, float], result: Union[datetime, Type[Exception]]) -> None: + if type(result) == type and issubclass(result, Exception): # pyright: ignore[reportUnnecessaryIsInstance] + with pytest.raises(result): + parse_datetime(value) + else: + assert parse_datetime(value) == result diff --git a/tests/utils.py b/tests/utils.py index ac183a7e..55521a9b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,7 +4,7 @@ import inspect import traceback import contextlib -from typing import Any, TypeVar, Iterator, cast +from typing import Any, TypeVar, Iterator, Sequence, cast from datetime import date, datetime from typing_extensions import Literal, get_args, get_origin, assert_type @@ -15,10 +15,11 @@ is_list_type, is_union_type, extract_type_arg, + is_sequence_type, is_annotated_type, is_type_alias_type, ) -from browserbase._compat import PYDANTIC_V2, field_outer_type, get_model_fields +from browserbase._compat import PYDANTIC_V1, field_outer_type, get_model_fields from browserbase._models import BaseModel BaseModelT = TypeVar("BaseModelT", bound=BaseModel) @@ -27,12 +28,12 @@ def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: list[str]) -> bool: for name, field in get_model_fields(model).items(): field_value = getattr(value, name) - if PYDANTIC_V2: - allow_none = False - else: + if PYDANTIC_V1: # in v1 nullability was structured differently # https://docs.pydantic.dev/2.0/migration/#required-optional-and-nullable-fields allow_none = getattr(field, "allow_none", False) + else: + allow_none = False assert_matches_type( field_outer_type(field), @@ -71,6 +72,13 @@ def assert_matches_type( if is_list_type(type_): return _assert_list_type(type_, value) + if is_sequence_type(type_): + assert isinstance(value, Sequence) + inner_type = get_args(type_)[0] + for entry in value: # type: ignore + assert_type(inner_type, entry) # type: ignore + return + if origin == str: assert isinstance(value, str) elif origin == int: From f6e02411245d100e5290d74c30f2a6d3eb5a12c6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 08:37:58 +0000 Subject: [PATCH 12/22] release: 1.5.0-alpha.1 (#154) * codegen metadata * chore(internal): move mypy configurations to `pyproject.toml` file * feat(api): api update * chore(tests): simplify `get_platform` test `nest_asyncio` is archived and broken on some platforms so it's not worth keeping in our test suite. * chore(internal): update pydantic dependency * chore(types): change optional parameter type from NotGiven to Omit * chore: do not install brew dependencies in ./scripts/bootstrap by default * feat(api): manual updates * release: 1.5.0-alpha.1 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- .stats.yml | 6 +- CHANGELOG.md | 18 +++++ mypy.ini | 50 -------------- pyproject.toml | 55 +++++++++++++++- requirements-dev.lock | 8 ++- requirements.lock | 7 +- scripts/bootstrap | 14 +++- src/browserbase/__init__.py | 4 +- src/browserbase/_base_client.py | 18 ++--- src/browserbase/_client.py | 16 ++--- src/browserbase/_models.py | 14 ++-- src/browserbase/_qs.py | 14 ++-- src/browserbase/_types.py | 29 ++++---- src/browserbase/_utils/_transform.py | 4 +- src/browserbase/_utils/_utils.py | 8 +-- src/browserbase/_version.py | 2 +- src/browserbase/resources/contexts.py | 14 ++-- src/browserbase/resources/extensions.py | 14 ++-- src/browserbase/resources/projects.py | 14 ++-- .../resources/sessions/downloads.py | 6 +- src/browserbase/resources/sessions/logs.py | 6 +- .../resources/sessions/recording.py | 6 +- .../resources/sessions/sessions.py | 66 +++++++++++-------- src/browserbase/resources/sessions/uploads.py | 6 +- .../types/session_create_params.py | 12 +++- tests/api_resources/test_sessions.py | 4 +- tests/test_client.py | 53 ++------------- tests/test_transform.py | 11 +++- 29 files changed, 254 insertions(+), 227 deletions(-) delete mode 100644 mypy.ini diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7a22c4a0..3d362d5e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.5.0-alpha.0" + ".": "1.5.0-alpha.1" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index a50ccc0a..e1cd805d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-a9ab6f9017f7645722d220eb8172516a7a5400e86542c28fc7e121adcd1f344f.yml -openapi_spec_hash: e29347aba2697d4efa3dce7794810dbd -config_hash: ec077c0d8cde29588ca4ff30d49575a4 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-0b96e0120f7cf3fba797371433e15a08d14727c0526d718b728faee615624297.yml +openapi_spec_hash: 8d007eed388933bf9d74c5488a56be41 +config_hash: b3ca4ec5b02e5333af51ebc2e9fdef1b diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b9474e..76a56a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 1.5.0-alpha.1 (2025-10-07) + +Full Changelog: [v1.5.0-alpha.0...v1.5.0-alpha.1](https://github.com/browserbase/sdk-python/compare/v1.5.0-alpha.0...v1.5.0-alpha.1) + +### Features + +* **api:** api update ([3bdf24e](https://github.com/browserbase/sdk-python/commit/3bdf24e69fd14e6e488af830e6e5a7786c21640d)) +* **api:** manual updates ([99b1cfb](https://github.com/browserbase/sdk-python/commit/99b1cfb41a51af014f5c350f0850331cd73abf08)) + + +### Chores + +* do not install brew dependencies in ./scripts/bootstrap by default ([6915700](https://github.com/browserbase/sdk-python/commit/69157006cc0df8f9e5effd0f53d79df88fe14e7d)) +* **internal:** move mypy configurations to `pyproject.toml` file ([545938f](https://github.com/browserbase/sdk-python/commit/545938fde4ace7142c413f9e0ac25e3b9c717980)) +* **internal:** update pydantic dependency ([4dcad8e](https://github.com/browserbase/sdk-python/commit/4dcad8e96f1220e79f3e9b5cdee2e19dfb5a1e11)) +* **tests:** simplify `get_platform` test ([6421017](https://github.com/browserbase/sdk-python/commit/64210177c60ca05c5d0eead33c3ecee3f4d18718)) +* **types:** change optional parameter type from NotGiven to Omit ([a46d293](https://github.com/browserbase/sdk-python/commit/a46d293766d0eb89b93739af0fbbd038eea083bd)) + ## 1.5.0-alpha.0 (2025-09-05) Full Changelog: [v1.4.0...v1.5.0-alpha.0](https://github.com/browserbase/sdk-python/compare/v1.4.0...v1.5.0-alpha.0) diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 811af717..00000000 --- a/mypy.ini +++ /dev/null @@ -1,50 +0,0 @@ -[mypy] -pretty = True -show_error_codes = True - -# Exclude _files.py because mypy isn't smart enough to apply -# the correct type narrowing and as this is an internal module -# it's fine to just use Pyright. -# -# We also exclude our `tests` as mypy doesn't always infer -# types correctly and Pyright will still catch any type errors. -exclude = ^(src/browserbase/_files\.py|_dev/.*\.py|tests/.*)$ - -strict_equality = True -implicit_reexport = True -check_untyped_defs = True -no_implicit_optional = True - -warn_return_any = True -warn_unreachable = True -warn_unused_configs = True - -# Turn these options off as it could cause conflicts -# with the Pyright options. -warn_unused_ignores = False -warn_redundant_casts = False - -disallow_any_generics = True -disallow_untyped_defs = True -disallow_untyped_calls = True -disallow_subclassing_any = True -disallow_incomplete_defs = True -disallow_untyped_decorators = True -cache_fine_grained = True - -# By default, mypy reports an error if you assign a value to the result -# of a function call that doesn't return anything. We do this in our test -# cases: -# ``` -# result = ... -# assert result is None -# ``` -# Changing this codegen to make mypy happy would increase complexity -# and would not be worth it. -disable_error_code = func-returns-value,overload-cannot-match - -# https://github.com/python/mypy/issues/12162 -[mypy.overrides] -module = "black.files.*" -ignore_errors = true -ignore_missing_imports = true diff --git a/pyproject.toml b/pyproject.toml index d42ea806..217c1da2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.5.0-alpha.0" +version = "1.5.0-alpha.1" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -57,7 +57,6 @@ dev-dependencies = [ "dirty-equals>=0.6.0", "importlib-metadata>=6.7.0", "rich>=13.7.1", - "nest_asyncio==1.6.0", "pytest-xdist>=3.6.1", "python-dotenv", "playwright", @@ -166,6 +165,58 @@ reportOverlappingOverload = false reportImportCycles = false reportPrivateUsage = false +[tool.mypy] +pretty = true +show_error_codes = true + +# Exclude _files.py because mypy isn't smart enough to apply +# the correct type narrowing and as this is an internal module +# it's fine to just use Pyright. +# +# We also exclude our `tests` as mypy doesn't always infer +# types correctly and Pyright will still catch any type errors. +exclude = ['src/browserbase/_files.py', '_dev/.*.py', 'tests/.*'] + +strict_equality = true +implicit_reexport = true +check_untyped_defs = true +no_implicit_optional = true + +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true + +# Turn these options off as it could cause conflicts +# with the Pyright options. +warn_unused_ignores = false +warn_redundant_casts = false + +disallow_any_generics = true +disallow_untyped_defs = true +disallow_untyped_calls = true +disallow_subclassing_any = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +cache_fine_grained = true + +# By default, mypy reports an error if you assign a value to the result +# of a function call that doesn't return anything. We do this in our test +# cases: +# ``` +# result = ... +# assert result is None +# ``` +# Changing this codegen to make mypy happy would increase complexity +# and would not be worth it. +disable_error_code = "func-returns-value,overload-cannot-match" + +# https://github.com/python/mypy/issues/12162 +[[tool.mypy.overrides]] +module = "black.files.*" +ignore_errors = true +ignore_missing_imports = true + + [tool.ruff] line-length = 120 output-format = "grouped" diff --git a/requirements-dev.lock b/requirements-dev.lock index 23d3fedf..e6008248 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -88,7 +88,6 @@ multidict==6.6.4 mypy==1.14.1 mypy-extensions==1.0.0 # via mypy -nest-asyncio==1.6.0 nodeenv==1.9.1 # via pyright nox==2024.10.9 @@ -106,9 +105,9 @@ pluggy==1.5.0 propcache==0.3.2 # via aiohttp # via yarl -pydantic==2.10.3 +pydantic==2.11.9 # via browserbase -pydantic-core==2.27.1 +pydantic-core==2.33.2 # via pydantic pyee==12.0.0 # via playwright @@ -168,6 +167,9 @@ typing-extensions==4.12.2 # via pydantic-core # via pyee # via pyright + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic # via rich # via selenium urllib3==2.2.3 diff --git a/requirements.lock b/requirements.lock index 6f4c4c9e..2495a260 100644 --- a/requirements.lock +++ b/requirements.lock @@ -55,9 +55,9 @@ multidict==6.4.4 propcache==0.3.1 # via aiohttp # via yarl -pydantic==2.10.3 +pydantic==2.11.9 # via browserbase -pydantic-core==2.27.1 +pydantic-core==2.33.2 # via pydantic sniffio==1.3.0 # via anyio @@ -68,5 +68,8 @@ typing-extensions==4.12.2 # via multidict # via pydantic # via pydantic-core + # via typing-inspection +typing-inspection==0.4.1 + # via pydantic yarl==1.20.0 # via aiohttp diff --git a/scripts/bootstrap b/scripts/bootstrap index e84fe62c..b430fee3 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,10 +4,18 @@ set -e cd "$(dirname "$0")/.." -if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { - echo "==> Installing Homebrew dependencies…" - brew bundle + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo } fi diff --git a/src/browserbase/__init__.py b/src/browserbase/__init__.py index 8e128845..a7356c1e 100644 --- a/src/browserbase/__init__.py +++ b/src/browserbase/__init__.py @@ -3,7 +3,7 @@ import typing as _t from . import types -from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes +from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given from ._utils import file_from_path from ._client import ( Client, @@ -48,7 +48,9 @@ "ProxiesTypes", "NotGiven", "NOT_GIVEN", + "not_given", "Omit", + "omit", "BrowserbaseError", "APIError", "APIStatusError", diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index 89549337..2485e4e6 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_base_client.py @@ -42,7 +42,6 @@ from ._qs import Querystring from ._files import to_httpx_files, async_to_httpx_files from ._types import ( - NOT_GIVEN, Body, Omit, Query, @@ -57,6 +56,7 @@ RequestOptions, HttpxRequestFiles, ModelBuilderProtocol, + not_given, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping from ._compat import PYDANTIC_V1, model_copy, model_dump @@ -145,9 +145,9 @@ def __init__( def __init__( self, *, - url: URL | NotGiven = NOT_GIVEN, - json: Body | NotGiven = NOT_GIVEN, - params: Query | NotGiven = NOT_GIVEN, + url: URL | NotGiven = not_given, + json: Body | NotGiven = not_given, + params: Query | NotGiven = not_given, ) -> None: self.url = url self.json = json @@ -595,7 +595,7 @@ def _maybe_override_cast_to(self, cast_to: type[ResponseT], options: FinalReques # we internally support defining a temporary header to override the # default `cast_to` type for use with `.with_raw_response` and `.with_streaming_response` # see _response.py for implementation details - override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, NOT_GIVEN) + override_cast_to = headers.pop(OVERRIDE_CAST_TO_HEADER, not_given) if is_given(override_cast_to): options.headers = headers return cast(Type[ResponseT], override_cast_to) @@ -825,7 +825,7 @@ def __init__( version: str, base_url: str | URL, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, @@ -1356,7 +1356,7 @@ def __init__( base_url: str | URL, _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, @@ -1818,8 +1818,8 @@ def make_request_options( extra_query: Query | None = None, extra_body: Body | None = None, idempotency_key: str | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, - post_parser: PostParser | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + post_parser: PostParser | NotGiven = not_given, ) -> RequestOptions: """Create a dict of type RequestOptions without keys of NotGiven values.""" options: RequestOptions = {} diff --git a/src/browserbase/_client.py b/src/browserbase/_client.py index a7039a2a..8b54a5bb 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Union, Mapping +from typing import Any, Mapping from typing_extensions import Self, override import httpx @@ -11,13 +11,13 @@ from . import _exceptions from ._qs import Querystring from ._types import ( - NOT_GIVEN, Omit, Timeout, NotGiven, Transport, ProxiesTypes, RequestOptions, + not_given, ) from ._utils import is_given, get_async_library from ._version import __version__ @@ -59,7 +59,7 @@ def __init__( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -137,9 +137,9 @@ def copy( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.Client | None = None, - max_retries: int | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = not_given, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -233,7 +233,7 @@ def __init__( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: Union[float, Timeout, None, NotGiven] = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, @@ -311,9 +311,9 @@ def copy( *, api_key: str | None = None, base_url: str | httpx.URL | None = None, - timeout: float | Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | Timeout | None | NotGiven = not_given, http_client: httpx.AsyncClient | None = None, - max_retries: int | NotGiven = NOT_GIVEN, + max_retries: int | NotGiven = not_given, default_headers: Mapping[str, str] | None = None, set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index 3a6017ef..6a3cd1d2 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -256,7 +256,7 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, - by_alias: bool = False, + by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, @@ -264,6 +264,7 @@ def model_dump( warnings: bool | Literal["none", "warn", "error"] = True, context: dict[str, Any] | None = None, serialize_as_any: bool = False, + fallback: Callable[[Any], Any] | None = None, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -295,10 +296,12 @@ def model_dump( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, - by_alias=by_alias, + by_alias=by_alias if by_alias is not None else False, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, @@ -313,13 +316,14 @@ def model_dump_json( indent: int | None = None, include: IncEx | None = None, exclude: IncEx | None = None, - by_alias: bool = False, + by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, context: dict[str, Any] | None = None, + fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json @@ -348,11 +352,13 @@ def model_dump_json( raise ValueError("context is only supported in Pydantic v2") if serialize_as_any != False: raise ValueError("serialize_as_any is only supported in Pydantic v2") + if fallback is not None: + raise ValueError("fallback is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, exclude=exclude, - by_alias=by_alias, + by_alias=by_alias if by_alias is not None else False, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, diff --git a/src/browserbase/_qs.py b/src/browserbase/_qs.py index 274320ca..ada6fd3f 100644 --- a/src/browserbase/_qs.py +++ b/src/browserbase/_qs.py @@ -4,7 +4,7 @@ from urllib.parse import parse_qs, urlencode from typing_extensions import Literal, get_args -from ._types import NOT_GIVEN, NotGiven, NotGivenOr +from ._types import NotGiven, not_given from ._utils import flatten _T = TypeVar("_T") @@ -41,8 +41,8 @@ def stringify( self, params: Params, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> str: return urlencode( self.stringify_items( @@ -56,8 +56,8 @@ def stringify_items( self, params: Params, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> list[tuple[str, str]]: opts = Options( qs=self, @@ -143,8 +143,8 @@ def __init__( self, qs: Querystring = _qs, *, - array_format: NotGivenOr[ArrayFormat] = NOT_GIVEN, - nested_format: NotGivenOr[NestedFormat] = NOT_GIVEN, + array_format: ArrayFormat | NotGiven = not_given, + nested_format: NestedFormat | NotGiven = not_given, ) -> None: self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format diff --git a/src/browserbase/_types.py b/src/browserbase/_types.py index b954306a..f86be54d 100644 --- a/src/browserbase/_types.py +++ b/src/browserbase/_types.py @@ -117,18 +117,21 @@ class RequestOptions(TypedDict, total=False): # Sentinel class used until PEP 0661 is accepted class NotGiven: """ - A sentinel singleton class used to distinguish omitted keyword arguments - from those passed in with the value None (which may have different behavior). + For parameters with a meaningful None value, we need to distinguish between + the user explicitly passing None, and the user not passing the parameter at + all. + + User code shouldn't need to use not_given directly. For example: ```py - def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... + def create(timeout: Timeout | None | NotGiven = not_given): ... - get(timeout=1) # 1s timeout - get(timeout=None) # No timeout - get() # Default timeout behavior, which may not be statically known at the method definition. + create(timeout=1) # 1s timeout + create(timeout=None) # No timeout + create() # Default timeout behavior ``` """ @@ -140,13 +143,14 @@ def __repr__(self) -> str: return "NOT_GIVEN" -NotGivenOr = Union[_T, NotGiven] +not_given = NotGiven() +# for backwards compatibility: NOT_GIVEN = NotGiven() class Omit: - """In certain situations you need to be able to represent a case where a default value has - to be explicitly removed and `None` is not an appropriate substitute, for example: + """ + To explicitly omit something from being sent in a request, use `omit`. ```py # as the default `Content-Type` header is `application/json` that will be sent @@ -156,8 +160,8 @@ class Omit: # to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983' client.post(..., headers={"Content-Type": "multipart/form-data"}) - # instead you can remove the default `application/json` header by passing Omit - client.post(..., headers={"Content-Type": Omit()}) + # instead you can remove the default `application/json` header by passing omit + client.post(..., headers={"Content-Type": omit}) ``` """ @@ -165,6 +169,9 @@ def __bool__(self) -> Literal[False]: return False +omit = Omit() + + @runtime_checkable class ModelBuilderProtocol(Protocol): @classmethod diff --git a/src/browserbase/_utils/_transform.py b/src/browserbase/_utils/_transform.py index c19124f0..52075492 100644 --- a/src/browserbase/_utils/_transform.py +++ b/src/browserbase/_utils/_transform.py @@ -268,7 +268,7 @@ def _transform_typeddict( annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): if not is_given(value): - # we don't need to include `NotGiven` values here as they'll + # we don't need to include omitted values here as they'll # be stripped out before the request is sent anyway continue @@ -434,7 +434,7 @@ async def _async_transform_typeddict( annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): if not is_given(value): - # we don't need to include `NotGiven` values here as they'll + # we don't need to include omitted values here as they'll # be stripped out before the request is sent anyway continue diff --git a/src/browserbase/_utils/_utils.py b/src/browserbase/_utils/_utils.py index f0818595..50d59269 100644 --- a/src/browserbase/_utils/_utils.py +++ b/src/browserbase/_utils/_utils.py @@ -21,7 +21,7 @@ import sniffio -from .._types import NotGiven, FileTypes, NotGivenOr, HeadersLike +from .._types import Omit, NotGiven, FileTypes, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -63,7 +63,7 @@ def _extract_items( try: key = path[index] except IndexError: - if isinstance(obj, NotGiven): + if not is_given(obj): # no value was provided - we can safely ignore return [] @@ -126,8 +126,8 @@ def _extract_items( return [] -def is_given(obj: NotGivenOr[_T]) -> TypeGuard[_T]: - return not isinstance(obj, NotGiven) +def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: + return not isinstance(obj, NotGiven) and not isinstance(obj, Omit) # Type safe methods for narrowing types with TypeVars. diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 25f19565..6fa8f70b 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.5.0-alpha.0" # x-release-please-version +__version__ = "1.5.0-alpha.1" # x-release-please-version diff --git a/src/browserbase/resources/contexts.py b/src/browserbase/resources/contexts.py index bc4d1cc8..d2bb4167 100644 --- a/src/browserbase/resources/contexts.py +++ b/src/browserbase/resources/contexts.py @@ -5,7 +5,7 @@ import httpx from ..types import context_create_params -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Query, Headers, NotGiven, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -52,7 +52,7 @@ def create( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContextCreateResponse: """Create a Context @@ -88,7 +88,7 @@ def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContextRetrieveResponse: """ Get a Context @@ -121,7 +121,7 @@ def update( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContextUpdateResponse: """ Update a Context @@ -175,7 +175,7 @@ async def create( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContextCreateResponse: """Create a Context @@ -211,7 +211,7 @@ async def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContextRetrieveResponse: """ Get a Context @@ -244,7 +244,7 @@ async def update( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ContextUpdateResponse: """ Update a Context diff --git a/src/browserbase/resources/extensions.py b/src/browserbase/resources/extensions.py index 4dcd248f..21d06e70 100644 --- a/src/browserbase/resources/extensions.py +++ b/src/browserbase/resources/extensions.py @@ -7,7 +7,7 @@ import httpx from ..types import extension_create_params -from .._types import NOT_GIVEN, Body, Query, Headers, NoneType, NotGiven, FileTypes +from .._types import Body, Query, Headers, NoneType, NotGiven, FileTypes, not_given from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -53,7 +53,7 @@ def create( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ExtensionCreateResponse: """ Upload an Extension @@ -92,7 +92,7 @@ def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ExtensionRetrieveResponse: """ Get an Extension @@ -125,7 +125,7 @@ def delete( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: """ Delete an Extension @@ -180,7 +180,7 @@ async def create( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ExtensionCreateResponse: """ Upload an Extension @@ -219,7 +219,7 @@ async def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ExtensionRetrieveResponse: """ Get an Extension @@ -252,7 +252,7 @@ async def delete( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: """ Delete an Extension diff --git a/src/browserbase/resources/projects.py b/src/browserbase/resources/projects.py index e0e73b40..62c28afa 100644 --- a/src/browserbase/resources/projects.py +++ b/src/browserbase/resources/projects.py @@ -4,7 +4,7 @@ import httpx -from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from .._types import Body, Query, Headers, NotGiven, not_given from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -50,7 +50,7 @@ def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ProjectRetrieveResponse: """ Get a Project @@ -82,7 +82,7 @@ def list( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ProjectListResponse: """List Projects""" return self._get( @@ -102,7 +102,7 @@ def usage( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ProjectUsageResponse: """ Get Project Usage @@ -156,7 +156,7 @@ async def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ProjectRetrieveResponse: """ Get a Project @@ -188,7 +188,7 @@ async def list( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ProjectListResponse: """List Projects""" return await self._get( @@ -208,7 +208,7 @@ async def usage( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ProjectUsageResponse: """ Get Project Usage diff --git a/src/browserbase/resources/sessions/downloads.py b/src/browserbase/resources/sessions/downloads.py index 9ee49759..6195c30b 100644 --- a/src/browserbase/resources/sessions/downloads.py +++ b/src/browserbase/resources/sessions/downloads.py @@ -4,7 +4,7 @@ import httpx -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._types import Body, Query, Headers, NotGiven, not_given from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -51,7 +51,7 @@ def list( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BinaryAPIResponse: """ Session Downloads @@ -106,7 +106,7 @@ async def list( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> AsyncBinaryAPIResponse: """ Session Downloads diff --git a/src/browserbase/resources/sessions/logs.py b/src/browserbase/resources/sessions/logs.py index 2a42c9dc..b1c90f52 100644 --- a/src/browserbase/resources/sessions/logs.py +++ b/src/browserbase/resources/sessions/logs.py @@ -4,7 +4,7 @@ import httpx -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._types import Body, Query, Headers, NotGiven, not_given from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -48,7 +48,7 @@ def list( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> LogListResponse: """ Session Logs @@ -102,7 +102,7 @@ async def list( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> LogListResponse: """ Session Logs diff --git a/src/browserbase/resources/sessions/recording.py b/src/browserbase/resources/sessions/recording.py index 856b2927..789087a8 100644 --- a/src/browserbase/resources/sessions/recording.py +++ b/src/browserbase/resources/sessions/recording.py @@ -4,7 +4,7 @@ import httpx -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._types import Body, Query, Headers, NotGiven, not_given from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -48,7 +48,7 @@ def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RecordingRetrieveResponse: """ Session Recording @@ -102,7 +102,7 @@ async def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RecordingRetrieveResponse: """ Session Recording diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index 01a4943a..5e58bbbb 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -24,7 +24,7 @@ UploadsResourceWithStreamingResponse, AsyncUploadsResourceWithStreamingResponse, ) -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from .downloads import ( @@ -100,19 +100,20 @@ def create( self, *, project_id: str, - browser_settings: session_create_params.BrowserSettings | NotGiven = NOT_GIVEN, - extension_id: str | NotGiven = NOT_GIVEN, - keep_alive: bool | NotGiven = NOT_GIVEN, - proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | NotGiven = NOT_GIVEN, - region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | NotGiven = NOT_GIVEN, - api_timeout: int | NotGiven = NOT_GIVEN, - user_metadata: Dict[str, object] | NotGiven = NOT_GIVEN, + browser_settings: session_create_params.BrowserSettings | Omit = omit, + extension_id: str | Omit = omit, + keep_alive: bool | Omit = omit, + proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | Omit = omit, + proxy_settings: session_create_params.ProxySettings | Omit = omit, + region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | Omit = omit, + api_timeout: int | Omit = omit, + user_metadata: Dict[str, object] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionCreateResponse: """Create a Session @@ -131,6 +132,8 @@ def create( proxies: Proxy configuration. Can be true for default proxy, or an array of proxy configurations. + proxy_settings: [NOT IN DOCS] Supplementary proxy settings. Optional. + region: The region where the Session should run. api_timeout: Duration in seconds after which the session will automatically end. Defaults to @@ -156,6 +159,7 @@ def create( "extension_id": extension_id, "keep_alive": keep_alive, "proxies": proxies, + "proxy_settings": proxy_settings, "region": region, "api_timeout": api_timeout, "user_metadata": user_metadata, @@ -177,7 +181,7 @@ def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionRetrieveResponse: """ Get a Session @@ -212,7 +216,7 @@ def update( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionUpdateResponse: """Update a Session @@ -253,14 +257,14 @@ def update( def list( self, *, - q: str | NotGiven = NOT_GIVEN, - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | NotGiven = NOT_GIVEN, + q: str | Omit = omit, + status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionListResponse: """List Sessions @@ -306,7 +310,7 @@ def debug( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionDebugResponse: """ Session Live URLs @@ -371,19 +375,20 @@ async def create( self, *, project_id: str, - browser_settings: session_create_params.BrowserSettings | NotGiven = NOT_GIVEN, - extension_id: str | NotGiven = NOT_GIVEN, - keep_alive: bool | NotGiven = NOT_GIVEN, - proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | NotGiven = NOT_GIVEN, - region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | NotGiven = NOT_GIVEN, - api_timeout: int | NotGiven = NOT_GIVEN, - user_metadata: Dict[str, object] | NotGiven = NOT_GIVEN, + browser_settings: session_create_params.BrowserSettings | Omit = omit, + extension_id: str | Omit = omit, + keep_alive: bool | Omit = omit, + proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | Omit = omit, + proxy_settings: session_create_params.ProxySettings | Omit = omit, + region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | Omit = omit, + api_timeout: int | Omit = omit, + user_metadata: Dict[str, object] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionCreateResponse: """Create a Session @@ -402,6 +407,8 @@ async def create( proxies: Proxy configuration. Can be true for default proxy, or an array of proxy configurations. + proxy_settings: [NOT IN DOCS] Supplementary proxy settings. Optional. + region: The region where the Session should run. api_timeout: Duration in seconds after which the session will automatically end. Defaults to @@ -427,6 +434,7 @@ async def create( "extension_id": extension_id, "keep_alive": keep_alive, "proxies": proxies, + "proxy_settings": proxy_settings, "region": region, "api_timeout": api_timeout, "user_metadata": user_metadata, @@ -448,7 +456,7 @@ async def retrieve( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionRetrieveResponse: """ Get a Session @@ -483,7 +491,7 @@ async def update( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionUpdateResponse: """Update a Session @@ -524,14 +532,14 @@ async def update( async def list( self, *, - q: str | NotGiven = NOT_GIVEN, - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | NotGiven = NOT_GIVEN, + q: str | Omit = omit, + status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionListResponse: """List Sessions @@ -577,7 +585,7 @@ async def debug( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SessionDebugResponse: """ Session Live URLs diff --git a/src/browserbase/resources/sessions/uploads.py b/src/browserbase/resources/sessions/uploads.py index 69b6ccbe..aba72b64 100644 --- a/src/browserbase/resources/sessions/uploads.py +++ b/src/browserbase/resources/sessions/uploads.py @@ -6,7 +6,7 @@ import httpx -from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven, FileTypes +from ..._types import Body, Query, Headers, NotGiven, FileTypes, not_given from ..._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource @@ -53,7 +53,7 @@ def create( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UploadCreateResponse: """ Create Session Uploads @@ -116,7 +116,7 @@ async def create( extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, + timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> UploadCreateResponse: """ Create Session Uploads diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index 31a08ceb..7fafe448 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -19,6 +19,7 @@ "ProxiesUnionMember0UnionMember0", "ProxiesUnionMember0UnionMember0Geolocation", "ProxiesUnionMember0UnionMember1", + "ProxySettings", ] @@ -49,6 +50,9 @@ class SessionCreateParams(TypedDict, total=False): Can be true for default proxy, or an array of proxy configurations. """ + proxy_settings: Annotated[ProxySettings, PropertyInfo(alias="proxySettings")] + """[NOT IN DOCS] Supplementary proxy settings. Optional.""" + region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] """The region where the Session should run.""" @@ -141,9 +145,6 @@ class BrowserSettings(TypedDict, total=False): [on the Stealth Mode page](/features/stealth-mode#fingerprinting) """ - headful: bool - """[NOT IN DOCS] Enable or disable headful mode. Defaults to `false`.""" - log_session: Annotated[bool, PropertyInfo(alias="logSession")] """Enable or disable session logging. Defaults to `true`.""" @@ -211,3 +212,8 @@ class ProxiesUnionMember0UnionMember1(TypedDict, total=False): ProxiesUnionMember0: TypeAlias = Union[ProxiesUnionMember0UnionMember0, ProxiesUnionMember0UnionMember1] + + +class ProxySettings(TypedDict, total=False): + ca_certificates: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="caCertificates")]] + """[NOT IN DOCS] The TLS certificate IDs to trust. Optional.""" diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index d7d6a903..24da8f0b 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -57,7 +57,6 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: "min_width": 0, }, }, - "headful": True, "log_session": True, "os": "windows", "record_session": True, @@ -80,6 +79,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: }, } ], + proxy_settings={"ca_certificates": ["182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e"]}, region="us-west-2", api_timeout=60, user_metadata={"foo": "bar"}, @@ -305,7 +305,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas "min_width": 0, }, }, - "headful": True, "log_session": True, "os": "windows", "record_session": True, @@ -328,6 +327,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas }, } ], + proxy_settings={"ca_certificates": ["182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e"]}, region="us-west-2", api_timeout=60, user_metadata={"foo": "bar"}, diff --git a/tests/test_client.py b/tests/test_client.py index bf058259..aed68baf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,13 +6,10 @@ import os import sys import json -import time import asyncio import inspect -import subprocess import tracemalloc from typing import Any, Union, cast -from textwrap import dedent from unittest import mock from typing_extensions import Literal @@ -23,14 +20,17 @@ from browserbase import Browserbase, AsyncBrowserbase, APIResponseValidationError from browserbase._types import Omit +from browserbase._utils import asyncify from browserbase._models import BaseModel, FinalRequestOptions from browserbase._exceptions import APIStatusError, APITimeoutError, BrowserbaseError, APIResponseValidationError from browserbase._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, + OtherPlatform, DefaultHttpxClient, DefaultAsyncHttpxClient, + get_platform, make_request_options, ) @@ -1647,50 +1647,9 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: assert response.http_request.headers.get("x-stainless-retry-count") == "42" - def test_get_platform(self) -> None: - # A previous implementation of asyncify could leave threads unterminated when - # used with nest_asyncio. - # - # Since nest_asyncio.apply() is global and cannot be un-applied, this - # test is run in a separate process to avoid affecting other tests. - test_code = dedent(""" - import asyncio - import nest_asyncio - import threading - - from browserbase._utils import asyncify - from browserbase._base_client import get_platform - - async def test_main() -> None: - result = await asyncify(get_platform)() - print(result) - for thread in threading.enumerate(): - print(thread.name) - - nest_asyncio.apply() - asyncio.run(test_main()) - """) - with subprocess.Popen( - [sys.executable, "-c", test_code], - text=True, - ) as process: - timeout = 10 # seconds - - start_time = time.monotonic() - while True: - return_code = process.poll() - if return_code is not None: - if return_code != 0: - raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") - - # success - break - - if time.monotonic() - start_time > timeout: - process.kill() - raise AssertionError("calling get_platform using asyncify resulted in a hung process") - - time.sleep(0.1) + async def test_get_platform(self) -> None: + platform = await asyncify(get_platform)() + assert isinstance(platform, (str, OtherPlatform)) async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly diff --git a/tests/test_transform.py b/tests/test_transform.py index 498d0d93..c31b1f40 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -8,7 +8,7 @@ import pytest -from browserbase._types import NOT_GIVEN, Base64FileInput +from browserbase._types import Base64FileInput, omit, not_given from browserbase._utils import ( PropertyInfo, transform as _transform, @@ -450,4 +450,11 @@ async def test_transform_skipping(use_async: bool) -> None: @pytest.mark.asyncio async def test_strips_notgiven(use_async: bool) -> None: assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} - assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {} + assert await transform({"foo_bar": not_given}, Foo1, use_async) == {} + + +@parametrize +@pytest.mark.asyncio +async def test_strips_omit(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": omit}, Foo1, use_async) == {} From e1f6a2bad57a81379350b563f2388b18985e6fd9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:45:36 -0800 Subject: [PATCH 13/22] release: 1.5.0-alpha.2 (#155) * feat(api): api update * chore(internal): detect missing future annotations with ruff * chore: bump `httpx-aiohttp` version to 0.1.9 * fix(client): close streams without requiring full consumption * chore(internal/tests): avoid race condition with implicit client cleanup * chore(internal): grammar fix (it's -> its) * chore(package): drop Python 3.8 support * fix: compat with Python 3.14 * fix(compat): update signatures of `model_dump` and `model_dump_json` for Pydantic v1 * fix(pydantic): ignore model extras in pydantic v2 (#156) * fix(pydantic): ignore model extras * only handle v2 for now * chore: add Python 3.14 classifier and testing * fix: ensure streams are always closed * chore(deps): mypy 1.18.1 has a regression, pin to 1.17 * chore: update lockfile * chore(docs): use environment variables for authentication in code snippets * fix(types): allow pyright to infer TypedDict types within SequenceNotStr * chore: add missing docstrings * chore(internal): add missing files argument to base client * chore: speedup initial import * fix: use async_to_httpx_files in patch method * chore(internal): add `--fix` argument to lint script * feat(api): api update * chore(internal): codegen related update * feat(client): add support for binary request streaming * chore(internal): update `actions/checkout` version * chore(ci): upgrade `actions/github-script` * feat(client): add custom JSON encoder for extended type support * chore(internal): bump dependencies * chore(internal): fix lint error on Python 3.14 * chore: format all `api.md` files * chore: update mock server docs * feat(api): manual updates * feat(api): manual updates * feat(api): manual updates * feat(api): api update * feat(api): manual updates * codegen metadata * feat(api): manual updates * feat(api): manual updates * feat(api): manual updates * feat(api): manual updates * feat(api): api update * chore(internal): add request options to SSE classes * chore(internal): make `test_proxy_environment_variables` more resilient * feat(api): api update * chore(internal): make `test_proxy_environment_variables` more resilient to env * feat(api): api update * codegen metadata * codegen metadata * feat: [CORE-] Restore models and components in SDK * codegen metadata * release: 1.5.0-alpha.2 * fix: revert pydantic v2 extra="ignore" and update ExtensionRetrieveResponse usage Reverts the change from 9cae18c that set extra="ignore" in pydantic v2, which caused unknown/extra API fields to be silently dropped. Also updates examples to use the consolidated Extension type instead of the removed ExtensionRetrieveResponse. Co-Authored-By: Claude Opus 4.6 * fix: sort import order in playwright_extensions example Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Dominic Saadi Co-authored-by: Derek Meegan Co-authored-by: Claude Opus 4.6 --- .github/workflows/ci.yml | 8 +- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- .release-please-manifest.json | 2 +- .stats.yml | 8 +- CHANGELOG.md | 62 ++ CONTRIBUTING.md | 3 +- LICENSE | 2 +- README.md | 17 +- api.md | 29 +- examples/playwright_extensions.py | 4 +- pyproject.toml | 32 +- requirements-dev.lock | 128 ++-- requirements.lock | 41 +- scripts/lint | 9 +- src/browserbase/_base_client.py | 158 ++++- src/browserbase/_client.py | 224 +++++-- src/browserbase/_compat.py | 6 +- src/browserbase/_models.py | 69 +- src/browserbase/_response.py | 3 + src/browserbase/_streaming.py | 35 +- src/browserbase/_types.py | 14 +- src/browserbase/_utils/_compat.py | 2 +- src/browserbase/_utils/_json.py | 35 ++ src/browserbase/_utils/_sync.py | 34 +- src/browserbase/_utils/_utils.py | 2 +- src/browserbase/_version.py | 2 +- src/browserbase/resources/contexts.py | 102 ++- src/browserbase/resources/extensions.py | 19 +- src/browserbase/resources/projects.py | 20 +- .../resources/sessions/sessions.py | 90 ++- src/browserbase/types/__init__.py | 13 +- ...ontext_retrieve_response.py => context.py} | 4 +- .../types/context_create_params.py | 7 +- ...ension_create_response.py => extension.py} | 4 +- .../types/extension_retrieve_response.py | 22 - ...roject_retrieve_response.py => project.py} | 4 +- .../types/project_list_response.py | 27 +- ...ect_usage_response.py => project_usage.py} | 4 +- ...{session_update_response.py => session.py} | 10 +- .../types/session_create_params.py | 89 +-- .../types/session_create_response.py | 50 +- .../types/session_list_response.py | 58 +- ...debug_response.py => session_live_urls.py} | 4 +- .../types/session_retrieve_response.py | 50 +- .../types/session_update_params.py | 13 +- src/browserbase/types/sessions/__init__.py | 2 + .../types/sessions/log_list_response.py | 48 +- .../sessions/recording_retrieve_response.py | 26 +- src/browserbase/types/sessions/session_log.py | 46 ++ .../types/sessions/session_recording.py | 24 + tests/api_resources/test_contexts.py | 120 +++- tests/api_resources/test_extensions.py | 26 +- tests/api_resources/test_projects.py | 26 +- tests/api_resources/test_sessions.py | 110 ++-- tests/test_client.py | 595 ++++++++++++------ tests/test_models.py | 8 +- tests/test_utils/test_json.py | 126 ++++ 58 files changed, 1685 insertions(+), 995 deletions(-) create mode 100644 src/browserbase/_utils/_json.py rename src/browserbase/types/{context_retrieve_response.py => context.py} (84%) rename src/browserbase/types/{extension_create_response.py => extension.py} (85%) delete mode 100644 src/browserbase/types/extension_retrieve_response.py rename src/browserbase/types/{project_retrieve_response.py => project.py} (87%) rename src/browserbase/types/{project_usage_response.py => project_usage.py} (78%) rename src/browserbase/types/{session_update_response.py => session.py} (83%) rename src/browserbase/types/{session_debug_response.py => session_live_urls.py} (88%) create mode 100644 src/browserbase/types/sessions/session_log.py create mode 100644 src/browserbase/types/sessions/session_recording.py create mode 100644 tests/test_utils/test_json.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8edf5a60..c77d6d73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -44,7 +44,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | @@ -63,7 +63,7 @@ jobs: - name: Get GitHub OIDC Token if: github.repository == 'stainless-sdks/browserbase-python' id: github-oidc - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); @@ -81,7 +81,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b3c832c7..7fb6d449 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 3e17e458..5beedb0d 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'browserbase/sdk-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3d362d5e..70fc11c6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.5.0-alpha.1" + ".": "1.5.0-alpha.2" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index e1cd805d..05de0332 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 18 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-0b96e0120f7cf3fba797371433e15a08d14727c0526d718b728faee615624297.yml -openapi_spec_hash: 8d007eed388933bf9d74c5488a56be41 -config_hash: b3ca4ec5b02e5333af51ebc2e9fdef1b +configured_endpoints: 19 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-215bc4361122162181eecce83c0dbdda7c45a21801e7addb75102e8011413069.yml +openapi_spec_hash: c4fadc5bb6b84cd3988c8d864b67bf61 +config_hash: a106b247c7cdf02ac1033077402cfe2d diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a56a68..f38a54f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## 1.5.0-alpha.2 (2026-03-03) + +Full Changelog: [v1.5.0-alpha.1...v1.5.0-alpha.2](https://github.com/browserbase/sdk-python/compare/v1.5.0-alpha.1...v1.5.0-alpha.2) + +### Features + +* [CORE-] Restore models and components in SDK ([49f2dab](https://github.com/browserbase/sdk-python/commit/49f2dab3af74ead628337f9475c381023505db6f)) +* **api:** api update ([1c149cb](https://github.com/browserbase/sdk-python/commit/1c149cb5559ce87c275e8e3ff8dabe60f44cb592)) +* **api:** api update ([72aa790](https://github.com/browserbase/sdk-python/commit/72aa790fc596e3762ea73a605134ebbc4ffe0d74)) +* **api:** api update ([a0edac2](https://github.com/browserbase/sdk-python/commit/a0edac2143ff73bc099bf5455191970d46b63628)) +* **api:** api update ([0b8da8a](https://github.com/browserbase/sdk-python/commit/0b8da8acb8aa4a299655719a2b31d472f8d9750f)) +* **api:** api update ([20dcbdc](https://github.com/browserbase/sdk-python/commit/20dcbdc81a95efe53e2e57ff7b205e8a3c023bbc)) +* **api:** api update ([97edfd0](https://github.com/browserbase/sdk-python/commit/97edfd04ce506478373c878b68cab3a8d6adc80d)) +* **api:** manual updates ([892fe71](https://github.com/browserbase/sdk-python/commit/892fe7135c9c454526e7bcf76b4c830b57aa6c22)) +* **api:** manual updates ([32e4d51](https://github.com/browserbase/sdk-python/commit/32e4d5194c9b2da94b7d0da9ce16de2208b47a2d)) +* **api:** manual updates ([3bf8100](https://github.com/browserbase/sdk-python/commit/3bf8100929de96894b568a39a85a1763d46a6cc9)) +* **api:** manual updates ([f46e475](https://github.com/browserbase/sdk-python/commit/f46e475d63812af0f7e53971429adee2dd207d33)) +* **api:** manual updates ([7ace939](https://github.com/browserbase/sdk-python/commit/7ace9396d265afabebd57169e6a32dcedec3b0d4)) +* **api:** manual updates ([94d9db6](https://github.com/browserbase/sdk-python/commit/94d9db6142e868b739649027144eade26e092cb3)) +* **api:** manual updates ([e281b89](https://github.com/browserbase/sdk-python/commit/e281b8958b9a5b30ca90af0db36693a1eeaed8c5)) +* **api:** manual updates ([f0ba58f](https://github.com/browserbase/sdk-python/commit/f0ba58f620f3b30e4f275cce521de1d6e7fd7164)) +* **client:** add custom JSON encoder for extended type support ([da8ce14](https://github.com/browserbase/sdk-python/commit/da8ce14deb91e1da722dbafa3f7e35bfd1fbd983)) +* **client:** add support for binary request streaming ([972c837](https://github.com/browserbase/sdk-python/commit/972c8370ed69360de2479cf0fc818be3df4679cf)) + + +### Bug Fixes + +* **client:** close streams without requiring full consumption ([bb617be](https://github.com/browserbase/sdk-python/commit/bb617bee85cc709ea14c4b53eac06058f28318e9)) +* compat with Python 3.14 ([8f4df7c](https://github.com/browserbase/sdk-python/commit/8f4df7c2a20dd87d54d5ca12a8060a25223d2ef5)) +* **compat:** update signatures of `model_dump` and `model_dump_json` for Pydantic v1 ([135104e](https://github.com/browserbase/sdk-python/commit/135104eec5555bd650d25743dd91587e9c74e549)) +* ensure streams are always closed ([588f8f4](https://github.com/browserbase/sdk-python/commit/588f8f480efaa914d5bbcb693dadd97936d66750)) +* **pydantic:** ignore model extras in pydantic v2 ([#156](https://github.com/browserbase/sdk-python/issues/156)) ([9cae18c](https://github.com/browserbase/sdk-python/commit/9cae18c71078c8baae46bfb13298a8eadcfe5675)) +* **types:** allow pyright to infer TypedDict types within SequenceNotStr ([6e13710](https://github.com/browserbase/sdk-python/commit/6e1371033109ed3a2519660bcc0da3f44f65f097)) +* use async_to_httpx_files in patch method ([4370dab](https://github.com/browserbase/sdk-python/commit/4370dab0f116d5535259ee557c0dddf3fc006c80)) + + +### Chores + +* add missing docstrings ([0332edf](https://github.com/browserbase/sdk-python/commit/0332edf2964f32119e074d86baf3ef35e30f1b8d)) +* add Python 3.14 classifier and testing ([ec88bf3](https://github.com/browserbase/sdk-python/commit/ec88bf3f829ced4db3ee9bbaa249d0f447d0095d)) +* bump `httpx-aiohttp` version to 0.1.9 ([42685a1](https://github.com/browserbase/sdk-python/commit/42685a189cf7a465d8696fbc8902123567f1e9e0)) +* **ci:** upgrade `actions/github-script` ([9363c3d](https://github.com/browserbase/sdk-python/commit/9363c3dd4142917eca9e7f5872d68d73b0400a17)) +* **deps:** mypy 1.18.1 has a regression, pin to 1.17 ([b0d4efb](https://github.com/browserbase/sdk-python/commit/b0d4efb5945050ddfa449e468306e39aadeccf6a)) +* **docs:** use environment variables for authentication in code snippets ([97a777f](https://github.com/browserbase/sdk-python/commit/97a777f837a1f06940e05506e4b0237ace54cef8)) +* format all `api.md` files ([36b3fc5](https://github.com/browserbase/sdk-python/commit/36b3fc50c44221848b583e5d5f66e06e9f857a14)) +* **internal/tests:** avoid race condition with implicit client cleanup ([6ca21dc](https://github.com/browserbase/sdk-python/commit/6ca21dcf117d076b9baaa43e0b1efd676c518845)) +* **internal:** add `--fix` argument to lint script ([b96137c](https://github.com/browserbase/sdk-python/commit/b96137ced74f55b77e904026ab76b8bf306a7543)) +* **internal:** add missing files argument to base client ([851f268](https://github.com/browserbase/sdk-python/commit/851f268b6fbfa815f819bd3390d732ee26445b09)) +* **internal:** add request options to SSE classes ([0451d35](https://github.com/browserbase/sdk-python/commit/0451d3509b3bd14695e506ecc5ece3367a91eba6)) +* **internal:** bump dependencies ([b0c3306](https://github.com/browserbase/sdk-python/commit/b0c33068ffe4c033748a4ca7e4394cdd7c4c97be)) +* **internal:** codegen related update ([5dad097](https://github.com/browserbase/sdk-python/commit/5dad0978043a3fe8d65c5ff06a064e2d9b40cda9)) +* **internal:** detect missing future annotations with ruff ([ea60157](https://github.com/browserbase/sdk-python/commit/ea60157e52c3f8477ecf20f2a39ec2a722c83fed)) +* **internal:** fix lint error on Python 3.14 ([ba06a3e](https://github.com/browserbase/sdk-python/commit/ba06a3e6d617c4b10be4874856006797fdec6e93)) +* **internal:** grammar fix (it's -> its) ([1d6aebd](https://github.com/browserbase/sdk-python/commit/1d6aebda8211f3ffe1602420cfca5672d84561bd)) +* **internal:** make `test_proxy_environment_variables` more resilient ([b768b4f](https://github.com/browserbase/sdk-python/commit/b768b4f48ad6ed219cb3b7407baf0f598452f044)) +* **internal:** make `test_proxy_environment_variables` more resilient to env ([976c1fb](https://github.com/browserbase/sdk-python/commit/976c1fb236eddec936bdcbc73844a36a91f41397)) +* **internal:** update `actions/checkout` version ([7b01d95](https://github.com/browserbase/sdk-python/commit/7b01d951f1e4abaa345ee686d8b08c9066f183ca)) +* **package:** drop Python 3.8 support ([e36cf2b](https://github.com/browserbase/sdk-python/commit/e36cf2bcad59f24078b30d5e463e0e2325f9439c)) +* speedup initial import ([ca27085](https://github.com/browserbase/sdk-python/commit/ca270852b7f1c3dd5544f73daeec8ddb41eac253)) +* update lockfile ([738e9be](https://github.com/browserbase/sdk-python/commit/738e9be4acd99ad82e69ea876f1249948310f896)) +* update mock server docs ([9c92875](https://github.com/browserbase/sdk-python/commit/9c928759fb88085da94df9921ef35663d1da926e)) + ## 1.5.0-alpha.1 (2025-10-07) Full Changelog: [v1.5.0-alpha.0...v1.5.0-alpha.1](https://github.com/browserbase/sdk-python/compare/v1.5.0-alpha.0...v1.5.0-alpha.1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f8bfea6..4f99ed67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,8 +88,7 @@ $ pip install ./path-to-wheel-file.whl Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. ```sh -# you will need npm installed -$ npx prism mock path/to/your/openapi.yml +$ ./scripts/mock ``` ```sh diff --git a/LICENSE b/LICENSE index 2cec9d4b..9d3232f7 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Browserbase + Copyright 2026 Browserbase Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 5c1155d1..715a9079 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![PyPI version](https://img.shields.io/pypi/v/browserbase.svg?label=pypi%20(stable))](https://pypi.org/project/browserbase/) -The Browserbase Python library provides convenient access to the Browserbase REST API from any Python 3.8+ +The Browserbase Python library provides convenient access to the Browserbase 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). @@ -17,7 +17,7 @@ The REST API documentation can be found on [docs.browserbase.com](https://docs.b ```sh # install from PyPI -pip install --pre browserbase +pip install '--pre browserbase' ``` ## Usage @@ -40,7 +40,7 @@ client = Browserbase( session = client.sessions.create( project_id=BROWSERBASE_PROJECT_ID, ) -print(session.id) +``` def run(playwright: Playwright) -> None: @@ -90,12 +90,13 @@ You can enable this by installing `aiohttp`: ```sh # install from PyPI -pip install --pre browserbase[aiohttp] +pip install '--pre browserbase[aiohttp]' ``` Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python +import os import asyncio from browserbase import DefaultAioHttpClient from browserbase import AsyncBrowserbase @@ -103,13 +104,12 @@ from browserbase import AsyncBrowserbase async def main() -> None: async with AsyncBrowserbase( - api_key="My API Key", + api_key=os.environ.get("BROWSERBASE_API_KEY"), # This is the default and can be omitted http_client=DefaultAioHttpClient(), ) as client: session = await client.sessions.create( project_id="your_project_id", ) - print(session.id) asyncio.run(main()) @@ -134,7 +134,6 @@ from browserbase import Browserbase client = Browserbase() session = client.sessions.create( - project_id="projectId", browser_settings={}, ) print(session.browser_settings) @@ -292,7 +291,7 @@ response = client.sessions.with_raw_response.create( print(response.headers.get('X-My-Header')) session = response.parse() # get the object that `sessions.create()` would have returned -print(session.id) +print(session) ``` These methods return an [`APIResponse`](https://github.com/browserbase/sdk-python/tree/main/src/browserbase/_response.py) object. @@ -418,7 +417,7 @@ print(browserbase.__version__) ## Requirements -Python 3.8 or higher. +Python 3.9 or higher. ## Contributing diff --git a/api.md b/api.md index 01454851..d2d26e57 100644 --- a/api.md +++ b/api.md @@ -3,27 +3,28 @@ Types: ```python -from browserbase.types import ContextCreateResponse, ContextRetrieveResponse, ContextUpdateResponse +from browserbase.types import Context, ContextCreateResponse, ContextUpdateResponse ``` Methods: - client.contexts.create(\*\*params) -> ContextCreateResponse -- client.contexts.retrieve(id) -> ContextRetrieveResponse +- client.contexts.retrieve(id) -> Context - client.contexts.update(id) -> ContextUpdateResponse +- client.contexts.delete(id) -> None # Extensions Types: ```python -from browserbase.types import ExtensionCreateResponse, ExtensionRetrieveResponse +from browserbase.types import Extension ``` Methods: -- client.extensions.create(\*\*params) -> ExtensionCreateResponse -- client.extensions.retrieve(id) -> ExtensionRetrieveResponse +- client.extensions.create(\*\*params) -> Extension +- client.extensions.retrieve(id) -> Extension - client.extensions.delete(id) -> None # Projects @@ -31,14 +32,14 @@ Methods: Types: ```python -from browserbase.types import ProjectRetrieveResponse, ProjectListResponse, ProjectUsageResponse +from browserbase.types import Project, ProjectUsage, ProjectListResponse ``` Methods: -- client.projects.retrieve(id) -> ProjectRetrieveResponse +- client.projects.retrieve(id) -> Project - client.projects.list() -> ProjectListResponse -- client.projects.usage(id) -> ProjectUsageResponse +- client.projects.usage(id) -> ProjectUsage # Sessions @@ -46,11 +47,11 @@ Types: ```python from browserbase.types import ( + Session, + SessionLiveURLs, SessionCreateResponse, SessionRetrieveResponse, - SessionUpdateResponse, SessionListResponse, - SessionDebugResponse, ) ``` @@ -58,9 +59,9 @@ Methods: - client.sessions.create(\*\*params) -> SessionCreateResponse - client.sessions.retrieve(id) -> SessionRetrieveResponse -- client.sessions.update(id, \*\*params) -> SessionUpdateResponse +- client.sessions.update(id, \*\*params) -> Session - client.sessions.list(\*\*params) -> SessionListResponse -- client.sessions.debug(id) -> SessionDebugResponse +- client.sessions.debug(id) -> SessionLiveURLs ## Downloads @@ -73,7 +74,7 @@ Methods: Types: ```python -from browserbase.types.sessions import LogListResponse +from browserbase.types.sessions import SessionLog, LogListResponse ``` Methods: @@ -85,7 +86,7 @@ Methods: Types: ```python -from browserbase.types.sessions import RecordingRetrieveResponse +from browserbase.types.sessions import SessionRecording, RecordingRetrieveResponse ``` Methods: diff --git a/examples/playwright_extensions.py b/examples/playwright_extensions.py index f2c2f7f9..b7f6332c 100644 --- a/examples/playwright_extensions.py +++ b/examples/playwright_extensions.py @@ -10,7 +10,7 @@ BROWSERBASE_PROJECT_ID, bb, ) -from browserbase.types import SessionCreateResponse, ExtensionRetrieveResponse +from browserbase.types import Extension, SessionCreateResponse PATH_TO_EXTENSION = Path.cwd() / "examples" / "packages" / "extensions" / "browserbase-test" @@ -51,7 +51,7 @@ def create_extension() -> str: return extension.id -def get_extension(id: str) -> ExtensionRetrieveResponse: +def get_extension(id: str) -> Extension: return bb.extensions.retrieve(id) diff --git a/pyproject.toml b/pyproject.toml index 217c1da2..4d5c491a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,30 +1,32 @@ [project] name = "browserbase" -version = "1.5.0-alpha.1" +version = "1.5.0-alpha.2" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" authors = [ { name = "Browserbase", email = "support@browserbase.com" }, ] + dependencies = [ - "httpx>=0.23.0, <1", - "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", - "anyio>=3.5.0, <5", - "distro>=1.7.0, <2", - "sniffio", + "httpx>=0.23.0, <1", + "pydantic>=1.9.0, <3", + "typing-extensions>=4.10, <5", + "anyio>=3.5.0, <5", + "distro>=1.7.0, <2", + "sniffio", ] -requires-python = ">= 3.8" + +requires-python = ">= 3.9" classifiers = [ "Typing :: Typed", "Intended Audience :: Developers", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -39,14 +41,14 @@ Homepage = "https://github.com/browserbase/sdk-python" Repository = "https://github.com/browserbase/sdk-python" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"] [tool.rye] managed = true # version pins are in requirements-dev.lock dev-dependencies = [ "pyright==1.1.399", - "mypy", + "mypy==1.17", "respx", "pytest", "pytest-asyncio", @@ -71,7 +73,7 @@ format = { chain = [ # run formatting again to fix any inconsistencies when imports are stripped "format:ruff", ]} -"format:docs" = "python scripts/utils/ruffen-docs.py README.md api.md" +"format:docs" = "bash -c 'python scripts/utils/ruffen-docs.py README.md $(find . -type f -name api.md)'" "format:ruff" = "ruff format" "lint" = { chain = [ @@ -150,7 +152,7 @@ filterwarnings = [ # there are a couple of flags that are still disabled by # default in strict mode as they are experimental and niche. typeCheckingMode = "strict" -pythonVersion = "3.8" +pythonVersion = "3.9" exclude = [ "_dev", @@ -233,6 +235,8 @@ select = [ "B", # remove unused imports "F401", + # check for missing future annotations + "FA102", # bare except statements "E722", # unused arguments @@ -255,6 +259,8 @@ unfixable = [ "T203", ] +extend-safe-fixes = ["FA102"] + [tool.ruff.lint.flake8-tidy-imports.banned-api] "functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" diff --git a/requirements-dev.lock b/requirements-dev.lock index e6008248..79e2e13d 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,176 +12,190 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.15 +aiohttp==3.13.3 # via browserbase # via httpx-aiohttp aiosignal==1.4.0 # via aiohttp annotated-types==0.7.0 # via pydantic -anyio==4.6.2.post1 +anyio==4.12.1 # via browserbase # via httpx -argcomplete==3.5.1 +argcomplete==3.6.3 # via nox async-timeout==5.0.1 # via aiohttp -attrs==24.2.0 +attrs==25.4.0 # via aiohttp + # via nox # via outcome # via trio -certifi==2024.8.30 +backports-asyncio-runner==1.2.0 + # via pytest-asyncio +certifi==2026.1.4 # via httpcore # via httpx # via requests # via selenium -charset-normalizer==3.4.0 +charset-normalizer==3.4.4 # via requests -colorlog==6.8.2 +colorlog==6.10.1 # via nox -dirty-equals==0.8.0 -distlib==0.3.9 +dependency-groups==1.3.1 + # via nox +dirty-equals==0.11 +distlib==0.4.0 # via virtualenv distro==1.9.0 # via browserbase -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio # via pytest # via trio # via trio-websocket -execnet==2.1.1 +execnet==2.1.2 # via pytest-xdist -filelock==3.16.1 +filelock==3.19.1 # via virtualenv -frozenlist==1.7.0 +frozenlist==1.8.0 # via aiohttp # via aiosignal -greenlet==3.1.1 +greenlet==3.2.5 # via playwright -h11==0.14.0 +h11==0.16.0 # via httpcore # via wsproto -httpcore==1.0.6 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via browserbase # via httpx-aiohttp # via respx -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.12 # via browserbase -idna==3.10 +humanize==4.13.0 + # via nox +idna==3.11 # via anyio # via httpx # via requests # via trio # via yarl -importlib-metadata==8.5.0 -iniconfig==2.0.0 +importlib-metadata==8.7.1 +iniconfig==2.1.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -multidict==6.6.4 +multidict==6.7.0 # via aiohttp # via yarl -mypy==1.14.1 -mypy-extensions==1.0.0 +mypy==1.17.0 +mypy-extensions==1.1.0 # via mypy -nodeenv==1.9.1 +nodeenv==1.10.0 # via pyright -nox==2024.10.9 +nox==2025.11.12 outcome==1.3.0.post0 # via trio -packaging==24.1 + # via trio-websocket +packaging==25.0 + # via dependency-groups # via nox # via pytest -platformdirs==3.11.0 +pathspec==1.0.3 + # via mypy +platformdirs==4.4.0 # via virtualenv -playwright==1.48.0 +playwright==1.58.0 # via pytest-playwright -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -propcache==0.3.2 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via browserbase -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -pyee==12.0.0 +pyee==13.0.1 # via playwright -pygments==2.18.0 +pygments==2.19.2 + # via pytest # via rich pyright==1.1.399 pysocks==1.7.1 # via urllib3 -pytest==8.3.3 +pytest==8.4.2 # via pytest-asyncio # via pytest-base-url # via pytest-playwright # via pytest-xdist -pytest-asyncio==0.24.0 +pytest-asyncio==1.2.0 pytest-base-url==2.1.0 # via pytest-playwright -pytest-playwright==0.5.2 +pytest-playwright==0.7.1 pytest-xdist==3.8.0 python-dateutil==2.9.0.post0 # via time-machine -python-dotenv==1.0.1 +python-dotenv==1.2.1 python-slugify==8.0.4 # via pytest-playwright -requests==2.32.3 +requests==2.32.5 # via pytest-base-url respx==0.22.0 -rich==13.9.3 -ruff==0.7.1 -selenium==4.25.0 -six==1.16.0 +rich==14.2.0 +ruff==0.14.13 +selenium==4.36.0 +six==1.17.0 # via python-dateutil sniffio==1.3.1 - # via anyio # via browserbase # via trio sortedcontainers==2.4.0 # via trio text-unidecode==1.3 # via python-slugify -time-machine==2.16.0 -tomli==2.0.2 +time-machine==2.19.0 +tomli==2.4.0 + # via dependency-groups # via mypy # via nox # via pytest -trio==0.27.0 +trio==0.31.0 # via selenium # via trio-websocket -trio-websocket==0.11.1 +trio-websocket==0.12.2 # via selenium -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via aiosignal # via anyio # via browserbase + # via exceptiongroup # via multidict # via mypy # via pydantic # via pydantic-core # via pyee # via pyright + # via pytest-asyncio + # via selenium # via typing-inspection -typing-inspection==0.4.1 + # via virtualenv +typing-inspection==0.4.2 # via pydantic - # via rich - # via selenium -urllib3==2.2.3 +urllib3==2.6.3 # via requests # via selenium -virtualenv==20.24.5 +virtualenv==20.36.1 # via nox -websocket-client==1.8.0 +websocket-client==1.9.0 # via selenium wsproto==1.2.0 # via trio-websocket -yarl==1.20.1 +yarl==1.22.0 # via aiohttp -zipp==3.20.2 +zipp==3.23.0 # via importlib-metadata diff --git a/requirements.lock b/requirements.lock index 2495a260..ba67caa6 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,28 +12,28 @@ -e file:. aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.12.8 +aiohttp==3.13.3 # via browserbase # via httpx-aiohttp -aiosignal==1.3.2 +aiosignal==1.4.0 # via aiohttp -annotated-types==0.6.0 +annotated-types==0.7.0 # via pydantic -anyio==4.4.0 +anyio==4.12.1 # via browserbase # via httpx async-timeout==5.0.1 # via aiohttp -attrs==25.3.0 +attrs==25.4.0 # via aiohttp -certifi==2023.7.22 +certifi==2026.1.4 # via httpcore # via httpx -distro==1.8.0 +distro==1.9.0 # via browserbase -exceptiongroup==1.2.2 +exceptiongroup==1.3.1 # via anyio -frozenlist==1.6.2 +frozenlist==1.8.0 # via aiohttp # via aiosignal h11==0.16.0 @@ -43,33 +43,34 @@ httpcore==1.0.9 httpx==0.28.1 # via browserbase # via httpx-aiohttp -httpx-aiohttp==0.1.8 +httpx-aiohttp==0.1.12 # via browserbase -idna==3.4 +idna==3.11 # via anyio # via httpx # via yarl -multidict==6.4.4 +multidict==6.7.0 # via aiohttp # via yarl -propcache==0.3.1 +propcache==0.4.1 # via aiohttp # via yarl -pydantic==2.11.9 +pydantic==2.12.5 # via browserbase -pydantic-core==2.33.2 +pydantic-core==2.41.5 # via pydantic -sniffio==1.3.0 - # via anyio +sniffio==1.3.1 # via browserbase -typing-extensions==4.12.2 +typing-extensions==4.15.0 + # via aiosignal # via anyio # via browserbase + # via exceptiongroup # via multidict # via pydantic # via pydantic-core # via typing-inspection -typing-inspection==0.4.1 +typing-inspection==0.4.2 # via pydantic -yarl==1.20.0 +yarl==1.22.0 # via aiohttp diff --git a/scripts/lint b/scripts/lint index feccbdde..7bc921af 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,8 +4,13 @@ set -e cd "$(dirname "$0")/.." -echo "==> Running lints" -rye run lint +if [ "$1" = "--fix" ]; then + echo "==> Running lints with --fix" + rye run fix:ruff +else + echo "==> Running lints" + rye run lint +fi echo "==> Making sure it imports" rye run python -c 'import browserbase' diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index 2485e4e6..5bc9823d 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_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, @@ -83,6 +86,7 @@ APIConnectionError, APIResponseValidationError, ) +from ._utils._json import openapi_dumps log: logging.Logger = logging.getLogger(__name__) @@ -477,8 +481,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,10 +547,18 @@ 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 + elif not files: + # Don't set content when JSON is sent as multipart/form-data, + # since httpx's content param overrides other body arguments + kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None kwargs["files"] = files else: headers.pop("Content-Type", None) @@ -1194,6 +1217,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 +1230,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 +1244,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 +1257,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,9 +1285,24 @@ def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: BinaryTypes | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", 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 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, content=content, files=to_httpx_files(files), **options + ) return self.request(cast_to, opts) def put( @@ -1258,11 +1311,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) @@ -1272,9 +1337,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( @@ -1714,6 +1789,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, @@ -1726,6 +1802,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], @@ -1739,6 +1816,7 @@ async def post( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, @@ -1751,13 +1829,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) @@ -1767,9 +1857,29 @@ async def patch( *, cast_to: Type[ResponseT], body: Body | None = None, + content: AsyncBinaryTypes | None = None, + files: RequestFiles | None = None, options: RequestOptions = {}, ) -> ResponseT: - opts = FinalRequestOptions.construct(method="patch", 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 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, + content=content, + files=await async_to_httpx_files(files), + **options, + ) return await self.request(cast_to, opts) async def put( @@ -1778,11 +1888,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) @@ -1792,9 +1914,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/browserbase/_client.py b/src/browserbase/_client.py index 8b54a5bb..5bb997a7 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from typing import Any, Mapping +from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, override import httpx @@ -20,8 +20,8 @@ not_given, ) from ._utils import is_given, get_async_library +from ._compat import cached_property from ._version import __version__ -from .resources import contexts, projects, extensions from ._streaming import Stream as Stream, AsyncStream as AsyncStream from ._exceptions import APIStatusError, BrowserbaseError from ._base_client import ( @@ -29,7 +29,13 @@ SyncAPIClient, AsyncAPIClient, ) -from .resources.sessions import sessions + +if TYPE_CHECKING: + from .resources import contexts, projects, sessions, extensions + from .resources.contexts import ContextsResource, AsyncContextsResource + from .resources.projects import ProjectsResource, AsyncProjectsResource + from .resources.extensions import ExtensionsResource, AsyncExtensionsResource + from .resources.sessions.sessions import SessionsResource, AsyncSessionsResource __all__ = [ "Timeout", @@ -44,13 +50,6 @@ class Browserbase(SyncAPIClient): - contexts: contexts.ContextsResource - extensions: extensions.ExtensionsResource - projects: projects.ProjectsResource - sessions: sessions.SessionsResource - with_raw_response: BrowserbaseWithRawResponse - with_streaming_response: BrowserbaseWithStreamedResponse - # client options api_key: str @@ -105,12 +104,37 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.contexts = contexts.ContextsResource(self) - self.extensions = extensions.ExtensionsResource(self) - self.projects = projects.ProjectsResource(self) - self.sessions = sessions.SessionsResource(self) - self.with_raw_response = BrowserbaseWithRawResponse(self) - self.with_streaming_response = BrowserbaseWithStreamedResponse(self) + @cached_property + def contexts(self) -> ContextsResource: + from .resources.contexts import ContextsResource + + return ContextsResource(self) + + @cached_property + def extensions(self) -> ExtensionsResource: + from .resources.extensions import ExtensionsResource + + return ExtensionsResource(self) + + @cached_property + def projects(self) -> ProjectsResource: + from .resources.projects import ProjectsResource + + return ProjectsResource(self) + + @cached_property + def sessions(self) -> SessionsResource: + from .resources.sessions import SessionsResource + + return SessionsResource(self) + + @cached_property + def with_raw_response(self) -> BrowserbaseWithRawResponse: + return BrowserbaseWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> BrowserbaseWithStreamedResponse: + return BrowserbaseWithStreamedResponse(self) @property @override @@ -218,13 +242,6 @@ def _make_status_error( class AsyncBrowserbase(AsyncAPIClient): - contexts: contexts.AsyncContextsResource - extensions: extensions.AsyncExtensionsResource - projects: projects.AsyncProjectsResource - sessions: sessions.AsyncSessionsResource - with_raw_response: AsyncBrowserbaseWithRawResponse - with_streaming_response: AsyncBrowserbaseWithStreamedResponse - # client options api_key: str @@ -279,12 +296,37 @@ def __init__( _strict_response_validation=_strict_response_validation, ) - self.contexts = contexts.AsyncContextsResource(self) - self.extensions = extensions.AsyncExtensionsResource(self) - self.projects = projects.AsyncProjectsResource(self) - self.sessions = sessions.AsyncSessionsResource(self) - self.with_raw_response = AsyncBrowserbaseWithRawResponse(self) - self.with_streaming_response = AsyncBrowserbaseWithStreamedResponse(self) + @cached_property + def contexts(self) -> AsyncContextsResource: + from .resources.contexts import AsyncContextsResource + + return AsyncContextsResource(self) + + @cached_property + def extensions(self) -> AsyncExtensionsResource: + from .resources.extensions import AsyncExtensionsResource + + return AsyncExtensionsResource(self) + + @cached_property + def projects(self) -> AsyncProjectsResource: + from .resources.projects import AsyncProjectsResource + + return AsyncProjectsResource(self) + + @cached_property + def sessions(self) -> AsyncSessionsResource: + from .resources.sessions import AsyncSessionsResource + + return AsyncSessionsResource(self) + + @cached_property + def with_raw_response(self) -> AsyncBrowserbaseWithRawResponse: + return AsyncBrowserbaseWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncBrowserbaseWithStreamedResponse: + return AsyncBrowserbaseWithStreamedResponse(self) @property @override @@ -392,35 +434,127 @@ def _make_status_error( class BrowserbaseWithRawResponse: + _client: Browserbase + def __init__(self, client: Browserbase) -> None: - self.contexts = contexts.ContextsResourceWithRawResponse(client.contexts) - self.extensions = extensions.ExtensionsResourceWithRawResponse(client.extensions) - self.projects = projects.ProjectsResourceWithRawResponse(client.projects) - self.sessions = sessions.SessionsResourceWithRawResponse(client.sessions) + self._client = client + + @cached_property + def contexts(self) -> contexts.ContextsResourceWithRawResponse: + from .resources.contexts import ContextsResourceWithRawResponse + + return ContextsResourceWithRawResponse(self._client.contexts) + + @cached_property + def extensions(self) -> extensions.ExtensionsResourceWithRawResponse: + from .resources.extensions import ExtensionsResourceWithRawResponse + + return ExtensionsResourceWithRawResponse(self._client.extensions) + + @cached_property + def projects(self) -> projects.ProjectsResourceWithRawResponse: + from .resources.projects import ProjectsResourceWithRawResponse + + return ProjectsResourceWithRawResponse(self._client.projects) + + @cached_property + def sessions(self) -> sessions.SessionsResourceWithRawResponse: + from .resources.sessions import SessionsResourceWithRawResponse + + return SessionsResourceWithRawResponse(self._client.sessions) class AsyncBrowserbaseWithRawResponse: + _client: AsyncBrowserbase + def __init__(self, client: AsyncBrowserbase) -> None: - self.contexts = contexts.AsyncContextsResourceWithRawResponse(client.contexts) - self.extensions = extensions.AsyncExtensionsResourceWithRawResponse(client.extensions) - self.projects = projects.AsyncProjectsResourceWithRawResponse(client.projects) - self.sessions = sessions.AsyncSessionsResourceWithRawResponse(client.sessions) + self._client = client + + @cached_property + def contexts(self) -> contexts.AsyncContextsResourceWithRawResponse: + from .resources.contexts import AsyncContextsResourceWithRawResponse + + return AsyncContextsResourceWithRawResponse(self._client.contexts) + + @cached_property + def extensions(self) -> extensions.AsyncExtensionsResourceWithRawResponse: + from .resources.extensions import AsyncExtensionsResourceWithRawResponse + + return AsyncExtensionsResourceWithRawResponse(self._client.extensions) + + @cached_property + def projects(self) -> projects.AsyncProjectsResourceWithRawResponse: + from .resources.projects import AsyncProjectsResourceWithRawResponse + + return AsyncProjectsResourceWithRawResponse(self._client.projects) + + @cached_property + def sessions(self) -> sessions.AsyncSessionsResourceWithRawResponse: + from .resources.sessions import AsyncSessionsResourceWithRawResponse + + return AsyncSessionsResourceWithRawResponse(self._client.sessions) class BrowserbaseWithStreamedResponse: + _client: Browserbase + def __init__(self, client: Browserbase) -> None: - self.contexts = contexts.ContextsResourceWithStreamingResponse(client.contexts) - self.extensions = extensions.ExtensionsResourceWithStreamingResponse(client.extensions) - self.projects = projects.ProjectsResourceWithStreamingResponse(client.projects) - self.sessions = sessions.SessionsResourceWithStreamingResponse(client.sessions) + self._client = client + + @cached_property + def contexts(self) -> contexts.ContextsResourceWithStreamingResponse: + from .resources.contexts import ContextsResourceWithStreamingResponse + + return ContextsResourceWithStreamingResponse(self._client.contexts) + + @cached_property + def extensions(self) -> extensions.ExtensionsResourceWithStreamingResponse: + from .resources.extensions import ExtensionsResourceWithStreamingResponse + + return ExtensionsResourceWithStreamingResponse(self._client.extensions) + + @cached_property + def projects(self) -> projects.ProjectsResourceWithStreamingResponse: + from .resources.projects import ProjectsResourceWithStreamingResponse + + return ProjectsResourceWithStreamingResponse(self._client.projects) + + @cached_property + def sessions(self) -> sessions.SessionsResourceWithStreamingResponse: + from .resources.sessions import SessionsResourceWithStreamingResponse + + return SessionsResourceWithStreamingResponse(self._client.sessions) class AsyncBrowserbaseWithStreamedResponse: + _client: AsyncBrowserbase + def __init__(self, client: AsyncBrowserbase) -> None: - self.contexts = contexts.AsyncContextsResourceWithStreamingResponse(client.contexts) - self.extensions = extensions.AsyncExtensionsResourceWithStreamingResponse(client.extensions) - self.projects = projects.AsyncProjectsResourceWithStreamingResponse(client.projects) - self.sessions = sessions.AsyncSessionsResourceWithStreamingResponse(client.sessions) + self._client = client + + @cached_property + def contexts(self) -> contexts.AsyncContextsResourceWithStreamingResponse: + from .resources.contexts import AsyncContextsResourceWithStreamingResponse + + return AsyncContextsResourceWithStreamingResponse(self._client.contexts) + + @cached_property + def extensions(self) -> extensions.AsyncExtensionsResourceWithStreamingResponse: + from .resources.extensions import AsyncExtensionsResourceWithStreamingResponse + + return AsyncExtensionsResourceWithStreamingResponse(self._client.extensions) + + @cached_property + def projects(self) -> projects.AsyncProjectsResourceWithStreamingResponse: + from .resources.projects import AsyncProjectsResourceWithStreamingResponse + + return AsyncProjectsResourceWithStreamingResponse(self._client.projects) + + @cached_property + def sessions(self) -> sessions.AsyncSessionsResourceWithStreamingResponse: + from .resources.sessions import AsyncSessionsResourceWithStreamingResponse + + return AsyncSessionsResourceWithStreamingResponse(self._client.sessions) Client = Browserbase diff --git a/src/browserbase/_compat.py b/src/browserbase/_compat.py index bdef67f0..786ff42a 100644 --- a/src/browserbase/_compat.py +++ b/src/browserbase/_compat.py @@ -139,6 +139,7 @@ def model_dump( exclude_defaults: bool = False, warnings: bool = True, mode: Literal["json", "python"] = "python", + by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): return model.model_dump( @@ -148,13 +149,12 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, + by_alias=by_alias, ) return cast( "dict[str, Any]", model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast] - exclude=exclude, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, + exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias) ), ) diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index 6a3cd1d2..29070e05 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -2,7 +2,21 @@ import os import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast +import weakref +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, @@ -256,15 +270,16 @@ def model_dump( mode: Literal["json", "python"] | str = "python", include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, - serialize_as_any: bool = False, fallback: Callable[[Any], Any] | None = None, + serialize_as_any: bool = False, ) -> dict[str, Any]: """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump @@ -272,16 +287,24 @@ def model_dump( Args: mode: The mode in which `to_python` should run. - If mode is 'json', the dictionary will only contain JSON serializable types. - If mode is 'python', the dictionary may contain any Python objects. - include: A list of fields to include in the output. - exclude: A list of fields to exclude from the output. + If mode is 'json', the output will only contain JSON serializable types. + If mode is 'python', the output may contain non-JSON-serializable Python objects. + include: A set of fields to include in the output. + exclude: A set of fields to exclude from the output. + context: Additional context to pass to the serializer. by_alias: Whether to use the field's alias in the dictionary key if defined. - exclude_unset: Whether to exclude fields that are unset or None from the output. - exclude_defaults: Whether to exclude fields that are set to their default value from the output. - exclude_none: Whether to exclude fields that have a value of `None` from the output. - round_trip: Whether to enable serialization and deserialization round-trip support. - warnings: Whether to log warnings when invalid fields are encountered. + exclude_unset: Whether to exclude fields that have not been explicitly set. + exclude_defaults: Whether to exclude fields that are set to their default value. + exclude_none: Whether to exclude fields that have a value of `None`. + exclude_computed_fields: Whether to exclude computed fields. + While this can be useful for round-tripping, it is usually recommended to use the dedicated + `round_trip` parameter instead. + round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T]. + warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors, + "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. + fallback: A function to call when an unknown value is encountered. If not provided, + a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised. + serialize_as_any: Whether to serialize fields with duck-typing serialization behavior. Returns: A dictionary representation of the model. @@ -298,6 +321,8 @@ def model_dump( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") dumped = super().dict( # pyright: ignore[reportDeprecated] include=include, exclude=exclude, @@ -314,15 +339,17 @@ def model_dump_json( self, *, indent: int | None = None, + ensure_ascii: bool = False, include: IncEx | None = None, exclude: IncEx | None = None, + context: Any | None = None, by_alias: bool | None = None, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, + exclude_computed_fields: bool = False, round_trip: bool = False, warnings: bool | Literal["none", "warn", "error"] = True, - context: dict[str, Any] | None = None, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, ) -> str: @@ -354,6 +381,10 @@ def model_dump_json( raise ValueError("serialize_as_any is only supported in Pydantic v2") if fallback is not None: raise ValueError("fallback is only supported in Pydantic v2") + if ensure_ascii != False: + raise ValueError("ensure_ascii is only supported in Pydantic v2") + if exclude_computed_fields != False: + raise ValueError("exclude_computed_fields is only supported in Pydantic v2") return super().json( # type: ignore[reportDeprecated] indent=indent, include=include, @@ -573,6 +604,9 @@ class CachedDiscriminatorType(Protocol): __discriminator__: DiscriminatorDetails +DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary() + + class DiscriminatorDetails: field_name: str """The name of the discriminator field in the variant class, e.g. @@ -615,8 +649,9 @@ def __init__( def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None: - if isinstance(union, CachedDiscriminatorType): - return union.__discriminator__ + cached = DISCRIMINATOR_CACHE.get(union) + if cached is not None: + return cached discriminator_field_name: str | None = None @@ -669,7 +704,7 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, discriminator_field=discriminator_field_name, discriminator_alias=discriminator_alias, ) - cast(CachedDiscriminatorType, union).__discriminator__ = details + DISCRIMINATOR_CACHE.setdefault(union, details) return details @@ -765,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 @@ -783,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/browserbase/_response.py b/src/browserbase/_response.py index 5f8d0f48..eeef6426 100644 --- a/src/browserbase/_response.py +++ b/src/browserbase/_response.py @@ -152,6 +152,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: ), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -162,6 +163,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=extract_stream_chunk_type(self._stream_cls), response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) @@ -175,6 +177,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: cast_to=cast_to, response=self.http_response, client=cast(Any, self._client), + options=self._options, ), ) diff --git a/src/browserbase/_streaming.py b/src/browserbase/_streaming.py index c04b2332..d1ebde6c 100644 --- a/src/browserbase/_streaming.py +++ b/src/browserbase/_streaming.py @@ -4,7 +4,7 @@ import json import inspect from types import TracebackType -from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, AsyncIterator, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, Optional, AsyncIterator, cast from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable import httpx @@ -13,6 +13,7 @@ if TYPE_CHECKING: from ._client import Browserbase, AsyncBrowserbase + from ._models import FinalRequestOptions _T = TypeVar("_T") @@ -22,7 +23,7 @@ class Stream(Generic[_T]): """Provides the core interface to iterate over a synchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEBytesDecoder def __init__( @@ -31,10 +32,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: Browserbase, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() @@ -54,12 +57,12 @@ def __stream__(self) -> Iterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # Ensure the entire stream is consumed - for _sse in iterator: - ... + try: + for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + response.close() def __enter__(self) -> Self: return self @@ -85,7 +88,7 @@ class AsyncStream(Generic[_T]): """Provides the core interface to iterate over an asynchronous stream response.""" response: httpx.Response - + _options: Optional[FinalRequestOptions] = None _decoder: SSEDecoder | SSEBytesDecoder def __init__( @@ -94,10 +97,12 @@ def __init__( cast_to: type[_T], response: httpx.Response, client: AsyncBrowserbase, + options: Optional[FinalRequestOptions] = None, ) -> None: self.response = response self._cast_to = cast_to self._client = client + self._options = options self._decoder = client._make_sse_decoder() self._iterator = self.__stream__() @@ -118,12 +123,12 @@ async def __stream__(self) -> AsyncIterator[_T]: process_data = self._client._process_response_data iterator = self._iter_events() - async for sse in iterator: - yield process_data(data=sse.json(), cast_to=cast_to, response=response) - - # Ensure the entire stream is consumed - async for _sse in iterator: - ... + try: + async for sse in iterator: + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + finally: + # Ensure the response is closed even if the consumer doesn't read all data + await response.aclose() async def __aenter__(self) -> Self: return self diff --git a/src/browserbase/_types.py b/src/browserbase/_types.py index f86be54d..abefae08 100644 --- a/src/browserbase/_types.py +++ b/src/browserbase/_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, @@ -243,6 +252,9 @@ class HttpxSendArgs(TypedDict, total=False): if TYPE_CHECKING: # This works because str.__contains__ does not accept object (either in typeshed or at runtime) # https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285 + # + # Note: index() and count() methods are intentionally omitted to allow pyright to properly + # infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr. class SequenceNotStr(Protocol[_T_co]): @overload def __getitem__(self, index: SupportsIndex, /) -> _T_co: ... @@ -251,8 +263,6 @@ def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ... def __contains__(self, value: object, /) -> bool: ... def __len__(self) -> int: ... def __iter__(self) -> Iterator[_T_co]: ... - def index(self, value: Any, start: int = 0, stop: int = ..., /) -> int: ... - def count(self, value: Any, /) -> int: ... def __reversed__(self) -> Iterator[_T_co]: ... else: # just point this to a normal `Sequence` at runtime to avoid having to special case diff --git a/src/browserbase/_utils/_compat.py b/src/browserbase/_utils/_compat.py index dd703233..2c70b299 100644 --- a/src/browserbase/_utils/_compat.py +++ b/src/browserbase/_utils/_compat.py @@ -26,7 +26,7 @@ def is_union(tp: Optional[Type[Any]]) -> bool: else: import types - return tp is Union or tp is types.UnionType + return tp is Union or tp is types.UnionType # type: ignore[comparison-overlap] def is_typeddict(tp: Type[Any]) -> bool: diff --git a/src/browserbase/_utils/_json.py b/src/browserbase/_utils/_json.py new file mode 100644 index 00000000..60584214 --- /dev/null +++ b/src/browserbase/_utils/_json.py @@ -0,0 +1,35 @@ +import json +from typing import Any +from datetime import datetime +from typing_extensions import override + +import pydantic + +from .._compat import model_dump + + +def openapi_dumps(obj: Any) -> bytes: + """ + Serialize an object to UTF-8 encoded JSON bytes. + + Extends the standard json.dumps with support for additional types + commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc. + """ + return json.dumps( + obj, + cls=_CustomEncoder, + # Uses the same defaults as httpx's JSON serialization + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ).encode() + + +class _CustomEncoder(json.JSONEncoder): + @override + def default(self, o: Any) -> Any: + if isinstance(o, datetime): + return o.isoformat() + if isinstance(o, pydantic.BaseModel): + return model_dump(o, exclude_unset=True, mode="json", by_alias=True) + return super().default(o) diff --git a/src/browserbase/_utils/_sync.py b/src/browserbase/_utils/_sync.py index ad7ec71b..f6027c18 100644 --- a/src/browserbase/_utils/_sync.py +++ b/src/browserbase/_utils/_sync.py @@ -1,10 +1,8 @@ from __future__ import annotations -import sys import asyncio import functools -import contextvars -from typing import Any, TypeVar, Callable, Awaitable +from typing import TypeVar, Callable, Awaitable from typing_extensions import ParamSpec import anyio @@ -15,34 +13,11 @@ T_ParamSpec = ParamSpec("T_ParamSpec") -if sys.version_info >= (3, 9): - _asyncio_to_thread = asyncio.to_thread -else: - # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread - # for Python 3.8 support - async def _asyncio_to_thread( - func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs - ) -> Any: - """Asynchronously run function *func* in a separate thread. - - Any *args and **kwargs supplied for this function are directly passed - to *func*. Also, the current :class:`contextvars.Context` is propagated, - allowing context variables from the main thread to be accessed in the - separate thread. - - Returns a coroutine that can be awaited to get the eventual result of *func*. - """ - loop = asyncio.events.get_running_loop() - ctx = contextvars.copy_context() - func_call = functools.partial(ctx.run, func, *args, **kwargs) - return await loop.run_in_executor(None, func_call) - - async def to_thread( func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs ) -> T_Retval: if sniffio.current_async_library() == "asyncio": - return await _asyncio_to_thread(func, *args, **kwargs) + return await asyncio.to_thread(func, *args, **kwargs) return await anyio.to_thread.run_sync( functools.partial(func, *args, **kwargs), @@ -53,10 +28,7 @@ async def to_thread( def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ Take a blocking function and create an async one that receives the same - positional and keyword arguments. For python version 3.9 and above, it uses - asyncio.to_thread to run the function in a separate thread. For python version - 3.8, it uses locally defined copy of the asyncio.to_thread function which was - introduced in python 3.9. + positional and keyword arguments. Usage: diff --git a/src/browserbase/_utils/_utils.py b/src/browserbase/_utils/_utils.py index 50d59269..eec7f4a1 100644 --- a/src/browserbase/_utils/_utils.py +++ b/src/browserbase/_utils/_utils.py @@ -133,7 +133,7 @@ def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]: # Type safe methods for narrowing types with TypeVars. # The default narrowing for isinstance(obj, dict) is dict[unknown, unknown], # however this cause Pyright to rightfully report errors. As we know we don't -# care about the contained types we can safely use `object` in it's place. +# care about the contained types we can safely use `object` in its place. # # There are two separate functions defined, `is_*` and `is_*_t` for different use cases. # `is_*` is for when you're dealing with an unknown input diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 6fa8f70b..afe412cf 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.5.0-alpha.1" # x-release-please-version +__version__ = "1.5.0-alpha.2" # x-release-please-version diff --git a/src/browserbase/resources/contexts.py b/src/browserbase/resources/contexts.py index d2bb4167..685daee4 100644 --- a/src/browserbase/resources/contexts.py +++ b/src/browserbase/resources/contexts.py @@ -5,7 +5,7 @@ import httpx from ..types import context_create_params -from .._types import Body, Query, Headers, NotGiven, not_given +from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource @@ -16,9 +16,9 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options +from ..types.context import Context from ..types.context_create_response import ContextCreateResponse from ..types.context_update_response import ContextUpdateResponse -from ..types.context_retrieve_response import ContextRetrieveResponse __all__ = ["ContextsResource", "AsyncContextsResource"] @@ -46,7 +46,7 @@ def with_streaming_response(self) -> ContextsResourceWithStreamingResponse: def create( self, *, - project_id: str, + project_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -60,7 +60,8 @@ def create( project_id: The Project ID. Can be found in - [Settings](https://www.browserbase.com/settings). + [Settings](https://www.browserbase.com/settings). Optional - if not provided, + the project will be inferred from the API key. extra_headers: Send extra headers @@ -89,7 +90,7 @@ def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ContextRetrieveResponse: + ) -> Context: """ Get a Context @@ -109,7 +110,7 @@ def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ContextRetrieveResponse, + cast_to=Context, ) def update( @@ -145,6 +146,40 @@ def update( cast_to=ContextUpdateResponse, ) + def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + Delete a Context + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + f"/v1/contexts/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + class AsyncContextsResource(AsyncAPIResource): @cached_property @@ -169,7 +204,7 @@ def with_streaming_response(self) -> AsyncContextsResourceWithStreamingResponse: async def create( self, *, - project_id: str, + project_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -183,7 +218,8 @@ async def create( project_id: The Project ID. Can be found in - [Settings](https://www.browserbase.com/settings). + [Settings](https://www.browserbase.com/settings). Optional - if not provided, + the project will be inferred from the API key. extra_headers: Send extra headers @@ -212,7 +248,7 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ContextRetrieveResponse: + ) -> Context: """ Get a Context @@ -232,7 +268,7 @@ async def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ContextRetrieveResponse, + cast_to=Context, ) async def update( @@ -268,6 +304,40 @@ async def update( cast_to=ContextUpdateResponse, ) + async def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + Delete a Context + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + f"/v1/contexts/{id}", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + class ContextsResourceWithRawResponse: def __init__(self, contexts: ContextsResource) -> None: @@ -282,6 +352,9 @@ def __init__(self, contexts: ContextsResource) -> None: self.update = to_raw_response_wrapper( contexts.update, ) + self.delete = to_raw_response_wrapper( + contexts.delete, + ) class AsyncContextsResourceWithRawResponse: @@ -297,6 +370,9 @@ def __init__(self, contexts: AsyncContextsResource) -> None: self.update = async_to_raw_response_wrapper( contexts.update, ) + self.delete = async_to_raw_response_wrapper( + contexts.delete, + ) class ContextsResourceWithStreamingResponse: @@ -312,6 +388,9 @@ def __init__(self, contexts: ContextsResource) -> None: self.update = to_streamed_response_wrapper( contexts.update, ) + self.delete = to_streamed_response_wrapper( + contexts.delete, + ) class AsyncContextsResourceWithStreamingResponse: @@ -327,3 +406,6 @@ def __init__(self, contexts: AsyncContextsResource) -> None: self.update = async_to_streamed_response_wrapper( contexts.update, ) + self.delete = async_to_streamed_response_wrapper( + contexts.delete, + ) diff --git a/src/browserbase/resources/extensions.py b/src/browserbase/resources/extensions.py index 21d06e70..534cd415 100644 --- a/src/browserbase/resources/extensions.py +++ b/src/browserbase/resources/extensions.py @@ -18,8 +18,7 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options -from ..types.extension_create_response import ExtensionCreateResponse -from ..types.extension_retrieve_response import ExtensionRetrieveResponse +from ..types.extension import Extension __all__ = ["ExtensionsResource", "AsyncExtensionsResource"] @@ -54,7 +53,7 @@ def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ExtensionCreateResponse: + ) -> Extension: """ Upload an Extension @@ -80,7 +79,7 @@ def create( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ExtensionCreateResponse, + cast_to=Extension, ) def retrieve( @@ -93,7 +92,7 @@ def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ExtensionRetrieveResponse: + ) -> Extension: """ Get an Extension @@ -113,7 +112,7 @@ def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ExtensionRetrieveResponse, + cast_to=Extension, ) def delete( @@ -181,7 +180,7 @@ async def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ExtensionCreateResponse: + ) -> Extension: """ Upload an Extension @@ -207,7 +206,7 @@ async def create( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ExtensionCreateResponse, + cast_to=Extension, ) async def retrieve( @@ -220,7 +219,7 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ExtensionRetrieveResponse: + ) -> Extension: """ Get an Extension @@ -240,7 +239,7 @@ async def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ExtensionRetrieveResponse, + cast_to=Extension, ) async def delete( diff --git a/src/browserbase/resources/projects.py b/src/browserbase/resources/projects.py index 62c28afa..5ce225fd 100644 --- a/src/browserbase/resources/projects.py +++ b/src/browserbase/resources/projects.py @@ -14,9 +14,9 @@ async_to_streamed_response_wrapper, ) from .._base_client import make_request_options +from ..types.project import Project +from ..types.project_usage import ProjectUsage from ..types.project_list_response import ProjectListResponse -from ..types.project_usage_response import ProjectUsageResponse -from ..types.project_retrieve_response import ProjectRetrieveResponse __all__ = ["ProjectsResource", "AsyncProjectsResource"] @@ -51,7 +51,7 @@ def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProjectRetrieveResponse: + ) -> Project: """ Get a Project @@ -71,7 +71,7 @@ def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ProjectRetrieveResponse, + cast_to=Project, ) def list( @@ -103,7 +103,7 @@ def usage( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProjectUsageResponse: + ) -> ProjectUsage: """ Get Project Usage @@ -123,7 +123,7 @@ def usage( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ProjectUsageResponse, + cast_to=ProjectUsage, ) @@ -157,7 +157,7 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProjectRetrieveResponse: + ) -> Project: """ Get a Project @@ -177,7 +177,7 @@ async def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ProjectRetrieveResponse, + cast_to=Project, ) async def list( @@ -209,7 +209,7 @@ async def usage( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProjectUsageResponse: + ) -> ProjectUsage: """ Get Project Usage @@ -229,7 +229,7 @@ async def usage( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=ProjectUsageResponse, + cast_to=ProjectUsage, ) diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index 5e58bbbb..35ff90ce 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -51,10 +51,10 @@ async_to_streamed_response_wrapper, ) from ..._base_client import make_request_options +from ...types.session import Session +from ...types.session_live_urls import SessionLiveURLs from ...types.session_list_response import SessionListResponse -from ...types.session_debug_response import SessionDebugResponse from ...types.session_create_response import SessionCreateResponse -from ...types.session_update_response import SessionUpdateResponse from ...types.session_retrieve_response import SessionRetrieveResponse __all__ = ["SessionsResource", "AsyncSessionsResource"] @@ -99,12 +99,11 @@ def with_streaming_response(self) -> SessionsResourceWithStreamingResponse: def create( self, *, - project_id: str, browser_settings: session_create_params.BrowserSettings | Omit = omit, extension_id: str | Omit = omit, keep_alive: bool | Omit = omit, + project_id: str | Omit = omit, proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | Omit = omit, - proxy_settings: session_create_params.ProxySettings | Omit = omit, region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | Omit = omit, api_timeout: int | Omit = omit, user_metadata: Dict[str, object] | Omit = omit, @@ -118,22 +117,21 @@ def create( """Create a Session Args: - project_id: The Project ID. + extension_id: The uploaded Extension ID. - Can be found in - [Settings](https://www.browserbase.com/settings). - - extension_id: The uploaded Extension ID. See + See [Upload Extension](/reference/api/upload-an-extension). keep_alive: Set to true to keep the session alive even after disconnections. Available on the Hobby Plan and above. + project_id: The Project ID. Can be found in + [Settings](https://www.browserbase.com/settings). Optional - if not provided, + the project will be inferred from the API key. + proxies: Proxy configuration. Can be true for default proxy, or an array of proxy configurations. - proxy_settings: [NOT IN DOCS] Supplementary proxy settings. Optional. - region: The region where the Session should run. api_timeout: Duration in seconds after which the session will automatically end. Defaults to @@ -154,12 +152,11 @@ def create( "/v1/sessions", body=maybe_transform( { - "project_id": project_id, "browser_settings": browser_settings, "extension_id": extension_id, "keep_alive": keep_alive, + "project_id": project_id, "proxies": proxies, - "proxy_settings": proxy_settings, "region": region, "api_timeout": api_timeout, "user_metadata": user_metadata, @@ -209,26 +206,26 @@ def update( self, id: str, *, - project_id: str, status: Literal["REQUEST_RELEASE"], + project_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SessionUpdateResponse: - """Update a Session + ) -> Session: + """ + Update a Session Args: - project_id: The Project ID. - - Can be found in - [Settings](https://www.browserbase.com/settings). - status: Set to `REQUEST_RELEASE` to request that the session complete. Use before session's timeout to avoid additional charges. + project_id: The Project ID. Can be found in + [Settings](https://www.browserbase.com/settings). Optional - if not provided, + the project will be inferred from the API key. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -243,15 +240,15 @@ def update( f"/v1/sessions/{id}", body=maybe_transform( { - "project_id": project_id, "status": status, + "project_id": project_id, }, session_update_params.SessionUpdateParams, ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=SessionUpdateResponse, + cast_to=Session, ) def list( @@ -311,7 +308,7 @@ def debug( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SessionDebugResponse: + ) -> SessionLiveURLs: """ Session Live URLs @@ -331,7 +328,7 @@ def debug( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=SessionDebugResponse, + cast_to=SessionLiveURLs, ) @@ -374,12 +371,11 @@ def with_streaming_response(self) -> AsyncSessionsResourceWithStreamingResponse: async def create( self, *, - project_id: str, browser_settings: session_create_params.BrowserSettings | Omit = omit, extension_id: str | Omit = omit, keep_alive: bool | Omit = omit, + project_id: str | Omit = omit, proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | Omit = omit, - proxy_settings: session_create_params.ProxySettings | Omit = omit, region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | Omit = omit, api_timeout: int | Omit = omit, user_metadata: Dict[str, object] | Omit = omit, @@ -393,22 +389,21 @@ async def create( """Create a Session Args: - project_id: The Project ID. + extension_id: The uploaded Extension ID. - Can be found in - [Settings](https://www.browserbase.com/settings). - - extension_id: The uploaded Extension ID. See + See [Upload Extension](/reference/api/upload-an-extension). keep_alive: Set to true to keep the session alive even after disconnections. Available on the Hobby Plan and above. + project_id: The Project ID. Can be found in + [Settings](https://www.browserbase.com/settings). Optional - if not provided, + the project will be inferred from the API key. + proxies: Proxy configuration. Can be true for default proxy, or an array of proxy configurations. - proxy_settings: [NOT IN DOCS] Supplementary proxy settings. Optional. - region: The region where the Session should run. api_timeout: Duration in seconds after which the session will automatically end. Defaults to @@ -429,12 +424,11 @@ async def create( "/v1/sessions", body=await async_maybe_transform( { - "project_id": project_id, "browser_settings": browser_settings, "extension_id": extension_id, "keep_alive": keep_alive, + "project_id": project_id, "proxies": proxies, - "proxy_settings": proxy_settings, "region": region, "api_timeout": api_timeout, "user_metadata": user_metadata, @@ -484,26 +478,26 @@ async def update( self, id: str, *, - project_id: str, status: Literal["REQUEST_RELEASE"], + project_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SessionUpdateResponse: - """Update a Session + ) -> Session: + """ + Update a Session Args: - project_id: The Project ID. - - Can be found in - [Settings](https://www.browserbase.com/settings). - status: Set to `REQUEST_RELEASE` to request that the session complete. Use before session's timeout to avoid additional charges. + project_id: The Project ID. Can be found in + [Settings](https://www.browserbase.com/settings). Optional - if not provided, + the project will be inferred from the API key. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -518,15 +512,15 @@ async def update( f"/v1/sessions/{id}", body=await async_maybe_transform( { - "project_id": project_id, "status": status, + "project_id": project_id, }, session_update_params.SessionUpdateParams, ), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=SessionUpdateResponse, + cast_to=Session, ) async def list( @@ -586,7 +580,7 @@ async def debug( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> SessionDebugResponse: + ) -> SessionLiveURLs: """ Session Live URLs @@ -606,7 +600,7 @@ async def debug( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=SessionDebugResponse, + cast_to=SessionLiveURLs, ) diff --git a/src/browserbase/types/__init__.py b/src/browserbase/types/__init__.py index 20e2f905..4dd85ddb 100644 --- a/src/browserbase/types/__init__.py +++ b/src/browserbase/types/__init__.py @@ -2,21 +2,20 @@ from __future__ import annotations +from .context import Context as Context +from .project import Project as Project +from .session import Session as Session +from .extension import Extension as Extension +from .project_usage import ProjectUsage as ProjectUsage +from .session_live_urls import SessionLiveURLs as SessionLiveURLs from .session_list_params import SessionListParams as SessionListParams from .context_create_params import ContextCreateParams as ContextCreateParams from .project_list_response import ProjectListResponse as ProjectListResponse from .session_create_params import SessionCreateParams as SessionCreateParams from .session_list_response import SessionListResponse as SessionListResponse from .session_update_params import SessionUpdateParams as SessionUpdateParams -from .project_usage_response import ProjectUsageResponse as ProjectUsageResponse -from .session_debug_response import SessionDebugResponse as SessionDebugResponse from .context_create_response import ContextCreateResponse as ContextCreateResponse from .context_update_response import ContextUpdateResponse as ContextUpdateResponse from .extension_create_params import ExtensionCreateParams as ExtensionCreateParams from .session_create_response import SessionCreateResponse as SessionCreateResponse -from .session_update_response import SessionUpdateResponse as SessionUpdateResponse -from .context_retrieve_response import ContextRetrieveResponse as ContextRetrieveResponse -from .extension_create_response import ExtensionCreateResponse as ExtensionCreateResponse -from .project_retrieve_response import ProjectRetrieveResponse as ProjectRetrieveResponse from .session_retrieve_response import SessionRetrieveResponse as SessionRetrieveResponse -from .extension_retrieve_response import ExtensionRetrieveResponse as ExtensionRetrieveResponse diff --git a/src/browserbase/types/context_retrieve_response.py b/src/browserbase/types/context.py similarity index 84% rename from src/browserbase/types/context_retrieve_response.py rename to src/browserbase/types/context.py index c2cd6925..cb5c32fd 100644 --- a/src/browserbase/types/context_retrieve_response.py +++ b/src/browserbase/types/context.py @@ -6,10 +6,10 @@ from .._models import BaseModel -__all__ = ["ContextRetrieveResponse"] +__all__ = ["Context"] -class ContextRetrieveResponse(BaseModel): +class Context(BaseModel): id: str created_at: datetime = FieldInfo(alias="createdAt") diff --git a/src/browserbase/types/context_create_params.py b/src/browserbase/types/context_create_params.py index 75cd1fcd..7f422f2d 100644 --- a/src/browserbase/types/context_create_params.py +++ b/src/browserbase/types/context_create_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing_extensions import Required, Annotated, TypedDict +from typing_extensions import Annotated, TypedDict from .._utils import PropertyInfo @@ -10,8 +10,9 @@ class ContextCreateParams(TypedDict, total=False): - project_id: Required[Annotated[str, PropertyInfo(alias="projectId")]] + project_id: Annotated[str, PropertyInfo(alias="projectId")] """The Project ID. - Can be found in [Settings](https://www.browserbase.com/settings). + Can be found in [Settings](https://www.browserbase.com/settings). Optional - if + not provided, the project will be inferred from the API key. """ diff --git a/src/browserbase/types/extension_create_response.py b/src/browserbase/types/extension.py similarity index 85% rename from src/browserbase/types/extension_create_response.py rename to src/browserbase/types/extension.py index d2b74f41..94582c34 100644 --- a/src/browserbase/types/extension_create_response.py +++ b/src/browserbase/types/extension.py @@ -6,10 +6,10 @@ from .._models import BaseModel -__all__ = ["ExtensionCreateResponse"] +__all__ = ["Extension"] -class ExtensionCreateResponse(BaseModel): +class Extension(BaseModel): id: str created_at: datetime = FieldInfo(alias="createdAt") diff --git a/src/browserbase/types/extension_retrieve_response.py b/src/browserbase/types/extension_retrieve_response.py deleted file mode 100644 index c786348e..00000000 --- a/src/browserbase/types/extension_retrieve_response.py +++ /dev/null @@ -1,22 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from datetime import datetime - -from pydantic import Field as FieldInfo - -from .._models import BaseModel - -__all__ = ["ExtensionRetrieveResponse"] - - -class ExtensionRetrieveResponse(BaseModel): - id: str - - created_at: datetime = FieldInfo(alias="createdAt") - - file_name: str = FieldInfo(alias="fileName") - - project_id: str = FieldInfo(alias="projectId") - """The Project ID linked to the uploaded Extension.""" - - updated_at: datetime = FieldInfo(alias="updatedAt") diff --git a/src/browserbase/types/project_retrieve_response.py b/src/browserbase/types/project.py similarity index 87% rename from src/browserbase/types/project_retrieve_response.py rename to src/browserbase/types/project.py index 78126679..dc3cf335 100644 --- a/src/browserbase/types/project_retrieve_response.py +++ b/src/browserbase/types/project.py @@ -6,10 +6,10 @@ from .._models import BaseModel -__all__ = ["ProjectRetrieveResponse"] +__all__ = ["Project"] -class ProjectRetrieveResponse(BaseModel): +class Project(BaseModel): id: str concurrency: int diff --git a/src/browserbase/types/project_list_response.py b/src/browserbase/types/project_list_response.py index e364b520..2d05a236 100644 --- a/src/browserbase/types/project_list_response.py +++ b/src/browserbase/types/project_list_response.py @@ -1,31 +1,10 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. from typing import List -from datetime import datetime from typing_extensions import TypeAlias -from pydantic import Field as FieldInfo +from .project import Project -from .._models import BaseModel +__all__ = ["ProjectListResponse"] -__all__ = ["ProjectListResponse", "ProjectListResponseItem"] - - -class ProjectListResponseItem(BaseModel): - id: str - - concurrency: int - """The maximum number of sessions that this project can run concurrently.""" - - created_at: datetime = FieldInfo(alias="createdAt") - - default_timeout: int = FieldInfo(alias="defaultTimeout") - - name: str - - owner_id: str = FieldInfo(alias="ownerId") - - updated_at: datetime = FieldInfo(alias="updatedAt") - - -ProjectListResponse: TypeAlias = List[ProjectListResponseItem] +ProjectListResponse: TypeAlias = List[Project] diff --git a/src/browserbase/types/project_usage_response.py b/src/browserbase/types/project_usage.py similarity index 78% rename from src/browserbase/types/project_usage_response.py rename to src/browserbase/types/project_usage.py index b52fccfe..c8a03f5b 100644 --- a/src/browserbase/types/project_usage_response.py +++ b/src/browserbase/types/project_usage.py @@ -4,10 +4,10 @@ from .._models import BaseModel -__all__ = ["ProjectUsageResponse"] +__all__ = ["ProjectUsage"] -class ProjectUsageResponse(BaseModel): +class ProjectUsage(BaseModel): browser_minutes: int = FieldInfo(alias="browserMinutes") proxy_bytes: int = FieldInfo(alias="proxyBytes") diff --git a/src/browserbase/types/session_update_response.py b/src/browserbase/types/session.py similarity index 83% rename from src/browserbase/types/session_update_response.py rename to src/browserbase/types/session.py index 67a13711..e983baaa 100644 --- a/src/browserbase/types/session_update_response.py +++ b/src/browserbase/types/session.py @@ -8,10 +8,10 @@ from .._models import BaseModel -__all__ = ["SessionUpdateResponse"] +__all__ = ["Session"] -class SessionUpdateResponse(BaseModel): +class Session(BaseModel): id: str created_at: datetime = FieldInfo(alias="createdAt") @@ -36,17 +36,11 @@ class SessionUpdateResponse(BaseModel): updated_at: datetime = FieldInfo(alias="updatedAt") - avg_cpu_usage: Optional[int] = FieldInfo(alias="avgCpuUsage", default=None) - """CPU used by the Session""" - context_id: Optional[str] = FieldInfo(alias="contextId", default=None) """Optional. The Context linked to the Session.""" ended_at: Optional[datetime] = FieldInfo(alias="endedAt", default=None) - memory_usage: Optional[int] = FieldInfo(alias="memoryUsage", default=None) - """Memory used by the Session""" - user_metadata: Optional[Dict[str, object]] = FieldInfo(alias="userMetadata", default=None) """Arbitrary user metadata to attach to the session. diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index 7fafe448..63805590 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -2,34 +2,25 @@ from __future__ import annotations -from typing import Dict, List, Union, Iterable +from typing import Dict, Union, Iterable from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict -from .._types import SequenceNotStr from .._utils import PropertyInfo __all__ = [ "SessionCreateParams", "BrowserSettings", "BrowserSettingsContext", - "BrowserSettingsFingerprint", - "BrowserSettingsFingerprintScreen", "BrowserSettingsViewport", "ProxiesUnionMember0", - "ProxiesUnionMember0UnionMember0", - "ProxiesUnionMember0UnionMember0Geolocation", - "ProxiesUnionMember0UnionMember1", - "ProxySettings", + "ProxiesUnionMember0BrowserbaseProxyConfig", + "ProxiesUnionMember0BrowserbaseProxyConfigGeolocation", + "ProxiesUnionMember0ExternalProxyConfig", + "ProxiesUnionMember0NoneProxyConfig", ] class SessionCreateParams(TypedDict, total=False): - project_id: Required[Annotated[str, PropertyInfo(alias="projectId")]] - """The Project ID. - - Can be found in [Settings](https://www.browserbase.com/settings). - """ - browser_settings: Annotated[BrowserSettings, PropertyInfo(alias="browserSettings")] extension_id: Annotated[str, PropertyInfo(alias="extensionId")] @@ -44,15 +35,19 @@ class SessionCreateParams(TypedDict, total=False): Available on the Hobby Plan and above. """ + project_id: Annotated[str, PropertyInfo(alias="projectId")] + """The Project ID. + + Can be found in [Settings](https://www.browserbase.com/settings). Optional - if + not provided, the project will be inferred from the API key. + """ + proxies: Union[Iterable[ProxiesUnionMember0], bool] """Proxy configuration. Can be true for default proxy, or an array of proxy configurations. """ - proxy_settings: Annotated[ProxySettings, PropertyInfo(alias="proxySettings")] - """[NOT IN DOCS] Supplementary proxy settings. Optional.""" - region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] """The region where the Session should run.""" @@ -78,32 +73,6 @@ class BrowserSettingsContext(TypedDict, total=False): """Whether or not to persist the context after browsing. Defaults to `false`.""" -class BrowserSettingsFingerprintScreen(TypedDict, total=False): - max_height: Annotated[int, PropertyInfo(alias="maxHeight")] - - max_width: Annotated[int, PropertyInfo(alias="maxWidth")] - - min_height: Annotated[int, PropertyInfo(alias="minHeight")] - - min_width: Annotated[int, PropertyInfo(alias="minWidth")] - - -class BrowserSettingsFingerprint(TypedDict, total=False): - browsers: List[Literal["chrome", "edge", "firefox", "safari"]] - - devices: List[Literal["desktop", "mobile"]] - - http_version: Annotated[Literal["1", "2"], PropertyInfo(alias="httpVersion")] - - locales: SequenceNotStr[str] - - operating_systems: Annotated[ - List[Literal["android", "ios", "linux", "macos", "windows"]], PropertyInfo(alias="operatingSystems") - ] - - screen: BrowserSettingsFingerprintScreen - - class BrowserSettingsViewport(TypedDict, total=False): height: int """The height of the browser.""" @@ -139,12 +108,6 @@ class BrowserSettings(TypedDict, total=False): See [Upload Extension](/reference/api/upload-an-extension). """ - fingerprint: BrowserSettingsFingerprint - """ - See usage examples - [on the Stealth Mode page](/features/stealth-mode#fingerprinting) - """ - log_session: Annotated[bool, PropertyInfo(alias="logSession")] """Enable or disable session logging. Defaults to `true`.""" @@ -163,7 +126,9 @@ class BrowserSettings(TypedDict, total=False): viewport: BrowserSettingsViewport -class ProxiesUnionMember0UnionMember0Geolocation(TypedDict, total=False): +class ProxiesUnionMember0BrowserbaseProxyConfigGeolocation(TypedDict, total=False): + """Geographic location for the proxy. Optional.""" + country: Required[str] """Country code in ISO 3166-1 alpha-2 format""" @@ -174,7 +139,7 @@ class ProxiesUnionMember0UnionMember0Geolocation(TypedDict, total=False): """US state code (2 characters). Must also specify US as the country. Optional.""" -class ProxiesUnionMember0UnionMember0(TypedDict, total=False): +class ProxiesUnionMember0BrowserbaseProxyConfig(TypedDict, total=False): type: Required[Literal["browserbase"]] """Type of proxy. @@ -187,11 +152,11 @@ class ProxiesUnionMember0UnionMember0(TypedDict, total=False): If omitted, defaults to all domains. Optional. """ - geolocation: ProxiesUnionMember0UnionMember0Geolocation + geolocation: ProxiesUnionMember0BrowserbaseProxyConfigGeolocation """Geographic location for the proxy. Optional.""" -class ProxiesUnionMember0UnionMember1(TypedDict, total=False): +class ProxiesUnionMember0ExternalProxyConfig(TypedDict, total=False): server: Required[str] """Server URL for external proxy. Required.""" @@ -211,9 +176,19 @@ class ProxiesUnionMember0UnionMember1(TypedDict, total=False): """Username for external proxy authentication. Optional.""" -ProxiesUnionMember0: TypeAlias = Union[ProxiesUnionMember0UnionMember0, ProxiesUnionMember0UnionMember1] +class ProxiesUnionMember0NoneProxyConfig(TypedDict, total=False): + type: Required[Literal["none"]] + """Type of proxy. Always 'none' for this config.""" + + domain_pattern: Annotated[str, PropertyInfo(alias="domainPattern")] + """Domain pattern for which this proxy should be used. + + If omitted, defaults to all domains. Optional. + """ -class ProxySettings(TypedDict, total=False): - ca_certificates: Required[Annotated[SequenceNotStr[str], PropertyInfo(alias="caCertificates")]] - """[NOT IN DOCS] The TLS certificate IDs to trust. Optional.""" +ProxiesUnionMember0: TypeAlias = Union[ + ProxiesUnionMember0BrowserbaseProxyConfig, + ProxiesUnionMember0ExternalProxyConfig, + ProxiesUnionMember0NoneProxyConfig, +] diff --git a/src/browserbase/types/session_create_response.py b/src/browserbase/types/session_create_response.py index b548d50f..c91a4d09 100644 --- a/src/browserbase/types/session_create_response.py +++ b/src/browserbase/types/session_create_response.py @@ -1,64 +1,18 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, Optional -from datetime import datetime -from typing_extensions import Literal - from pydantic import Field as FieldInfo -from .._models import BaseModel +from .session import Session __all__ = ["SessionCreateResponse"] -class SessionCreateResponse(BaseModel): - id: str - +class SessionCreateResponse(Session): connect_url: str = FieldInfo(alias="connectUrl") """WebSocket URL to connect to the Session.""" - created_at: datetime = FieldInfo(alias="createdAt") - - expires_at: datetime = FieldInfo(alias="expiresAt") - - keep_alive: bool = FieldInfo(alias="keepAlive") - """Indicates if the Session was created to be kept alive upon disconnections""" - - project_id: str = FieldInfo(alias="projectId") - """The Project ID linked to the Session.""" - - proxy_bytes: int = FieldInfo(alias="proxyBytes") - """Bytes used via the [Proxy](/features/stealth-mode#proxies-and-residential-ips)""" - - region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] - """The region where the Session is running.""" - selenium_remote_url: str = FieldInfo(alias="seleniumRemoteUrl") """HTTP URL to connect to the Session.""" signing_key: str = FieldInfo(alias="signingKey") """Signing key to use when connecting to the Session via HTTP.""" - - started_at: datetime = FieldInfo(alias="startedAt") - - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] - - updated_at: datetime = FieldInfo(alias="updatedAt") - - avg_cpu_usage: Optional[int] = FieldInfo(alias="avgCpuUsage", default=None) - """CPU used by the Session""" - - context_id: Optional[str] = FieldInfo(alias="contextId", default=None) - """Optional. The Context linked to the Session.""" - - ended_at: Optional[datetime] = FieldInfo(alias="endedAt", default=None) - - memory_usage: Optional[int] = FieldInfo(alias="memoryUsage", default=None) - """Memory used by the Session""" - - user_metadata: Optional[Dict[str, object]] = FieldInfo(alias="userMetadata", default=None) - """Arbitrary user metadata to attach to the session. - - To learn more about user metadata, see - [User Metadata](/features/sessions#user-metadata). - """ diff --git a/src/browserbase/types/session_list_response.py b/src/browserbase/types/session_list_response.py index 4c1bd885..ca162ddb 100644 --- a/src/browserbase/types/session_list_response.py +++ b/src/browserbase/types/session_list_response.py @@ -1,58 +1,10 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, List, Optional -from datetime import datetime -from typing_extensions import Literal, TypeAlias +from typing import List +from typing_extensions import TypeAlias -from pydantic import Field as FieldInfo +from .session import Session -from .._models import BaseModel +__all__ = ["SessionListResponse"] -__all__ = ["SessionListResponse", "SessionListResponseItem"] - - -class SessionListResponseItem(BaseModel): - id: str - - created_at: datetime = FieldInfo(alias="createdAt") - - expires_at: datetime = FieldInfo(alias="expiresAt") - - keep_alive: bool = FieldInfo(alias="keepAlive") - """Indicates if the Session was created to be kept alive upon disconnections""" - - project_id: str = FieldInfo(alias="projectId") - """The Project ID linked to the Session.""" - - proxy_bytes: int = FieldInfo(alias="proxyBytes") - """Bytes used via the [Proxy](/features/stealth-mode#proxies-and-residential-ips)""" - - region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] - """The region where the Session is running.""" - - started_at: datetime = FieldInfo(alias="startedAt") - - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] - - updated_at: datetime = FieldInfo(alias="updatedAt") - - avg_cpu_usage: Optional[int] = FieldInfo(alias="avgCpuUsage", default=None) - """CPU used by the Session""" - - context_id: Optional[str] = FieldInfo(alias="contextId", default=None) - """Optional. The Context linked to the Session.""" - - ended_at: Optional[datetime] = FieldInfo(alias="endedAt", default=None) - - memory_usage: Optional[int] = FieldInfo(alias="memoryUsage", default=None) - """Memory used by the Session""" - - user_metadata: Optional[Dict[str, object]] = FieldInfo(alias="userMetadata", default=None) - """Arbitrary user metadata to attach to the session. - - To learn more about user metadata, see - [User Metadata](/features/sessions#user-metadata). - """ - - -SessionListResponse: TypeAlias = List[SessionListResponseItem] +SessionListResponse: TypeAlias = List[Session] diff --git a/src/browserbase/types/session_debug_response.py b/src/browserbase/types/session_live_urls.py similarity index 88% rename from src/browserbase/types/session_debug_response.py rename to src/browserbase/types/session_live_urls.py index 9cee7a77..3c7ba320 100644 --- a/src/browserbase/types/session_debug_response.py +++ b/src/browserbase/types/session_live_urls.py @@ -6,7 +6,7 @@ from .._models import BaseModel -__all__ = ["SessionDebugResponse", "Page"] +__all__ = ["SessionLiveURLs", "Page"] class Page(BaseModel): @@ -23,7 +23,7 @@ class Page(BaseModel): url: str -class SessionDebugResponse(BaseModel): +class SessionLiveURLs(BaseModel): debugger_fullscreen_url: str = FieldInfo(alias="debuggerFullscreenUrl") debugger_url: str = FieldInfo(alias="debuggerUrl") diff --git a/src/browserbase/types/session_retrieve_response.py b/src/browserbase/types/session_retrieve_response.py index a9a4ff28..2203db0d 100644 --- a/src/browserbase/types/session_retrieve_response.py +++ b/src/browserbase/types/session_retrieve_response.py @@ -1,64 +1,20 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, Optional -from datetime import datetime -from typing_extensions import Literal +from typing import Optional from pydantic import Field as FieldInfo -from .._models import BaseModel +from .session import Session __all__ = ["SessionRetrieveResponse"] -class SessionRetrieveResponse(BaseModel): - id: str - - created_at: datetime = FieldInfo(alias="createdAt") - - expires_at: datetime = FieldInfo(alias="expiresAt") - - keep_alive: bool = FieldInfo(alias="keepAlive") - """Indicates if the Session was created to be kept alive upon disconnections""" - - project_id: str = FieldInfo(alias="projectId") - """The Project ID linked to the Session.""" - - proxy_bytes: int = FieldInfo(alias="proxyBytes") - """Bytes used via the [Proxy](/features/stealth-mode#proxies-and-residential-ips)""" - - region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] - """The region where the Session is running.""" - - started_at: datetime = FieldInfo(alias="startedAt") - - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] - - updated_at: datetime = FieldInfo(alias="updatedAt") - - avg_cpu_usage: Optional[int] = FieldInfo(alias="avgCpuUsage", default=None) - """CPU used by the Session""" - +class SessionRetrieveResponse(Session): connect_url: Optional[str] = FieldInfo(alias="connectUrl", default=None) """WebSocket URL to connect to the Session.""" - context_id: Optional[str] = FieldInfo(alias="contextId", default=None) - """Optional. The Context linked to the Session.""" - - ended_at: Optional[datetime] = FieldInfo(alias="endedAt", default=None) - - memory_usage: Optional[int] = FieldInfo(alias="memoryUsage", default=None) - """Memory used by the Session""" - selenium_remote_url: Optional[str] = FieldInfo(alias="seleniumRemoteUrl", default=None) """HTTP URL to connect to the Session.""" signing_key: Optional[str] = FieldInfo(alias="signingKey", default=None) """Signing key to use when connecting to the Session via HTTP.""" - - user_metadata: Optional[Dict[str, object]] = FieldInfo(alias="userMetadata", default=None) - """Arbitrary user metadata to attach to the session. - - To learn more about user metadata, see - [User Metadata](/features/sessions#user-metadata). - """ diff --git a/src/browserbase/types/session_update_params.py b/src/browserbase/types/session_update_params.py index 66dcd351..ed514d97 100644 --- a/src/browserbase/types/session_update_params.py +++ b/src/browserbase/types/session_update_params.py @@ -10,14 +10,15 @@ class SessionUpdateParams(TypedDict, total=False): - project_id: Required[Annotated[str, PropertyInfo(alias="projectId")]] - """The Project ID. - - Can be found in [Settings](https://www.browserbase.com/settings). - """ - status: Required[Literal["REQUEST_RELEASE"]] """Set to `REQUEST_RELEASE` to request that the session complete. Use before session's timeout to avoid additional charges. """ + + project_id: Annotated[str, PropertyInfo(alias="projectId")] + """The Project ID. + + Can be found in [Settings](https://www.browserbase.com/settings). Optional - if + not provided, the project will be inferred from the API key. + """ diff --git a/src/browserbase/types/sessions/__init__.py b/src/browserbase/types/sessions/__init__.py index 69d54703..0cef6b19 100644 --- a/src/browserbase/types/sessions/__init__.py +++ b/src/browserbase/types/sessions/__init__.py @@ -2,7 +2,9 @@ from __future__ import annotations +from .session_log import SessionLog as SessionLog from .log_list_response import LogListResponse as LogListResponse +from .session_recording import SessionRecording as SessionRecording from .upload_create_params import UploadCreateParams as UploadCreateParams from .upload_create_response import UploadCreateResponse as UploadCreateResponse from .recording_retrieve_response import RecordingRetrieveResponse as RecordingRetrieveResponse diff --git a/src/browserbase/types/sessions/log_list_response.py b/src/browserbase/types/sessions/log_list_response.py index efd848ab..2b325a8c 100644 --- a/src/browserbase/types/sessions/log_list_response.py +++ b/src/browserbase/types/sessions/log_list_response.py @@ -1,50 +1,10 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, List, Optional +from typing import List from typing_extensions import TypeAlias -from pydantic import Field as FieldInfo +from .session_log import SessionLog -from ..._models import BaseModel +__all__ = ["LogListResponse"] -__all__ = ["LogListResponse", "LogListResponseItem", "LogListResponseItemRequest", "LogListResponseItemResponse"] - - -class LogListResponseItemRequest(BaseModel): - params: Dict[str, object] - - raw_body: str = FieldInfo(alias="rawBody") - - timestamp: Optional[int] = None - """milliseconds that have elapsed since the UNIX epoch""" - - -class LogListResponseItemResponse(BaseModel): - raw_body: str = FieldInfo(alias="rawBody") - - result: Dict[str, object] - - timestamp: Optional[int] = None - """milliseconds that have elapsed since the UNIX epoch""" - - -class LogListResponseItem(BaseModel): - method: str - - page_id: int = FieldInfo(alias="pageId") - - session_id: str = FieldInfo(alias="sessionId") - - frame_id: Optional[str] = FieldInfo(alias="frameId", default=None) - - loader_id: Optional[str] = FieldInfo(alias="loaderId", default=None) - - request: Optional[LogListResponseItemRequest] = None - - response: Optional[LogListResponseItemResponse] = None - - timestamp: Optional[int] = None - """milliseconds that have elapsed since the UNIX epoch""" - - -LogListResponse: TypeAlias = List[LogListResponseItem] +LogListResponse: TypeAlias = List[SessionLog] diff --git a/src/browserbase/types/sessions/recording_retrieve_response.py b/src/browserbase/types/sessions/recording_retrieve_response.py index d3613b8c..951969bb 100644 --- a/src/browserbase/types/sessions/recording_retrieve_response.py +++ b/src/browserbase/types/sessions/recording_retrieve_response.py @@ -1,28 +1,10 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict, List +from typing import List from typing_extensions import TypeAlias -from pydantic import Field as FieldInfo +from .session_recording import SessionRecording -from ..._models import BaseModel +__all__ = ["RecordingRetrieveResponse"] -__all__ = ["RecordingRetrieveResponse", "RecordingRetrieveResponseItem"] - - -class RecordingRetrieveResponseItem(BaseModel): - data: Dict[str, object] - """ - See - [rrweb documentation](https://github.com/rrweb-io/rrweb/blob/master/docs/recipes/dive-into-event.md). - """ - - session_id: str = FieldInfo(alias="sessionId") - - timestamp: int - """milliseconds that have elapsed since the UNIX epoch""" - - type: int - - -RecordingRetrieveResponse: TypeAlias = List[RecordingRetrieveResponseItem] +RecordingRetrieveResponse: TypeAlias = List[SessionRecording] diff --git a/src/browserbase/types/sessions/session_log.py b/src/browserbase/types/sessions/session_log.py new file mode 100644 index 00000000..428f518a --- /dev/null +++ b/src/browserbase/types/sessions/session_log.py @@ -0,0 +1,46 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, Optional + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["SessionLog", "Request", "Response"] + + +class Request(BaseModel): + params: Dict[str, object] + + raw_body: str = FieldInfo(alias="rawBody") + + timestamp: Optional[int] = None + """milliseconds that have elapsed since the UNIX epoch""" + + +class Response(BaseModel): + raw_body: str = FieldInfo(alias="rawBody") + + result: Dict[str, object] + + timestamp: Optional[int] = None + """milliseconds that have elapsed since the UNIX epoch""" + + +class SessionLog(BaseModel): + method: str + + page_id: int = FieldInfo(alias="pageId") + + session_id: str = FieldInfo(alias="sessionId") + + frame_id: Optional[str] = FieldInfo(alias="frameId", default=None) + + loader_id: Optional[str] = FieldInfo(alias="loaderId", default=None) + + request: Optional[Request] = None + + response: Optional[Response] = None + + timestamp: Optional[int] = None + """milliseconds that have elapsed since the UNIX epoch""" diff --git a/src/browserbase/types/sessions/session_recording.py b/src/browserbase/types/sessions/session_recording.py new file mode 100644 index 00000000..c8471371 --- /dev/null +++ b/src/browserbase/types/sessions/session_recording.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["SessionRecording"] + + +class SessionRecording(BaseModel): + data: Dict[str, object] + """ + See + [rrweb documentation](https://github.com/rrweb-io/rrweb/blob/master/docs/recipes/dive-into-event.md). + """ + + session_id: str = FieldInfo(alias="sessionId") + + timestamp: int + """milliseconds that have elapsed since the UNIX epoch""" + + type: int diff --git a/tests/api_resources/test_contexts.py b/tests/api_resources/test_contexts.py index 4ad27733..31fb97d0 100644 --- a/tests/api_resources/test_contexts.py +++ b/tests/api_resources/test_contexts.py @@ -9,11 +9,7 @@ from browserbase import Browserbase, AsyncBrowserbase from tests.utils import assert_matches_type -from browserbase.types import ( - ContextCreateResponse, - ContextUpdateResponse, - ContextRetrieveResponse, -) +from browserbase.types import Context, ContextCreateResponse, ContextUpdateResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -23,6 +19,11 @@ class TestContexts: @parametrize def test_method_create(self, client: Browserbase) -> None: + context = client.contexts.create() + assert_matches_type(ContextCreateResponse, context, path=["response"]) + + @parametrize + def test_method_create_with_all_params(self, client: Browserbase) -> None: context = client.contexts.create( project_id="projectId", ) @@ -30,9 +31,7 @@ def test_method_create(self, client: Browserbase) -> None: @parametrize def test_raw_response_create(self, client: Browserbase) -> None: - response = client.contexts.with_raw_response.create( - project_id="projectId", - ) + response = client.contexts.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -41,9 +40,7 @@ def test_raw_response_create(self, client: Browserbase) -> None: @parametrize def test_streaming_response_create(self, client: Browserbase) -> None: - with client.contexts.with_streaming_response.create( - project_id="projectId", - ) as response: + with client.contexts.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -57,7 +54,7 @@ def test_method_retrieve(self, client: Browserbase) -> None: context = client.contexts.retrieve( "id", ) - assert_matches_type(ContextRetrieveResponse, context, path=["response"]) + assert_matches_type(Context, context, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Browserbase) -> None: @@ -68,7 +65,7 @@ def test_raw_response_retrieve(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" context = response.parse() - assert_matches_type(ContextRetrieveResponse, context, path=["response"]) + assert_matches_type(Context, context, path=["response"]) @parametrize def test_streaming_response_retrieve(self, client: Browserbase) -> None: @@ -79,7 +76,7 @@ def test_streaming_response_retrieve(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" context = response.parse() - assert_matches_type(ContextRetrieveResponse, context, path=["response"]) + assert_matches_type(Context, context, path=["response"]) assert cast(Any, response.is_closed) is True @@ -128,6 +125,44 @@ def test_path_params_update(self, client: Browserbase) -> None: "", ) + @parametrize + def test_method_delete(self, client: Browserbase) -> None: + context = client.contexts.delete( + "id", + ) + assert context is None + + @parametrize + def test_raw_response_delete(self, client: Browserbase) -> None: + response = client.contexts.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + context = response.parse() + assert context is None + + @parametrize + def test_streaming_response_delete(self, client: Browserbase) -> None: + with client.contexts.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + context = response.parse() + assert context is None + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_delete(self, client: Browserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.contexts.with_raw_response.delete( + "", + ) + class TestAsyncContexts: parametrize = pytest.mark.parametrize( @@ -136,6 +171,11 @@ class TestAsyncContexts: @parametrize async def test_method_create(self, async_client: AsyncBrowserbase) -> None: + context = await async_client.contexts.create() + assert_matches_type(ContextCreateResponse, context, path=["response"]) + + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncBrowserbase) -> None: context = await async_client.contexts.create( project_id="projectId", ) @@ -143,9 +183,7 @@ async def test_method_create(self, async_client: AsyncBrowserbase) -> None: @parametrize async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None: - response = await async_client.contexts.with_raw_response.create( - project_id="projectId", - ) + response = await async_client.contexts.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -154,9 +192,7 @@ async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None @parametrize async def test_streaming_response_create(self, async_client: AsyncBrowserbase) -> None: - async with async_client.contexts.with_streaming_response.create( - project_id="projectId", - ) as response: + async with async_client.contexts.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -170,7 +206,7 @@ async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: context = await async_client.contexts.retrieve( "id", ) - assert_matches_type(ContextRetrieveResponse, context, path=["response"]) + assert_matches_type(Context, context, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -181,7 +217,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" context = await response.parse() - assert_matches_type(ContextRetrieveResponse, context, path=["response"]) + assert_matches_type(Context, context, path=["response"]) @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -192,7 +228,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) assert response.http_request.headers.get("X-Stainless-Lang") == "python" context = await response.parse() - assert_matches_type(ContextRetrieveResponse, context, path=["response"]) + assert_matches_type(Context, context, path=["response"]) assert cast(Any, response.is_closed) is True @@ -240,3 +276,41 @@ async def test_path_params_update(self, async_client: AsyncBrowserbase) -> None: await async_client.contexts.with_raw_response.update( "", ) + + @parametrize + async def test_method_delete(self, async_client: AsyncBrowserbase) -> None: + context = await async_client.contexts.delete( + "id", + ) + assert context is None + + @parametrize + async def test_raw_response_delete(self, async_client: AsyncBrowserbase) -> None: + response = await async_client.contexts.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + context = await response.parse() + assert context is None + + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncBrowserbase) -> None: + async with async_client.contexts.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + context = await response.parse() + assert context is None + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_delete(self, async_client: AsyncBrowserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.contexts.with_raw_response.delete( + "", + ) diff --git a/tests/api_resources/test_extensions.py b/tests/api_resources/test_extensions.py index e32ae9b0..6b6a0183 100644 --- a/tests/api_resources/test_extensions.py +++ b/tests/api_resources/test_extensions.py @@ -9,7 +9,7 @@ from browserbase import Browserbase, AsyncBrowserbase from tests.utils import assert_matches_type -from browserbase.types import ExtensionCreateResponse, ExtensionRetrieveResponse +from browserbase.types import Extension base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -22,7 +22,7 @@ def test_method_create(self, client: Browserbase) -> None: extension = client.extensions.create( file=b"raw file contents", ) - assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) @parametrize def test_raw_response_create(self, client: Browserbase) -> None: @@ -33,7 +33,7 @@ def test_raw_response_create(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = response.parse() - assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) @parametrize def test_streaming_response_create(self, client: Browserbase) -> None: @@ -44,7 +44,7 @@ def test_streaming_response_create(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = response.parse() - assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) assert cast(Any, response.is_closed) is True @@ -53,7 +53,7 @@ def test_method_retrieve(self, client: Browserbase) -> None: extension = client.extensions.retrieve( "id", ) - assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Browserbase) -> None: @@ -64,7 +64,7 @@ def test_raw_response_retrieve(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = response.parse() - assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) @parametrize def test_streaming_response_retrieve(self, client: Browserbase) -> None: @@ -75,7 +75,7 @@ def test_streaming_response_retrieve(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = response.parse() - assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) assert cast(Any, response.is_closed) is True @@ -135,7 +135,7 @@ async def test_method_create(self, async_client: AsyncBrowserbase) -> None: extension = await async_client.extensions.create( file=b"raw file contents", ) - assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None: @@ -146,7 +146,7 @@ async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = await response.parse() - assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) @parametrize async def test_streaming_response_create(self, async_client: AsyncBrowserbase) -> None: @@ -157,7 +157,7 @@ async def test_streaming_response_create(self, async_client: AsyncBrowserbase) - assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = await response.parse() - assert_matches_type(ExtensionCreateResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) assert cast(Any, response.is_closed) is True @@ -166,7 +166,7 @@ async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: extension = await async_client.extensions.retrieve( "id", ) - assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -177,7 +177,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = await response.parse() - assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -188,7 +188,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) assert response.http_request.headers.get("X-Stainless-Lang") == "python" extension = await response.parse() - assert_matches_type(ExtensionRetrieveResponse, extension, path=["response"]) + assert_matches_type(Extension, extension, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_projects.py b/tests/api_resources/test_projects.py index 0d8e3c94..c8241bf8 100644 --- a/tests/api_resources/test_projects.py +++ b/tests/api_resources/test_projects.py @@ -9,7 +9,7 @@ from browserbase import Browserbase, AsyncBrowserbase from tests.utils import assert_matches_type -from browserbase.types import ProjectListResponse, ProjectUsageResponse, ProjectRetrieveResponse +from browserbase.types import Project, ProjectUsage, ProjectListResponse base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -22,7 +22,7 @@ def test_method_retrieve(self, client: Browserbase) -> None: project = client.projects.retrieve( "id", ) - assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) + assert_matches_type(Project, project, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Browserbase) -> None: @@ -33,7 +33,7 @@ def test_raw_response_retrieve(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = response.parse() - assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) + assert_matches_type(Project, project, path=["response"]) @parametrize def test_streaming_response_retrieve(self, client: Browserbase) -> None: @@ -44,7 +44,7 @@ def test_streaming_response_retrieve(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = response.parse() - assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) + assert_matches_type(Project, project, path=["response"]) assert cast(Any, response.is_closed) is True @@ -85,7 +85,7 @@ def test_method_usage(self, client: Browserbase) -> None: project = client.projects.usage( "id", ) - assert_matches_type(ProjectUsageResponse, project, path=["response"]) + assert_matches_type(ProjectUsage, project, path=["response"]) @parametrize def test_raw_response_usage(self, client: Browserbase) -> None: @@ -96,7 +96,7 @@ def test_raw_response_usage(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = response.parse() - assert_matches_type(ProjectUsageResponse, project, path=["response"]) + assert_matches_type(ProjectUsage, project, path=["response"]) @parametrize def test_streaming_response_usage(self, client: Browserbase) -> None: @@ -107,7 +107,7 @@ def test_streaming_response_usage(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = response.parse() - assert_matches_type(ProjectUsageResponse, project, path=["response"]) + assert_matches_type(ProjectUsage, project, path=["response"]) assert cast(Any, response.is_closed) is True @@ -129,7 +129,7 @@ async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: project = await async_client.projects.retrieve( "id", ) - assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) + assert_matches_type(Project, project, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -140,7 +140,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> No assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = await response.parse() - assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) + assert_matches_type(Project, project, path=["response"]) @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: @@ -151,7 +151,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = await response.parse() - assert_matches_type(ProjectRetrieveResponse, project, path=["response"]) + assert_matches_type(Project, project, path=["response"]) assert cast(Any, response.is_closed) is True @@ -192,7 +192,7 @@ async def test_method_usage(self, async_client: AsyncBrowserbase) -> None: project = await async_client.projects.usage( "id", ) - assert_matches_type(ProjectUsageResponse, project, path=["response"]) + assert_matches_type(ProjectUsage, project, path=["response"]) @parametrize async def test_raw_response_usage(self, async_client: AsyncBrowserbase) -> None: @@ -203,7 +203,7 @@ async def test_raw_response_usage(self, async_client: AsyncBrowserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = await response.parse() - assert_matches_type(ProjectUsageResponse, project, path=["response"]) + assert_matches_type(ProjectUsage, project, path=["response"]) @parametrize async def test_streaming_response_usage(self, async_client: AsyncBrowserbase) -> None: @@ -214,7 +214,7 @@ async def test_streaming_response_usage(self, async_client: AsyncBrowserbase) -> assert response.http_request.headers.get("X-Stainless-Lang") == "python" project = await response.parse() - assert_matches_type(ProjectUsageResponse, project, path=["response"]) + assert_matches_type(ProjectUsage, project, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 24da8f0b..d237069c 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -10,10 +10,10 @@ from browserbase import Browserbase, AsyncBrowserbase from tests.utils import assert_matches_type from browserbase.types import ( + Session, + SessionLiveURLs, SessionListResponse, - SessionDebugResponse, SessionCreateResponse, - SessionUpdateResponse, SessionRetrieveResponse, ) @@ -25,15 +25,12 @@ class TestSessions: @parametrize def test_method_create(self, client: Browserbase) -> None: - session = client.sessions.create( - project_id="projectId", - ) + session = client.sessions.create() assert_matches_type(SessionCreateResponse, session, path=["response"]) @parametrize def test_method_create_with_all_params(self, client: Browserbase) -> None: session = client.sessions.create( - project_id="projectId", browser_settings={ "advanced_stealth": True, "block_ads": True, @@ -44,19 +41,6 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: "persist": True, }, "extension_id": "extensionId", - "fingerprint": { - "browsers": ["chrome"], - "devices": ["desktop"], - "http_version": "1", - "locales": ["string"], - "operating_systems": ["android"], - "screen": { - "max_height": 0, - "max_width": 0, - "min_height": 0, - "min_width": 0, - }, - }, "log_session": True, "os": "windows", "record_session": True, @@ -68,6 +52,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: }, extension_id="extensionId", keep_alive=True, + project_id="projectId", proxies=[ { "type": "browserbase", @@ -79,7 +64,6 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: }, } ], - proxy_settings={"ca_certificates": ["182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e"]}, region="us-west-2", api_timeout=60, user_metadata={"foo": "bar"}, @@ -88,9 +72,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: @parametrize def test_raw_response_create(self, client: Browserbase) -> None: - response = client.sessions.with_raw_response.create( - project_id="projectId", - ) + response = client.sessions.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -99,9 +81,7 @@ def test_raw_response_create(self, client: Browserbase) -> None: @parametrize def test_streaming_response_create(self, client: Browserbase) -> None: - with client.sessions.with_streaming_response.create( - project_id="projectId", - ) as response: + with client.sessions.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -152,36 +132,42 @@ def test_path_params_retrieve(self, client: Browserbase) -> None: def test_method_update(self, client: Browserbase) -> None: session = client.sessions.update( id="id", - project_id="projectId", status="REQUEST_RELEASE", ) - assert_matches_type(SessionUpdateResponse, session, path=["response"]) + assert_matches_type(Session, session, path=["response"]) + + @parametrize + def test_method_update_with_all_params(self, client: Browserbase) -> None: + session = client.sessions.update( + id="id", + status="REQUEST_RELEASE", + project_id="projectId", + ) + assert_matches_type(Session, session, path=["response"]) @parametrize def test_raw_response_update(self, client: Browserbase) -> None: response = client.sessions.with_raw_response.update( id="id", - project_id="projectId", status="REQUEST_RELEASE", ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(SessionUpdateResponse, session, path=["response"]) + assert_matches_type(Session, session, path=["response"]) @parametrize def test_streaming_response_update(self, client: Browserbase) -> None: with client.sessions.with_streaming_response.update( id="id", - project_id="projectId", status="REQUEST_RELEASE", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(SessionUpdateResponse, session, path=["response"]) + assert_matches_type(Session, session, path=["response"]) assert cast(Any, response.is_closed) is True @@ -190,7 +176,6 @@ def test_path_params_update(self, client: Browserbase) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): client.sessions.with_raw_response.update( id="", - project_id="projectId", status="REQUEST_RELEASE", ) @@ -232,7 +217,7 @@ def test_method_debug(self, client: Browserbase) -> None: session = client.sessions.debug( "id", ) - assert_matches_type(SessionDebugResponse, session, path=["response"]) + assert_matches_type(SessionLiveURLs, session, path=["response"]) @parametrize def test_raw_response_debug(self, client: Browserbase) -> None: @@ -243,7 +228,7 @@ def test_raw_response_debug(self, client: Browserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(SessionDebugResponse, session, path=["response"]) + assert_matches_type(SessionLiveURLs, session, path=["response"]) @parametrize def test_streaming_response_debug(self, client: Browserbase) -> None: @@ -254,7 +239,7 @@ def test_streaming_response_debug(self, client: Browserbase) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = response.parse() - assert_matches_type(SessionDebugResponse, session, path=["response"]) + assert_matches_type(SessionLiveURLs, session, path=["response"]) assert cast(Any, response.is_closed) is True @@ -273,15 +258,12 @@ class TestAsyncSessions: @parametrize async def test_method_create(self, async_client: AsyncBrowserbase) -> None: - session = await async_client.sessions.create( - project_id="projectId", - ) + session = await async_client.sessions.create() assert_matches_type(SessionCreateResponse, session, path=["response"]) @parametrize async def test_method_create_with_all_params(self, async_client: AsyncBrowserbase) -> None: session = await async_client.sessions.create( - project_id="projectId", browser_settings={ "advanced_stealth": True, "block_ads": True, @@ -292,19 +274,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas "persist": True, }, "extension_id": "extensionId", - "fingerprint": { - "browsers": ["chrome"], - "devices": ["desktop"], - "http_version": "1", - "locales": ["string"], - "operating_systems": ["android"], - "screen": { - "max_height": 0, - "max_width": 0, - "min_height": 0, - "min_width": 0, - }, - }, "log_session": True, "os": "windows", "record_session": True, @@ -316,6 +285,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas }, extension_id="extensionId", keep_alive=True, + project_id="projectId", proxies=[ { "type": "browserbase", @@ -327,7 +297,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas }, } ], - proxy_settings={"ca_certificates": ["182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e"]}, region="us-west-2", api_timeout=60, user_metadata={"foo": "bar"}, @@ -336,9 +305,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas @parametrize async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None: - response = await async_client.sessions.with_raw_response.create( - project_id="projectId", - ) + response = await async_client.sessions.with_raw_response.create() assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -347,9 +314,7 @@ async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None @parametrize async def test_streaming_response_create(self, async_client: AsyncBrowserbase) -> None: - async with async_client.sessions.with_streaming_response.create( - project_id="projectId", - ) as response: + async with async_client.sessions.with_streaming_response.create() as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -400,36 +365,42 @@ async def test_path_params_retrieve(self, async_client: AsyncBrowserbase) -> Non async def test_method_update(self, async_client: AsyncBrowserbase) -> None: session = await async_client.sessions.update( id="id", - project_id="projectId", status="REQUEST_RELEASE", ) - assert_matches_type(SessionUpdateResponse, session, path=["response"]) + assert_matches_type(Session, session, path=["response"]) + + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncBrowserbase) -> None: + session = await async_client.sessions.update( + id="id", + status="REQUEST_RELEASE", + project_id="projectId", + ) + assert_matches_type(Session, session, path=["response"]) @parametrize async def test_raw_response_update(self, async_client: AsyncBrowserbase) -> None: response = await async_client.sessions.with_raw_response.update( id="id", - project_id="projectId", status="REQUEST_RELEASE", ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(SessionUpdateResponse, session, path=["response"]) + assert_matches_type(Session, session, path=["response"]) @parametrize async def test_streaming_response_update(self, async_client: AsyncBrowserbase) -> None: async with async_client.sessions.with_streaming_response.update( id="id", - project_id="projectId", status="REQUEST_RELEASE", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(SessionUpdateResponse, session, path=["response"]) + assert_matches_type(Session, session, path=["response"]) assert cast(Any, response.is_closed) is True @@ -438,7 +409,6 @@ async def test_path_params_update(self, async_client: AsyncBrowserbase) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): await async_client.sessions.with_raw_response.update( id="", - project_id="projectId", status="REQUEST_RELEASE", ) @@ -480,7 +450,7 @@ async def test_method_debug(self, async_client: AsyncBrowserbase) -> None: session = await async_client.sessions.debug( "id", ) - assert_matches_type(SessionDebugResponse, session, path=["response"]) + assert_matches_type(SessionLiveURLs, session, path=["response"]) @parametrize async def test_raw_response_debug(self, async_client: AsyncBrowserbase) -> None: @@ -491,7 +461,7 @@ async def test_raw_response_debug(self, async_client: AsyncBrowserbase) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(SessionDebugResponse, session, path=["response"]) + assert_matches_type(SessionLiveURLs, session, path=["response"]) @parametrize async def test_streaming_response_debug(self, async_client: AsyncBrowserbase) -> None: @@ -502,7 +472,7 @@ async def test_streaming_response_debug(self, async_client: AsyncBrowserbase) -> assert response.http_request.headers.get("X-Stainless-Lang") == "python" session = await response.parse() - assert_matches_type(SessionDebugResponse, session, path=["response"]) + assert_matches_type(SessionLiveURLs, session, path=["response"]) assert cast(Any, response.is_closed) is True diff --git a/tests/test_client.py b/tests/test_client.py index aed68baf..1d0d68b3 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 @@ -36,6 +37,7 @@ from .utils import update_env +T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = "My API Key" @@ -50,6 +52,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: Browserbase | AsyncBrowserbase) -> int: transport = client._client._transport assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) @@ -59,51 +112,49 @@ def _get_open_connections(client: Browserbase | AsyncBrowserbase) -> int: class TestBrowserbase: - client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - def test_raw_response(self, respx_mock: MockRouter) -> None: + def test_raw_response(self, respx_mock: MockRouter, client: Browserbase) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + def test_raw_response_for_binary(self, respx_mock: MockRouter, client: Browserbase) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = self.client.post("/foo", cast_to=httpx.Response) + response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, client: Browserbase) -> None: + copied = client.copy() + assert id(copied) != id(client) - copied = self.client.copy(api_key="another My API Key") + copied = client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert client.api_key == "My API Key" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, client: Browserbase) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(client.timeout, httpx.Timeout) + copied = client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(client.timeout, httpx.Timeout) def test_copy_default_headers(self) -> None: client = Browserbase( @@ -138,6 +189,7 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + client.close() def test_copy_default_query(self) -> None: client = Browserbase( @@ -175,13 +227,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + client.close() + + def test_copy_signature(self, client: Browserbase) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -192,12 +246,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, client: Browserbase) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -254,14 +308,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + def test_request_timeout(self, client: Browserbase) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( - FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) - ) + request = client._build_request(FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0))) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(100.0) @@ -274,6 +326,8 @@ def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + client.close() + def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used with httpx.Client(timeout=None) as http_client: @@ -285,6 +339,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + client.close() + # no timeout given to the httpx client should not use the httpx default with httpx.Client() as http_client: client = Browserbase( @@ -295,6 +351,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + client.close() + # explicitly passing the default timeout currently results in it being ignored with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = Browserbase( @@ -305,6 +363,8 @@ def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + client.close() + async def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): async with httpx.AsyncClient() as http_client: @@ -316,14 +376,14 @@ async def test_invalid_http_client(self) -> None: ) def test_default_headers_option(self) -> None: - client = Browserbase( + test_client = Browserbase( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = Browserbase( + test_client2 = Browserbase( base_url=base_url, api_key=api_key, _strict_response_validation=True, @@ -332,10 +392,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + test_client.close() + test_client2.close() + def test_validate_headers(self) -> None: client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -364,8 +427,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + client.close() + + def test_request_extra_json(self, client: Browserbase) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -376,7 +441,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -387,7 +452,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -398,8 +463,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: Browserbase) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -409,7 +474,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -420,8 +485,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: Browserbase) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -434,7 +499,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -448,7 +513,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -491,7 +556,71 @@ def test_multipart_repeating_array(self, client: Browserbase) -> None: ] @pytest.mark.respx(base_url=base_url) - def test_basic_union_response(self, respx_mock: MockRouter) -> None: + def test_binary_content_upload(self, respx_mock: MockRouter, client: Browserbase) -> 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 Browserbase( + base_url=base_url, + api_key=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: Browserbase) -> 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: Browserbase) -> None: class Model1(BaseModel): name: str @@ -500,12 +629,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + def test_union_response_different_types(self, respx_mock: MockRouter, client: Browserbase) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -516,18 +645,18 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter, client: Browserbase) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -543,7 +672,7 @@ class Model(BaseModel): ) ) - response = self.client.get("/foo", cast_to=Model) + response = client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 @@ -557,6 +686,8 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" + client.close() + def test_base_url_env(self) -> None: with update_env(BROWSERBASE_BASE_URL="http://localhost:5000/from/env"): client = Browserbase(api_key=api_key, _strict_response_validation=True) @@ -586,6 +717,7 @@ def test_base_url_trailing_slash(self, client: Browserbase) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -611,6 +743,7 @@ def test_base_url_no_trailing_slash(self, client: Browserbase) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + client.close() @pytest.mark.parametrize( "client", @@ -636,35 +769,36 @@ def test_absolute_request_url(self, client: Browserbase) -> None: ), ) assert request.url == "https://myapi.com/foo" + client.close() def test_copied_client_does_not_close_http(self) -> None: - client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() + test_client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied - assert not client.is_closed() + assert not test_client.is_closed() def test_client_context_manager(self) -> None: - client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) - with client as c2: - assert c2 is client + test_client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) + with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + def test_client_response_validation_error(self, respx_mock: MockRouter, client: Browserbase) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - self.client.get("/foo", cast_to=Model) + client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -686,11 +820,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): strict_client.get("/foo", cast_to=Model) - client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=False) + non_strict_client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=False) - response = client.get("/foo", cast_to=Model) + response = non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + strict_client.close() + non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -713,9 +850,9 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = Browserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) - + def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, client: Browserbase + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) calculated = client._calculate_retry_timeout(remaining_retries, options, headers) @@ -727,9 +864,9 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter, clien respx_mock.post("/v1/sessions").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - client.sessions.with_streaming_response.create(project_id="projectId").__enter__() + client.sessions.with_streaming_response.create().__enter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(client) == 0 @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -737,8 +874,8 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter, client respx_mock.post("/v1/sessions").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - client.sessions.with_streaming_response.create(project_id="projectId").__enter__() - assert _get_open_connections(self.client) == 0 + client.sessions.with_streaming_response.create().__enter__() + assert _get_open_connections(client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -766,7 +903,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.post("/v1/sessions").mock(side_effect=retry_handler) - response = client.sessions.with_raw_response.create(project_id="projectId") + response = client.sessions.with_raw_response.create() assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @@ -790,9 +927,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.post("/v1/sessions").mock(side_effect=retry_handler) - response = client.sessions.with_raw_response.create( - project_id="projectId", extra_headers={"x-stainless-retry-count": Omit()} - ) + response = client.sessions.with_raw_response.create(extra_headers={"x-stainless-retry-count": Omit()}) assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @@ -815,15 +950,21 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.post("/v1/sessions").mock(side_effect=retry_handler) - response = client.sessions.with_raw_response.create( - project_id="projectId", extra_headers={"x-stainless-retry-count": "42"} - ) + response = client.sessions.with_raw_response.create(extra_headers={"x-stainless-retry-count": "42"}) assert response.http_request.headers.get("x-stainless-retry-count") == "42" def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultHttpxClient() @@ -844,83 +985,77 @@ def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - def test_follow_redirects(self, respx_mock: MockRouter) -> None: + def test_follow_redirects(self, respx_mock: MockRouter, client: Browserbase) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + def test_follow_redirects_disabled(self, respx_mock: MockRouter, client: Browserbase) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - self.client.post( - "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response - ) + client.post("/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response) assert exc_info.value.response.status_code == 302 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" class TestAsyncBrowserbase: - client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) - @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response(self, respx_mock: MockRouter) -> None: + async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncBrowserbase) -> None: respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: + async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_client: AsyncBrowserbase) -> None: respx_mock.post("/foo").mock( return_value=httpx.Response(200, headers={"Content-Type": "application/binary"}, content='{"foo": "bar"}') ) - response = await self.client.post("/foo", cast_to=httpx.Response) + response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) assert response.json() == {"foo": "bar"} - def test_copy(self) -> None: - copied = self.client.copy() - assert id(copied) != id(self.client) + def test_copy(self, async_client: AsyncBrowserbase) -> None: + copied = async_client.copy() + assert id(copied) != id(async_client) - copied = self.client.copy(api_key="another My API Key") + copied = async_client.copy(api_key="another My API Key") assert copied.api_key == "another My API Key" - assert self.client.api_key == "My API Key" + assert async_client.api_key == "My API Key" - def test_copy_default_options(self) -> None: + def test_copy_default_options(self, async_client: AsyncBrowserbase) -> None: # options that have a default are overridden correctly - copied = self.client.copy(max_retries=7) + copied = async_client.copy(max_retries=7) assert copied.max_retries == 7 - assert self.client.max_retries == 2 + assert async_client.max_retries == 2 copied2 = copied.copy(max_retries=6) assert copied2.max_retries == 6 assert copied.max_retries == 7 # timeout - assert isinstance(self.client.timeout, httpx.Timeout) - copied = self.client.copy(timeout=None) + assert isinstance(async_client.timeout, httpx.Timeout) + copied = async_client.copy(timeout=None) assert copied.timeout is None - assert isinstance(self.client.timeout, httpx.Timeout) + assert isinstance(async_client.timeout, httpx.Timeout) - def test_copy_default_headers(self) -> None: + async def test_copy_default_headers(self) -> None: client = AsyncBrowserbase( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) @@ -953,8 +1088,9 @@ def test_copy_default_headers(self) -> None: match="`default_headers` and `set_default_headers` arguments are mutually exclusive", ): client.copy(set_default_headers={}, default_headers={"X-Foo": "Bar"}) + await client.close() - def test_copy_default_query(self) -> None: + async def test_copy_default_query(self) -> None: client = AsyncBrowserbase( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"foo": "bar"} ) @@ -990,13 +1126,15 @@ def test_copy_default_query(self) -> None: ): client.copy(set_default_query={}, default_query={"foo": "Bar"}) - def test_copy_signature(self) -> None: + await client.close() + + def test_copy_signature(self, async_client: AsyncBrowserbase) -> None: # ensure the same parameters that can be passed to the client are defined in the `.copy()` method init_signature = inspect.signature( # mypy doesn't like that we access the `__init__` property. - self.client.__init__, # type: ignore[misc] + async_client.__init__, # type: ignore[misc] ) - copy_signature = inspect.signature(self.client.copy) + copy_signature = inspect.signature(async_client.copy) exclude_params = {"transport", "proxies", "_strict_response_validation"} for name in init_signature.parameters.keys(): @@ -1007,12 +1145,12 @@ def test_copy_signature(self) -> None: assert copy_param is not None, f"copy() signature is missing the {name} param" @pytest.mark.skipif(sys.version_info >= (3, 10), reason="fails because of a memory leak that started from 3.12") - def test_copy_build_request(self) -> None: + def test_copy_build_request(self, async_client: AsyncBrowserbase) -> None: options = FinalRequestOptions(method="get", url="/foo") def build_request(options: FinalRequestOptions) -> None: - client = self.client.copy() - client._build_request(options) + client_copy = async_client.copy() + client_copy._build_request(options) # ensure that the machinery is warmed up before tracing starts. build_request(options) @@ -1069,12 +1207,12 @@ def add_leak(leaks: list[tracemalloc.StatisticDiff], diff: tracemalloc.Statistic print(frame) raise AssertionError() - async def test_request_timeout(self) -> None: - request = self.client._build_request(FinalRequestOptions(method="get", url="/foo")) + async def test_request_timeout(self, async_client: AsyncBrowserbase) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo")) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT - request = self.client._build_request( + request = async_client._build_request( FinalRequestOptions(method="get", url="/foo", timeout=httpx.Timeout(100.0)) ) timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore @@ -1089,6 +1227,8 @@ async def test_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(0) + await client.close() + async def test_http_client_timeout_option(self) -> None: # custom timeout given to the httpx client should be used async with httpx.AsyncClient(timeout=None) as http_client: @@ -1100,6 +1240,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == httpx.Timeout(None) + await client.close() + # no timeout given to the httpx client should not use the httpx default async with httpx.AsyncClient() as http_client: client = AsyncBrowserbase( @@ -1110,6 +1252,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT + await client.close() + # explicitly passing the default timeout currently results in it being ignored async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client: client = AsyncBrowserbase( @@ -1120,6 +1264,8 @@ async def test_http_client_timeout_option(self) -> None: timeout = httpx.Timeout(**request.extensions["timeout"]) # type: ignore assert timeout == DEFAULT_TIMEOUT # our default + await client.close() + def test_invalid_http_client(self) -> None: with pytest.raises(TypeError, match="Invalid `http_client` arg"): with httpx.Client() as http_client: @@ -1130,15 +1276,15 @@ def test_invalid_http_client(self) -> None: http_client=cast(Any, http_client), ) - def test_default_headers_option(self) -> None: - client = AsyncBrowserbase( + async def test_default_headers_option(self) -> None: + test_client = AsyncBrowserbase( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_headers={"X-Foo": "bar"} ) - request = client._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "bar" assert request.headers.get("x-stainless-lang") == "python" - client2 = AsyncBrowserbase( + test_client2 = AsyncBrowserbase( base_url=base_url, api_key=api_key, _strict_response_validation=True, @@ -1147,10 +1293,13 @@ def test_default_headers_option(self) -> None: "X-Stainless-Lang": "my-overriding-header", }, ) - request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + request = test_client2._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-foo") == "stainless" assert request.headers.get("x-stainless-lang") == "my-overriding-header" + await test_client.close() + await test_client2.close() + def test_validate_headers(self) -> None: client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) request = client._build_request(FinalRequestOptions(method="get", url="/foo")) @@ -1161,7 +1310,7 @@ def test_validate_headers(self) -> None: client2 = AsyncBrowserbase(base_url=base_url, api_key=None, _strict_response_validation=True) _ = client2 - def test_default_query_option(self) -> None: + async def test_default_query_option(self) -> None: client = AsyncBrowserbase( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} ) @@ -1179,8 +1328,10 @@ def test_default_query_option(self) -> None: url = httpx.URL(request.url) assert dict(url.params) == {"foo": "baz", "query_param": "overridden"} - def test_request_extra_json(self) -> None: - request = self.client._build_request( + await client.close() + + def test_request_extra_json(self, client: Browserbase) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1191,7 +1342,7 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": False} - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1202,7 +1353,7 @@ def test_request_extra_json(self) -> None: assert data == {"baz": False} # `extra_json` takes priority over `json_data` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1213,8 +1364,8 @@ def test_request_extra_json(self) -> None: data = json.loads(request.content.decode("utf-8")) assert data == {"foo": "bar", "baz": None} - def test_request_extra_headers(self) -> None: - request = self.client._build_request( + def test_request_extra_headers(self, client: Browserbase) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1224,7 +1375,7 @@ def test_request_extra_headers(self) -> None: assert request.headers.get("X-Foo") == "Foo" # `extra_headers` takes priority over `default_headers` when keys clash - request = self.client.with_options(default_headers={"X-Bar": "true"})._build_request( + request = client.with_options(default_headers={"X-Bar": "true"})._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1235,8 +1386,8 @@ def test_request_extra_headers(self) -> None: ) assert request.headers.get("X-Bar") == "false" - def test_request_extra_query(self) -> None: - request = self.client._build_request( + def test_request_extra_query(self, client: Browserbase) -> None: + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1249,7 +1400,7 @@ def test_request_extra_query(self) -> None: assert params == {"my_query_param": "Foo"} # if both `query` and `extra_query` are given, they are merged - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1263,7 +1414,7 @@ def test_request_extra_query(self) -> None: assert params == {"bar": "1", "foo": "2"} # `extra_query` takes priority over `query` when keys clash - request = self.client._build_request( + request = client._build_request( FinalRequestOptions( method="post", url="/foo", @@ -1306,7 +1457,73 @@ def test_multipart_repeating_array(self, async_client: AsyncBrowserbase) -> None ] @pytest.mark.respx(base_url=base_url) - async def test_basic_union_response(self, respx_mock: MockRouter) -> None: + async def test_binary_content_upload(self, respx_mock: MockRouter, async_client: AsyncBrowserbase) -> 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 AsyncBrowserbase( + base_url=base_url, + api_key=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: AsyncBrowserbase + ) -> 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: AsyncBrowserbase) -> None: class Model1(BaseModel): name: str @@ -1315,12 +1532,12 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" @pytest.mark.respx(base_url=base_url) - async def test_union_response_different_types(self, respx_mock: MockRouter) -> None: + async def test_union_response_different_types(self, respx_mock: MockRouter, async_client: AsyncBrowserbase) -> None: """Union of objects with the same field name using a different type""" class Model1(BaseModel): @@ -1331,18 +1548,20 @@ class Model2(BaseModel): respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model2) assert response.foo == "bar" respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": 1})) - response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) + response = await async_client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 @pytest.mark.respx(base_url=base_url) - async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + async def test_non_application_json_content_type_for_json_data( + self, respx_mock: MockRouter, async_client: AsyncBrowserbase + ) -> None: """ Response that sets Content-Type to something other than application/json but returns json data """ @@ -1358,11 +1577,11 @@ class Model(BaseModel): ) ) - response = await self.client.get("/foo", cast_to=Model) + response = await async_client.get("/foo", cast_to=Model) assert isinstance(response, Model) assert response.foo == 2 - def test_base_url_setter(self) -> None: + async def test_base_url_setter(self) -> None: client = AsyncBrowserbase( base_url="https://example.com/from_init", api_key=api_key, _strict_response_validation=True ) @@ -1372,7 +1591,9 @@ def test_base_url_setter(self) -> None: assert client.base_url == "https://example.com/from_setter/" - def test_base_url_env(self) -> None: + await client.close() + + async def test_base_url_env(self) -> None: with update_env(BROWSERBASE_BASE_URL="http://localhost:5000/from/env"): client = AsyncBrowserbase(api_key=api_key, _strict_response_validation=True) assert client.base_url == "http://localhost:5000/from/env/" @@ -1392,7 +1613,7 @@ def test_base_url_env(self) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_trailing_slash(self, client: AsyncBrowserbase) -> None: + async def test_base_url_trailing_slash(self, client: AsyncBrowserbase) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1401,6 +1622,7 @@ def test_base_url_trailing_slash(self, client: AsyncBrowserbase) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1417,7 +1639,7 @@ def test_base_url_trailing_slash(self, client: AsyncBrowserbase) -> None: ], ids=["standard", "custom http client"], ) - def test_base_url_no_trailing_slash(self, client: AsyncBrowserbase) -> None: + async def test_base_url_no_trailing_slash(self, client: AsyncBrowserbase) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1426,6 +1648,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncBrowserbase) -> None: ), ) assert request.url == "http://localhost:5000/custom/path/foo" + await client.close() @pytest.mark.parametrize( "client", @@ -1442,7 +1665,7 @@ def test_base_url_no_trailing_slash(self, client: AsyncBrowserbase) -> None: ], ids=["standard", "custom http client"], ) - def test_absolute_request_url(self, client: AsyncBrowserbase) -> None: + async def test_absolute_request_url(self, client: AsyncBrowserbase) -> None: request = client._build_request( FinalRequestOptions( method="post", @@ -1451,37 +1674,39 @@ def test_absolute_request_url(self, client: AsyncBrowserbase) -> None: ), ) assert request.url == "https://myapi.com/foo" + await client.close() async def test_copied_client_does_not_close_http(self) -> None: - client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) - assert not client.is_closed() + test_client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) + assert not test_client.is_closed() - copied = client.copy() - assert copied is not client + copied = test_client.copy() + assert copied is not test_client del copied await asyncio.sleep(0.2) - assert not client.is_closed() + assert not test_client.is_closed() async def test_client_context_manager(self) -> None: - client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) - async with client as c2: - assert c2 is client + test_client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) + async with test_client as c2: + assert c2 is test_client assert not c2.is_closed() - assert not client.is_closed() - assert client.is_closed() + assert not test_client.is_closed() + assert test_client.is_closed() @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio - async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + async def test_client_response_validation_error( + self, respx_mock: MockRouter, async_client: AsyncBrowserbase + ) -> None: class Model(BaseModel): foo: str respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) with pytest.raises(APIResponseValidationError) as exc: - await self.client.get("/foo", cast_to=Model) + await async_client.get("/foo", cast_to=Model) assert isinstance(exc.value.__cause__, ValidationError) @@ -1492,7 +1717,6 @@ async def test_client_max_retries_validation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): name: str @@ -1504,11 +1728,14 @@ class Model(BaseModel): with pytest.raises(APIResponseValidationError): await strict_client.get("/foo", cast_to=Model) - client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=False) + non_strict_client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=False) - response = await client.get("/foo", cast_to=Model) + response = await non_strict_client.get("/foo", cast_to=Model) assert isinstance(response, str) # type: ignore[unreachable] + await strict_client.close() + await non_strict_client.close() + @pytest.mark.parametrize( "remaining_retries,retry_after,timeout", [ @@ -1531,13 +1758,12 @@ class Model(BaseModel): ], ) @mock.patch("time.time", mock.MagicMock(return_value=1696004797)) - @pytest.mark.asyncio - async def test_parse_retry_after_header(self, remaining_retries: int, retry_after: str, timeout: float) -> None: - client = AsyncBrowserbase(base_url=base_url, api_key=api_key, _strict_response_validation=True) - + async def test_parse_retry_after_header( + self, remaining_retries: int, retry_after: str, timeout: float, async_client: AsyncBrowserbase + ) -> None: headers = httpx.Headers({"retry-after": retry_after}) options = FinalRequestOptions(method="get", url="/foo", max_retries=3) - calculated = client._calculate_retry_timeout(remaining_retries, options, headers) + calculated = async_client._calculate_retry_timeout(remaining_retries, options, headers) assert calculated == pytest.approx(timeout, 0.5 * 0.875) # pyright: ignore[reportUnknownMemberType] @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @@ -1548,9 +1774,9 @@ async def test_retrying_timeout_errors_doesnt_leak( respx_mock.post("/v1/sessions").mock(side_effect=httpx.TimeoutException("Test timeout error")) with pytest.raises(APITimeoutError): - await async_client.sessions.with_streaming_response.create(project_id="projectId").__aenter__() + await async_client.sessions.with_streaming_response.create().__aenter__() - assert _get_open_connections(self.client) == 0 + assert _get_open_connections(async_client) == 0 @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) @@ -1560,13 +1786,12 @@ async def test_retrying_status_errors_doesnt_leak( respx_mock.post("/v1/sessions").mock(return_value=httpx.Response(500)) with pytest.raises(APIStatusError): - await async_client.sessions.with_streaming_response.create(project_id="projectId").__aenter__() - assert _get_open_connections(self.client) == 0 + await async_client.sessions.with_streaming_response.create().__aenter__() + assert _get_open_connections(async_client) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio @pytest.mark.parametrize("failure_mode", ["status", "exception"]) async def test_retries_taken( self, @@ -1590,7 +1815,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.post("/v1/sessions").mock(side_effect=retry_handler) - response = await client.sessions.with_raw_response.create(project_id="projectId") + response = await client.sessions.with_raw_response.create() assert response.retries_taken == failures_before_success assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success @@ -1598,7 +1823,6 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_omit_retry_count_header( self, async_client: AsyncBrowserbase, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1615,16 +1839,13 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.post("/v1/sessions").mock(side_effect=retry_handler) - response = await client.sessions.with_raw_response.create( - project_id="projectId", extra_headers={"x-stainless-retry-count": Omit()} - ) + response = await client.sessions.with_raw_response.create(extra_headers={"x-stainless-retry-count": Omit()}) assert len(response.http_request.headers.get_list("x-stainless-retry-count")) == 0 @pytest.mark.parametrize("failures_before_success", [0, 2, 4]) @mock.patch("browserbase._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) @pytest.mark.respx(base_url=base_url) - @pytest.mark.asyncio async def test_overwrite_retry_count_header( self, async_client: AsyncBrowserbase, failures_before_success: int, respx_mock: MockRouter ) -> None: @@ -1641,9 +1862,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response: respx_mock.post("/v1/sessions").mock(side_effect=retry_handler) - response = await client.sessions.with_raw_response.create( - project_id="projectId", extra_headers={"x-stainless-retry-count": "42"} - ) + response = await client.sessions.with_raw_response.create(extra_headers={"x-stainless-retry-count": "42"}) assert response.http_request.headers.get("x-stainless-retry-count") == "42" @@ -1654,6 +1873,14 @@ async def test_get_platform(self) -> None: async def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None: # Test that the proxy environment variables are set correctly monkeypatch.setenv("HTTPS_PROXY", "https://example.org") + # Delete in case our environment has any proxy env vars set + monkeypatch.delenv("HTTP_PROXY", raising=False) + monkeypatch.delenv("ALL_PROXY", raising=False) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("http_proxy", raising=False) + monkeypatch.delenv("https_proxy", raising=False) + monkeypatch.delenv("all_proxy", raising=False) + monkeypatch.delenv("no_proxy", raising=False) client = DefaultAsyncHttpxClient() @@ -1674,26 +1901,26 @@ async def test_default_client_creation(self) -> None: ) @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects(self, respx_mock: MockRouter, async_client: AsyncBrowserbase) -> None: # Test that the default follow_redirects=True allows following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) respx_mock.get("/redirected").mock(return_value=httpx.Response(200, json={"status": "ok"})) - response = await self.client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) + response = await async_client.post("/redirect", body={"key": "value"}, cast_to=httpx.Response) assert response.status_code == 200 assert response.json() == {"status": "ok"} @pytest.mark.respx(base_url=base_url) - async def test_follow_redirects_disabled(self, respx_mock: MockRouter) -> None: + async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_client: AsyncBrowserbase) -> None: # Test that follow_redirects=False prevents following redirects respx_mock.post("/redirect").mock( return_value=httpx.Response(302, headers={"Location": f"{base_url}/redirected"}) ) with pytest.raises(APIStatusError) as exc_info: - await self.client.post( + await async_client.post( "/redirect", body={"key": "value"}, options={"follow_redirects": False}, cast_to=httpx.Response ) diff --git a/tests/test_models.py b/tests/test_models.py index 34f87334..1ecdeecf 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,7 +9,7 @@ from browserbase._utils import PropertyInfo from browserbase._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from browserbase._models import BaseModel, construct_type +from browserbase._models import DISCRIMINATOR_CACHE, BaseModel, construct_type class BasicModel(BaseModel): @@ -809,7 +809,7 @@ class B(BaseModel): UnionType = cast(Any, Union[A, B]) - assert not hasattr(UnionType, "__discriminator__") + assert not DISCRIMINATOR_CACHE.get(UnionType) m = construct_type( value={"type": "b", "data": "foo"}, type_=cast(Any, Annotated[UnionType, PropertyInfo(discriminator="type")]) @@ -818,7 +818,7 @@ class B(BaseModel): assert m.type == "b" assert m.data == "foo" # type: ignore[comparison-overlap] - discriminator = UnionType.__discriminator__ + discriminator = DISCRIMINATOR_CACHE.get(UnionType) assert discriminator is not None m = construct_type( @@ -830,7 +830,7 @@ class B(BaseModel): # if the discriminator details object stays the same between invocations then # we hit the cache - assert UnionType.__discriminator__ is discriminator + assert DISCRIMINATOR_CACHE.get(UnionType) is discriminator @pytest.mark.skipif(PYDANTIC_V1, reason="TypeAliasType is not supported in Pydantic v1") diff --git a/tests/test_utils/test_json.py b/tests/test_utils/test_json.py new file mode 100644 index 00000000..9cf7b782 --- /dev/null +++ b/tests/test_utils/test_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import datetime +from typing import Union + +import pydantic + +from browserbase import _compat +from browserbase._utils._json import openapi_dumps + + +class TestOpenapiDumps: + def test_basic(self) -> None: + data = {"key": "value", "number": 42} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"key":"value","number":42}' + + def test_datetime_serialization(self) -> None: + dt = datetime.datetime(2023, 1, 1, 12, 0, 0) + data = {"datetime": dt} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}' + + def test_pydantic_model_serialization(self) -> None: + class User(pydantic.BaseModel): + first_name: str + last_name: str + age: int + + model_instance = User(first_name="John", last_name="Kramer", age=83) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}' + + def test_pydantic_model_with_default_values(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + score: int = 0 + + model_instance = User(name="Alice") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Alice"}}' + + def test_pydantic_model_with_default_values_overridden(self) -> None: + class User(pydantic.BaseModel): + name: str + role: str = "user" + active: bool = True + + model_instance = User(name="Bob", role="admin", active=False) + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}' + + def test_pydantic_model_with_alias(self) -> None: + class User(pydantic.BaseModel): + first_name: str = pydantic.Field(alias="firstName") + last_name: str = pydantic.Field(alias="lastName") + + model_instance = User(firstName="John", lastName="Doe") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}' + + def test_pydantic_model_with_alias_and_default(self) -> None: + class User(pydantic.BaseModel): + user_name: str = pydantic.Field(alias="userName") + user_role: str = pydantic.Field(default="member", alias="userRole") + is_active: bool = pydantic.Field(default=True, alias="isActive") + + model_instance = User(userName="charlie") + data = {"model": model_instance} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"charlie"}}' + + model_with_overrides = User(userName="diana", userRole="admin", isActive=False) + data = {"model": model_with_overrides} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}' + + def test_pydantic_model_with_nested_models_and_defaults(self) -> None: + class Address(pydantic.BaseModel): + street: str + city: str = "Unknown" + + class User(pydantic.BaseModel): + name: str + address: Address + verified: bool = False + + if _compat.PYDANTIC_V1: + # to handle forward references in Pydantic v1 + User.update_forward_refs(**locals()) # type: ignore[reportDeprecated] + + address = Address(street="123 Main St") + user = User(name="Diana", address=address) + data = {"user": user} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}' + + address_with_city = Address(street="456 Oak Ave", city="Boston") + user_verified = User(name="Eve", address=address_with_city, verified=True) + data = {"user": user_verified} + json_bytes = openapi_dumps(data) + assert ( + json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}' + ) + + def test_pydantic_model_with_optional_fields(self) -> None: + class User(pydantic.BaseModel): + name: str + email: Union[str, None] + phone: Union[str, None] + + model_with_none = User(name="Eve", email=None, phone=None) + data = {"model": model_with_none} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}' + + model_with_values = User(name="Frank", email="frank@example.com", phone=None) + data = {"model": model_with_values} + json_bytes = openapi_dumps(data) + assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}' From 74a25c2c36a93e3ecdbd4c49cb86340b8c363cf0 Mon Sep 17 00:00:00 2001 From: Derek <75138022+derekmeegan@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:34:54 -0800 Subject: [PATCH 14/22] release: 1.5.0 (#159) Co-authored-by: Claude Opus 4.6 --- .release-please-manifest.json | 2 +- pyproject.toml | 2 +- src/browserbase/_version.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 70fc11c6..fbd9082d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.5.0-alpha.2" + ".": "1.5.0" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4d5c491a..f0fb3526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.5.0-alpha.2" +version = "1.5.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index afe412cf..a4f65d6f 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.5.0-alpha.2" # x-release-please-version +__version__ = "1.5.0" # x-release-please-version From 3b5d801df4e2bdf8149a5cacb6bf6462e7883ed6 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:43:38 +0000 Subject: [PATCH 15/22] release: 1.6.0 (#160) * chore(test): do not count install time for mock server timeout * chore(ci): skip uploading artifacts on stainless-internal branches * chore: update placeholder string * feat: [CORE-1804][apps/api] Add fetch API schema * release: 1.6.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 +- .release-please-manifest.json | 2 +- .stats.yml | 8 +- CHANGELOG.md | 33 +++ api.md | 12 ++ pyproject.toml | 2 +- scripts/mock | 13 +- src/browserbase/_client.py | 39 +++- src/browserbase/_version.py | 2 +- src/browserbase/resources/__init__.py | 14 ++ src/browserbase/resources/fetch_api.py | 201 ++++++++++++++++++ src/browserbase/types/__init__.py | 2 + .../types/fetch_api_create_params.py | 23 ++ .../types/fetch_api_create_response.py | 31 +++ tests/api_resources/sessions/test_uploads.py | 16 +- tests/api_resources/test_extensions.py | 12 +- tests/api_resources/test_fetch_api.py | 106 +++++++++ 17 files changed, 499 insertions(+), 25 deletions(-) create mode 100644 src/browserbase/resources/fetch_api.py create mode 100644 src/browserbase/types/fetch_api_create_params.py create mode 100644 src/browserbase/types/fetch_api_create_response.py create mode 100644 tests/api_resources/test_fetch_api.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c77d6d73..0925562d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,14 +61,18 @@ jobs: run: rye build - name: Get GitHub OIDC Token - if: github.repository == 'stainless-sdks/browserbase-python' + if: |- + github.repository == 'stainless-sdks/browserbase-python' && + !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball - if: github.repository == 'stainless-sdks/browserbase-python' + if: |- + github.repository == 'stainless-sdks/browserbase-python' && + !startsWith(github.ref, 'refs/heads/stl/') 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 fbd9082d..7deae338 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.5.0" + ".": "1.6.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 05de0332..3aabff9c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 19 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-215bc4361122162181eecce83c0dbdda7c45a21801e7addb75102e8011413069.yml -openapi_spec_hash: c4fadc5bb6b84cd3988c8d864b67bf61 -config_hash: a106b247c7cdf02ac1033077402cfe2d +configured_endpoints: 20 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-b20f9fea14d79990ab1af3d276f931e026cd955ac623ec6ace80b2af90de170f.yml +openapi_spec_hash: 943ff4b3297014503fdc9854544cb9a4 +config_hash: 55c54fdafc9e80be584829b5724b00ab diff --git a/CHANGELOG.md b/CHANGELOG.md index f38a54f2..a482d7b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 1.6.0 (2026-03-11) + +Full Changelog: [v1.5.0...v1.6.0](https://github.com/browserbase/sdk-python/compare/v1.5.0...v1.6.0) + +### Features + +* [CORE-1804][apps/api] Add fetch API schema ([a56673c](https://github.com/browserbase/sdk-python/commit/a56673cf002d95b97d855befa64d46bd55559323)) +* **api:** api update ([#39](https://github.com/browserbase/sdk-python/issues/39)) ([8c98d9e](https://github.com/browserbase/sdk-python/commit/8c98d9e9da61daba527262aa1b0a1334da22a596)) +* **api:** api update ([#41](https://github.com/browserbase/sdk-python/issues/41)) ([0557ee5](https://github.com/browserbase/sdk-python/commit/0557ee507fc35faa9aabd8d06ce8047bb07843aa)) +* **api:** api update ([#44](https://github.com/browserbase/sdk-python/issues/44)) ([718ef11](https://github.com/browserbase/sdk-python/commit/718ef11db8e865347c24b2205b22f906bccb24e7)) +* **api:** update via SDK Studio ([#10](https://github.com/browserbase/sdk-python/issues/10)) ([712cb9f](https://github.com/browserbase/sdk-python/commit/712cb9f5d36d3f882e36848413fb96f32558b67c)) +* **api:** update via SDK Studio ([#13](https://github.com/browserbase/sdk-python/issues/13)) ([ec90079](https://github.com/browserbase/sdk-python/commit/ec900790bd56f92b174304bb227461a65209cbdb)) +* **api:** update via SDK Studio ([#16](https://github.com/browserbase/sdk-python/issues/16)) ([3739129](https://github.com/browserbase/sdk-python/commit/37391297ef3c3d59105c618ba9026ecae551a77b)) +* **api:** update via SDK Studio ([#18](https://github.com/browserbase/sdk-python/issues/18)) ([c8ff679](https://github.com/browserbase/sdk-python/commit/c8ff67974dbcf8a99dae87aea6e9b18046bf5acd)) +* **api:** update via SDK Studio ([#19](https://github.com/browserbase/sdk-python/issues/19)) ([1556b70](https://github.com/browserbase/sdk-python/commit/1556b7064ad2ec507d74536a1d074e5f464c3d12)) +* **api:** update via SDK Studio ([#20](https://github.com/browserbase/sdk-python/issues/20)) ([8876008](https://github.com/browserbase/sdk-python/commit/887600814fca35fbaf4cb4ddd2a0112a51a3f068)) +* **api:** update via SDK Studio ([#21](https://github.com/browserbase/sdk-python/issues/21)) ([8f22de1](https://github.com/browserbase/sdk-python/commit/8f22de1da97f9157ef52647f20895cabe5ecfd26)) +* **api:** update via SDK Studio ([#27](https://github.com/browserbase/sdk-python/issues/27)) ([b1facb8](https://github.com/browserbase/sdk-python/commit/b1facb8db6d821a2d3c5465dbeb474e0140ed90e)) +* **api:** update via SDK Studio ([#28](https://github.com/browserbase/sdk-python/issues/28)) ([0735db2](https://github.com/browserbase/sdk-python/commit/0735db24fb833dbeb5d09f30b8fd18e67d7a2e5d)) +* **api:** update via SDK Studio ([#29](https://github.com/browserbase/sdk-python/issues/29)) ([c1e0eb9](https://github.com/browserbase/sdk-python/commit/c1e0eb9d84a8b83b30fce673a4c5e6262ce732b2)) +* **api:** update via SDK Studio ([#32](https://github.com/browserbase/sdk-python/issues/32)) ([54a7ed4](https://github.com/browserbase/sdk-python/commit/54a7ed479088a68434961cd92ae225064bf8e8a1)) +* **api:** update via SDK Studio ([#35](https://github.com/browserbase/sdk-python/issues/35)) ([cdc05f9](https://github.com/browserbase/sdk-python/commit/cdc05f947c676bd56bed22e77fcfd48c313f3596)) +* **api:** update via SDK Studio ([#7](https://github.com/browserbase/sdk-python/issues/7)) ([90f80c7](https://github.com/browserbase/sdk-python/commit/90f80c7d8a44b155cac6dc9ed73ac55dbbb9596d)) +* various codegen changes ([a70c78e](https://github.com/browserbase/sdk-python/commit/a70c78ecf5c0f4763c9222a332c164249c1060bc)) + + +### Chores + +* **ci:** skip uploading artifacts on stainless-internal branches ([99d0409](https://github.com/browserbase/sdk-python/commit/99d0409a98d14dc19359b80a966c30e2aa5d99f0)) +* **test:** do not count install time for mock server timeout ([b19e21b](https://github.com/browserbase/sdk-python/commit/b19e21bf4d411face9fd8f252f35bac1981370ef)) +* update placeholder string ([8ff9f32](https://github.com/browserbase/sdk-python/commit/8ff9f32b3d608ea9a2a4e6bd9120c029bd05f6d4)) +* update SDK settings ([#1](https://github.com/browserbase/sdk-python/issues/1)) ([dc1e229](https://github.com/browserbase/sdk-python/commit/dc1e229189470b0f3df3c5a2e7da4e789fc76279)) + ## 1.5.0-alpha.2 (2026-03-03) Full Changelog: [v1.5.0-alpha.1...v1.5.0-alpha.2](https://github.com/browserbase/sdk-python/compare/v1.5.0-alpha.1...v1.5.0-alpha.2) diff --git a/api.md b/api.md index d2d26e57..ac45583b 100644 --- a/api.md +++ b/api.md @@ -27,6 +27,18 @@ Methods: - client.extensions.retrieve(id) -> Extension - client.extensions.delete(id) -> None +# FetchAPI + +Types: + +```python +from browserbase.types import FetchAPICreateResponse +``` + +Methods: + +- client.fetch_api.create(\*\*params) -> FetchAPICreateResponse + # Projects Types: diff --git a/pyproject.toml b/pyproject.toml index f0fb3526..425bcc4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.5.0" +version = "1.6.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/scripts/mock b/scripts/mock index 0b28f6ea..bcf3b392 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & - # Wait for server to come online + # Wait for server to come online (max 30s) echo -n "Waiting for server" + attempts=0 while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Prism server to start" + cat .prism.log + exit 1 + fi echo -n "." sleep 0.1 done diff --git a/src/browserbase/_client.py b/src/browserbase/_client.py index 5bb997a7..70a7b71d 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -31,9 +31,10 @@ ) if TYPE_CHECKING: - from .resources import contexts, projects, sessions, extensions + from .resources import contexts, projects, sessions, fetch_api, extensions from .resources.contexts import ContextsResource, AsyncContextsResource from .resources.projects import ProjectsResource, AsyncProjectsResource + from .resources.fetch_api import FetchAPIResource, AsyncFetchAPIResource from .resources.extensions import ExtensionsResource, AsyncExtensionsResource from .resources.sessions.sessions import SessionsResource, AsyncSessionsResource @@ -116,6 +117,12 @@ def extensions(self) -> ExtensionsResource: return ExtensionsResource(self) + @cached_property + def fetch_api(self) -> FetchAPIResource: + from .resources.fetch_api import FetchAPIResource + + return FetchAPIResource(self) + @cached_property def projects(self) -> ProjectsResource: from .resources.projects import ProjectsResource @@ -308,6 +315,12 @@ def extensions(self) -> AsyncExtensionsResource: return AsyncExtensionsResource(self) + @cached_property + def fetch_api(self) -> AsyncFetchAPIResource: + from .resources.fetch_api import AsyncFetchAPIResource + + return AsyncFetchAPIResource(self) + @cached_property def projects(self) -> AsyncProjectsResource: from .resources.projects import AsyncProjectsResource @@ -451,6 +464,12 @@ def extensions(self) -> extensions.ExtensionsResourceWithRawResponse: return ExtensionsResourceWithRawResponse(self._client.extensions) + @cached_property + def fetch_api(self) -> fetch_api.FetchAPIResourceWithRawResponse: + from .resources.fetch_api import FetchAPIResourceWithRawResponse + + return FetchAPIResourceWithRawResponse(self._client.fetch_api) + @cached_property def projects(self) -> projects.ProjectsResourceWithRawResponse: from .resources.projects import ProjectsResourceWithRawResponse @@ -482,6 +501,12 @@ def extensions(self) -> extensions.AsyncExtensionsResourceWithRawResponse: return AsyncExtensionsResourceWithRawResponse(self._client.extensions) + @cached_property + def fetch_api(self) -> fetch_api.AsyncFetchAPIResourceWithRawResponse: + from .resources.fetch_api import AsyncFetchAPIResourceWithRawResponse + + return AsyncFetchAPIResourceWithRawResponse(self._client.fetch_api) + @cached_property def projects(self) -> projects.AsyncProjectsResourceWithRawResponse: from .resources.projects import AsyncProjectsResourceWithRawResponse @@ -513,6 +538,12 @@ def extensions(self) -> extensions.ExtensionsResourceWithStreamingResponse: return ExtensionsResourceWithStreamingResponse(self._client.extensions) + @cached_property + def fetch_api(self) -> fetch_api.FetchAPIResourceWithStreamingResponse: + from .resources.fetch_api import FetchAPIResourceWithStreamingResponse + + return FetchAPIResourceWithStreamingResponse(self._client.fetch_api) + @cached_property def projects(self) -> projects.ProjectsResourceWithStreamingResponse: from .resources.projects import ProjectsResourceWithStreamingResponse @@ -544,6 +575,12 @@ def extensions(self) -> extensions.AsyncExtensionsResourceWithStreamingResponse: return AsyncExtensionsResourceWithStreamingResponse(self._client.extensions) + @cached_property + def fetch_api(self) -> fetch_api.AsyncFetchAPIResourceWithStreamingResponse: + from .resources.fetch_api import AsyncFetchAPIResourceWithStreamingResponse + + return AsyncFetchAPIResourceWithStreamingResponse(self._client.fetch_api) + @cached_property def projects(self) -> projects.AsyncProjectsResourceWithStreamingResponse: from .resources.projects import AsyncProjectsResourceWithStreamingResponse diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index a4f65d6f..a7f6a7d4 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.5.0" # x-release-please-version +__version__ = "1.6.0" # x-release-please-version diff --git a/src/browserbase/resources/__init__.py b/src/browserbase/resources/__init__.py index 73451a50..f5a2bf0c 100644 --- a/src/browserbase/resources/__init__.py +++ b/src/browserbase/resources/__init__.py @@ -24,6 +24,14 @@ SessionsResourceWithStreamingResponse, AsyncSessionsResourceWithStreamingResponse, ) +from .fetch_api import ( + FetchAPIResource, + AsyncFetchAPIResource, + FetchAPIResourceWithRawResponse, + AsyncFetchAPIResourceWithRawResponse, + FetchAPIResourceWithStreamingResponse, + AsyncFetchAPIResourceWithStreamingResponse, +) from .extensions import ( ExtensionsResource, AsyncExtensionsResource, @@ -46,6 +54,12 @@ "AsyncExtensionsResourceWithRawResponse", "ExtensionsResourceWithStreamingResponse", "AsyncExtensionsResourceWithStreamingResponse", + "FetchAPIResource", + "AsyncFetchAPIResource", + "FetchAPIResourceWithRawResponse", + "AsyncFetchAPIResourceWithRawResponse", + "FetchAPIResourceWithStreamingResponse", + "AsyncFetchAPIResourceWithStreamingResponse", "ProjectsResource", "AsyncProjectsResource", "ProjectsResourceWithRawResponse", diff --git a/src/browserbase/resources/fetch_api.py b/src/browserbase/resources/fetch_api.py new file mode 100644 index 00000000..dc016722 --- /dev/null +++ b/src/browserbase/resources/fetch_api.py @@ -0,0 +1,201 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import fetch_api_create_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.fetch_api_create_response import FetchAPICreateResponse + +__all__ = ["FetchAPIResource", "AsyncFetchAPIResource"] + + +class FetchAPIResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> FetchAPIResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers + """ + return FetchAPIResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> FetchAPIResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/browserbase/sdk-python#with_streaming_response + """ + return FetchAPIResourceWithStreamingResponse(self) + + def create( + self, + *, + url: str, + allow_insecure_ssl: bool | Omit = omit, + allow_redirects: bool | Omit = omit, + proxies: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FetchAPICreateResponse: + """ + Fetch a page and return its content, headers, and metadata. + + Args: + url: The URL to fetch + + allow_insecure_ssl: Whether to bypass TLS certificate verification + + allow_redirects: Whether to follow HTTP redirects + + proxies: Whether to enable proxy support for the request + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/fetch", + body=maybe_transform( + { + "url": url, + "allow_insecure_ssl": allow_insecure_ssl, + "allow_redirects": allow_redirects, + "proxies": proxies, + }, + fetch_api_create_params.FetchAPICreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FetchAPICreateResponse, + ) + + +class AsyncFetchAPIResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncFetchAPIResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncFetchAPIResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncFetchAPIResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/browserbase/sdk-python#with_streaming_response + """ + return AsyncFetchAPIResourceWithStreamingResponse(self) + + async def create( + self, + *, + url: str, + allow_insecure_ssl: bool | Omit = omit, + allow_redirects: bool | Omit = omit, + proxies: bool | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FetchAPICreateResponse: + """ + Fetch a page and return its content, headers, and metadata. + + Args: + url: The URL to fetch + + allow_insecure_ssl: Whether to bypass TLS certificate verification + + allow_redirects: Whether to follow HTTP redirects + + proxies: Whether to enable proxy support for the request + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/fetch", + body=await async_maybe_transform( + { + "url": url, + "allow_insecure_ssl": allow_insecure_ssl, + "allow_redirects": allow_redirects, + "proxies": proxies, + }, + fetch_api_create_params.FetchAPICreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=FetchAPICreateResponse, + ) + + +class FetchAPIResourceWithRawResponse: + def __init__(self, fetch_api: FetchAPIResource) -> None: + self._fetch_api = fetch_api + + self.create = to_raw_response_wrapper( + fetch_api.create, + ) + + +class AsyncFetchAPIResourceWithRawResponse: + def __init__(self, fetch_api: AsyncFetchAPIResource) -> None: + self._fetch_api = fetch_api + + self.create = async_to_raw_response_wrapper( + fetch_api.create, + ) + + +class FetchAPIResourceWithStreamingResponse: + def __init__(self, fetch_api: FetchAPIResource) -> None: + self._fetch_api = fetch_api + + self.create = to_streamed_response_wrapper( + fetch_api.create, + ) + + +class AsyncFetchAPIResourceWithStreamingResponse: + def __init__(self, fetch_api: AsyncFetchAPIResource) -> None: + self._fetch_api = fetch_api + + self.create = async_to_streamed_response_wrapper( + fetch_api.create, + ) diff --git a/src/browserbase/types/__init__.py b/src/browserbase/types/__init__.py index 4dd85ddb..52659907 100644 --- a/src/browserbase/types/__init__.py +++ b/src/browserbase/types/__init__.py @@ -17,5 +17,7 @@ from .context_create_response import ContextCreateResponse as ContextCreateResponse from .context_update_response import ContextUpdateResponse as ContextUpdateResponse from .extension_create_params import ExtensionCreateParams as ExtensionCreateParams +from .fetch_api_create_params import FetchAPICreateParams as FetchAPICreateParams from .session_create_response import SessionCreateResponse as SessionCreateResponse +from .fetch_api_create_response import FetchAPICreateResponse as FetchAPICreateResponse from .session_retrieve_response import SessionRetrieveResponse as SessionRetrieveResponse diff --git a/src/browserbase/types/fetch_api_create_params.py b/src/browserbase/types/fetch_api_create_params.py new file mode 100644 index 00000000..84a8a052 --- /dev/null +++ b/src/browserbase/types/fetch_api_create_params.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["FetchAPICreateParams"] + + +class FetchAPICreateParams(TypedDict, total=False): + url: Required[str] + """The URL to fetch""" + + allow_insecure_ssl: Annotated[bool, PropertyInfo(alias="allowInsecureSsl")] + """Whether to bypass TLS certificate verification""" + + allow_redirects: Annotated[bool, PropertyInfo(alias="allowRedirects")] + """Whether to follow HTTP redirects""" + + proxies: bool + """Whether to enable proxy support for the request""" diff --git a/src/browserbase/types/fetch_api_create_response.py b/src/browserbase/types/fetch_api_create_response.py new file mode 100644 index 00000000..cfafbba3 --- /dev/null +++ b/src/browserbase/types/fetch_api_create_response.py @@ -0,0 +1,31 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["FetchAPICreateResponse"] + + +class FetchAPICreateResponse(BaseModel): + """Response body for fetch""" + + id: str + """Unique identifier for the fetch request""" + + content: str + """The response body content""" + + content_type: str = FieldInfo(alias="contentType") + """The MIME type of the response""" + + encoding: str + """The character encoding of the response""" + + headers: Dict[str, str] + """Response headers as key-value pairs""" + + status_code: int = FieldInfo(alias="statusCode") + """HTTP status code of the fetched response""" diff --git a/tests/api_resources/sessions/test_uploads.py b/tests/api_resources/sessions/test_uploads.py index 748b92e7..b98dc0a5 100644 --- a/tests/api_resources/sessions/test_uploads.py +++ b/tests/api_resources/sessions/test_uploads.py @@ -21,7 +21,7 @@ class TestUploads: def test_method_create(self, client: Browserbase) -> None: upload = client.sessions.uploads.create( id="id", - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(UploadCreateResponse, upload, path=["response"]) @@ -29,7 +29,7 @@ def test_method_create(self, client: Browserbase) -> None: def test_raw_response_create(self, client: Browserbase) -> None: response = client.sessions.uploads.with_raw_response.create( id="id", - file=b"raw file contents", + file=b"Example data", ) assert response.is_closed is True @@ -41,7 +41,7 @@ def test_raw_response_create(self, client: Browserbase) -> None: def test_streaming_response_create(self, client: Browserbase) -> None: with client.sessions.uploads.with_streaming_response.create( id="id", - file=b"raw file contents", + file=b"Example data", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -56,7 +56,7 @@ def test_path_params_create(self, client: Browserbase) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): client.sessions.uploads.with_raw_response.create( id="", - file=b"raw file contents", + file=b"Example data", ) @@ -69,7 +69,7 @@ class TestAsyncUploads: async def test_method_create(self, async_client: AsyncBrowserbase) -> None: upload = await async_client.sessions.uploads.create( id="id", - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(UploadCreateResponse, upload, path=["response"]) @@ -77,7 +77,7 @@ async def test_method_create(self, async_client: AsyncBrowserbase) -> None: async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None: response = await async_client.sessions.uploads.with_raw_response.create( id="id", - file=b"raw file contents", + file=b"Example data", ) assert response.is_closed is True @@ -89,7 +89,7 @@ async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None async def test_streaming_response_create(self, async_client: AsyncBrowserbase) -> None: async with async_client.sessions.uploads.with_streaming_response.create( id="id", - file=b"raw file contents", + file=b"Example data", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -104,5 +104,5 @@ async def test_path_params_create(self, async_client: AsyncBrowserbase) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): await async_client.sessions.uploads.with_raw_response.create( id="", - file=b"raw file contents", + file=b"Example data", ) diff --git a/tests/api_resources/test_extensions.py b/tests/api_resources/test_extensions.py index 6b6a0183..b6b6260b 100644 --- a/tests/api_resources/test_extensions.py +++ b/tests/api_resources/test_extensions.py @@ -20,14 +20,14 @@ class TestExtensions: @parametrize def test_method_create(self, client: Browserbase) -> None: extension = client.extensions.create( - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(Extension, extension, path=["response"]) @parametrize def test_raw_response_create(self, client: Browserbase) -> None: response = client.extensions.with_raw_response.create( - file=b"raw file contents", + file=b"Example data", ) assert response.is_closed is True @@ -38,7 +38,7 @@ def test_raw_response_create(self, client: Browserbase) -> None: @parametrize def test_streaming_response_create(self, client: Browserbase) -> None: with client.extensions.with_streaming_response.create( - file=b"raw file contents", + file=b"Example data", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -133,14 +133,14 @@ class TestAsyncExtensions: @parametrize async def test_method_create(self, async_client: AsyncBrowserbase) -> None: extension = await async_client.extensions.create( - file=b"raw file contents", + file=b"Example data", ) assert_matches_type(Extension, extension, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None: response = await async_client.extensions.with_raw_response.create( - file=b"raw file contents", + file=b"Example data", ) assert response.is_closed is True @@ -151,7 +151,7 @@ async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None @parametrize async def test_streaming_response_create(self, async_client: AsyncBrowserbase) -> None: async with async_client.extensions.with_streaming_response.create( - file=b"raw file contents", + file=b"Example data", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" diff --git a/tests/api_resources/test_fetch_api.py b/tests/api_resources/test_fetch_api.py new file mode 100644 index 00000000..b9a0455b --- /dev/null +++ b/tests/api_resources/test_fetch_api.py @@ -0,0 +1,106 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from browserbase import Browserbase, AsyncBrowserbase +from tests.utils import assert_matches_type +from browserbase.types import FetchAPICreateResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestFetchAPI: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_create(self, client: Browserbase) -> None: + fetch_api = client.fetch_api.create( + url="https://example.com", + ) + assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) + + @parametrize + def test_method_create_with_all_params(self, client: Browserbase) -> None: + fetch_api = client.fetch_api.create( + url="https://example.com", + allow_insecure_ssl=True, + allow_redirects=True, + proxies=True, + ) + assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) + + @parametrize + def test_raw_response_create(self, client: Browserbase) -> None: + response = client.fetch_api.with_raw_response.create( + url="https://example.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + fetch_api = response.parse() + assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) + + @parametrize + def test_streaming_response_create(self, client: Browserbase) -> None: + with client.fetch_api.with_streaming_response.create( + url="https://example.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + fetch_api = response.parse() + assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncFetchAPI: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_create(self, async_client: AsyncBrowserbase) -> None: + fetch_api = await async_client.fetch_api.create( + url="https://example.com", + ) + assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) + + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncBrowserbase) -> None: + fetch_api = await async_client.fetch_api.create( + url="https://example.com", + allow_insecure_ssl=True, + allow_redirects=True, + proxies=True, + ) + assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) + + @parametrize + async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None: + response = await async_client.fetch_api.with_raw_response.create( + url="https://example.com", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + fetch_api = await response.parse() + assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) + + @parametrize + async def test_streaming_response_create(self, async_client: AsyncBrowserbase) -> None: + async with async_client.fetch_api.with_streaming_response.create( + url="https://example.com", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + fetch_api = await response.parse() + assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) + + assert cast(Any, response.is_closed) is True From 36f677254a8db935295efd14b3dd30ed3a588917 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:01:01 +0000 Subject: [PATCH 16/22] release: 1.7.0 (#161) * feat: [CORE-1796][apps/api] Update Node.js SDK * feat: [GRO-000] docs: Add guide + docs for search api * release: 1.7.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- .stats.yml | 8 +- CHANGELOG.md | 9 + api.md | 12 ++ pyproject.toml | 2 +- src/browserbase/_client.py | 39 +++- src/browserbase/_version.py | 2 +- src/browserbase/resources/__init__.py | 14 ++ src/browserbase/resources/search.py | 185 ++++++++++++++++++ src/browserbase/types/__init__.py | 2 + .../types/fetch_api_create_response.py | 2 - src/browserbase/types/search_web_params.py | 17 ++ src/browserbase/types/search_web_response.py | 46 +++++ tests/api_resources/test_search.py | 102 ++++++++++ 14 files changed, 432 insertions(+), 10 deletions(-) create mode 100644 src/browserbase/resources/search.py create mode 100644 src/browserbase/types/search_web_params.py create mode 100644 src/browserbase/types/search_web_response.py create mode 100644 tests/api_resources/test_search.py diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7deae338..cce9d1c6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.6.0" + ".": "1.7.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 3aabff9c..28a1de1a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 20 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-b20f9fea14d79990ab1af3d276f931e026cd955ac623ec6ace80b2af90de170f.yml -openapi_spec_hash: 943ff4b3297014503fdc9854544cb9a4 -config_hash: 55c54fdafc9e80be584829b5724b00ab +configured_endpoints: 21 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-9b1e2a2abf39dd780601935a9a9ee04cb939e2c3ba76627535f625b6aeaf5eb7.yml +openapi_spec_hash: 12fe5f4306c43fdfb394a33f79391a82 +config_hash: cf04ecfb8dad5fbd8b85be25d6e9ec55 diff --git a/CHANGELOG.md b/CHANGELOG.md index a482d7b4..d6cc7bb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.7.0 (2026-03-16) + +Full Changelog: [v1.6.0...v1.7.0](https://github.com/browserbase/sdk-python/compare/v1.6.0...v1.7.0) + +### Features + +* [CORE-1796][apps/api] Update Node.js SDK ([ecafbb5](https://github.com/browserbase/sdk-python/commit/ecafbb511dbe9be78e84f4e1d34e1feefa1fce8e)) +* [GRO-000] docs: Add guide + docs for search api ([55e2d0d](https://github.com/browserbase/sdk-python/commit/55e2d0db2f31b2ca390eaa6855f22de1f1688a88)) + ## 1.6.0 (2026-03-11) Full Changelog: [v1.5.0...v1.6.0](https://github.com/browserbase/sdk-python/compare/v1.5.0...v1.6.0) diff --git a/api.md b/api.md index ac45583b..b6066cb8 100644 --- a/api.md +++ b/api.md @@ -53,6 +53,18 @@ Methods: - client.projects.list() -> ProjectListResponse - client.projects.usage(id) -> ProjectUsage +# Search + +Types: + +```python +from browserbase.types import SearchWebResponse +``` + +Methods: + +- client.search.web(\*\*params) -> SearchWebResponse + # Sessions Types: diff --git a/pyproject.toml b/pyproject.toml index 425bcc4f..ad5ff029 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.6.0" +version = "1.7.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browserbase/_client.py b/src/browserbase/_client.py index 70a7b71d..d642273a 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -31,7 +31,8 @@ ) if TYPE_CHECKING: - from .resources import contexts, projects, sessions, fetch_api, extensions + from .resources import search, contexts, projects, sessions, fetch_api, extensions + from .resources.search import SearchResource, AsyncSearchResource from .resources.contexts import ContextsResource, AsyncContextsResource from .resources.projects import ProjectsResource, AsyncProjectsResource from .resources.fetch_api import FetchAPIResource, AsyncFetchAPIResource @@ -129,6 +130,12 @@ def projects(self) -> ProjectsResource: return ProjectsResource(self) + @cached_property + def search(self) -> SearchResource: + from .resources.search import SearchResource + + return SearchResource(self) + @cached_property def sessions(self) -> SessionsResource: from .resources.sessions import SessionsResource @@ -327,6 +334,12 @@ def projects(self) -> AsyncProjectsResource: return AsyncProjectsResource(self) + @cached_property + def search(self) -> AsyncSearchResource: + from .resources.search import AsyncSearchResource + + return AsyncSearchResource(self) + @cached_property def sessions(self) -> AsyncSessionsResource: from .resources.sessions import AsyncSessionsResource @@ -476,6 +489,12 @@ def projects(self) -> projects.ProjectsResourceWithRawResponse: return ProjectsResourceWithRawResponse(self._client.projects) + @cached_property + def search(self) -> search.SearchResourceWithRawResponse: + from .resources.search import SearchResourceWithRawResponse + + return SearchResourceWithRawResponse(self._client.search) + @cached_property def sessions(self) -> sessions.SessionsResourceWithRawResponse: from .resources.sessions import SessionsResourceWithRawResponse @@ -513,6 +532,12 @@ def projects(self) -> projects.AsyncProjectsResourceWithRawResponse: return AsyncProjectsResourceWithRawResponse(self._client.projects) + @cached_property + def search(self) -> search.AsyncSearchResourceWithRawResponse: + from .resources.search import AsyncSearchResourceWithRawResponse + + return AsyncSearchResourceWithRawResponse(self._client.search) + @cached_property def sessions(self) -> sessions.AsyncSessionsResourceWithRawResponse: from .resources.sessions import AsyncSessionsResourceWithRawResponse @@ -550,6 +575,12 @@ def projects(self) -> projects.ProjectsResourceWithStreamingResponse: return ProjectsResourceWithStreamingResponse(self._client.projects) + @cached_property + def search(self) -> search.SearchResourceWithStreamingResponse: + from .resources.search import SearchResourceWithStreamingResponse + + return SearchResourceWithStreamingResponse(self._client.search) + @cached_property def sessions(self) -> sessions.SessionsResourceWithStreamingResponse: from .resources.sessions import SessionsResourceWithStreamingResponse @@ -587,6 +618,12 @@ def projects(self) -> projects.AsyncProjectsResourceWithStreamingResponse: return AsyncProjectsResourceWithStreamingResponse(self._client.projects) + @cached_property + def search(self) -> search.AsyncSearchResourceWithStreamingResponse: + from .resources.search import AsyncSearchResourceWithStreamingResponse + + return AsyncSearchResourceWithStreamingResponse(self._client.search) + @cached_property def sessions(self) -> sessions.AsyncSessionsResourceWithStreamingResponse: from .resources.sessions import AsyncSessionsResourceWithStreamingResponse diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index a7f6a7d4..1b8456be 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.6.0" # x-release-please-version +__version__ = "1.7.0" # x-release-please-version diff --git a/src/browserbase/resources/__init__.py b/src/browserbase/resources/__init__.py index f5a2bf0c..83a8788d 100644 --- a/src/browserbase/resources/__init__.py +++ b/src/browserbase/resources/__init__.py @@ -1,5 +1,13 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from .search import ( + SearchResource, + AsyncSearchResource, + SearchResourceWithRawResponse, + AsyncSearchResourceWithRawResponse, + SearchResourceWithStreamingResponse, + AsyncSearchResourceWithStreamingResponse, +) from .contexts import ( ContextsResource, AsyncContextsResource, @@ -66,6 +74,12 @@ "AsyncProjectsResourceWithRawResponse", "ProjectsResourceWithStreamingResponse", "AsyncProjectsResourceWithStreamingResponse", + "SearchResource", + "AsyncSearchResource", + "SearchResourceWithRawResponse", + "AsyncSearchResourceWithRawResponse", + "SearchResourceWithStreamingResponse", + "AsyncSearchResourceWithStreamingResponse", "SessionsResource", "AsyncSessionsResource", "SessionsResourceWithRawResponse", diff --git a/src/browserbase/resources/search.py b/src/browserbase/resources/search.py new file mode 100644 index 00000000..87f34e60 --- /dev/null +++ b/src/browserbase/resources/search.py @@ -0,0 +1,185 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..types import search_web_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.search_web_response import SearchWebResponse + +__all__ = ["SearchResource", "AsyncSearchResource"] + + +class SearchResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> SearchResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers + """ + return SearchResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> SearchResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/browserbase/sdk-python#with_streaming_response + """ + return SearchResourceWithStreamingResponse(self) + + def web( + self, + *, + query: str, + num_results: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SearchWebResponse: + """ + Perform a web search and return structured results. + + Args: + query: The search query string + + num_results: Number of results to return (1-25) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._post( + "/v1/search", + body=maybe_transform( + { + "query": query, + "num_results": num_results, + }, + search_web_params.SearchWebParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SearchWebResponse, + ) + + +class AsyncSearchResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncSearchResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncSearchResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncSearchResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/browserbase/sdk-python#with_streaming_response + """ + return AsyncSearchResourceWithStreamingResponse(self) + + async def web( + self, + *, + query: str, + num_results: int | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SearchWebResponse: + """ + Perform a web search and return structured results. + + Args: + query: The search query string + + num_results: Number of results to return (1-25) + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return await self._post( + "/v1/search", + body=await async_maybe_transform( + { + "query": query, + "num_results": num_results, + }, + search_web_params.SearchWebParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=SearchWebResponse, + ) + + +class SearchResourceWithRawResponse: + def __init__(self, search: SearchResource) -> None: + self._search = search + + self.web = to_raw_response_wrapper( + search.web, + ) + + +class AsyncSearchResourceWithRawResponse: + def __init__(self, search: AsyncSearchResource) -> None: + self._search = search + + self.web = async_to_raw_response_wrapper( + search.web, + ) + + +class SearchResourceWithStreamingResponse: + def __init__(self, search: SearchResource) -> None: + self._search = search + + self.web = to_streamed_response_wrapper( + search.web, + ) + + +class AsyncSearchResourceWithStreamingResponse: + def __init__(self, search: AsyncSearchResource) -> None: + self._search = search + + self.web = async_to_streamed_response_wrapper( + search.web, + ) diff --git a/src/browserbase/types/__init__.py b/src/browserbase/types/__init__.py index 52659907..0a9a3b84 100644 --- a/src/browserbase/types/__init__.py +++ b/src/browserbase/types/__init__.py @@ -7,7 +7,9 @@ from .session import Session as Session from .extension import Extension as Extension from .project_usage import ProjectUsage as ProjectUsage +from .search_web_params import SearchWebParams as SearchWebParams from .session_live_urls import SessionLiveURLs as SessionLiveURLs +from .search_web_response import SearchWebResponse as SearchWebResponse from .session_list_params import SessionListParams as SessionListParams from .context_create_params import ContextCreateParams as ContextCreateParams from .project_list_response import ProjectListResponse as ProjectListResponse diff --git a/src/browserbase/types/fetch_api_create_response.py b/src/browserbase/types/fetch_api_create_response.py index cfafbba3..f97f5635 100644 --- a/src/browserbase/types/fetch_api_create_response.py +++ b/src/browserbase/types/fetch_api_create_response.py @@ -10,8 +10,6 @@ class FetchAPICreateResponse(BaseModel): - """Response body for fetch""" - id: str """Unique identifier for the fetch request""" diff --git a/src/browserbase/types/search_web_params.py b/src/browserbase/types/search_web_params.py new file mode 100644 index 00000000..68926b56 --- /dev/null +++ b/src/browserbase/types/search_web_params.py @@ -0,0 +1,17 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, Annotated, TypedDict + +from .._utils import PropertyInfo + +__all__ = ["SearchWebParams"] + + +class SearchWebParams(TypedDict, total=False): + query: Required[str] + """The search query string""" + + num_results: Annotated[int, PropertyInfo(alias="numResults")] + """Number of results to return (1-25)""" diff --git a/src/browserbase/types/search_web_response.py b/src/browserbase/types/search_web_response.py new file mode 100644 index 00000000..0243d22a --- /dev/null +++ b/src/browserbase/types/search_web_response.py @@ -0,0 +1,46 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from datetime import datetime + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["SearchWebResponse", "Result"] + + +class Result(BaseModel): + id: str + """Unique identifier for the result""" + + title: str + """The title of the search result""" + + url: str + """The URL of the search result""" + + author: Optional[str] = None + """Author of the content if available""" + + favicon: Optional[str] = None + """Favicon URL""" + + image: Optional[str] = None + """Image URL if available""" + + published_date: Optional[datetime] = FieldInfo(alias="publishedDate", default=None) + """Publication date in ISO 8601 format""" + + +class SearchWebResponse(BaseModel): + """Response body for web search""" + + query: str + """The search query that was executed""" + + request_id: str = FieldInfo(alias="requestId") + """Unique identifier for the request""" + + results: List[Result] + """List of search results""" diff --git a/tests/api_resources/test_search.py b/tests/api_resources/test_search.py new file mode 100644 index 00000000..fa00b773 --- /dev/null +++ b/tests/api_resources/test_search.py @@ -0,0 +1,102 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from browserbase import Browserbase, AsyncBrowserbase +from tests.utils import assert_matches_type +from browserbase.types import SearchWebResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestSearch: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_web(self, client: Browserbase) -> None: + search = client.search.web( + query="x", + ) + assert_matches_type(SearchWebResponse, search, path=["response"]) + + @parametrize + def test_method_web_with_all_params(self, client: Browserbase) -> None: + search = client.search.web( + query="x", + num_results=1, + ) + assert_matches_type(SearchWebResponse, search, path=["response"]) + + @parametrize + def test_raw_response_web(self, client: Browserbase) -> None: + response = client.search.with_raw_response.web( + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + search = response.parse() + assert_matches_type(SearchWebResponse, search, path=["response"]) + + @parametrize + def test_streaming_response_web(self, client: Browserbase) -> None: + with client.search.with_streaming_response.web( + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + search = response.parse() + assert_matches_type(SearchWebResponse, search, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncSearch: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_web(self, async_client: AsyncBrowserbase) -> None: + search = await async_client.search.web( + query="x", + ) + assert_matches_type(SearchWebResponse, search, path=["response"]) + + @parametrize + async def test_method_web_with_all_params(self, async_client: AsyncBrowserbase) -> None: + search = await async_client.search.web( + query="x", + num_results=1, + ) + assert_matches_type(SearchWebResponse, search, path=["response"]) + + @parametrize + async def test_raw_response_web(self, async_client: AsyncBrowserbase) -> None: + response = await async_client.search.with_raw_response.web( + query="x", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + search = await response.parse() + assert_matches_type(SearchWebResponse, search, path=["response"]) + + @parametrize + async def test_streaming_response_web(self, async_client: AsyncBrowserbase) -> None: + async with async_client.search.with_streaming_response.web( + query="x", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + search = await response.parse() + assert_matches_type(SearchWebResponse, search, path=["response"]) + + assert cast(Any, response.is_closed) is True From 50ec5ae3e977d9f425e188311b605ce45b44ef86 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:31:05 +0000 Subject: [PATCH 17/22] release: 1.8.0 (#162) * feat: Update search docs by removing description from typebox schemas * fix(pydantic): do not pass `by_alias` unless set * fix(deps): bump minimum typing-extensions version * chore(internal): tweak CI branches * codegen metadata * fix: sanitize endpoint path params * refactor(tests): switch from prism to steady * chore(tests): bump steady to v0.19.4 * chore(tests): bump steady to v0.19.5 * chore(internal): update gitignore * chore(tests): bump steady to v0.19.6 * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * chore(ci): skip lint on metadata-only changes Note that we still want to run tests, as these depend on the metadata. * chore(tests): bump steady to v0.19.7 * feat(internal): implement indices array format for query and form serialization * codegen metadata * chore(tests): bump steady to v0.20.1 * chore(tests): bump steady to v0.20.2 * feat: [CORE-000][apps/api] Add verified to SDK * release: 1.8.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 18 +-- .gitignore | 1 + .release-please-manifest.json | 2 +- .stats.yml | 4 +- CHANGELOG.md | 35 +++++ CONTRIBUTING.md | 2 +- pyproject.toml | 4 +- scripts/mock | 26 ++-- scripts/test | 16 +-- src/browserbase/_compat.py | 11 +- src/browserbase/_qs.py | 5 +- src/browserbase/_utils/__init__.py | 1 + src/browserbase/_utils/_path.py | 127 ++++++++++++++++++ src/browserbase/_version.py | 2 +- src/browserbase/resources/contexts.py | 14 +- src/browserbase/resources/extensions.py | 10 +- src/browserbase/resources/projects.py | 9 +- .../resources/sessions/downloads.py | 5 +- src/browserbase/resources/sessions/logs.py | 5 +- .../resources/sessions/recording.py | 5 +- .../resources/sessions/sessions.py | 14 +- src/browserbase/resources/sessions/uploads.py | 6 +- src/browserbase/types/search_web_response.py | 2 - .../types/session_create_params.py | 3 + tests/api_resources/test_sessions.py | 2 + tests/test_utils/test_path.py | 89 ++++++++++++ 26 files changed, 345 insertions(+), 73 deletions(-) create mode 100644 src/browserbase/_utils/_path.py create mode 100644 tests/test_utils/test_path.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0925562d..9c4beaa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: push: - branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' pull_request: branches-ignore: - 'stl-preview-head/**' @@ -17,7 +19,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@v6 @@ -36,7 +38,7 @@ jobs: run: ./scripts/lint build: - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') timeout-minutes: 10 name: build permissions: diff --git a/.gitignore b/.gitignore index 117701e6..70bfded1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .prism.log +.stdy.log _dev __pycache__ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cce9d1c6..c523ce19 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.7.0" + ".": "1.8.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 28a1de1a..bf216680 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-9b1e2a2abf39dd780601935a9a9ee04cb939e2c3ba76627535f625b6aeaf5eb7.yml -openapi_spec_hash: 12fe5f4306c43fdfb394a33f79391a82 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-921d3c61c7aa06269f74bee63cee993597944f913429caa2aa2e00dd51fab60f.yml +openapi_spec_hash: d35b9613c41bf172fa2b28aceef10b39 config_hash: cf04ecfb8dad5fbd8b85be25d6e9ec55 diff --git a/CHANGELOG.md b/CHANGELOG.md index d6cc7bb2..3a2dbc4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 1.8.0 (2026-04-06) + +Full Changelog: [v1.7.0...v1.8.0](https://github.com/browserbase/sdk-python/compare/v1.7.0...v1.8.0) + +### Features + +* [CORE-000][apps/api] Add verified to SDK ([1579bf9](https://github.com/browserbase/sdk-python/commit/1579bf91b323f565abc7bdf54a131cd3e8e2f4d9)) +* **internal:** implement indices array format for query and form serialization ([17a85d5](https://github.com/browserbase/sdk-python/commit/17a85d54d36f45acf242d066b13c49e90b4a2777)) +* Update search docs by removing description from typebox schemas ([7913112](https://github.com/browserbase/sdk-python/commit/7913112d697666bf4381119f5962c284bd91b410)) + + +### Bug Fixes + +* **deps:** bump minimum typing-extensions version ([962e72a](https://github.com/browserbase/sdk-python/commit/962e72a121f27a603f92311917426c9aee84d805)) +* **pydantic:** do not pass `by_alias` unless set ([3e6a912](https://github.com/browserbase/sdk-python/commit/3e6a912dc0a117c2deb229bb5230d2294de3b129)) +* sanitize endpoint path params ([8a63eb5](https://github.com/browserbase/sdk-python/commit/8a63eb5e275eb25849f22cf210d42487e8283078)) + + +### Chores + +* **ci:** skip lint on metadata-only changes ([80a6a3c](https://github.com/browserbase/sdk-python/commit/80a6a3c61b73be9c2f99fbdd20b2e47e9024fec5)) +* **internal:** tweak CI branches ([9901c59](https://github.com/browserbase/sdk-python/commit/9901c595fa68e1ab213ac22eb06aeebc722edb03)) +* **internal:** update gitignore ([2e3ba9b](https://github.com/browserbase/sdk-python/commit/2e3ba9b833a663b2b355b2b4a4f4d698de731e5e)) +* **tests:** bump steady to v0.19.4 ([f81180d](https://github.com/browserbase/sdk-python/commit/f81180d7502d612aaaab8665740f2cc133da1e80)) +* **tests:** bump steady to v0.19.5 ([10d0b70](https://github.com/browserbase/sdk-python/commit/10d0b70f04059ff66f199b9512ffb4ab11699262)) +* **tests:** bump steady to v0.19.6 ([f9d4f90](https://github.com/browserbase/sdk-python/commit/f9d4f900ca05f245a5ac91b26df8e8a4520ec1d3)) +* **tests:** bump steady to v0.19.7 ([27c797d](https://github.com/browserbase/sdk-python/commit/27c797da65010f04a0cd3d2f42cfef5d6eeb8105)) +* **tests:** bump steady to v0.20.1 ([17f2e35](https://github.com/browserbase/sdk-python/commit/17f2e357b8c9f09c34eec9a3f7a055b3f9c72d14)) +* **tests:** bump steady to v0.20.2 ([85ef397](https://github.com/browserbase/sdk-python/commit/85ef397598b8856217510a081fa5ebecc94819d3)) + + +### Refactors + +* **tests:** switch from prism to steady ([39217ea](https://github.com/browserbase/sdk-python/commit/39217ea5ffd68b03900a6272378a3b3a8d5efc4a)) + ## 1.7.0 (2026-03-16) Full Changelog: [v1.6.0...v1.7.0](https://github.com/browserbase/sdk-python/compare/v1.6.0...v1.7.0) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f99ed67..09a6a8a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ $ pip install ./path-to-wheel-file.whl ## Running tests -Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. +Most tests require you to [set up a mock server](https://github.com/dgellow/steady) against the OpenAPI spec to run the tests. ```sh $ ./scripts/mock diff --git a/pyproject.toml b/pyproject.toml index ad5ff029..e39927c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.7.0" +version = "1.8.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -11,7 +11,7 @@ authors = [ dependencies = [ "httpx>=0.23.0, <1", "pydantic>=1.9.0, <3", - "typing-extensions>=4.10, <5", + "typing-extensions>=4.14, <5", "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", diff --git a/scripts/mock b/scripts/mock index bcf3b392..5cd7c157 100755 --- a/scripts/mock +++ b/scripts/mock @@ -19,34 +19,34 @@ fi echo "==> Starting mock server with URL ${URL}" -# Run prism mock on the given spec +# Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version + npm exec --package=@stdy/cli@0.20.2 -- steady --version - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & - # Wait for server to come online (max 30s) + # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" attempts=0 - while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do + if ! kill -0 $! 2>/dev/null; then + echo + cat .stdy.log + exit 1 + fi attempts=$((attempts + 1)) if [ "$attempts" -ge 300 ]; then echo - echo "Timed out waiting for Prism server to start" - cat .prism.log + echo "Timed out waiting for Steady server to start" + cat .stdy.log exit 1 fi echo -n "." sleep 0.1 done - if grep -q "✖ fatal" ".prism.log"; then - cat .prism.log - exit 1 - fi - echo else - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index dbeda2d2..b8143aa3 100755 --- a/scripts/test +++ b/scripts/test @@ -9,8 +9,8 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color -function prism_is_running() { - curl --silent "http://localhost:4010" >/dev/null 2>&1 +function steady_is_running() { + curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1 } kill_server_on_port() { @@ -25,7 +25,7 @@ function is_overriding_api_base_url() { [ -n "$TEST_API_BASE_URL" ] } -if ! is_overriding_api_base_url && ! prism_is_running ; then +if ! is_overriding_api_base_url && ! steady_is_running ; then # When we exit this script, make sure to kill the background mock server process trap 'kill_server_on_port 4010' EXIT @@ -36,19 +36,19 @@ fi if is_overriding_api_base_url ; then echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" echo -elif ! prism_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" +elif ! steady_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server" echo -e "running against your OpenAPI spec." echo echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the prism command:" + echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 else - echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}" echo fi diff --git a/src/browserbase/_compat.py b/src/browserbase/_compat.py index 786ff42a..e6690a4f 100644 --- a/src/browserbase/_compat.py +++ b/src/browserbase/_compat.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload from datetime import date, datetime -from typing_extensions import Self, Literal +from typing_extensions import Self, Literal, TypedDict import pydantic from pydantic.fields import FieldInfo @@ -131,6 +131,10 @@ def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str: return model.model_dump_json(indent=indent) +class _ModelDumpKwargs(TypedDict, total=False): + by_alias: bool + + def model_dump( model: pydantic.BaseModel, *, @@ -142,6 +146,9 @@ def model_dump( by_alias: bool | None = None, ) -> dict[str, Any]: if (not PYDANTIC_V1) or hasattr(model, "model_dump"): + kwargs: _ModelDumpKwargs = {} + if by_alias is not None: + kwargs["by_alias"] = by_alias return model.model_dump( mode=mode, exclude=exclude, @@ -149,7 +156,7 @@ def model_dump( exclude_defaults=exclude_defaults, # warnings are not supported in Pydantic v1 warnings=True if PYDANTIC_V1 else warnings, - by_alias=by_alias, + **kwargs, ) return cast( "dict[str, Any]", diff --git a/src/browserbase/_qs.py b/src/browserbase/_qs.py index ada6fd3f..de8c99bc 100644 --- a/src/browserbase/_qs.py +++ b/src/browserbase/_qs.py @@ -101,7 +101,10 @@ def _stringify_item( items.extend(self._stringify_item(key, item, opts)) return items elif array_format == "indices": - raise NotImplementedError("The array indices format is not supported yet") + items = [] + for i, item in enumerate(value): + items.extend(self._stringify_item(f"{key}[{i}]", item, opts)) + return items elif array_format == "brackets": items = [] key = key + "[]" diff --git a/src/browserbase/_utils/__init__.py b/src/browserbase/_utils/__init__.py index dc64e29a..10cb66d2 100644 --- a/src/browserbase/_utils/__init__.py +++ b/src/browserbase/_utils/__init__.py @@ -1,3 +1,4 @@ +from ._path import path_template as path_template from ._sync import asyncify as asyncify from ._proxy import LazyProxy as LazyProxy from ._utils import ( diff --git a/src/browserbase/_utils/_path.py b/src/browserbase/_utils/_path.py new file mode 100644 index 00000000..4d6e1e4c --- /dev/null +++ b/src/browserbase/_utils/_path.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import re +from typing import ( + Any, + Mapping, + Callable, +) +from urllib.parse import quote + +# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E). +_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$") + +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + + +def _quote_path_segment_part(value: str) -> str: + """Percent-encode `value` for use in a URI path segment. + + Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + """ + # quote() already treats unreserved characters (letters, digits, and -._~) + # as safe, so we only need to add sub-delims, ':', and '@'. + # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted. + return quote(value, safe="!$&'()*+,;=:@") + + +def _quote_query_part(value: str) -> str: + """Percent-encode `value` for use in a URI query string. + + Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 + """ + return quote(value, safe="!$'()*+,;:@/?") + + +def _quote_fragment_part(value: str) -> str: + """Percent-encode `value` for use in a URI fragment. + + Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.5 + """ + return quote(value, safe="!$&'()*+,;=:@/?") + + +def _interpolate( + template: str, + values: Mapping[str, Any], + quoter: Callable[[str], str], +) -> str: + """Replace {name} placeholders in `template`, quoting each value with `quoter`. + + Placeholder names are looked up in `values`. + + Raises: + KeyError: If a placeholder is not found in `values`. + """ + # re.split with a capturing group returns alternating + # [text, name, text, name, ..., text] elements. + parts = _PLACEHOLDER_RE.split(template) + + for i in range(1, len(parts), 2): + name = parts[i] + if name not in values: + raise KeyError(f"a value for placeholder {{{name}}} was not provided") + val = values[name] + if val is None: + parts[i] = "null" + elif isinstance(val, bool): + parts[i] = "true" if val else "false" + else: + parts[i] = quoter(str(values[name])) + + return "".join(parts) + + +def path_template(template: str, /, **kwargs: Any) -> str: + """Interpolate {name} placeholders in `template` from keyword arguments. + + Args: + template: The template string containing {name} placeholders. + **kwargs: Keyword arguments to interpolate into the template. + + Returns: + The template with placeholders interpolated and percent-encoded. + + Safe characters for percent-encoding are dependent on the URI component. + Placeholders in path and fragment portions are percent-encoded where the `segment` + and `fragment` sets from RFC 3986 respectively are considered safe. + Placeholders in the query portion are percent-encoded where the `query` set from + RFC 3986 §3.3 is considered safe except for = and & characters. + + Raises: + KeyError: If a placeholder is not found in `kwargs`. + ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments). + """ + # Split the template into path, query, and fragment portions. + fragment_template: str | None = None + query_template: str | None = None + + rest = template + if "#" in rest: + rest, fragment_template = rest.split("#", 1) + if "?" in rest: + rest, query_template = rest.split("?", 1) + path_template = rest + + # Interpolate each portion with the appropriate quoting rules. + path_result = _interpolate(path_template, kwargs, _quote_path_segment_part) + + # Reject dot-segments (. and ..) in the final assembled path. The check + # runs after interpolation so that adjacent placeholders or a mix of static + # text and placeholders that together form a dot-segment are caught. + # Also reject percent-encoded dot-segments to protect against incorrectly + # implemented normalization in servers/proxies. + for segment in path_result.split("/"): + if _DOT_SEGMENT_RE.match(segment): + raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed") + + result = path_result + if query_template is not None: + result += "?" + _interpolate(query_template, kwargs, _quote_query_part) + if fragment_template is not None: + result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part) + + return result diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 1b8456be..10594de4 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.7.0" # x-release-please-version +__version__ = "1.8.0" # x-release-please-version diff --git a/src/browserbase/resources/contexts.py b/src/browserbase/resources/contexts.py index 685daee4..f3f8f650 100644 --- a/src/browserbase/resources/contexts.py +++ b/src/browserbase/resources/contexts.py @@ -6,7 +6,7 @@ from ..types import context_create_params from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given -from .._utils import maybe_transform, async_maybe_transform +from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -106,7 +106,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/v1/contexts/{id}", + path_template("/v1/contexts/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -139,7 +139,7 @@ def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._put( - f"/v1/contexts/{id}", + path_template("/v1/contexts/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -173,7 +173,7 @@ def delete( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._delete( - f"/v1/contexts/{id}", + path_template("/v1/contexts/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -264,7 +264,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/v1/contexts/{id}", + path_template("/v1/contexts/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -297,7 +297,7 @@ async def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._put( - f"/v1/contexts/{id}", + path_template("/v1/contexts/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -331,7 +331,7 @@ async def delete( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._delete( - f"/v1/contexts/{id}", + path_template("/v1/contexts/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/browserbase/resources/extensions.py b/src/browserbase/resources/extensions.py index 534cd415..2d6fb1b0 100644 --- a/src/browserbase/resources/extensions.py +++ b/src/browserbase/resources/extensions.py @@ -8,7 +8,7 @@ from ..types import extension_create_params from .._types import Body, Query, Headers, NoneType, NotGiven, FileTypes, not_given -from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from .._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -108,7 +108,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/v1/extensions/{id}", + path_template("/v1/extensions/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -142,7 +142,7 @@ def delete( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._delete( - f"/v1/extensions/{id}", + path_template("/v1/extensions/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -235,7 +235,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/v1/extensions/{id}", + path_template("/v1/extensions/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -269,7 +269,7 @@ async def delete( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._delete( - f"/v1/extensions/{id}", + path_template("/v1/extensions/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/browserbase/resources/projects.py b/src/browserbase/resources/projects.py index 5ce225fd..791fe595 100644 --- a/src/browserbase/resources/projects.py +++ b/src/browserbase/resources/projects.py @@ -5,6 +5,7 @@ import httpx from .._types import Body, Query, Headers, NotGiven, not_given +from .._utils import path_template from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -67,7 +68,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/v1/projects/{id}", + path_template("/v1/projects/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -119,7 +120,7 @@ def usage( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/v1/projects/{id}/usage", + path_template("/v1/projects/{id}/usage", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -173,7 +174,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/v1/projects/{id}", + path_template("/v1/projects/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -225,7 +226,7 @@ async def usage( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/v1/projects/{id}/usage", + path_template("/v1/projects/{id}/usage", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/browserbase/resources/sessions/downloads.py b/src/browserbase/resources/sessions/downloads.py index 6195c30b..2cff906c 100644 --- a/src/browserbase/resources/sessions/downloads.py +++ b/src/browserbase/resources/sessions/downloads.py @@ -5,6 +5,7 @@ import httpx from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import path_template from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -69,7 +70,7 @@ def list( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") extra_headers = {"Accept": "application/zip", **(extra_headers or {})} return self._get( - f"/v1/sessions/{id}/downloads", + path_template("/v1/sessions/{id}/downloads", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -124,7 +125,7 @@ async def list( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") extra_headers = {"Accept": "application/zip", **(extra_headers or {})} return await self._get( - f"/v1/sessions/{id}/downloads", + path_template("/v1/sessions/{id}/downloads", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/browserbase/resources/sessions/logs.py b/src/browserbase/resources/sessions/logs.py index b1c90f52..55988c4c 100644 --- a/src/browserbase/resources/sessions/logs.py +++ b/src/browserbase/resources/sessions/logs.py @@ -5,6 +5,7 @@ import httpx from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import path_template from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -65,7 +66,7 @@ def list( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/v1/sessions/{id}/logs", + path_template("/v1/sessions/{id}/logs", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -119,7 +120,7 @@ async def list( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/v1/sessions/{id}/logs", + path_template("/v1/sessions/{id}/logs", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/browserbase/resources/sessions/recording.py b/src/browserbase/resources/sessions/recording.py index 789087a8..57e2d44e 100644 --- a/src/browserbase/resources/sessions/recording.py +++ b/src/browserbase/resources/sessions/recording.py @@ -5,6 +5,7 @@ import httpx from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import path_template from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -65,7 +66,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/v1/sessions/{id}/recording", + path_template("/v1/sessions/{id}/recording", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -119,7 +120,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/v1/sessions/{id}/recording", + path_template("/v1/sessions/{id}/recording", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index 35ff90ce..18bb0a73 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -25,7 +25,7 @@ AsyncUploadsResourceWithStreamingResponse, ) from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ..._utils import maybe_transform, async_maybe_transform +from ..._utils import path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from .downloads import ( DownloadsResource, @@ -195,7 +195,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/v1/sessions/{id}", + path_template("/v1/sessions/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -237,7 +237,7 @@ def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._post( - f"/v1/sessions/{id}", + path_template("/v1/sessions/{id}", id=id), body=maybe_transform( { "status": status, @@ -324,7 +324,7 @@ def debug( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/v1/sessions/{id}/debug", + path_template("/v1/sessions/{id}/debug", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -467,7 +467,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/v1/sessions/{id}", + path_template("/v1/sessions/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -509,7 +509,7 @@ async def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._post( - f"/v1/sessions/{id}", + path_template("/v1/sessions/{id}", id=id), body=await async_maybe_transform( { "status": status, @@ -596,7 +596,7 @@ async def debug( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/v1/sessions/{id}/debug", + path_template("/v1/sessions/{id}/debug", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/browserbase/resources/sessions/uploads.py b/src/browserbase/resources/sessions/uploads.py index aba72b64..7c776029 100644 --- a/src/browserbase/resources/sessions/uploads.py +++ b/src/browserbase/resources/sessions/uploads.py @@ -7,7 +7,7 @@ import httpx from ..._types import Body, Query, Headers, NotGiven, FileTypes, not_given -from ..._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform +from ..._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -76,7 +76,7 @@ def create( # multipart/form-data; boundary=---abc-- extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} return self._post( - f"/v1/sessions/{id}/uploads", + path_template("/v1/sessions/{id}/uploads", id=id), body=maybe_transform(body, upload_create_params.UploadCreateParams), files=files, options=make_request_options( @@ -139,7 +139,7 @@ async def create( # multipart/form-data; boundary=---abc-- extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} return await self._post( - f"/v1/sessions/{id}/uploads", + path_template("/v1/sessions/{id}/uploads", id=id), body=await async_maybe_transform(body, upload_create_params.UploadCreateParams), files=files, options=make_request_options( diff --git a/src/browserbase/types/search_web_response.py b/src/browserbase/types/search_web_response.py index 0243d22a..4694ffa6 100644 --- a/src/browserbase/types/search_web_response.py +++ b/src/browserbase/types/search_web_response.py @@ -34,8 +34,6 @@ class Result(BaseModel): class SearchWebResponse(BaseModel): - """Response body for web search""" - query: str """The search query that was executed""" diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index 63805590..c7180636 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -123,6 +123,9 @@ class BrowserSettings(TypedDict, total=False): solve_captchas: Annotated[bool, PropertyInfo(alias="solveCaptchas")] """Enable or disable captcha solving in the browser. Defaults to `true`.""" + verified: bool + """Verified Browser Mode""" + viewport: BrowserSettingsViewport diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index d237069c..eb07d3fc 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -45,6 +45,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: "os": "windows", "record_session": True, "solve_captchas": True, + "verified": True, "viewport": { "height": 0, "width": 0, @@ -278,6 +279,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas "os": "windows", "record_session": True, "solve_captchas": True, + "verified": True, "viewport": { "height": 0, "width": 0, diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py new file mode 100644 index 00000000..d1eb3ba3 --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from browserbase._utils._path import path_template + + +@pytest.mark.parametrize( + "template, kwargs, expected", + [ + ("/v1/{id}", dict(id="abc"), "/v1/abc"), + ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"), + ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"), + ("/{w}/{w}", dict(w="echo"), "/echo/echo"), + ("/v1/static", {}, "/v1/static"), + ("", {}, ""), + ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"), + ("/v1/{v}", dict(v=None), "/v1/null"), + ("/v1/{v}", dict(v=True), "/v1/true"), + ("/v1/{v}", dict(v=False), "/v1/false"), + ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok + ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok + ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok + ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok + ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine + ( + "/v1/{a}?query={b}", + dict(a="../../other/endpoint", b="a&bad=true"), + "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue", + ), + ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"), + ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"), + ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"), + ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input + # Query: slash and ? are safe, # is not + ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"), + ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"), + ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"), + ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"), + # Fragment: slash and ? are safe + ("/docs#{v}", dict(v="a/b"), "/docs#a/b"), + ("/docs#{v}", dict(v="a?b"), "/docs#a?b"), + # Path: slash, ? and # are all encoded + ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"), + ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"), + ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"), + # same var encoded differently by component + ( + "/v1/{v}?q={v}#{v}", + dict(v="a/b?c#d"), + "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d", + ), + ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection + ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection + ], +) +def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None: + assert path_template(template, **kwargs) == expected + + +def test_missing_kwarg_raises_key_error() -> None: + with pytest.raises(KeyError, match="org_id"): + path_template("/v1/{org_id}") + + +@pytest.mark.parametrize( + "template, kwargs", + [ + ("{a}/path", dict(a=".")), + ("{a}/path", dict(a="..")), + ("/v1/{a}", dict(a=".")), + ("/v1/{a}", dict(a="..")), + ("/v1/{a}/path", dict(a=".")), + ("/v1/{a}/path", dict(a="..")), + ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".." + ("/v1/{a}.", dict(a=".")), # var + static → ".." + ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "." + ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text + ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/{v}?q=1", dict(v="..")), + ("/v1/{v}#frag", dict(v="..")), + ], +) +def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError, match="dot-segment"): + path_template(template, **kwargs) From dfaea802901c42cfd5bdf3fd7d4eddec5f6fe7b5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 18:54:19 +0000 Subject: [PATCH 18/22] release: 1.9.0 (#163) * feat: [CORE-1928][apps/api] Add `PENDING` as a valid session state * fix(client): preserve hardcoded query params when merging with user params * codegen metadata * codegen metadata * fix: ensure file data are only sent as 1 parameter * perf(client): optimize file structure copying in multipart requests * chore(tests): bump steady to v0.22.1 * chore(internal): more robust bootstrap script * feat: [CORE-1979] [apps/api] Regenerate OpenAPI spec to match current routes * fix: use correct field name format for multipart file arrays * feat: support setting headers via env * codegen metadata * codegen metadata * chore(internal): reformat pyproject.toml * fix(client): add missing f-string prefix in file type error message * feat(internal/types): support eagerly validating pydantic iterators * codegen metadata * ci: pin GitHub Actions to commit SHAs Pin all GitHub Actions referenced in generated workflows (both first-party `actions/*` and third-party) to immutable commit SHAs. Updating pinned actions is now a deliberate codegen-side bump rather than implicit on every workflow run. * feat(api): add replays * release: 1.9.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 +- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- .release-please-manifest.json | 2 +- .stats.yml | 8 +- CHANGELOG.md | 32 +++ api.md | 13 + pyproject.toml | 4 +- scripts/bootstrap | 2 +- scripts/mock | 6 +- scripts/test | 2 +- src/browserbase/_base_client.py | 4 + src/browserbase/_client.py | 24 +- src/browserbase/_files.py | 58 +++- src/browserbase/_models.py | 80 ++++++ src/browserbase/_qs.py | 8 +- src/browserbase/_types.py | 3 + src/browserbase/_utils/__init__.py | 1 - src/browserbase/_utils/_utils.py | 62 ++-- src/browserbase/_version.py | 2 +- src/browserbase/resources/extensions.py | 7 +- .../resources/sessions/__init__.py | 14 + src/browserbase/resources/sessions/replays.py | 266 ++++++++++++++++++ .../resources/sessions/sessions.py | 36 ++- src/browserbase/resources/sessions/uploads.py | 7 +- src/browserbase/types/session.py | 2 +- .../types/session_create_params.py | 6 + src/browserbase/types/session_list_params.py | 2 +- src/browserbase/types/sessions/__init__.py | 1 + .../sessions/replay_retrieve_response.py | 25 ++ tests/api_resources/sessions/test_replays.py | 242 ++++++++++++++++ tests/api_resources/test_sessions.py | 6 +- tests/test_client.py | 48 ++++ tests/test_deepcopy.py | 58 ---- tests/test_extract_files.py | 29 +- tests/test_files.py | 99 ++++++- tests/test_models.py | 60 +++- 37 files changed, 1100 insertions(+), 131 deletions(-) create mode 100644 src/browserbase/resources/sessions/replays.py create mode 100644 src/browserbase/types/sessions/replay_retrieve_response.py create mode 100644 tests/api_resources/sessions/test_replays.py delete mode 100644 tests/test_deepcopy.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c4beaa4..0b57e9e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rye run: | @@ -46,7 +46,7 @@ jobs: id-token: write runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rye run: | @@ -67,7 +67,7 @@ jobs: github.repository == 'stainless-sdks/browserbase-python' && !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: core.setOutput('github_token', await core.getIDToken()); @@ -87,7 +87,7 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/browserbase-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rye run: | diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 7fb6d449..ebe645f8 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Rye run: | diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 5beedb0d..1760864d 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'browserbase/sdk-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c523ce19..c3c95522 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.8.0" + ".": "1.9.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index bf216680..4e2d03e1 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 21 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fbrowserbase-921d3c61c7aa06269f74bee63cee993597944f913429caa2aa2e00dd51fab60f.yml -openapi_spec_hash: d35b9613c41bf172fa2b28aceef10b39 -config_hash: cf04ecfb8dad5fbd8b85be25d6e9ec55 +configured_endpoints: 23 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-2118fd938d408dda6ed82d06c48b0785fad91fd54b5397acc3421a49a386c791.yml +openapi_spec_hash: 8e48a39a55a11b128028b47747aea775 +config_hash: 40fbac80e24faaa0dc19e93368bcd821 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a2dbc4d..b08802a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 1.9.0 (2026-05-13) + +Full Changelog: [v1.8.0...v1.9.0](https://github.com/browserbase/sdk-python/compare/v1.8.0...v1.9.0) + +### Features + +* [CORE-1928][apps/api] Add `PENDING` as a valid session state ([4f1248d](https://github.com/browserbase/sdk-python/commit/4f1248dfb1bf79194f65854a6fcd6a0d53433ba1)) +* [CORE-1979] [apps/api] Regenerate OpenAPI spec to match current routes ([0e366a6](https://github.com/browserbase/sdk-python/commit/0e366a6a0a01e2ce8e661d73f9fa312e56d1a582)) +* **api:** add replays ([58e18df](https://github.com/browserbase/sdk-python/commit/58e18df7d7b0376234a322a197584a7163eba4b4)) +* **internal/types:** support eagerly validating pydantic iterators ([9d56949](https://github.com/browserbase/sdk-python/commit/9d569494a2050437404866315561e341f8e38a92)) +* support setting headers via env ([308d35e](https://github.com/browserbase/sdk-python/commit/308d35edb58454e542fcf58f379061fb742bd83b)) + + +### Bug Fixes + +* **client:** add missing f-string prefix in file type error message ([e01f048](https://github.com/browserbase/sdk-python/commit/e01f0484313315bb9d99338b8486054d53f2b46b)) +* **client:** preserve hardcoded query params when merging with user params ([953fd3e](https://github.com/browserbase/sdk-python/commit/953fd3ecd54a7ffc2ee390eac67f2063df89b8e9)) +* ensure file data are only sent as 1 parameter ([a837357](https://github.com/browserbase/sdk-python/commit/a83735708037eec6cb4807e4220ca7452b5b6503)) +* use correct field name format for multipart file arrays ([9488fb3](https://github.com/browserbase/sdk-python/commit/9488fb39a656b0aaaf740ccec0a9dce57b996b02)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([4146f22](https://github.com/browserbase/sdk-python/commit/4146f22bb6c054e7491e4c10021dff3e9e2c8824)) + + +### Chores + +* **internal:** more robust bootstrap script ([83d1f68](https://github.com/browserbase/sdk-python/commit/83d1f686936e4593c3017c719056573089bbd1e0)) +* **internal:** reformat pyproject.toml ([979436a](https://github.com/browserbase/sdk-python/commit/979436a2cb86944f8bf400a2e623e946188d87c1)) +* **tests:** bump steady to v0.22.1 ([bafb680](https://github.com/browserbase/sdk-python/commit/bafb68055a4f36ef6b7d6a308606db9e8d33257b)) + ## 1.8.0 (2026-04-06) Full Changelog: [v1.7.0...v1.8.0](https://github.com/browserbase/sdk-python/compare/v1.7.0...v1.8.0) diff --git a/api.md b/api.md index b6066cb8..581574a3 100644 --- a/api.md +++ b/api.md @@ -128,3 +128,16 @@ from browserbase.types.sessions import UploadCreateResponse Methods: - client.sessions.uploads.create(id, \*\*params) -> UploadCreateResponse + +## Replays + +Types: + +```python +from browserbase.types.sessions import ReplayRetrieveResponse +``` + +Methods: + +- client.sessions.replays.retrieve(id) -> ReplayRetrieveResponse +- client.sessions.replays.retrieve_page(page_id, \*, id) -> BinaryAPIResponse diff --git a/pyproject.toml b/pyproject.toml index e39927c3..8f62bf0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.8.0" +version = "1.9.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" @@ -177,7 +177,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/browserbase/_files.py', '_dev/.*.py', 'tests/.*'] +exclude = ["src/browserbase/_files.py", "_dev/.*.py", "tests/.*"] strict_equality = true implicit_reexport = true diff --git a/scripts/bootstrap b/scripts/bootstrap index b430fee3..fe8451e4 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response diff --git a/scripts/mock b/scripts/mock index 5cd7c157..feebe5ed 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.2 -- steady --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index b8143aa3..19acc916 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=comma --validator-form-array-format=comma --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 diff --git a/src/browserbase/_base_client.py b/src/browserbase/_base_client.py index 5bc9823d..bd88f594 100644 --- a/src/browserbase/_base_client.py +++ b/src/browserbase/_base_client.py @@ -540,6 +540,10 @@ def _build_request( files = cast(HttpxRequestFiles, ForceMultipartDict()) prepared_url = self._prepare_url(options.url) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) if "_" in prepared_url.host: # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} diff --git a/src/browserbase/_client.py b/src/browserbase/_client.py index d642273a..b64caef8 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -19,7 +19,11 @@ RequestOptions, not_given, ) -from ._utils import is_given, get_async_library +from ._utils import ( + is_given, + is_mapping_t, + get_async_library, +) from ._compat import cached_property from ._version import __version__ from ._streaming import Stream as Stream, AsyncStream as AsyncStream @@ -95,6 +99,15 @@ def __init__( if base_url is None: base_url = f"https://api.browserbase.com" + custom_headers_env = os.environ.get("BROWSERBASE_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, @@ -299,6 +312,15 @@ def __init__( if base_url is None: base_url = f"https://api.browserbase.com" + custom_headers_env = os.environ.get("BROWSERBASE_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, diff --git a/src/browserbase/_files.py b/src/browserbase/_files.py index ff951be7..8042111f 100644 --- a/src/browserbase/_files.py +++ b/src/browserbase/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -97,7 +99,7 @@ async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles elif is_sequence_t(files): files = [(key, await _async_transform_file(file)) for key, file in files] else: - raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence") + raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence") return files @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/browserbase/_models.py b/src/browserbase/_models.py index 29070e05..8c5ab260 100644 --- a/src/browserbase/_models.py +++ b/src/browserbase/_models.py @@ -25,7 +25,9 @@ ClassVar, Protocol, Required, + Annotated, ParamSpec, + TypeAlias, TypedDict, TypeGuard, final, @@ -79,7 +81,15 @@ from ._constants import RAW_RESPONSE_HEADER if TYPE_CHECKING: + from pydantic import GetCoreSchemaHandler, ValidatorFunctionWrapHandler + from pydantic_core import CoreSchema, core_schema from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema +else: + try: + from pydantic_core import CoreSchema, core_schema + except ImportError: + CoreSchema = None + core_schema = None __all__ = ["BaseModel", "GenericModel"] @@ -396,6 +406,76 @@ def model_dump_json( ) +class _EagerIterable(list[_T], Generic[_T]): + """ + Accepts any Iterable[T] input (including generators), consumes it + eagerly, and validates all items upfront. + + Validation preserves the original container type where possible + (e.g. a set[T] stays a set[T]). Serialization (model_dump / JSON) + always emits a list — round-tripping through model_dump() will not + restore the original container type. + """ + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> CoreSchema: + (item_type,) = get_args(source_type) or (Any,) + item_schema: CoreSchema = handler.generate_schema(item_type) + list_of_items_schema: CoreSchema = core_schema.list_schema(item_schema) + + return core_schema.no_info_wrap_validator_function( + cls._validate, + list_of_items_schema, + serialization=core_schema.plain_serializer_function_ser_schema( + cls._serialize, + info_arg=False, + ), + ) + + @staticmethod + def _validate(v: Iterable[_T], handler: "ValidatorFunctionWrapHandler") -> Any: + original_type: type[Any] = type(v) + + # Normalize to list so list_schema can validate each item + if isinstance(v, list): + items: list[_T] = v + else: + try: + items = list(v) + except TypeError as e: + raise TypeError("Value is not iterable") from e + + # Validate items against the inner schema + validated: list[_T] = handler(items) + + # Reconstruct original container type + if original_type is list: + return validated + # str(list) produces the list's repr, not a string built from items, + # so skip reconstruction for str and its subclasses. + if issubclass(original_type, str): + return validated + try: + return original_type(validated) + except (TypeError, ValueError): + # If the type cannot be reconstructed, just return the validated list + return validated + + @staticmethod + def _serialize(v: Iterable[_T]) -> list[_T]: + """Always serialize as a list so Pydantic's JSON encoder is happy.""" + if isinstance(v, list): + return v + return list(v) + + +EagerIterable: TypeAlias = Annotated[Iterable[_T], _EagerIterable] + + def _construct_field(value: object, field: FieldInfo, key: str) -> object: if value is None: return field_get_default(field) diff --git a/src/browserbase/_qs.py b/src/browserbase/_qs.py index de8c99bc..4127c19c 100644 --- a/src/browserbase/_qs.py +++ b/src/browserbase/_qs.py @@ -2,17 +2,13 @@ from typing import Any, List, Tuple, Union, Mapping, TypeVar from urllib.parse import parse_qs, urlencode -from typing_extensions import Literal, get_args +from typing_extensions import get_args -from ._types import NotGiven, not_given +from ._types import NotGiven, ArrayFormat, NestedFormat, not_given from ._utils import flatten _T = TypeVar("_T") - -ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] -NestedFormat = Literal["dots", "brackets"] - PrimitiveData = Union[str, int, float, bool, None] # this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] # https://github.com/microsoft/pyright/issues/3555 diff --git a/src/browserbase/_types.py b/src/browserbase/_types.py index abefae08..b2ac81be 100644 --- a/src/browserbase/_types.py +++ b/src/browserbase/_types.py @@ -47,6 +47,9 @@ ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) _T = TypeVar("_T") +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + # Approximates httpx internal ProxiesTypes and RequestFiles types # while adding support for `PathLike` instances diff --git a/src/browserbase/_utils/__init__.py b/src/browserbase/_utils/__init__.py index 10cb66d2..1c090e51 100644 --- a/src/browserbase/_utils/__init__.py +++ b/src/browserbase/_utils/__init__.py @@ -24,7 +24,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/browserbase/_utils/_utils.py b/src/browserbase/_utils/_utils.py index eec7f4a1..199cd231 100644 --- a/src/browserbase/_utils/_utils.py +++ b/src/browserbase/_utils/_utils.py @@ -17,11 +17,11 @@ ) from pathlib import Path from datetime import date, datetime -from typing_extensions import TypeGuard +from typing_extensions import TypeGuard, get_args import sniffio -from .._types import Omit, NotGiven, FileTypes, HeadersLike +from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -40,25 +40,45 @@ def extract_files( query: Mapping[str, object], *, paths: Sequence[Sequence[str]], + array_format: ArrayFormat = "brackets", ) -> list[tuple[str, FileTypes]]: """Recursively extract files from the given dictionary based on specified paths. A path may look like this ['foo', 'files', '', 'data']. + ``array_format`` controls how ```` segments contribute to the emitted + field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and + ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). + Note: this mutates the given dictionary. """ files: list[tuple[str, FileTypes]] = [] for path in paths: - files.extend(_extract_items(query, path, index=0, flattened_key=None)) + files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format)) return files +def _array_suffix(array_format: ArrayFormat, array_index: int) -> str: + if array_format == "brackets": + return "[]" + if array_format == "indices": + return f"[{array_index}]" + if array_format == "repeat" or array_format == "comma": + # Both repeat the bare field name for each file part; there is no + # meaningful way to comma-join binary parts. + return "" + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + def _extract_items( obj: object, path: Sequence[str], *, index: int, flattened_key: str | None, + array_format: ArrayFormat, ) -> list[tuple[str, FileTypes]]: try: key = path[index] @@ -75,9 +95,11 @@ def _extract_items( if is_list(obj): files: list[tuple[str, FileTypes]] = [] - for entry in obj: - assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") - files.append((flattened_key + "[]", cast(FileTypes, entry))) + for array_index, entry in enumerate(obj): + suffix = _array_suffix(array_format, array_index) + emitted_key = (flattened_key + suffix) if flattened_key else suffix + assert_is_file_content(entry, key=emitted_key) + files.append((emitted_key, cast(FileTypes, entry))) return files assert_is_file_content(obj, key=flattened_key) @@ -86,8 +108,9 @@ def _extract_items( index += 1 if is_dict(obj): try: - # We are at the last entry in the path so we must remove the field - if (len(path)) == index: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in path[index:]): item = obj.pop(key) else: item = obj[key] @@ -105,6 +128,7 @@ def _extract_items( path, index=index, flattened_key=flattened_key, + array_format=array_format, ) elif is_list(obj): if key != "": @@ -116,9 +140,12 @@ def _extract_items( item, path, index=index, - flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + flattened_key=( + (flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index) + ), + array_format=array_format, ) - for item in obj + for array_index, item in enumerate(obj) ] ) @@ -176,21 +203,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 10594de4..eaa01ae1 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.8.0" # x-release-please-version +__version__ = "1.9.0" # x-release-please-version diff --git a/src/browserbase/resources/extensions.py b/src/browserbase/resources/extensions.py index 2d6fb1b0..9325d6b6 100644 --- a/src/browserbase/resources/extensions.py +++ b/src/browserbase/resources/extensions.py @@ -7,8 +7,9 @@ import httpx from ..types import extension_create_params +from .._files import deepcopy_with_paths from .._types import Body, Query, Headers, NoneType, NotGiven, FileTypes, not_given -from .._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform +from .._utils import extract_files, path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -66,7 +67,7 @@ def create( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal({"file": file}) + body = deepcopy_with_paths({"file": file}, [["file"]]) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. @@ -193,7 +194,7 @@ async def create( timeout: Override the client-level default timeout for this request, in seconds """ - body = deepcopy_minimal({"file": file}) + body = deepcopy_with_paths({"file": file}, [["file"]]) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. diff --git a/src/browserbase/resources/sessions/__init__.py b/src/browserbase/resources/sessions/__init__.py index b3877e12..e66ee0ce 100644 --- a/src/browserbase/resources/sessions/__init__.py +++ b/src/browserbase/resources/sessions/__init__.py @@ -8,6 +8,14 @@ LogsResourceWithStreamingResponse, AsyncLogsResourceWithStreamingResponse, ) +from .replays import ( + ReplaysResource, + AsyncReplaysResource, + ReplaysResourceWithRawResponse, + AsyncReplaysResourceWithRawResponse, + ReplaysResourceWithStreamingResponse, + AsyncReplaysResourceWithStreamingResponse, +) from .uploads import ( UploadsResource, AsyncUploadsResource, @@ -66,6 +74,12 @@ "AsyncUploadsResourceWithRawResponse", "UploadsResourceWithStreamingResponse", "AsyncUploadsResourceWithStreamingResponse", + "ReplaysResource", + "AsyncReplaysResource", + "ReplaysResourceWithRawResponse", + "AsyncReplaysResourceWithRawResponse", + "ReplaysResourceWithStreamingResponse", + "AsyncReplaysResourceWithStreamingResponse", "SessionsResource", "AsyncSessionsResource", "SessionsResourceWithRawResponse", diff --git a/src/browserbase/resources/sessions/replays.py b/src/browserbase/resources/sessions/replays.py new file mode 100644 index 00000000..c9240356 --- /dev/null +++ b/src/browserbase/resources/sessions/replays.py @@ -0,0 +1,266 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import httpx + +from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import path_template +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, + async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.sessions.replay_retrieve_response import ReplayRetrieveResponse + +__all__ = ["ReplaysResource", "AsyncReplaysResource"] + + +class ReplaysResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> ReplaysResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers + """ + return ReplaysResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ReplaysResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/browserbase/sdk-python#with_streaming_response + """ + return ReplaysResourceWithStreamingResponse(self) + + def retrieve( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReplayRetrieveResponse: + """ + Returns page metadata for a session replay, including timing information and the + URL of each page's HLS playlist. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get( + path_template("/v1/sessions/{id}/replays", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ReplayRetrieveResponse, + ) + + def retrieve_page( + self, + page_id: str, + *, + id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BinaryAPIResponse: + """ + Returns an HLS VOD media playlist (.m3u8) for a specific page of a session + replay. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if not page_id: + raise ValueError(f"Expected a non-empty value for `page_id` but received {page_id!r}") + extra_headers = {"Accept": "application/vnd.apple.mpegurl", **(extra_headers or {})} + return self._get( + path_template("/v1/sessions/{id}/replays/{page_id}", id=id, page_id=page_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BinaryAPIResponse, + ) + + +class AsyncReplaysResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncReplaysResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncReplaysResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncReplaysResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/browserbase/sdk-python#with_streaming_response + """ + return AsyncReplaysResourceWithStreamingResponse(self) + + async def retrieve( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReplayRetrieveResponse: + """ + Returns page metadata for a session replay, including timing information and the + URL of each page's HLS playlist. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._get( + path_template("/v1/sessions/{id}/replays", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=ReplayRetrieveResponse, + ) + + async def retrieve_page( + self, + page_id: str, + *, + id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncBinaryAPIResponse: + """ + Returns an HLS VOD media playlist (.m3u8) for a specific page of a session + replay. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + if not page_id: + raise ValueError(f"Expected a non-empty value for `page_id` but received {page_id!r}") + extra_headers = {"Accept": "application/vnd.apple.mpegurl", **(extra_headers or {})} + return await self._get( + path_template("/v1/sessions/{id}/replays/{page_id}", id=id, page_id=page_id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=AsyncBinaryAPIResponse, + ) + + +class ReplaysResourceWithRawResponse: + def __init__(self, replays: ReplaysResource) -> None: + self._replays = replays + + self.retrieve = to_raw_response_wrapper( + replays.retrieve, + ) + self.retrieve_page = to_custom_raw_response_wrapper( + replays.retrieve_page, + BinaryAPIResponse, + ) + + +class AsyncReplaysResourceWithRawResponse: + def __init__(self, replays: AsyncReplaysResource) -> None: + self._replays = replays + + self.retrieve = async_to_raw_response_wrapper( + replays.retrieve, + ) + self.retrieve_page = async_to_custom_raw_response_wrapper( + replays.retrieve_page, + AsyncBinaryAPIResponse, + ) + + +class ReplaysResourceWithStreamingResponse: + def __init__(self, replays: ReplaysResource) -> None: + self._replays = replays + + self.retrieve = to_streamed_response_wrapper( + replays.retrieve, + ) + self.retrieve_page = to_custom_streamed_response_wrapper( + replays.retrieve_page, + StreamedBinaryAPIResponse, + ) + + +class AsyncReplaysResourceWithStreamingResponse: + def __init__(self, replays: AsyncReplaysResource) -> None: + self._replays = replays + + self.retrieve = async_to_streamed_response_wrapper( + replays.retrieve, + ) + self.retrieve_page = async_to_custom_streamed_response_wrapper( + replays.retrieve_page, + AsyncStreamedBinaryAPIResponse, + ) diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index 18bb0a73..a54d7a72 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -16,6 +16,14 @@ AsyncLogsResourceWithStreamingResponse, ) from ...types import session_list_params, session_create_params, session_update_params +from .replays import ( + ReplaysResource, + AsyncReplaysResource, + ReplaysResourceWithRawResponse, + AsyncReplaysResourceWithRawResponse, + ReplaysResourceWithStreamingResponse, + AsyncReplaysResourceWithStreamingResponse, +) from .uploads import ( UploadsResource, AsyncUploadsResource, @@ -77,6 +85,10 @@ def recording(self) -> RecordingResource: def uploads(self) -> UploadsResource: return UploadsResource(self._client) + @cached_property + def replays(self) -> ReplaysResource: + return ReplaysResource(self._client) + @cached_property def with_raw_response(self) -> SessionsResourceWithRawResponse: """ @@ -255,7 +267,7 @@ def list( self, *, q: str | Omit = omit, - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | Omit = omit, + status: Literal["PENDING", "RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -349,6 +361,10 @@ def recording(self) -> AsyncRecordingResource: def uploads(self) -> AsyncUploadsResource: return AsyncUploadsResource(self._client) + @cached_property + def replays(self) -> AsyncReplaysResource: + return AsyncReplaysResource(self._client) + @cached_property def with_raw_response(self) -> AsyncSessionsResourceWithRawResponse: """ @@ -527,7 +543,7 @@ async def list( self, *, q: str | Omit = omit, - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | Omit = omit, + status: Literal["PENDING", "RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -640,6 +656,10 @@ def recording(self) -> RecordingResourceWithRawResponse: def uploads(self) -> UploadsResourceWithRawResponse: return UploadsResourceWithRawResponse(self._sessions.uploads) + @cached_property + def replays(self) -> ReplaysResourceWithRawResponse: + return ReplaysResourceWithRawResponse(self._sessions.replays) + class AsyncSessionsResourceWithRawResponse: def __init__(self, sessions: AsyncSessionsResource) -> None: @@ -677,6 +697,10 @@ def recording(self) -> AsyncRecordingResourceWithRawResponse: def uploads(self) -> AsyncUploadsResourceWithRawResponse: return AsyncUploadsResourceWithRawResponse(self._sessions.uploads) + @cached_property + def replays(self) -> AsyncReplaysResourceWithRawResponse: + return AsyncReplaysResourceWithRawResponse(self._sessions.replays) + class SessionsResourceWithStreamingResponse: def __init__(self, sessions: SessionsResource) -> None: @@ -714,6 +738,10 @@ def recording(self) -> RecordingResourceWithStreamingResponse: def uploads(self) -> UploadsResourceWithStreamingResponse: return UploadsResourceWithStreamingResponse(self._sessions.uploads) + @cached_property + def replays(self) -> ReplaysResourceWithStreamingResponse: + return ReplaysResourceWithStreamingResponse(self._sessions.replays) + class AsyncSessionsResourceWithStreamingResponse: def __init__(self, sessions: AsyncSessionsResource) -> None: @@ -750,3 +778,7 @@ def recording(self) -> AsyncRecordingResourceWithStreamingResponse: @cached_property def uploads(self) -> AsyncUploadsResourceWithStreamingResponse: return AsyncUploadsResourceWithStreamingResponse(self._sessions.uploads) + + @cached_property + def replays(self) -> AsyncReplaysResourceWithStreamingResponse: + return AsyncReplaysResourceWithStreamingResponse(self._sessions.replays) diff --git a/src/browserbase/resources/sessions/uploads.py b/src/browserbase/resources/sessions/uploads.py index 7c776029..f5d22d96 100644 --- a/src/browserbase/resources/sessions/uploads.py +++ b/src/browserbase/resources/sessions/uploads.py @@ -6,8 +6,9 @@ import httpx +from ..._files import deepcopy_with_paths from ..._types import Body, Query, Headers, NotGiven, FileTypes, not_given -from ..._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform +from ..._utils import extract_files, path_template, maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -69,7 +70,7 @@ def create( """ if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - body = deepcopy_minimal({"file": file}) + body = deepcopy_with_paths({"file": file}, [["file"]]) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. @@ -132,7 +133,7 @@ async def create( """ if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - body = deepcopy_minimal({"file": file}) + body = deepcopy_with_paths({"file": file}, [["file"]]) files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) # It should be noted that the actual Content-Type header that will be # sent to the server will contain a `boundary` parameter, e.g. diff --git a/src/browserbase/types/session.py b/src/browserbase/types/session.py index e983baaa..eac6a815 100644 --- a/src/browserbase/types/session.py +++ b/src/browserbase/types/session.py @@ -32,7 +32,7 @@ class Session(BaseModel): started_at: datetime = FieldInfo(alias="startedAt") - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] + status: Literal["PENDING", "RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] updated_at: datetime = FieldInfo(alias="updatedAt") diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index c7180636..3ff1d058 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -108,6 +108,12 @@ class BrowserSettings(TypedDict, total=False): See [Upload Extension](/reference/api/upload-an-extension). """ + ignore_certificate_errors: Annotated[bool, PropertyInfo(alias="ignoreCertificateErrors")] + """Enable or disable ignoring of certificate errors in the browser. + + Defaults to `true`. + """ + log_session: Annotated[bool, PropertyInfo(alias="logSession")] """Enable or disable session logging. Defaults to `true`.""" diff --git a/src/browserbase/types/session_list_params.py b/src/browserbase/types/session_list_params.py index 54b0a05c..c21b98e1 100644 --- a/src/browserbase/types/session_list_params.py +++ b/src/browserbase/types/session_list_params.py @@ -16,4 +16,4 @@ class SessionListParams(TypedDict, total=False): for the schema of this query. """ - status: Literal["RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] + status: Literal["PENDING", "RUNNING", "ERROR", "TIMED_OUT", "COMPLETED"] diff --git a/src/browserbase/types/sessions/__init__.py b/src/browserbase/types/sessions/__init__.py index 0cef6b19..c7ea4671 100644 --- a/src/browserbase/types/sessions/__init__.py +++ b/src/browserbase/types/sessions/__init__.py @@ -7,4 +7,5 @@ from .session_recording import SessionRecording as SessionRecording from .upload_create_params import UploadCreateParams as UploadCreateParams from .upload_create_response import UploadCreateResponse as UploadCreateResponse +from .replay_retrieve_response import ReplayRetrieveResponse as ReplayRetrieveResponse from .recording_retrieve_response import RecordingRetrieveResponse as RecordingRetrieveResponse diff --git a/src/browserbase/types/sessions/replay_retrieve_response.py b/src/browserbase/types/sessions/replay_retrieve_response.py new file mode 100644 index 00000000..7f0b02c0 --- /dev/null +++ b/src/browserbase/types/sessions/replay_retrieve_response.py @@ -0,0 +1,25 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List + +from pydantic import Field as FieldInfo + +from ..._models import BaseModel + +__all__ = ["ReplayRetrieveResponse", "Page"] + + +class Page(BaseModel): + end_time_ms: float = FieldInfo(alias="endTimeMs") + + page_id: str = FieldInfo(alias="pageId") + + start_time_ms: float = FieldInfo(alias="startTimeMs") + + url: str + + +class ReplayRetrieveResponse(BaseModel): + page_count: int = FieldInfo(alias="pageCount") + + pages: List[Page] diff --git a/tests/api_resources/sessions/test_replays.py b/tests/api_resources/sessions/test_replays.py new file mode 100644 index 00000000..a82c7880 --- /dev/null +++ b/tests/api_resources/sessions/test_replays.py @@ -0,0 +1,242 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import httpx +import pytest +from respx import MockRouter + +from browserbase import Browserbase, AsyncBrowserbase +from tests.utils import assert_matches_type +from browserbase._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, +) +from browserbase.types.sessions import ReplayRetrieveResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestReplays: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_retrieve(self, client: Browserbase) -> None: + replay = client.sessions.replays.retrieve( + "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + assert_matches_type(ReplayRetrieveResponse, replay, path=["response"]) + + @parametrize + def test_raw_response_retrieve(self, client: Browserbase) -> None: + response = client.sessions.replays.with_raw_response.retrieve( + "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + replay = response.parse() + assert_matches_type(ReplayRetrieveResponse, replay, path=["response"]) + + @parametrize + def test_streaming_response_retrieve(self, client: Browserbase) -> None: + with client.sessions.replays.with_streaming_response.retrieve( + "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + replay = response.parse() + assert_matches_type(ReplayRetrieveResponse, replay, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_retrieve(self, client: Browserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.sessions.replays.with_raw_response.retrieve( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_method_retrieve_page(self, client: Browserbase, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/sessions/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e/replays/090").mock( + return_value=httpx.Response(200, json={"foo": "bar"}) + ) + replay = client.sessions.replays.retrieve_page( + page_id="090", + id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + assert replay.is_closed + assert replay.json() == {"foo": "bar"} + assert cast(Any, replay.is_closed) is True + assert isinstance(replay, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_raw_response_retrieve_page(self, client: Browserbase, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/sessions/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e/replays/090").mock( + return_value=httpx.Response(200, json={"foo": "bar"}) + ) + + replay = client.sessions.replays.with_raw_response.retrieve_page( + page_id="090", + id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + + assert replay.is_closed is True + assert replay.http_request.headers.get("X-Stainless-Lang") == "python" + assert replay.json() == {"foo": "bar"} + assert isinstance(replay, BinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_streaming_response_retrieve_page(self, client: Browserbase, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/sessions/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e/replays/090").mock( + return_value=httpx.Response(200, json={"foo": "bar"}) + ) + with client.sessions.replays.with_streaming_response.retrieve_page( + page_id="090", + id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) as replay: + assert not replay.is_closed + assert replay.http_request.headers.get("X-Stainless-Lang") == "python" + + assert replay.json() == {"foo": "bar"} + assert cast(Any, replay.is_closed) is True + assert isinstance(replay, StreamedBinaryAPIResponse) + + assert cast(Any, replay.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + def test_path_params_retrieve_page(self, client: Browserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.sessions.replays.with_raw_response.retrieve_page( + page_id="090", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `page_id` but received ''"): + client.sessions.replays.with_raw_response.retrieve_page( + page_id="", + id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + + +class TestAsyncReplays: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: + replay = await async_client.sessions.replays.retrieve( + "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + assert_matches_type(ReplayRetrieveResponse, replay, path=["response"]) + + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: + response = await async_client.sessions.replays.with_raw_response.retrieve( + "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + replay = await response.parse() + assert_matches_type(ReplayRetrieveResponse, replay, path=["response"]) + + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: + async with async_client.sessions.replays.with_streaming_response.retrieve( + "182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + replay = await response.parse() + assert_matches_type(ReplayRetrieveResponse, replay, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncBrowserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.sessions.replays.with_raw_response.retrieve( + "", + ) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_method_retrieve_page(self, async_client: AsyncBrowserbase, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/sessions/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e/replays/090").mock( + return_value=httpx.Response(200, json={"foo": "bar"}) + ) + replay = await async_client.sessions.replays.retrieve_page( + page_id="090", + id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + assert replay.is_closed + assert await replay.json() == {"foo": "bar"} + assert cast(Any, replay.is_closed) is True + assert isinstance(replay, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_raw_response_retrieve_page(self, async_client: AsyncBrowserbase, respx_mock: MockRouter) -> None: + respx_mock.get("/v1/sessions/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e/replays/090").mock( + return_value=httpx.Response(200, json={"foo": "bar"}) + ) + + replay = await async_client.sessions.replays.with_raw_response.retrieve_page( + page_id="090", + id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) + + assert replay.is_closed is True + assert replay.http_request.headers.get("X-Stainless-Lang") == "python" + assert await replay.json() == {"foo": "bar"} + assert isinstance(replay, AsyncBinaryAPIResponse) + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_streaming_response_retrieve_page( + self, async_client: AsyncBrowserbase, respx_mock: MockRouter + ) -> None: + respx_mock.get("/v1/sessions/182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e/replays/090").mock( + return_value=httpx.Response(200, json={"foo": "bar"}) + ) + async with async_client.sessions.replays.with_streaming_response.retrieve_page( + page_id="090", + id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) as replay: + assert not replay.is_closed + assert replay.http_request.headers.get("X-Stainless-Lang") == "python" + + assert await replay.json() == {"foo": "bar"} + assert cast(Any, replay.is_closed) is True + assert isinstance(replay, AsyncStreamedBinaryAPIResponse) + + assert cast(Any, replay.is_closed) is True + + @parametrize + @pytest.mark.respx(base_url=base_url) + async def test_path_params_retrieve_page(self, async_client: AsyncBrowserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.sessions.replays.with_raw_response.retrieve_page( + page_id="090", + id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `page_id` but received ''"): + await async_client.sessions.replays.with_raw_response.retrieve_page( + page_id="", + id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e", + ) diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index eb07d3fc..41f2a0bd 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -41,6 +41,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: "persist": True, }, "extension_id": "extensionId", + "ignore_certificate_errors": True, "log_session": True, "os": "windows", "record_session": True, @@ -189,7 +190,7 @@ def test_method_list(self, client: Browserbase) -> None: def test_method_list_with_all_params(self, client: Browserbase) -> None: session = client.sessions.list( q="q", - status="RUNNING", + status="PENDING", ) assert_matches_type(SessionListResponse, session, path=["response"]) @@ -275,6 +276,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas "persist": True, }, "extension_id": "extensionId", + "ignore_certificate_errors": True, "log_session": True, "os": "windows", "record_session": True, @@ -423,7 +425,7 @@ async def test_method_list(self, async_client: AsyncBrowserbase) -> None: async def test_method_list_with_all_params(self, async_client: AsyncBrowserbase) -> None: session = await async_client.sessions.list( q="q", - status="RUNNING", + status="PENDING", ) assert_matches_type(SessionListResponse, session, path=["response"]) diff --git a/tests/test_client.py b/tests/test_client.py index 1d0d68b3..95ae8e03 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -429,6 +429,30 @@ def test_default_query_option(self) -> None: client.close() + def test_hardcoded_query_params_in_url(self, client: Browserbase) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: Browserbase) -> None: request = client._build_request( FinalRequestOptions( @@ -1330,6 +1354,30 @@ async def test_default_query_option(self) -> None: await client.close() + async def test_hardcoded_query_params_in_url(self, async_client: AsyncBrowserbase) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: Browserbase) -> None: request = client._build_request( FinalRequestOptions( diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index a2a29e38..00000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from browserbase._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 3c0fcb36..07deeb6d 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -4,7 +4,7 @@ import pytest -from browserbase._types import FileTypes +from browserbase._types import FileTypes, ArrayFormat from browserbase._utils import extract_files @@ -35,6 +35,12 @@ def test_multiple_files() -> None: assert query == {"documents": [{}, {}]} +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] + assert query == {"title": "hello"} + + @pytest.mark.parametrize( "query,paths,expected", [ @@ -62,3 +68,24 @@ def test_ignores_incorrect_paths( expected: list[tuple[str, FileTypes]], ) -> None: assert extract_files(query, paths=paths) == expected + + +@pytest.mark.parametrize( + "array_format,expected_top_level,expected_nested", + [ + ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]), + ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]), + ], +) +def test_array_format_controls_file_field_names( + array_format: ArrayFormat, + expected_top_level: list[tuple[str, FileTypes]], + expected_nested: list[tuple[str, FileTypes]], +) -> None: + top_level = {"files": [b"a", b"b"]} + assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level + + nested = {"items": [{"file": b"a"}, {"file": b"b"}]} + assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested diff --git a/tests/test_files.py b/tests/test_files.py index d8842d61..713c5994 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from browserbase._files import to_httpx_files, async_to_httpx_files +from browserbase._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from browserbase._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert [entry for _, entry in extracted] == [file1, file2] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + } diff --git a/tests/test_models.py b/tests/test_models.py index 1ecdeecf..d65d819a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,8 @@ import json -from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Iterable, Optional, cast from datetime import datetime, timezone -from typing_extensions import Literal, Annotated, TypeAliasType +from collections import deque +from typing_extensions import Literal, Annotated, TypedDict, TypeAliasType import pytest import pydantic @@ -9,7 +10,7 @@ from browserbase._utils import PropertyInfo from browserbase._compat import PYDANTIC_V1, parse_obj, model_dump, model_json -from browserbase._models import DISCRIMINATOR_CACHE, BaseModel, construct_type +from browserbase._models import DISCRIMINATOR_CACHE, BaseModel, EagerIterable, construct_type class BasicModel(BaseModel): @@ -961,3 +962,56 @@ def __getattr__(self, attr: str) -> Item: ... assert model.a.prop == 1 assert isinstance(model.a, Item) assert model.other == "foo" + + +# NOTE: Workaround for Pydantic Iterable behavior. +# Iterable fields are replaced with a ValidatorIterator and may be consumed +# during serialization, which can cause subsequent dumps to return empty data. +# See: https://github.com/pydantic/pydantic/issues/9541 +@pytest.mark.parametrize( + "data, expected_validated", + [ + ([1, 2, 3], [1, 2, 3]), + ((1, 2, 3), (1, 2, 3)), + (set([1, 2, 3]), set([1, 2, 3])), + (iter([1, 2, 3]), [1, 2, 3]), + ([], []), + ((x for x in [1, 2, 3]), [1, 2, 3]), + (map(lambda x: x, [1, 2, 3]), [1, 2, 3]), + (frozenset([1, 2, 3]), frozenset([1, 2, 3])), + (deque([1, 2, 3]), deque([1, 2, 3])), + ], + ids=["list", "tuple", "set", "iterator", "empty", "generator", "map", "frozenset", "deque"], +) +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction(data: Iterable[int], expected_validated: Iterable[int]) -> None: + class TypeWithIterable(TypedDict): + items: EagerIterable[int] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": data}}) + assert m.data["items"] == expected_validated + + # Verify repeated dumps don't lose data (the original bug) + assert m.model_dump()["data"]["items"] == list(expected_validated) + assert m.model_dump()["data"]["items"] == list(expected_validated) + + +@pytest.mark.skipif(PYDANTIC_V1, reason="this is only supported in pydantic v2") +def test_iterable_construction_str_falls_back_to_list() -> None: + # str is iterable (over chars), but str(list_of_chars) produces the list's repr + # rather than reconstructing a string from items. We special-case str to fall + # back to list instead of attempting reconstruction. + class TypeWithIterable(TypedDict): + items: EagerIterable[str] + + class Model(BaseModel): + data: TypeWithIterable + + m = Model.model_validate({"data": {"items": "hello"}}) + + # falls back to list of chars rather than calling str(["h", "e", "l", "l", "o"]) + assert m.data["items"] == ["h", "e", "l", "l", "o"] + assert m.model_dump()["data"]["items"] == ["h", "e", "l", "l", "o"] From f3b6011241be83d41df2ce82f1c19d0a91dfd1dd Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 20:22:26 +0000 Subject: [PATCH 19/22] release: 1.10.0 (#165) * feat: Cast replay page start/end times as integer * release: 1.10.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- .stats.yml | 4 ++-- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/browserbase/_version.py | 2 +- .../types/sessions/replay_retrieve_response.py | 4 ++-- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c3c95522..eb4e0dba 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.9.0" + ".": "1.10.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 4e2d03e1..192797ac 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-2118fd938d408dda6ed82d06c48b0785fad91fd54b5397acc3421a49a386c791.yml -openapi_spec_hash: 8e48a39a55a11b128028b47747aea775 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-466614a040e7f31307530bd6ba443e714b6303eaa141904e7d32e6641d5ec55f.yml +openapi_spec_hash: 2d06680e7c17847e4fbcac35124d2456 config_hash: 40fbac80e24faaa0dc19e93368bcd821 diff --git a/CHANGELOG.md b/CHANGELOG.md index b08802a8..ac4cadc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.10.0 (2026-05-13) + +Full Changelog: [v1.9.0...v1.10.0](https://github.com/browserbase/sdk-python/compare/v1.9.0...v1.10.0) + +### Features + +* Cast replay page start/end times as integer ([ef3642a](https://github.com/browserbase/sdk-python/commit/ef3642a11680a1cda0de0ad46f15b23ef7de99d3)) + ## 1.9.0 (2026-05-13) Full Changelog: [v1.8.0...v1.9.0](https://github.com/browserbase/sdk-python/compare/v1.8.0...v1.9.0) diff --git a/pyproject.toml b/pyproject.toml index 8f62bf0c..404289b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.9.0" +version = "1.10.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index eaa01ae1..c811ccc9 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.9.0" # x-release-please-version +__version__ = "1.10.0" # x-release-please-version diff --git a/src/browserbase/types/sessions/replay_retrieve_response.py b/src/browserbase/types/sessions/replay_retrieve_response.py index 7f0b02c0..ec16398a 100644 --- a/src/browserbase/types/sessions/replay_retrieve_response.py +++ b/src/browserbase/types/sessions/replay_retrieve_response.py @@ -10,11 +10,11 @@ class Page(BaseModel): - end_time_ms: float = FieldInfo(alias="endTimeMs") + end_time_ms: int = FieldInfo(alias="endTimeMs") page_id: str = FieldInfo(alias="pageId") - start_time_ms: float = FieldInfo(alias="startTimeMs") + start_time_ms: int = FieldInfo(alias="startTimeMs") url: str From d0a62ccdfd79562ddb6a93d7a7bec94918db7a12 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 22:42:51 +0000 Subject: [PATCH 20/22] release: 1.11.0 (#166) * feat: [AI-1993] - Surface structured JSON content on /v2/fetch * feat: [AI-1972] - Move fetch v2 handler into /v1/fetch * feat: [AI-1748][apps/api] Obtain custom certificates in API during session reservation * feat(api): manual updates * feat(api): manual updates * codegen metadata * release: 1.11.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- .stats.yml | 4 +-- CHANGELOG.md | 12 +++++++++ pyproject.toml | 2 +- src/browserbase/_version.py | 2 +- src/browserbase/resources/fetch_api.py | 25 +++++++++++++++++++ .../types/fetch_api_create_params.py | 16 +++++++++++- .../types/fetch_api_create_response.py | 10 +++++--- tests/api_resources/test_fetch_api.py | 4 +++ 9 files changed, 68 insertions(+), 9 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index eb4e0dba..caf14871 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.10.0" + ".": "1.11.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 192797ac..f40d1167 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-466614a040e7f31307530bd6ba443e714b6303eaa141904e7d32e6641d5ec55f.yml -openapi_spec_hash: 2d06680e7c17847e4fbcac35124d2456 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-b2831c9c836f039762834825afdc20569587a825d29ac5c3748c78b009bf059b.yml +openapi_spec_hash: dd85a934900cb6583f12ebf6117be884 config_hash: 40fbac80e24faaa0dc19e93368bcd821 diff --git a/CHANGELOG.md b/CHANGELOG.md index ac4cadc0..859bfcce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.11.0 (2026-05-20) + +Full Changelog: [v1.10.0...v1.11.0](https://github.com/browserbase/sdk-python/compare/v1.10.0...v1.11.0) + +### Features + +* [AI-1748][apps/api] Obtain custom certificates in API during session reservation ([9c0b78d](https://github.com/browserbase/sdk-python/commit/9c0b78d40adf9e9e2f49a2dbb34e37aa622b8991)) +* [AI-1972] - Move fetch v2 handler into /v1/fetch ([de76605](https://github.com/browserbase/sdk-python/commit/de766051f87c90c307b93f0614b2df48fd4d4ee9)) +* [AI-1993] - Surface structured JSON content on /v2/fetch ([97f1c02](https://github.com/browserbase/sdk-python/commit/97f1c02a520d433abe467b11a04f7fbab85fd3f3)) +* **api:** manual updates ([1d7beb8](https://github.com/browserbase/sdk-python/commit/1d7beb84745bd33ae11c9948a4f057f8240467a0)) +* **api:** manual updates ([23a3a2f](https://github.com/browserbase/sdk-python/commit/23a3a2f0d156d438b0c9aa1430663dc51f49f11e)) + ## 1.10.0 (2026-05-13) Full Changelog: [v1.9.0...v1.10.0](https://github.com/browserbase/sdk-python/compare/v1.9.0...v1.10.0) diff --git a/pyproject.toml b/pyproject.toml index 404289b8..3338ab43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.10.0" +version = "1.11.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index c811ccc9..1184ad60 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.10.0" # x-release-please-version +__version__ = "1.11.0" # x-release-please-version diff --git a/src/browserbase/resources/fetch_api.py b/src/browserbase/resources/fetch_api.py index dc016722..fc23ac33 100644 --- a/src/browserbase/resources/fetch_api.py +++ b/src/browserbase/resources/fetch_api.py @@ -2,6 +2,9 @@ from __future__ import annotations +from typing import Dict +from typing_extensions import Literal + import httpx from ..types import fetch_api_create_params @@ -47,7 +50,9 @@ def create( url: str, allow_insecure_ssl: bool | Omit = omit, allow_redirects: bool | Omit = omit, + format: Literal["raw", "json", "markdown"] | Omit = omit, proxies: bool | Omit = omit, + schema: Dict[str, object] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -65,8 +70,15 @@ def create( allow_redirects: Whether to follow HTTP redirects + format: Output format for the response content. `raw` (default) returns the response + body unchanged; `json` returns structured data (requires `schema`); `markdown` + returns the page as markdown. + proxies: Whether to enable proxy support for the request + schema: JSON Schema describing the desired structure of the response. Only used when + `format` is `json`. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -82,7 +94,9 @@ def create( "url": url, "allow_insecure_ssl": allow_insecure_ssl, "allow_redirects": allow_redirects, + "format": format, "proxies": proxies, + "schema": schema, }, fetch_api_create_params.FetchAPICreateParams, ), @@ -119,7 +133,9 @@ async def create( url: str, allow_insecure_ssl: bool | Omit = omit, allow_redirects: bool | Omit = omit, + format: Literal["raw", "json", "markdown"] | Omit = omit, proxies: bool | Omit = omit, + schema: Dict[str, object] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -137,8 +153,15 @@ async def create( allow_redirects: Whether to follow HTTP redirects + format: Output format for the response content. `raw` (default) returns the response + body unchanged; `json` returns structured data (requires `schema`); `markdown` + returns the page as markdown. + proxies: Whether to enable proxy support for the request + schema: JSON Schema describing the desired structure of the response. Only used when + `format` is `json`. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -154,7 +177,9 @@ async def create( "url": url, "allow_insecure_ssl": allow_insecure_ssl, "allow_redirects": allow_redirects, + "format": format, "proxies": proxies, + "schema": schema, }, fetch_api_create_params.FetchAPICreateParams, ), diff --git a/src/browserbase/types/fetch_api_create_params.py b/src/browserbase/types/fetch_api_create_params.py index 84a8a052..f83096f7 100644 --- a/src/browserbase/types/fetch_api_create_params.py +++ b/src/browserbase/types/fetch_api_create_params.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing_extensions import Required, Annotated, TypedDict +from typing import Dict +from typing_extensions import Literal, Required, Annotated, TypedDict from .._utils import PropertyInfo @@ -19,5 +20,18 @@ class FetchAPICreateParams(TypedDict, total=False): allow_redirects: Annotated[bool, PropertyInfo(alias="allowRedirects")] """Whether to follow HTTP redirects""" + format: Literal["raw", "json", "markdown"] + """Output format for the response content. + + `raw` (default) returns the response body unchanged; `json` returns structured + data (requires `schema`); `markdown` returns the page as markdown. + """ + proxies: bool """Whether to enable proxy support for the request""" + + schema: Dict[str, object] + """JSON Schema describing the desired structure of the response. + + Only used when `format` is `json`. + """ diff --git a/src/browserbase/types/fetch_api_create_response.py b/src/browserbase/types/fetch_api_create_response.py index f97f5635..6a378000 100644 --- a/src/browserbase/types/fetch_api_create_response.py +++ b/src/browserbase/types/fetch_api_create_response.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Dict +from typing import Dict, Union from pydantic import Field as FieldInfo @@ -13,8 +13,12 @@ class FetchAPICreateResponse(BaseModel): id: str """Unique identifier for the fetch request""" - content: str - """The response body content""" + content: Union[str, Dict[str, object]] + """The response body content. + + A string for `raw` and `markdown` formats; a structured object for `json` format + (the schema-extracted result). + """ content_type: str = FieldInfo(alias="contentType") """The MIME type of the response""" diff --git a/tests/api_resources/test_fetch_api.py b/tests/api_resources/test_fetch_api.py index b9a0455b..def3304d 100644 --- a/tests/api_resources/test_fetch_api.py +++ b/tests/api_resources/test_fetch_api.py @@ -30,7 +30,9 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: url="https://example.com", allow_insecure_ssl=True, allow_redirects=True, + format="raw", proxies=True, + schema={"foo": "bar"}, ) assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) @@ -77,7 +79,9 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas url="https://example.com", allow_insecure_ssl=True, allow_redirects=True, + format="raw", proxies=True, + schema={"foo": "bar"}, ) assert_matches_type(FetchAPICreateResponse, fetch_api, path=["response"]) From 279aa9ad70c3fa36b9734fa58d4efd770596900a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:03:26 +0000 Subject: [PATCH 21/22] release: 1.12.0 (#167) * codegen metadata * codegen metadata * feat: [CORE-2194][apps/api] Add BYOC (cert) CRUD to SDKs * release: 1.12.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- .stats.yml | 8 +- CHANGELOG.md | 8 + README.md | 2 +- api.md | 15 + pyproject.toml | 2 +- src/browserbase/_client.py | 39 +- src/browserbase/_version.py | 2 +- src/browserbase/resources/__init__.py | 14 + src/browserbase/resources/certificates.py | 389 ++++++++++++++++++ src/browserbase/types/__init__.py | 3 + src/browserbase/types/certificate.py | 20 + .../types/certificate_create_params.py | 13 + .../types/certificate_list_response.py | 10 + tests/api_resources/test_certificates.py | 288 +++++++++++++ 15 files changed, 806 insertions(+), 9 deletions(-) create mode 100644 src/browserbase/resources/certificates.py create mode 100644 src/browserbase/types/certificate.py create mode 100644 src/browserbase/types/certificate_create_params.py create mode 100644 src/browserbase/types/certificate_list_response.py create mode 100644 tests/api_resources/test_certificates.py diff --git a/.release-please-manifest.json b/.release-please-manifest.json index caf14871..de0960ab 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.11.0" + ".": "1.12.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index f40d1167..17cb18b8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-b2831c9c836f039762834825afdc20569587a825d29ac5c3748c78b009bf059b.yml -openapi_spec_hash: dd85a934900cb6583f12ebf6117be884 -config_hash: 40fbac80e24faaa0dc19e93368bcd821 +configured_endpoints: 27 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-9bab373fc62ce19147560ed3ec29fe09ad59d9a5b406d9ed21a22f15a511d9cb.yml +openapi_spec_hash: 518fdefff1eabc4bb8a3b54ddf7fa293 +config_hash: d4b0c534eaf7665ea25168e0e824c9d3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 859bfcce..3a9193fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.12.0 (2026-06-05) + +Full Changelog: [v1.11.0...v1.12.0](https://github.com/browserbase/sdk-python/compare/v1.11.0...v1.12.0) + +### Features + +* [CORE-2194][apps/api] Add BYOC (cert) CRUD to SDKs ([5ed9746](https://github.com/browserbase/sdk-python/commit/5ed9746938237ae4eaa78f8652a9b576e6be0e7c)) + ## 1.11.0 (2026-05-20) Full Changelog: [v1.10.0...v1.11.0](https://github.com/browserbase/sdk-python/compare/v1.10.0...v1.11.0) diff --git a/README.md b/README.md index 715a9079..743defe3 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ from browserbase import Browserbase client = Browserbase() -client.extensions.create( +client.certificates.create( file=Path("/path/to/file"), ) ``` diff --git a/api.md b/api.md index 581574a3..c3a3f4eb 100644 --- a/api.md +++ b/api.md @@ -1,3 +1,18 @@ +# Certificates + +Types: + +```python +from browserbase.types import Certificate, CertificateListResponse +``` + +Methods: + +- client.certificates.create(\*\*params) -> Certificate +- client.certificates.retrieve(id) -> Certificate +- client.certificates.list() -> CertificateListResponse +- client.certificates.delete(id) -> None + # Contexts Types: diff --git a/pyproject.toml b/pyproject.toml index 3338ab43..129b1667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.11.0" +version = "1.12.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browserbase/_client.py b/src/browserbase/_client.py index b64caef8..d86814d7 100644 --- a/src/browserbase/_client.py +++ b/src/browserbase/_client.py @@ -35,12 +35,13 @@ ) if TYPE_CHECKING: - from .resources import search, contexts, projects, sessions, fetch_api, extensions + from .resources import search, contexts, projects, sessions, fetch_api, extensions, certificates from .resources.search import SearchResource, AsyncSearchResource from .resources.contexts import ContextsResource, AsyncContextsResource from .resources.projects import ProjectsResource, AsyncProjectsResource from .resources.fetch_api import FetchAPIResource, AsyncFetchAPIResource from .resources.extensions import ExtensionsResource, AsyncExtensionsResource + from .resources.certificates import CertificatesResource, AsyncCertificatesResource from .resources.sessions.sessions import SessionsResource, AsyncSessionsResource __all__ = [ @@ -119,6 +120,12 @@ def __init__( _strict_response_validation=_strict_response_validation, ) + @cached_property + def certificates(self) -> CertificatesResource: + from .resources.certificates import CertificatesResource + + return CertificatesResource(self) + @cached_property def contexts(self) -> ContextsResource: from .resources.contexts import ContextsResource @@ -332,6 +339,12 @@ def __init__( _strict_response_validation=_strict_response_validation, ) + @cached_property + def certificates(self) -> AsyncCertificatesResource: + from .resources.certificates import AsyncCertificatesResource + + return AsyncCertificatesResource(self) + @cached_property def contexts(self) -> AsyncContextsResource: from .resources.contexts import AsyncContextsResource @@ -487,6 +500,12 @@ class BrowserbaseWithRawResponse: def __init__(self, client: Browserbase) -> None: self._client = client + @cached_property + def certificates(self) -> certificates.CertificatesResourceWithRawResponse: + from .resources.certificates import CertificatesResourceWithRawResponse + + return CertificatesResourceWithRawResponse(self._client.certificates) + @cached_property def contexts(self) -> contexts.ContextsResourceWithRawResponse: from .resources.contexts import ContextsResourceWithRawResponse @@ -530,6 +549,12 @@ class AsyncBrowserbaseWithRawResponse: def __init__(self, client: AsyncBrowserbase) -> None: self._client = client + @cached_property + def certificates(self) -> certificates.AsyncCertificatesResourceWithRawResponse: + from .resources.certificates import AsyncCertificatesResourceWithRawResponse + + return AsyncCertificatesResourceWithRawResponse(self._client.certificates) + @cached_property def contexts(self) -> contexts.AsyncContextsResourceWithRawResponse: from .resources.contexts import AsyncContextsResourceWithRawResponse @@ -573,6 +598,12 @@ class BrowserbaseWithStreamedResponse: def __init__(self, client: Browserbase) -> None: self._client = client + @cached_property + def certificates(self) -> certificates.CertificatesResourceWithStreamingResponse: + from .resources.certificates import CertificatesResourceWithStreamingResponse + + return CertificatesResourceWithStreamingResponse(self._client.certificates) + @cached_property def contexts(self) -> contexts.ContextsResourceWithStreamingResponse: from .resources.contexts import ContextsResourceWithStreamingResponse @@ -616,6 +647,12 @@ class AsyncBrowserbaseWithStreamedResponse: def __init__(self, client: AsyncBrowserbase) -> None: self._client = client + @cached_property + def certificates(self) -> certificates.AsyncCertificatesResourceWithStreamingResponse: + from .resources.certificates import AsyncCertificatesResourceWithStreamingResponse + + return AsyncCertificatesResourceWithStreamingResponse(self._client.certificates) + @cached_property def contexts(self) -> contexts.AsyncContextsResourceWithStreamingResponse: from .resources.contexts import AsyncContextsResourceWithStreamingResponse diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 1184ad60..156b5867 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.11.0" # x-release-please-version +__version__ = "1.12.0" # x-release-please-version diff --git a/src/browserbase/resources/__init__.py b/src/browserbase/resources/__init__.py index 83a8788d..6bd09881 100644 --- a/src/browserbase/resources/__init__.py +++ b/src/browserbase/resources/__init__.py @@ -48,8 +48,22 @@ ExtensionsResourceWithStreamingResponse, AsyncExtensionsResourceWithStreamingResponse, ) +from .certificates import ( + CertificatesResource, + AsyncCertificatesResource, + CertificatesResourceWithRawResponse, + AsyncCertificatesResourceWithRawResponse, + CertificatesResourceWithStreamingResponse, + AsyncCertificatesResourceWithStreamingResponse, +) __all__ = [ + "CertificatesResource", + "AsyncCertificatesResource", + "CertificatesResourceWithRawResponse", + "AsyncCertificatesResourceWithRawResponse", + "CertificatesResourceWithStreamingResponse", + "AsyncCertificatesResourceWithStreamingResponse", "ContextsResource", "AsyncContextsResource", "ContextsResourceWithRawResponse", diff --git a/src/browserbase/resources/certificates.py b/src/browserbase/resources/certificates.py new file mode 100644 index 00000000..a617e776 --- /dev/null +++ b/src/browserbase/resources/certificates.py @@ -0,0 +1,389 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Mapping, cast + +import httpx + +from ..types import certificate_create_params +from .._files import deepcopy_with_paths +from .._types import Body, Query, Headers, NoneType, NotGiven, FileTypes, not_given +from .._utils import extract_files, path_template, maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.certificate import Certificate +from ..types.certificate_list_response import CertificateListResponse + +__all__ = ["CertificatesResource", "AsyncCertificatesResource"] + + +class CertificatesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> CertificatesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers + """ + return CertificatesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> CertificatesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/browserbase/sdk-python#with_streaming_response + """ + return CertificatesResourceWithStreamingResponse(self) + + def create( + self, + *, + file: FileTypes, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Certificate: + """ + Upload a Certificate + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + body = deepcopy_with_paths({"file": file}, [["file"]]) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return self._post( + "/v1/certificates", + body=maybe_transform(body, certificate_create_params.CertificateCreateParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Certificate, + ) + + def retrieve( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Certificate: + """ + Get a Certificate + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get( + path_template("/v1/certificates/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Certificate, + ) + + def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CertificateListResponse: + """List Certificates""" + return self._get( + "/v1/certificates", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CertificateListResponse, + ) + + def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + Delete a Certificate + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return self._delete( + path_template("/v1/certificates/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class AsyncCertificatesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncCertificatesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/browserbase/sdk-python#accessing-raw-response-data-eg-headers + """ + return AsyncCertificatesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncCertificatesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/browserbase/sdk-python#with_streaming_response + """ + return AsyncCertificatesResourceWithStreamingResponse(self) + + async def create( + self, + *, + file: FileTypes, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Certificate: + """ + Upload a Certificate + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + body = deepcopy_with_paths({"file": file}, [["file"]]) + files = extract_files(cast(Mapping[str, object], body), paths=[["file"]]) + # It should be noted that the actual Content-Type header that will be + # sent to the server will contain a `boundary` parameter, e.g. + # multipart/form-data; boundary=---abc-- + extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})} + return await self._post( + "/v1/certificates", + body=await async_maybe_transform(body, certificate_create_params.CertificateCreateParams), + files=files, + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Certificate, + ) + + async def retrieve( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Certificate: + """ + Get a Certificate + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._get( + path_template("/v1/certificates/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=Certificate, + ) + + async def list( + self, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> CertificateListResponse: + """List Certificates""" + return await self._get( + "/v1/certificates", + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=CertificateListResponse, + ) + + async def delete( + self, + id: str, + *, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + """ + Delete a Certificate + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + extra_headers = {"Accept": "*/*", **(extra_headers or {})} + return await self._delete( + path_template("/v1/certificates/{id}", id=id), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=NoneType, + ) + + +class CertificatesResourceWithRawResponse: + def __init__(self, certificates: CertificatesResource) -> None: + self._certificates = certificates + + self.create = to_raw_response_wrapper( + certificates.create, + ) + self.retrieve = to_raw_response_wrapper( + certificates.retrieve, + ) + self.list = to_raw_response_wrapper( + certificates.list, + ) + self.delete = to_raw_response_wrapper( + certificates.delete, + ) + + +class AsyncCertificatesResourceWithRawResponse: + def __init__(self, certificates: AsyncCertificatesResource) -> None: + self._certificates = certificates + + self.create = async_to_raw_response_wrapper( + certificates.create, + ) + self.retrieve = async_to_raw_response_wrapper( + certificates.retrieve, + ) + self.list = async_to_raw_response_wrapper( + certificates.list, + ) + self.delete = async_to_raw_response_wrapper( + certificates.delete, + ) + + +class CertificatesResourceWithStreamingResponse: + def __init__(self, certificates: CertificatesResource) -> None: + self._certificates = certificates + + self.create = to_streamed_response_wrapper( + certificates.create, + ) + self.retrieve = to_streamed_response_wrapper( + certificates.retrieve, + ) + self.list = to_streamed_response_wrapper( + certificates.list, + ) + self.delete = to_streamed_response_wrapper( + certificates.delete, + ) + + +class AsyncCertificatesResourceWithStreamingResponse: + def __init__(self, certificates: AsyncCertificatesResource) -> None: + self._certificates = certificates + + self.create = async_to_streamed_response_wrapper( + certificates.create, + ) + self.retrieve = async_to_streamed_response_wrapper( + certificates.retrieve, + ) + self.list = async_to_streamed_response_wrapper( + certificates.list, + ) + self.delete = async_to_streamed_response_wrapper( + certificates.delete, + ) diff --git a/src/browserbase/types/__init__.py b/src/browserbase/types/__init__.py index 0a9a3b84..72d472ac 100644 --- a/src/browserbase/types/__init__.py +++ b/src/browserbase/types/__init__.py @@ -6,6 +6,7 @@ from .project import Project as Project from .session import Session as Session from .extension import Extension as Extension +from .certificate import Certificate as Certificate from .project_usage import ProjectUsage as ProjectUsage from .search_web_params import SearchWebParams as SearchWebParams from .session_live_urls import SessionLiveURLs as SessionLiveURLs @@ -21,5 +22,7 @@ from .extension_create_params import ExtensionCreateParams as ExtensionCreateParams from .fetch_api_create_params import FetchAPICreateParams as FetchAPICreateParams from .session_create_response import SessionCreateResponse as SessionCreateResponse +from .certificate_create_params import CertificateCreateParams as CertificateCreateParams +from .certificate_list_response import CertificateListResponse as CertificateListResponse from .fetch_api_create_response import FetchAPICreateResponse as FetchAPICreateResponse from .session_retrieve_response import SessionRetrieveResponse as SessionRetrieveResponse diff --git a/src/browserbase/types/certificate.py b/src/browserbase/types/certificate.py new file mode 100644 index 00000000..0f59e105 --- /dev/null +++ b/src/browserbase/types/certificate.py @@ -0,0 +1,20 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from pydantic import Field as FieldInfo + +from .._models import BaseModel + +__all__ = ["Certificate"] + + +class Certificate(BaseModel): + id: str + + created_at: datetime = FieldInfo(alias="createdAt") + + project_id: str = FieldInfo(alias="projectId") + """The Project ID linked to the uploaded Certificate.""" + + updated_at: datetime = FieldInfo(alias="updatedAt") diff --git a/src/browserbase/types/certificate_create_params.py b/src/browserbase/types/certificate_create_params.py new file mode 100644 index 00000000..577c0e07 --- /dev/null +++ b/src/browserbase/types/certificate_create_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +from .._types import FileTypes + +__all__ = ["CertificateCreateParams"] + + +class CertificateCreateParams(TypedDict, total=False): + file: Required[FileTypes] diff --git a/src/browserbase/types/certificate_list_response.py b/src/browserbase/types/certificate_list_response.py new file mode 100644 index 00000000..7a8a8f06 --- /dev/null +++ b/src/browserbase/types/certificate_list_response.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List +from typing_extensions import TypeAlias + +from .certificate import Certificate + +__all__ = ["CertificateListResponse"] + +CertificateListResponse: TypeAlias = List[Certificate] diff --git a/tests/api_resources/test_certificates.py b/tests/api_resources/test_certificates.py new file mode 100644 index 00000000..aaa767ed --- /dev/null +++ b/tests/api_resources/test_certificates.py @@ -0,0 +1,288 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from browserbase import Browserbase, AsyncBrowserbase +from tests.utils import assert_matches_type +from browserbase.types import Certificate, CertificateListResponse + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestCertificates: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_create(self, client: Browserbase) -> None: + certificate = client.certificates.create( + file=b"Example data", + ) + assert_matches_type(Certificate, certificate, path=["response"]) + + @parametrize + def test_raw_response_create(self, client: Browserbase) -> None: + response = client.certificates.with_raw_response.create( + file=b"Example data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + certificate = response.parse() + assert_matches_type(Certificate, certificate, path=["response"]) + + @parametrize + def test_streaming_response_create(self, client: Browserbase) -> None: + with client.certificates.with_streaming_response.create( + file=b"Example data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + certificate = response.parse() + assert_matches_type(Certificate, certificate, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_retrieve(self, client: Browserbase) -> None: + certificate = client.certificates.retrieve( + "id", + ) + assert_matches_type(Certificate, certificate, path=["response"]) + + @parametrize + def test_raw_response_retrieve(self, client: Browserbase) -> None: + response = client.certificates.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + certificate = response.parse() + assert_matches_type(Certificate, certificate, path=["response"]) + + @parametrize + def test_streaming_response_retrieve(self, client: Browserbase) -> None: + with client.certificates.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + certificate = response.parse() + assert_matches_type(Certificate, certificate, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_retrieve(self, client: Browserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.certificates.with_raw_response.retrieve( + "", + ) + + @parametrize + def test_method_list(self, client: Browserbase) -> None: + certificate = client.certificates.list() + assert_matches_type(CertificateListResponse, certificate, path=["response"]) + + @parametrize + def test_raw_response_list(self, client: Browserbase) -> None: + response = client.certificates.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + certificate = response.parse() + assert_matches_type(CertificateListResponse, certificate, path=["response"]) + + @parametrize + def test_streaming_response_list(self, client: Browserbase) -> None: + with client.certificates.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + certificate = response.parse() + assert_matches_type(CertificateListResponse, certificate, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_method_delete(self, client: Browserbase) -> None: + certificate = client.certificates.delete( + "id", + ) + assert certificate is None + + @parametrize + def test_raw_response_delete(self, client: Browserbase) -> None: + response = client.certificates.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + certificate = response.parse() + assert certificate is None + + @parametrize + def test_streaming_response_delete(self, client: Browserbase) -> None: + with client.certificates.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + certificate = response.parse() + assert certificate is None + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_delete(self, client: Browserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.certificates.with_raw_response.delete( + "", + ) + + +class TestAsyncCertificates: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_create(self, async_client: AsyncBrowserbase) -> None: + certificate = await async_client.certificates.create( + file=b"Example data", + ) + assert_matches_type(Certificate, certificate, path=["response"]) + + @parametrize + async def test_raw_response_create(self, async_client: AsyncBrowserbase) -> None: + response = await async_client.certificates.with_raw_response.create( + file=b"Example data", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + certificate = await response.parse() + assert_matches_type(Certificate, certificate, path=["response"]) + + @parametrize + async def test_streaming_response_create(self, async_client: AsyncBrowserbase) -> None: + async with async_client.certificates.with_streaming_response.create( + file=b"Example data", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + certificate = await response.parse() + assert_matches_type(Certificate, certificate, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_retrieve(self, async_client: AsyncBrowserbase) -> None: + certificate = await async_client.certificates.retrieve( + "id", + ) + assert_matches_type(Certificate, certificate, path=["response"]) + + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncBrowserbase) -> None: + response = await async_client.certificates.with_raw_response.retrieve( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + certificate = await response.parse() + assert_matches_type(Certificate, certificate, path=["response"]) + + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncBrowserbase) -> None: + async with async_client.certificates.with_streaming_response.retrieve( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + certificate = await response.parse() + assert_matches_type(Certificate, certificate, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncBrowserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.certificates.with_raw_response.retrieve( + "", + ) + + @parametrize + async def test_method_list(self, async_client: AsyncBrowserbase) -> None: + certificate = await async_client.certificates.list() + assert_matches_type(CertificateListResponse, certificate, path=["response"]) + + @parametrize + async def test_raw_response_list(self, async_client: AsyncBrowserbase) -> None: + response = await async_client.certificates.with_raw_response.list() + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + certificate = await response.parse() + assert_matches_type(CertificateListResponse, certificate, path=["response"]) + + @parametrize + async def test_streaming_response_list(self, async_client: AsyncBrowserbase) -> None: + async with async_client.certificates.with_streaming_response.list() as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + certificate = await response.parse() + assert_matches_type(CertificateListResponse, certificate, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_method_delete(self, async_client: AsyncBrowserbase) -> None: + certificate = await async_client.certificates.delete( + "id", + ) + assert certificate is None + + @parametrize + async def test_raw_response_delete(self, async_client: AsyncBrowserbase) -> None: + response = await async_client.certificates.with_raw_response.delete( + "id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + certificate = await response.parse() + assert certificate is None + + @parametrize + async def test_streaming_response_delete(self, async_client: AsyncBrowserbase) -> None: + async with async_client.certificates.with_streaming_response.delete( + "id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + certificate = await response.parse() + assert certificate is None + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_delete(self, async_client: AsyncBrowserbase) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.certificates.with_raw_response.delete( + "", + ) From 20437af015acbeba239882b2917c22eeea1b7aaf Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:40:45 +0000 Subject: [PATCH 22/22] release: 1.13.0 (#168) * feat: [AI-2206][apps/api] Surface proxySettings.caCertificates in public SDK & docs * release: 1.13.0 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- .stats.yml | 4 ++-- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/browserbase/_version.py | 2 +- src/browserbase/resources/sessions/sessions.py | 8 ++++++++ src/browserbase/types/session_create_params.py | 12 ++++++++++++ tests/api_resources/test_sessions.py | 2 ++ 8 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index de0960ab..f94eeca2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.12.0" + ".": "1.13.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 17cb18b8..1ef2a27b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 27 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-9bab373fc62ce19147560ed3ec29fe09ad59d9a5b406d9ed21a22f15a511d9cb.yml -openapi_spec_hash: 518fdefff1eabc4bb8a3b54ddf7fa293 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/browserbase-f39b852755134d01a440f7c37701f6c5397f43d13740d9ba08739cae488382a7.yml +openapi_spec_hash: de6c25eebe5026d0fb9a4d7a93ec7718 config_hash: d4b0c534eaf7665ea25168e0e824c9d3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9193fb..4a851dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.13.0 (2026-06-08) + +Full Changelog: [v1.12.0...v1.13.0](https://github.com/browserbase/sdk-python/compare/v1.12.0...v1.13.0) + +### Features + +* [AI-2206][apps/api] Surface proxySettings.caCertificates in public SDK & docs ([0cf47ff](https://github.com/browserbase/sdk-python/commit/0cf47fff59d50d3624b7106c3c0cebfe1a4fdde0)) + ## 1.12.0 (2026-06-05) Full Changelog: [v1.11.0...v1.12.0](https://github.com/browserbase/sdk-python/compare/v1.11.0...v1.12.0) diff --git a/pyproject.toml b/pyproject.toml index 129b1667..dc2925d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browserbase" -version = "1.12.0" +version = "1.13.0" description = "The official Python library for the Browserbase API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/browserbase/_version.py b/src/browserbase/_version.py index 156b5867..5daf1b6a 100644 --- a/src/browserbase/_version.py +++ b/src/browserbase/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "browserbase" -__version__ = "1.12.0" # x-release-please-version +__version__ = "1.13.0" # x-release-please-version diff --git a/src/browserbase/resources/sessions/sessions.py b/src/browserbase/resources/sessions/sessions.py index a54d7a72..8cf81349 100644 --- a/src/browserbase/resources/sessions/sessions.py +++ b/src/browserbase/resources/sessions/sessions.py @@ -116,6 +116,7 @@ def create( keep_alive: bool | Omit = omit, project_id: str | Omit = omit, proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | Omit = omit, + proxy_settings: session_create_params.ProxySettings | Omit = omit, region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | Omit = omit, api_timeout: int | Omit = omit, user_metadata: Dict[str, object] | Omit = omit, @@ -144,6 +145,8 @@ def create( proxies: Proxy configuration. Can be true for default proxy, or an array of proxy configurations. + proxy_settings: Supplementary proxy settings. Optional. + region: The region where the Session should run. api_timeout: Duration in seconds after which the session will automatically end. Defaults to @@ -169,6 +172,7 @@ def create( "keep_alive": keep_alive, "project_id": project_id, "proxies": proxies, + "proxy_settings": proxy_settings, "region": region, "api_timeout": api_timeout, "user_metadata": user_metadata, @@ -392,6 +396,7 @@ async def create( keep_alive: bool | Omit = omit, project_id: str | Omit = omit, proxies: Union[Iterable[session_create_params.ProxiesUnionMember0], bool] | Omit = omit, + proxy_settings: session_create_params.ProxySettings | Omit = omit, region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] | Omit = omit, api_timeout: int | Omit = omit, user_metadata: Dict[str, object] | Omit = omit, @@ -420,6 +425,8 @@ async def create( proxies: Proxy configuration. Can be true for default proxy, or an array of proxy configurations. + proxy_settings: Supplementary proxy settings. Optional. + region: The region where the Session should run. api_timeout: Duration in seconds after which the session will automatically end. Defaults to @@ -445,6 +452,7 @@ async def create( "keep_alive": keep_alive, "project_id": project_id, "proxies": proxies, + "proxy_settings": proxy_settings, "region": region, "api_timeout": api_timeout, "user_metadata": user_metadata, diff --git a/src/browserbase/types/session_create_params.py b/src/browserbase/types/session_create_params.py index 3ff1d058..2d0d39ac 100644 --- a/src/browserbase/types/session_create_params.py +++ b/src/browserbase/types/session_create_params.py @@ -5,6 +5,7 @@ from typing import Dict, Union, Iterable from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict +from .._types import SequenceNotStr from .._utils import PropertyInfo __all__ = [ @@ -17,6 +18,7 @@ "ProxiesUnionMember0BrowserbaseProxyConfigGeolocation", "ProxiesUnionMember0ExternalProxyConfig", "ProxiesUnionMember0NoneProxyConfig", + "ProxySettings", ] @@ -48,6 +50,9 @@ class SessionCreateParams(TypedDict, total=False): Can be true for default proxy, or an array of proxy configurations. """ + proxy_settings: Annotated[ProxySettings, PropertyInfo(alias="proxySettings")] + """Supplementary proxy settings. Optional.""" + region: Literal["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"] """The region where the Session should run.""" @@ -201,3 +206,10 @@ class ProxiesUnionMember0NoneProxyConfig(TypedDict, total=False): ProxiesUnionMember0ExternalProxyConfig, ProxiesUnionMember0NoneProxyConfig, ] + + +class ProxySettings(TypedDict, total=False): + """Supplementary proxy settings. Optional.""" + + ca_certificates: Annotated[SequenceNotStr[str], PropertyInfo(alias="caCertificates")] + """The TLS certificate IDs to trust. Optional.""" diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 41f2a0bd..fe6d486e 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -66,6 +66,7 @@ def test_method_create_with_all_params(self, client: Browserbase) -> None: }, } ], + proxy_settings={"ca_certificates": ["182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e"]}, region="us-west-2", api_timeout=60, user_metadata={"foo": "bar"}, @@ -301,6 +302,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncBrowserbas }, } ], + proxy_settings={"ca_certificates": ["182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e"]}, region="us-west-2", api_timeout=60, user_metadata={"foo": "bar"},