From 1d70eff68d1cc63934aa074e8fd89503845865d6 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:15:05 +0000 Subject: [PATCH 1/2] feat: Add mid-session browser profile saves Stainless-Generated-From: 4fbf9561f04097a8d4949d901b8ca0db4f778769 --- .stats.yml | 2 +- api.md | 2 + src/kernel/resources/browsers/browsers.py | 174 ++++++++++++++++++ src/kernel/types/__init__.py | 1 + .../types/browser_save_profile_params.py | 15 ++ tests/api_resources/test_browsers.py | 105 +++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 src/kernel/types/browser_save_profile_params.py diff --git a/.stats.yml b/.stats.yml index caa7c062..ea0d5273 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1 +1 @@ -configured_endpoints: 175 +configured_endpoints: 176 diff --git a/api.md b/api.md index a2995e3a..734e8fbb 100644 --- a/api.md +++ b/api.md @@ -138,6 +138,7 @@ from kernel.types import ( BrowserUsage, BrowserVideoMemory, Profile, + SaveProfileRequest, Tags, VaultReference, BrowserCreateResponse, @@ -158,6 +159,7 @@ Methods: - client.browsers.delete_by_id(id_or_name) -> None - client.browsers.load_extensions(id_or_name, \*\*params) -> None - client.browsers.repl(id_or_name, \*\*params) -> BrowserReplResult +- client.browsers.save_profile(id_or_name, \*\*params) -> Profile ## Telemetry diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py index ec9b5176..00010a7e 100644 --- a/src/kernel/resources/browsers/browsers.py +++ b/src/kernel/resources/browsers/browsers.py @@ -33,6 +33,7 @@ browser_create_params, browser_update_params, browser_retrieve_params, + browser_save_profile_params, browser_load_extensions_params, ) from .process import ( @@ -88,6 +89,7 @@ AsyncWebmcpResourceWithStreamingResponse, ) from ..._base_client import AsyncPaginator, make_request_options +from ...types.profile import Profile from ...types.tags_param import TagsParam from .playwright.playwright import ( PlaywrightResource, @@ -897,6 +899,86 @@ def repl( cast_to=BrowserReplResult, ) + def save_profile( + self, + id_or_name: str, + *, + id: str | Omit = omit, + name: 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, + idempotency_key: str | None = None, + ) -> Profile: + """ + Saves the running browser's current user-data state to an existing or new + profile without ending the browser session. + + Saving restarts the browser. Active CDP and WebDriver connections are closed and + must reconnect, and WebDriver clients must create a new session. Open tabs are + restored, but pages reload and in-memory page state is lost. The browser is + stopped only while its user data is copied; this request returns once the + restarted browser accepts CDP connections again and the profile has been + uploaded. Caches, Service Worker storage and Google Drive IndexedDB data are + left out of the saved profile but kept in the running browser. + + This does not attach the destination profile to the browser or change its + save_changes setting. Active browsers that already loaded the destination + profile keep their in-memory state. Pools with refresh_on_profile_update enabled + flush idle sessions and replenish them from the new snapshot; other browsers + receive it the next time they load the profile. When saves made with this + endpoint to the same profile overlap, the save that started most recently wins, + even if an earlier one finishes later. The automatic save at the end of a + session whose profile has save_changes enabled is ordered against these saves by + when each finishes. + + A successful save restarts the browser's inactivity timeout (timeout_seconds). A + failed save leaves it unchanged. + + Returns 409 when the browser is no longer active, when a pooled browser was + released or reacquired after the request was made (browser_lease_changed), or + when the browser is briefly unable to accept a save (browser_busy), in which + case the request can be retried. + + Args: + id: Existing profile ID to overwrite. + + name: Profile name to overwrite or create. + + 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 + + idempotency_key: Specify a custom idempotency key for this request + """ + if not id_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return self._post( + path_template("/browsers/{id_or_name}/profiles", id_or_name=id_or_name), + body=maybe_transform( + { + "id": id, + "name": name, + }, + browser_save_profile_params.BrowserSaveProfileParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=Profile, + ) + class AsyncBrowsersResource(AsyncAPIResource): @cached_property @@ -1671,6 +1753,86 @@ async def repl( cast_to=BrowserReplResult, ) + async def save_profile( + self, + id_or_name: str, + *, + id: str | Omit = omit, + name: 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, + idempotency_key: str | None = None, + ) -> Profile: + """ + Saves the running browser's current user-data state to an existing or new + profile without ending the browser session. + + Saving restarts the browser. Active CDP and WebDriver connections are closed and + must reconnect, and WebDriver clients must create a new session. Open tabs are + restored, but pages reload and in-memory page state is lost. The browser is + stopped only while its user data is copied; this request returns once the + restarted browser accepts CDP connections again and the profile has been + uploaded. Caches, Service Worker storage and Google Drive IndexedDB data are + left out of the saved profile but kept in the running browser. + + This does not attach the destination profile to the browser or change its + save_changes setting. Active browsers that already loaded the destination + profile keep their in-memory state. Pools with refresh_on_profile_update enabled + flush idle sessions and replenish them from the new snapshot; other browsers + receive it the next time they load the profile. When saves made with this + endpoint to the same profile overlap, the save that started most recently wins, + even if an earlier one finishes later. The automatic save at the end of a + session whose profile has save_changes enabled is ordered against these saves by + when each finishes. + + A successful save restarts the browser's inactivity timeout (timeout_seconds). A + failed save leaves it unchanged. + + Returns 409 when the browser is no longer active, when a pooled browser was + released or reacquired after the request was made (browser_lease_changed), or + when the browser is briefly unable to accept a save (browser_busy), in which + case the request can be retried. + + Args: + id: Existing profile ID to overwrite. + + name: Profile name to overwrite or create. + + 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 + + idempotency_key: Specify a custom idempotency key for this request + """ + if not id_or_name: + raise ValueError(f"Expected a non-empty value for `id_or_name` but received {id_or_name!r}") + return await self._post( + path_template("/browsers/{id_or_name}/profiles", id_or_name=id_or_name), + body=await async_maybe_transform( + { + "id": id, + "name": name, + }, + browser_save_profile_params.BrowserSaveProfileParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=Profile, + ) + class BrowsersResourceWithRawResponse: def __init__(self, browsers: BrowsersResource) -> None: @@ -1700,6 +1862,9 @@ def __init__(self, browsers: BrowsersResource) -> None: self.repl = to_raw_response_wrapper( browsers.repl, ) + self.save_profile = to_raw_response_wrapper( + browsers.save_profile, + ) @cached_property def telemetry(self) -> TelemetryResourceWithRawResponse: @@ -1774,6 +1939,9 @@ def __init__(self, browsers: AsyncBrowsersResource) -> None: self.repl = async_to_raw_response_wrapper( browsers.repl, ) + self.save_profile = async_to_raw_response_wrapper( + browsers.save_profile, + ) @cached_property def telemetry(self) -> AsyncTelemetryResourceWithRawResponse: @@ -1848,6 +2016,9 @@ def __init__(self, browsers: BrowsersResource) -> None: self.repl = to_streamed_response_wrapper( browsers.repl, ) + self.save_profile = to_streamed_response_wrapper( + browsers.save_profile, + ) @cached_property def telemetry(self) -> TelemetryResourceWithStreamingResponse: @@ -1922,6 +2093,9 @@ def __init__(self, browsers: AsyncBrowsersResource) -> None: self.repl = async_to_streamed_response_wrapper( browsers.repl, ) + self.save_profile = async_to_streamed_response_wrapper( + browsers.save_profile, + ) @cached_property def telemetry(self) -> AsyncTelemetryResourceWithStreamingResponse: diff --git a/src/kernel/types/__init__.py b/src/kernel/types/__init__.py index a7735052..10bb66a1 100644 --- a/src/kernel/types/__init__.py +++ b/src/kernel/types/__init__.py @@ -135,6 +135,7 @@ from .invocation_update_response import InvocationUpdateResponse as InvocationUpdateResponse from .browser_pool_acquire_params import BrowserPoolAcquireParams as BrowserPoolAcquireParams from .browser_pool_release_params import BrowserPoolReleaseParams as BrowserPoolReleaseParams +from .browser_save_profile_params import BrowserSaveProfileParams as BrowserSaveProfileParams from .config_registry_list_params import ConfigRegistryListParams as ConfigRegistryListParams from .browser_network_config_param import BrowserNetworkConfigParam as BrowserNetworkConfigParam from .browser_network_update_param import BrowserNetworkUpdateParam as BrowserNetworkUpdateParam diff --git a/src/kernel/types/browser_save_profile_params.py b/src/kernel/types/browser_save_profile_params.py new file mode 100644 index 00000000..86942b81 --- /dev/null +++ b/src/kernel/types/browser_save_profile_params.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["BrowserSaveProfileParams"] + + +class BrowserSaveProfileParams(TypedDict, total=False): + id: str + """Existing profile ID to overwrite.""" + + name: str + """Profile name to overwrite or create.""" diff --git a/tests/api_resources/test_browsers.py b/tests/api_resources/test_browsers.py index 6c5e9696..9c6d8a79 100644 --- a/tests/api_resources/test_browsers.py +++ b/tests/api_resources/test_browsers.py @@ -10,6 +10,7 @@ from kernel import Kernel, AsyncKernel from tests.utils import assert_matches_type from kernel.types import ( + Profile, BrowserReplResult, BrowserCurlResponse, BrowserListResponse, @@ -578,6 +579,58 @@ def test_path_params_repl(self, client: Kernel) -> None: code="code", ) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_save_profile(self, client: Kernel) -> None: + browser = client.browsers.save_profile( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + assert_matches_type(Profile, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_save_profile_with_all_params(self, client: Kernel) -> None: + browser = client.browsers.save_profile( + id_or_name="htzv5orfit78e1m2biiifpbv", + id="x", + name="name", + ) + assert_matches_type(Profile, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_save_profile(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.save_profile( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert_matches_type(Profile, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_save_profile(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.save_profile( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert_matches_type(Profile, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_save_profile(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + client.browsers.with_raw_response.save_profile( + id_or_name="", + ) + class TestAsyncBrowsers: parametrize = pytest.mark.parametrize( @@ -1136,3 +1189,55 @@ async def test_path_params_repl(self, async_client: AsyncKernel) -> None: id_or_name="", code="code", ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_save_profile(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.save_profile( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + assert_matches_type(Profile, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_save_profile_with_all_params(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.save_profile( + id_or_name="htzv5orfit78e1m2biiifpbv", + id="x", + name="name", + ) + assert_matches_type(Profile, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_save_profile(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.save_profile( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert_matches_type(Profile, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_save_profile(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.save_profile( + id_or_name="htzv5orfit78e1m2biiifpbv", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert_matches_type(Profile, browser, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_save_profile(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): + await async_client.browsers.with_raw_response.save_profile( + id_or_name="", + ) From 953a51aa591eaf6a5f548cfa3c234119d09ce274 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:19:30 +0000 Subject: [PATCH 2/2] release: 0.134.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- src/kernel/_version.py | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4a1242df..774500bf 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.133.0" + ".": "0.134.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 471f5980..57441236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.134.0](https://github.com/kernel/kernel-python-sdk/compare/v0.133.0...v0.134.0) (2026-10-28) + + +### Features + +* Add mid-session browser profile saves ([1d70eff](https://github.com/kernel/kernel-python-sdk/commit/1d70eff68d1cc63934aa074e8fd89503845865d6)) + ## [0.133.0](https://github.com/kernel/kernel-python-sdk/compare/v0.132.0...v0.133.0) (2026-10-28) diff --git a/pyproject.toml b/pyproject.toml index bba5860d..1cd696b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kernel" -version = "0.133.0" +version = "0.134.0" description = "The official Python library for the kernel API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/kernel/_version.py b/src/kernel/_version.py index 720e7ab3..11d1945f 100644 --- a/src/kernel/_version.py +++ b/src/kernel/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "kernel" -__version__ = "0.133.0" # x-release-please-version +__version__ = "0.134.0" # x-release-please-version