Skip to content
Merged
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.51.0"
".": "0.52.0"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 112
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-a674e3c4c0063942621d1b4e7f67b72f7e240c12dd88564fe16627618ba33dd6.yml
openapi_spec_hash: 8b97c87f0dafe5fc5e5a7365f3687755
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-4ce09d1a7546ab36f578cb27d819187eeb90c580b11834c7ff7a375aa22f9a20.yml
openapi_spec_hash: 1043ab2d699f6c828680c3352cd4cece
config_hash: 08d55086449943a8fec212b870061a3f
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## 0.52.0 (2026-04-29)

Full Changelog: [v0.51.0...v0.52.0](https://github.com/kernel/kernel-python-sdk/compare/v0.51.0...v0.52.0)

### Features

* profile download: 409 for empty profile + surface API errors in dashboard ([fd0c20e](https://github.com/kernel/kernel-python-sdk/commit/fd0c20eb17f0a6df382672c34830a81fac9217d9))
* support setting headers via env ([37dda88](https://github.com/kernel/kernel-python-sdk/commit/37dda88a1f88fdf4bd8da615cbb6be1aadae3f41))


### Bug Fixes

* use correct field name format for multipart file arrays ([663f0a8](https://github.com/kernel/kernel-python-sdk/commit/663f0a8a633fd5b0f5f8b65ed169c4d780251e1d))


### Documentation

* annotate response with httpx.Response in browser routing example ([0dfa1fb](https://github.com/kernel/kernel-python-sdk/commit/0dfa1fb978da1c4fd474840ae8664f027e1feb39))
* print buffered body and mention httpx.Response semantics ([84cb2e3](https://github.com/kernel/kernel-python-sdk/commit/84cb2e38486f330635487ed049d692f7c9716a1f))
* show both raw streaming and buffered curl in routing example ([407e748](https://github.com/kernel/kernel-python-sdk/commit/407e74862f99db13320ea6c02a8752759d4de60a))
* simplify browser routing example ([58b0ee2](https://github.com/kernel/kernel-python-sdk/commit/58b0ee27cdac1f4021c6e333f8157318c838ec56))

## 0.51.0 (2026-04-25)

Full Changelog: [v0.50.0...v0.51.0](https://github.com/kernel/kernel-python-sdk/compare/v0.50.0...v0.51.0)
Expand Down
29 changes: 15 additions & 14 deletions examples/browser_routing.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
"""Example: direct-to-VM browser routing for process exec and raw HTTP."""

from typing import Any, cast
"""Example: direct-to-VM browser routing for raw HTTP."""

import httpx

from kernel import Kernel


def main() -> None:
with Kernel() as client:
browsers = cast(Any, client.browsers)
browser = browsers.create(headless=True)
try:
response = cast(httpx.Response, browsers.request(browser.session_id, "GET", "https://example.com"))
print("status", response.status_code)

with browsers.stream(browser.session_id, "GET", "https://example.com") as streamed:
print("streamed-bytes", len(streamed.read()))
finally:
browsers.delete_by_id(browser.session_id)
client = Kernel()

browser = client.browsers.create()

# Raw browser curl: streams the response. Use for large responses, when you want to stream,
# or when you want httpx.Response semantics.
response: httpx.Response = client.browsers.request(browser.session_id, "GET", "https://example.com")
print("status", response.status_code)

# Buffered browser curl: returns the full response in a JSON envelope. Use for small responses.
buffered = client.browsers.curl(browser.session_id, url="https://example.com", method="GET")
print("body", buffered.body)

client.browsers.delete_by_id(browser.session_id)


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "kernel"
version = "0.51.0"
version = "0.52.0"
description = "The official Python library for the kernel API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down
24 changes: 23 additions & 1 deletion src/kernel/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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 ._models import FinalRequestOptions
from ._version import __version__
Expand Down Expand Up @@ -158,6 +162,15 @@ def __init__(
except KeyError as exc:
raise ValueError(f"Unknown environment: {environment}") from exc

custom_headers_env = os.environ.get("KERNEL_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,
Expand Down Expand Up @@ -473,6 +486,15 @@ def __init__(
except KeyError as exc:
raise ValueError(f"Unknown environment: {environment}") from exc

custom_headers_env = os.environ.get("KERNEL_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,
Expand Down
8 changes: 2 additions & 6 deletions src/kernel/_qs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/kernel/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 34 additions & 8 deletions src/kernel/_utils/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...])
Expand All @@ -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', '<array>', 'data'].

``array_format`` controls how ``<array>`` 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]
Expand All @@ -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)
Expand Down Expand Up @@ -106,6 +128,7 @@ def _extract_items(
path,
index=index,
flattened_key=flattened_key,
array_format=array_format,
)
elif is_list(obj):
if key != "<array>":
Expand All @@ -117,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)
]
)

Expand Down
2 changes: 1 addition & 1 deletion src/kernel/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "kernel"
__version__ = "0.51.0" # x-release-please-version
__version__ = "0.52.0" # x-release-please-version
12 changes: 4 additions & 8 deletions src/kernel/resources/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,8 @@ def download(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> BinaryAPIResponse:
"""Download the profile.

Profiles are JSON files containing the pieces of state
that we save.
"""
Returns a zstd-compressed tar file of the full user-data directory.

Args:
extra_headers: Send extra headers
Expand Down Expand Up @@ -428,10 +426,8 @@ async def download(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> AsyncBinaryAPIResponse:
"""Download the profile.

Profiles are JSON files containing the pieces of state
that we save.
"""
Returns a zstd-compressed tar file of the full user-data directory.

Args:
extra_headers: Send extra headers
Expand Down
28 changes: 23 additions & 5 deletions tests/test_extract_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from kernel._types import FileTypes
from kernel._types import FileTypes, ArrayFormat
from kernel._utils import extract_files


Expand Down Expand Up @@ -37,10 +37,7 @@ def test_multiple_files() -> None:

def test_top_level_file_array() -> None:
query = {"files": [b"file one", b"file two"], "title": "hello"}
assert extract_files(query, paths=[["files", "<array>"]]) == [
("files[]", b"file one"),
("files[]", b"file two"),
]
assert extract_files(query, paths=[["files", "<array>"]]) == [("files[]", b"file one"), ("files[]", b"file two")]
assert query == {"title": "hello"}


Expand Down Expand Up @@ -71,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>"]], array_format=array_format) == expected_top_level

nested = {"items": [{"file": b"a"}, {"file": b"b"}]}
assert extract_files(nested, paths=[["items", "<array>", "file"]], array_format=array_format) == expected_nested
2 changes: 1 addition & 1 deletion tests/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None:
copied = deepcopy_with_paths(original, [["items", "<array>", "file"]])
extracted = extract_files(copied, paths=[["items", "<array>", "file"]])

assert extracted == [("items[][file]", file1), ("items[][file]", file2)]
assert [entry for _, entry in extracted] == [file1, file2]
assert original == {
"items": [
{"file": file1, "extra": 1},
Expand Down