diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 85c3118..284ce93 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.59.0" + ".": "0.60.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 5a5e563..9f173dc 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 117 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-1acd8f0b76ab00e36b53cc3ca90b72b2199f3388b3e307890adb464b87f9a2d8.yml -openapi_spec_hash: 82003125c1c2c5d82d19270bafb4a6ca +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-3a1db7f11a92b28681929255ada59d2317ee4db98b5ff5aa6ce142a0664058b3.yml +openapi_spec_hash: b3064eaa589ae2a84993686ad1a3ee43 config_hash: ede72e4ae65cc5a6d6927938b3455c46 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da0039..1d7d38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.60.0 (2026-06-03) + +Full Changelog: [v0.59.0...v0.60.0](https://github.com/kernel/kernel-python-sdk/compare/v0.59.0...v0.60.0) + +### Features + +* Add API-backed API key management endpoints ([55bd31e](https://github.com/kernel/kernel-python-sdk/commit/55bd31edfed74f3bc4b08f83e07448e2e53378b0)) +* **examples:** add browser-telemetry example ([4c29993](https://github.com/kernel/kernel-python-sdk/commit/4c29993cdda50fcde19ee7bf696a268cd8b0eb0b)) +* Fix browser pool update schema ([903fe13](https://github.com/kernel/kernel-python-sdk/commit/903fe13b84242acea9c27bc451c16fb5cd58e40c)) +* route browser telemetry directly to the VM by default ([cb50725](https://github.com/kernel/kernel-python-sdk/commit/cb5072516416627ffde7198900484c46dda5b9dc)) + + +### Bug Fixes + +* **streaming:** don't dispatch empty SSE keepalive comment frames ([a0ee2b2](https://github.com/kernel/kernel-python-sdk/commit/a0ee2b2653c581839be11bb29be5b68166e80658)) + ## 0.59.0 (2026-06-03) Full Changelog: [v0.58.0...v0.59.0](https://github.com/kernel/kernel-python-sdk/compare/v0.58.0...v0.59.0) diff --git a/examples/browser_telemetry.py b/examples/browser_telemetry.py new file mode 100644 index 0000000..8d1795c --- /dev/null +++ b/examples/browser_telemetry.py @@ -0,0 +1,32 @@ +"""Example: stream live browser telemetry events from a session.""" + +from kernel import Kernel + + +def main() -> None: + client = Kernel() + + # Enable telemetry capture when creating the browser. + browser = client.browsers.create(telemetry={"enabled": True}) + + try: + # Telemetry is a default direct-to-VM routing subresource, so the stream + # connects straight to the browser VM automatically. + stream = client.browsers.telemetry.stream(browser.session_id) + + # Make a few browser activity calls to generate events. The "api" telemetry + # category emits an event per VM API call, so events arrive within ~1s. + for _ in range(3): + client.browsers.curl(browser.session_id, url="https://example.com", method="GET") + + # Print a few events, then stop so we don't wait on the 15s keepalive. + for count, message in enumerate(stream, start=1): + print(message.seq, message.event.type) + if count >= 3: + break + finally: + client.browsers.delete_by_id(browser.session_id) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 24264fb..ad5fcae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kernel" -version = "0.59.0" +version = "0.60.0" description = "The official Python library for the kernel API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/kernel/_streaming.py b/src/kernel/_streaming.py index 5520edb..ddca7eb 100644 --- a/src/kernel/_streaming.py +++ b/src/kernel/_streaming.py @@ -251,7 +251,12 @@ def decode(self, line: str) -> ServerSentEvent | None: # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 if not line: - if not self._event and not self._data and not self._last_event_id and self._retry is None: + # Whether to dispatch depends only on what was set in the *current* block. last_event_id + # is sticky across events (per the SSE spec, it is intentionally not reset below), so it + # must not be part of this check -- otherwise, once any event carries an id, every + # subsequent comment-only block (e.g. a ``:\n\n`` keepalive) would dispatch an empty + # event, which then fails to JSON-decode in the typed Stream wrapper. + if not self._event and not self._data and self._retry is None: return None sse = ServerSentEvent( diff --git a/src/kernel/_version.py b/src/kernel/_version.py index 86fdb65..fdecb53 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.59.0" # x-release-please-version +__version__ = "0.60.0" # x-release-please-version diff --git a/src/kernel/lib/browser_routing/routing.py b/src/kernel/lib/browser_routing/routing.py index aa84cc1..f0f5e34 100644 --- a/src/kernel/lib/browser_routing/routing.py +++ b/src/kernel/lib/browser_routing/routing.py @@ -41,7 +41,7 @@ class BrowserRoutingConfig: def browser_routing_config_from_env() -> BrowserRoutingConfig: raw = os.environ.get("KERNEL_BROWSER_ROUTING_SUBRESOURCES") if raw is None: - return BrowserRoutingConfig(subresources=("curl",)) + return BrowserRoutingConfig(subresources=("curl", "telemetry")) if raw.strip() == "": return BrowserRoutingConfig() diff --git a/src/kernel/resources/api_keys.py b/src/kernel/resources/api_keys.py index aae9679..93e8015 100644 --- a/src/kernel/resources/api_keys.py +++ b/src/kernel/resources/api_keys.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Optional +from typing_extensions import Literal import httpx @@ -171,6 +172,9 @@ def list( *, limit: int | Omit = omit, offset: int | Omit = omit, + query: str | Omit = omit, + sort_by: Literal["created_at", "name", "expires_at"] | Omit = omit, + sort_direction: Literal["asc", "desc"] | 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, @@ -187,6 +191,13 @@ def list( offset: Number of results to skip + query: Case-insensitive substring match against API key name, creator, and project. API + key identifiers and masked keys match by exact value or prefix. + + sort_by: Field to sort API keys by. + + sort_direction: Sort direction for API keys. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -207,6 +218,9 @@ def list( { "limit": limit, "offset": offset, + "query": query, + "sort_by": sort_by, + "sort_direction": sort_direction, }, api_key_list_params.APIKeyListParams, ), @@ -395,6 +409,9 @@ def list( *, limit: int | Omit = omit, offset: int | Omit = omit, + query: str | Omit = omit, + sort_by: Literal["created_at", "name", "expires_at"] | Omit = omit, + sort_direction: Literal["asc", "desc"] | 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, @@ -411,6 +428,13 @@ def list( offset: Number of results to skip + query: Case-insensitive substring match against API key name, creator, and project. API + key identifiers and masked keys match by exact value or prefix. + + sort_by: Field to sort API keys by. + + sort_direction: Sort direction for API keys. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -431,6 +455,9 @@ def list( { "limit": limit, "offset": offset, + "query": query, + "sort_by": sort_by, + "sort_direction": sort_direction, }, api_key_list_params.APIKeyListParams, ), diff --git a/src/kernel/resources/browser_pools.py b/src/kernel/resources/browser_pools.py index db8ebbe..6475127 100644 --- a/src/kernel/resources/browser_pools.py +++ b/src/kernel/resources/browser_pools.py @@ -206,7 +206,6 @@ def update( self, id_or_name: str, *, - size: int, chrome_policy: Dict[str, object] | Omit = omit, discard_all_idle: bool | Omit = omit, extensions: Iterable[BrowserExtension] | Omit = omit, @@ -216,6 +215,7 @@ def update( name: str | Omit = omit, profile: BrowserProfile | Omit = omit, proxy_id: str | Omit = omit, + size: int | Omit = omit, start_url: str | Omit = omit, stealth: bool | Omit = omit, timeout_seconds: int | Omit = omit, @@ -231,10 +231,6 @@ def update( Updates the configuration used to create browsers in the pool. Args: - size: Number of browsers to maintain in the pool. The maximum size is determined by - your organization's pooled sessions limit (the sum of all pool sizes cannot - exceed your limit). - chrome_policy: Custom Chrome enterprise policy overrides applied to all browsers in this pool. Keys are Chrome enterprise policy names; values must match their expected types. Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See @@ -261,6 +257,10 @@ def update( proxy_id: Optional proxy to associate to the browser session. Must reference a proxy belonging to the caller's org. + size: Number of browsers to maintain in the pool. The maximum size is determined by + your organization's pooled sessions limit (the sum of all pool sizes cannot + exceed your limit). + start_url: Optional URL to navigate to when a new browser is warmed into the pool. Best-effort: failures to navigate do not fail pool fill. Only applied to newly-warmed browsers; browsers reused via release/acquire keep whatever URL the @@ -300,7 +300,6 @@ def update( path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name), body=maybe_transform( { - "size": size, "chrome_policy": chrome_policy, "discard_all_idle": discard_all_idle, "extensions": extensions, @@ -310,6 +309,7 @@ def update( "name": name, "profile": profile, "proxy_id": proxy_id, + "size": size, "start_url": start_url, "stealth": stealth, "timeout_seconds": timeout_seconds, @@ -684,7 +684,6 @@ async def update( self, id_or_name: str, *, - size: int, chrome_policy: Dict[str, object] | Omit = omit, discard_all_idle: bool | Omit = omit, extensions: Iterable[BrowserExtension] | Omit = omit, @@ -694,6 +693,7 @@ async def update( name: str | Omit = omit, profile: BrowserProfile | Omit = omit, proxy_id: str | Omit = omit, + size: int | Omit = omit, start_url: str | Omit = omit, stealth: bool | Omit = omit, timeout_seconds: int | Omit = omit, @@ -709,10 +709,6 @@ async def update( Updates the configuration used to create browsers in the pool. Args: - size: Number of browsers to maintain in the pool. The maximum size is determined by - your organization's pooled sessions limit (the sum of all pool sizes cannot - exceed your limit). - chrome_policy: Custom Chrome enterprise policy overrides applied to all browsers in this pool. Keys are Chrome enterprise policy names; values must match their expected types. Blocked: kernel-managed policies (extensions, proxy, CDP/automation). See @@ -739,6 +735,10 @@ async def update( proxy_id: Optional proxy to associate to the browser session. Must reference a proxy belonging to the caller's org. + size: Number of browsers to maintain in the pool. The maximum size is determined by + your organization's pooled sessions limit (the sum of all pool sizes cannot + exceed your limit). + start_url: Optional URL to navigate to when a new browser is warmed into the pool. Best-effort: failures to navigate do not fail pool fill. Only applied to newly-warmed browsers; browsers reused via release/acquire keep whatever URL the @@ -778,7 +778,6 @@ async def update( path_template("/browser_pools/{id_or_name}", id_or_name=id_or_name), body=await async_maybe_transform( { - "size": size, "chrome_policy": chrome_policy, "discard_all_idle": discard_all_idle, "extensions": extensions, @@ -788,6 +787,7 @@ async def update( "name": name, "profile": profile, "proxy_id": proxy_id, + "size": size, "start_url": start_url, "stealth": stealth, "timeout_seconds": timeout_seconds, diff --git a/src/kernel/types/api_key_list_params.py b/src/kernel/types/api_key_list_params.py index e7d807f..79a9c41 100644 --- a/src/kernel/types/api_key_list_params.py +++ b/src/kernel/types/api_key_list_params.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing_extensions import TypedDict +from typing_extensions import Literal, TypedDict __all__ = ["APIKeyListParams"] @@ -13,3 +13,15 @@ class APIKeyListParams(TypedDict, total=False): offset: int """Number of results to skip""" + + query: str + """Case-insensitive substring match against API key name, creator, and project. + + API key identifiers and masked keys match by exact value or prefix. + """ + + sort_by: Literal["created_at", "name", "expires_at"] + """Field to sort API keys by.""" + + sort_direction: Literal["asc", "desc"] + """Sort direction for API keys.""" diff --git a/src/kernel/types/browser_pool_update_params.py b/src/kernel/types/browser_pool_update_params.py index 4efc9ce..0750169 100644 --- a/src/kernel/types/browser_pool_update_params.py +++ b/src/kernel/types/browser_pool_update_params.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Dict, Iterable -from typing_extensions import Required, TypedDict +from typing_extensions import TypedDict from .shared_params.browser_profile import BrowserProfile from .shared_params.browser_viewport import BrowserViewport @@ -13,13 +13,6 @@ class BrowserPoolUpdateParams(TypedDict, total=False): - size: Required[int] - """Number of browsers to maintain in the pool. - - The maximum size is determined by your organization's pooled sessions limit (the - sum of all pool sizes cannot exceed your limit). - """ - chrome_policy: Dict[str, object] """Custom Chrome enterprise policy overrides applied to all browsers in this pool. @@ -68,6 +61,13 @@ class BrowserPoolUpdateParams(TypedDict, total=False): Must reference a proxy belonging to the caller's org. """ + size: int + """Number of browsers to maintain in the pool. + + The maximum size is determined by your organization's pooled sessions limit (the + sum of all pool sizes cannot exceed your limit). + """ + start_url: str """Optional URL to navigate to when a new browser is warmed into the pool. diff --git a/tests/api_resources/test_api_keys.py b/tests/api_resources/test_api_keys.py index da854b2..9c3abca 100644 --- a/tests/api_resources/test_api_keys.py +++ b/tests/api_resources/test_api_keys.py @@ -162,6 +162,9 @@ def test_method_list_with_all_params(self, client: Kernel) -> None: api_key = client.api_keys.list( limit=100, offset=0, + query="query", + sort_by="created_at", + sort_direction="asc", ) assert_matches_type(SyncOffsetPagination[APIKey], api_key, path=["response"]) @@ -379,6 +382,9 @@ async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> N api_key = await async_client.api_keys.list( limit=100, offset=0, + query="query", + sort_by="created_at", + sort_direction="asc", ) assert_matches_type(AsyncOffsetPagination[APIKey], api_key, path=["response"]) diff --git a/tests/api_resources/test_browser_pools.py b/tests/api_resources/test_browser_pools.py index 959eeef..42f47e6 100644 --- a/tests/api_resources/test_browser_pools.py +++ b/tests/api_resources/test_browser_pools.py @@ -135,7 +135,6 @@ def test_path_params_retrieve(self, client: Kernel) -> None: def test_method_update(self, client: Kernel) -> None: browser_pool = client.browser_pools.update( id_or_name="id_or_name", - size=10, ) assert_matches_type(BrowserPool, browser_pool, path=["response"]) @@ -144,7 +143,6 @@ def test_method_update(self, client: Kernel) -> None: def test_method_update_with_all_params(self, client: Kernel) -> None: browser_pool = client.browser_pools.update( id_or_name="id_or_name", - size=10, chrome_policy={"foo": "bar"}, discard_all_idle=False, extensions=[ @@ -163,6 +161,7 @@ def test_method_update_with_all_params(self, client: Kernel) -> None: "save_changes": True, }, proxy_id="proxy_id", + size=10, start_url="https://example.com", stealth=True, timeout_seconds=60, @@ -179,7 +178,6 @@ def test_method_update_with_all_params(self, client: Kernel) -> None: def test_raw_response_update(self, client: Kernel) -> None: response = client.browser_pools.with_raw_response.update( id_or_name="id_or_name", - size=10, ) assert response.is_closed is True @@ -192,7 +190,6 @@ def test_raw_response_update(self, client: Kernel) -> None: def test_streaming_response_update(self, client: Kernel) -> None: with client.browser_pools.with_streaming_response.update( id_or_name="id_or_name", - size=10, ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -208,7 +205,6 @@ def test_path_params_update(self, client: Kernel) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id_or_name` but received ''"): client.browser_pools.with_raw_response.update( id_or_name="", - size=10, ) @pytest.mark.skip(reason="Mock server tests are disabled") @@ -559,7 +555,6 @@ async def test_path_params_retrieve(self, async_client: AsyncKernel) -> None: async def test_method_update(self, async_client: AsyncKernel) -> None: browser_pool = await async_client.browser_pools.update( id_or_name="id_or_name", - size=10, ) assert_matches_type(BrowserPool, browser_pool, path=["response"]) @@ -568,7 +563,6 @@ async def test_method_update(self, async_client: AsyncKernel) -> None: async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> None: browser_pool = await async_client.browser_pools.update( id_or_name="id_or_name", - size=10, chrome_policy={"foo": "bar"}, discard_all_idle=False, extensions=[ @@ -587,6 +581,7 @@ async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> "save_changes": True, }, proxy_id="proxy_id", + size=10, start_url="https://example.com", stealth=True, timeout_seconds=60, @@ -603,7 +598,6 @@ async def test_method_update_with_all_params(self, async_client: AsyncKernel) -> async def test_raw_response_update(self, async_client: AsyncKernel) -> None: response = await async_client.browser_pools.with_raw_response.update( id_or_name="id_or_name", - size=10, ) assert response.is_closed is True @@ -616,7 +610,6 @@ async def test_raw_response_update(self, async_client: AsyncKernel) -> None: async def test_streaming_response_update(self, async_client: AsyncKernel) -> None: async with async_client.browser_pools.with_streaming_response.update( id_or_name="id_or_name", - size=10, ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -632,7 +625,6 @@ async def test_path_params_update(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.browser_pools.with_raw_response.update( id_or_name="", - size=10, ) @pytest.mark.skip(reason="Mock server tests are disabled") diff --git a/tests/test_browser_routing.py b/tests/test_browser_routing.py index ac84152..6b0c12a 100644 --- a/tests/test_browser_routing.py +++ b/tests/test_browser_routing.py @@ -97,6 +97,28 @@ def test_browser_request_uses_curl_raw() -> None: assert request.url.params.get("jwt") == "token-abc" +@respx.mock +def test_telemetry_stream_routes_directly_to_vm(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", "telemetry") + route = respx.get("http://browser-session.test/browser/kernel/telemetry/stream").mock( + return_value=httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=b'id: 1\ndata: {"category":"api"}\n\n', + ) + ) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + _cache_browser(client) + stream = client.browsers.telemetry.stream("sess-1") + stream.close() + + assert route.called + request = cast(httpx.Request, cast(Any, route.calls[0]).request) + assert request.url.path == "/browser/kernel/telemetry/stream" + assert request.url.params.get("jwt") == "token-abc" + assert request.headers.get("Authorization") is None + + @respx.mock def test_browser_request_params_cannot_override_target_url_or_jwt() -> None: route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( @@ -315,7 +337,7 @@ def test_browser_route_from_browser_requires_base_url_and_jwt() -> None: def test_browser_routing_config_from_env_defaults_to_curl(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False) - assert browser_routing_config_from_env().subresources == ("curl",) + assert browser_routing_config_from_env().subresources == ("curl", "telemetry") def test_browser_routing_config_from_env_empty_string_disables_routing(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 4b8e4e4..c2ebd43 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -26,6 +26,32 @@ def body() -> Iterator[bytes]: await assert_empty_iter(iterator) +@pytest.mark.asyncio +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +async def test_keepalive_comment_after_event_with_id(sync: bool, client: Kernel, async_client: AsyncKernel) -> None: + # A ``:`` comment frame (the server's SSE keepalive) that arrives after an event which set an + # id must be ignored, not dispatched as an empty event. last_event_id is sticky, so this is a + # regression guard against it leaking an undecodable empty frame into the typed stream. + def body() -> Iterator[bytes]: + yield b"id: 1\n" + yield b'data: {"foo":true}\n' + yield b"\n" + yield b":\n" + yield b"\n" + yield b'data: {"bar":false}\n' + yield b"\n" + + iterator = make_event_iterator(content=body(), sync=sync, client=client, async_client=async_client) + + sse = await iter_next(iterator) + assert sse.json() == {"foo": True} + + sse = await iter_next(iterator) + assert sse.json() == {"bar": False} + + await assert_empty_iter(iterator) + + @pytest.mark.asyncio @pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) async def test_data_missing_event(sync: bool, client: Kernel, async_client: AsyncKernel) -> None: