From 3e477ae1a26344b094ad24a8a57d7d20239ddf87 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:23:41 +0000 Subject: [PATCH 1/4] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index ad1b0f2..6b88334 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 111 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-49a1a92e00d1eb87e91e8527275cb0705fce2edea30e70fea745f134dd451fbd.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-7d048a0d07483d4fa8d1094f5ec172d1758f044b4e5ced1f41f92f1de8b47def.yml openapi_spec_hash: 0ffef6a95f9d9b1096180fc5e4c5b39c config_hash: 9818dd634f87b677410eefd013d7a179 From e566aa50a9022d5b284c6334b3704df2a96643cc Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 05:25:50 +0000 Subject: [PATCH 2/4] fix: ensure file data are only sent as 1 parameter --- src/kernel/_utils/_utils.py | 5 +++-- tests/test_extract_files.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/kernel/_utils/_utils.py b/src/kernel/_utils/_utils.py index eec7f4a..63b8cd6 100644 --- a/src/kernel/_utils/_utils.py +++ b/src/kernel/_utils/_utils.py @@ -86,8 +86,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] diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index e5cf4a1..14a3932 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -35,6 +35,15 @@ 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", [ From e91bc387e5ef74c1b02f62e19e9ae31867296af4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:23:15 +0000 Subject: [PATCH 3/4] feat: add POST /browsers/{id}/curl and /curl/raw endpoints --- .stats.yml | 8 +- api.md | 2 + src/kernel/resources/browsers/browsers.py | 144 +++++++++++++++++++++- src/kernel/types/__init__.py | 2 + src/kernel/types/browser_curl_params.py | 28 +++++ src/kernel/types/browser_curl_response.py | 23 ++++ tests/api_resources/test_browsers.py | 121 ++++++++++++++++++ 7 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 src/kernel/types/browser_curl_params.py create mode 100644 src/kernel/types/browser_curl_response.py diff --git a/.stats.yml b/.stats.yml index 6b88334..f0ba92f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 111 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-7d048a0d07483d4fa8d1094f5ec172d1758f044b4e5ced1f41f92f1de8b47def.yml -openapi_spec_hash: 0ffef6a95f9d9b1096180fc5e4c5b39c -config_hash: 9818dd634f87b677410eefd013d7a179 +configured_endpoints: 112 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-930823e8b25b4644b74098ad5479840f64a329321aa236460f8a9562ae9051bf.yml +openapi_spec_hash: 9f868e67df8fd2fec8d8fc3eb5ba0b26 +config_hash: 08d55086449943a8fec212b870061a3f diff --git a/api.md b/api.md index 96c90c4..3dea16a 100644 --- a/api.md +++ b/api.md @@ -88,6 +88,7 @@ from kernel.types import ( BrowserRetrieveResponse, BrowserUpdateResponse, BrowserListResponse, + BrowserCurlResponse, ) ``` @@ -98,6 +99,7 @@ Methods: - client.browsers.update(id, \*\*params) -> BrowserUpdateResponse - client.browsers.list(\*\*params) -> SyncOffsetPagination[BrowserListResponse] - client.browsers.delete(\*\*params) -> None +- client.browsers.curl(id, \*\*params) -> BrowserCurlResponse - client.browsers.delete_by_id(id) -> None - client.browsers.load_extensions(id, \*\*params) -> None diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py index 1e20142..3fc1049 100644 --- a/src/kernel/resources/browsers/browsers.py +++ b/src/kernel/resources/browsers/browsers.py @@ -3,7 +3,7 @@ from __future__ import annotations import typing_extensions -from typing import Mapping, Iterable, Optional, cast +from typing import Dict, Mapping, Iterable, Optional, cast from typing_extensions import Literal import httpx @@ -25,6 +25,7 @@ AsyncFsResourceWithStreamingResponse, ) from ...types import ( + browser_curl_params, browser_list_params, browser_create_params, browser_delete_params, @@ -76,6 +77,7 @@ ) from ...pagination import SyncOffsetPagination, AsyncOffsetPagination from ..._base_client import AsyncPaginator, make_request_options +from ...types.browser_curl_response import BrowserCurlResponse from ...types.browser_list_response import BrowserListResponse from ...types.browser_create_response import BrowserCreateResponse from ...types.browser_update_response import BrowserUpdateResponse @@ -443,6 +445,70 @@ def delete( cast_to=NoneType, ) + def curl( + self, + id: str, + *, + url: str, + body: str | Omit = omit, + headers: Dict[str, str] | Omit = omit, + method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] | Omit = omit, + response_encoding: Literal["utf8", "base64"] | Omit = omit, + timeout_ms: 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, + ) -> BrowserCurlResponse: + """ + Sends an HTTP request through Chrome's HTTP request stack, inheriting the + browser's TLS fingerprint, cookies, proxy configuration, and headers. Returns a + structured JSON response with status, headers, body, and timing. + + Args: + url: Target URL (must be http or https). + + body: Request body (for POST/PUT/PATCH). + + headers: Custom headers merged with browser defaults. + + method: HTTP method. + + response_encoding: Encoding for the response body. Use base64 for binary content. + + timeout_ms: Request timeout in milliseconds. + + 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._post( + path_template("/browsers/{id}/curl", id=id), + body=maybe_transform( + { + "url": url, + "body": body, + "headers": headers, + "method": method, + "response_encoding": response_encoding, + "timeout_ms": timeout_ms, + }, + browser_curl_params.BrowserCurlParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserCurlResponse, + ) + def delete_by_id( self, id: str, @@ -881,6 +947,70 @@ async def delete( cast_to=NoneType, ) + async def curl( + self, + id: str, + *, + url: str, + body: str | Omit = omit, + headers: Dict[str, str] | Omit = omit, + method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] | Omit = omit, + response_encoding: Literal["utf8", "base64"] | Omit = omit, + timeout_ms: 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, + ) -> BrowserCurlResponse: + """ + Sends an HTTP request through Chrome's HTTP request stack, inheriting the + browser's TLS fingerprint, cookies, proxy configuration, and headers. Returns a + structured JSON response with status, headers, body, and timing. + + Args: + url: Target URL (must be http or https). + + body: Request body (for POST/PUT/PATCH). + + headers: Custom headers merged with browser defaults. + + method: HTTP method. + + response_encoding: Encoding for the response body. Use base64 for binary content. + + timeout_ms: Request timeout in milliseconds. + + 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._post( + path_template("/browsers/{id}/curl", id=id), + body=await async_maybe_transform( + { + "url": url, + "body": body, + "headers": headers, + "method": method, + "response_encoding": response_encoding, + "timeout_ms": timeout_ms, + }, + browser_curl_params.BrowserCurlParams, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=BrowserCurlResponse, + ) + async def delete_by_id( self, id: str, @@ -983,6 +1113,9 @@ def __init__(self, browsers: BrowsersResource) -> None: browsers.delete, # pyright: ignore[reportDeprecated], ) ) + self.curl = to_raw_response_wrapper( + browsers.curl, + ) self.delete_by_id = to_raw_response_wrapper( browsers.delete_by_id, ) @@ -1041,6 +1174,9 @@ def __init__(self, browsers: AsyncBrowsersResource) -> None: browsers.delete, # pyright: ignore[reportDeprecated], ) ) + self.curl = async_to_raw_response_wrapper( + browsers.curl, + ) self.delete_by_id = async_to_raw_response_wrapper( browsers.delete_by_id, ) @@ -1099,6 +1235,9 @@ def __init__(self, browsers: BrowsersResource) -> None: browsers.delete, # pyright: ignore[reportDeprecated], ) ) + self.curl = to_streamed_response_wrapper( + browsers.curl, + ) self.delete_by_id = to_streamed_response_wrapper( browsers.delete_by_id, ) @@ -1157,6 +1296,9 @@ def __init__(self, browsers: AsyncBrowsersResource) -> None: browsers.delete, # pyright: ignore[reportDeprecated], ) ) + self.curl = async_to_streamed_response_wrapper( + browsers.curl, + ) self.delete_by_id = async_to_streamed_response_wrapper( browsers.delete_by_id, ) diff --git a/src/kernel/types/__init__.py b/src/kernel/types/__init__.py index 91e68d6..3838047 100644 --- a/src/kernel/types/__init__.py +++ b/src/kernel/types/__init__.py @@ -22,6 +22,7 @@ from .browser_pool_ref import BrowserPoolRef as BrowserPoolRef from .app_list_response import AppListResponse as AppListResponse from .proxy_check_params import ProxyCheckParams as ProxyCheckParams +from .browser_curl_params import BrowserCurlParams as BrowserCurlParams from .browser_list_params import BrowserListParams as BrowserListParams from .browser_persistence import BrowserPersistence as BrowserPersistence from .credential_provider import CredentialProvider as CredentialProvider @@ -31,6 +32,7 @@ from .proxy_list_response import ProxyListResponse as ProxyListResponse from .proxy_check_response import ProxyCheckResponse as ProxyCheckResponse from .browser_create_params import BrowserCreateParams as BrowserCreateParams +from .browser_curl_response import BrowserCurlResponse as BrowserCurlResponse from .browser_delete_params import BrowserDeleteParams as BrowserDeleteParams from .browser_list_response import BrowserListResponse as BrowserListResponse from .browser_update_params import BrowserUpdateParams as BrowserUpdateParams diff --git a/src/kernel/types/browser_curl_params.py b/src/kernel/types/browser_curl_params.py new file mode 100644 index 0000000..750bd6d --- /dev/null +++ b/src/kernel/types/browser_curl_params.py @@ -0,0 +1,28 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["BrowserCurlParams"] + + +class BrowserCurlParams(TypedDict, total=False): + url: Required[str] + """Target URL (must be http or https).""" + + body: str + """Request body (for POST/PUT/PATCH).""" + + headers: Dict[str, str] + """Custom headers merged with browser defaults.""" + + method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + """HTTP method.""" + + response_encoding: Literal["utf8", "base64"] + """Encoding for the response body. Use base64 for binary content.""" + + timeout_ms: int + """Request timeout in milliseconds.""" diff --git a/src/kernel/types/browser_curl_response.py b/src/kernel/types/browser_curl_response.py new file mode 100644 index 0000000..1b288e4 --- /dev/null +++ b/src/kernel/types/browser_curl_response.py @@ -0,0 +1,23 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Dict, List + +from .._models import BaseModel + +__all__ = ["BrowserCurlResponse"] + + +class BrowserCurlResponse(BaseModel): + """Structured response from the browser curl request.""" + + body: str + """Response body (UTF-8 string or base64 depending on request).""" + + duration_ms: int + """Total request duration in milliseconds.""" + + headers: Dict[str, List[str]] + """Response headers (multi-value).""" + + status: int + """HTTP status code from target.""" diff --git a/tests/api_resources/test_browsers.py b/tests/api_resources/test_browsers.py index 72c7b02..96742c7 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 ( + BrowserCurlResponse, BrowserListResponse, BrowserCreateResponse, BrowserUpdateResponse, @@ -276,6 +277,66 @@ def test_streaming_response_delete(self, client: Kernel) -> None: assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_curl(self, client: Kernel) -> None: + browser = client.browsers.curl( + id="id", + url="url", + ) + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_curl_with_all_params(self, client: Kernel) -> None: + browser = client.browsers.curl( + id="id", + url="url", + body="body", + headers={"foo": "string"}, + method="GET", + response_encoding="utf8", + timeout_ms=1000, + ) + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_curl(self, client: Kernel) -> None: + response = client.browsers.with_raw_response.curl( + id="id", + url="url", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = response.parse() + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_curl(self, client: Kernel) -> None: + with client.browsers.with_streaming_response.curl( + id="id", + url="url", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = response.parse() + assert_matches_type(BrowserCurlResponse, 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_curl(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.browsers.with_raw_response.curl( + id="", + url="url", + ) + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize def test_method_delete_by_id(self, client: Kernel) -> None: @@ -641,6 +702,66 @@ async def test_streaming_response_delete(self, async_client: AsyncKernel) -> Non assert cast(Any, response.is_closed) is True + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_curl(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.curl( + id="id", + url="url", + ) + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_curl_with_all_params(self, async_client: AsyncKernel) -> None: + browser = await async_client.browsers.curl( + id="id", + url="url", + body="body", + headers={"foo": "string"}, + method="GET", + response_encoding="utf8", + timeout_ms=1000, + ) + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_curl(self, async_client: AsyncKernel) -> None: + response = await async_client.browsers.with_raw_response.curl( + id="id", + url="url", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + browser = await response.parse() + assert_matches_type(BrowserCurlResponse, browser, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_curl(self, async_client: AsyncKernel) -> None: + async with async_client.browsers.with_streaming_response.curl( + id="id", + url="url", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + browser = await response.parse() + assert_matches_type(BrowserCurlResponse, 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_curl(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.browsers.with_raw_response.curl( + id="", + url="url", + ) + @pytest.mark.skip(reason="Mock server tests are disabled") @parametrize async def test_method_delete_by_id(self, async_client: AsyncKernel) -> None: From 31685e88af6b1f749fe62ebf4704bead55e4b5ac Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:24:14 +0000 Subject: [PATCH 4/4] release: 0.50.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- src/kernel/_version.py | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index dd7ced1..26b1ce2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.49.0" + ".": "0.50.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cf3d652..0194846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.50.0 (2026-04-13) + +Full Changelog: [v0.49.0...v0.50.0](https://github.com/kernel/kernel-python-sdk/compare/v0.49.0...v0.50.0) + +### Features + +* add POST /browsers/{id}/curl and /curl/raw endpoints ([e91bc38](https://github.com/kernel/kernel-python-sdk/commit/e91bc387e5ef74c1b02f62e19e9ae31867296af4)) + + +### Bug Fixes + +* ensure file data are only sent as 1 parameter ([e566aa5](https://github.com/kernel/kernel-python-sdk/commit/e566aa50a9022d5b284c6334b3704df2a96643cc)) + ## 0.49.0 (2026-04-10) Full Changelog: [v0.48.0...v0.49.0](https://github.com/kernel/kernel-python-sdk/compare/v0.48.0...v0.49.0) diff --git a/pyproject.toml b/pyproject.toml index 35efdc5..5516926 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kernel" -version = "0.49.0" +version = "0.50.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 443cf3f..81d0216 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.49.0" # x-release-please-version +__version__ = "0.50.0" # x-release-please-version