Skip to content

Commit 9817c9f

Browse files
authored
Merge pull request #93 from kernel/raf/browser-scoped-client
feat: add browser routing cache
2 parents 0ccb507 + 5328730 commit 9817c9f

8 files changed

Lines changed: 1014 additions & 2 deletions

File tree

examples/browser_routing.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Example: direct-to-VM browser routing for process exec and raw HTTP."""
2+
3+
from typing import Any, cast
4+
5+
import httpx
6+
7+
from kernel import Kernel
8+
9+
10+
def main() -> None:
11+
with Kernel() as client:
12+
browsers = cast(Any, client.browsers)
13+
browser = browsers.create(headless=True)
14+
try:
15+
response = cast(httpx.Response, browsers.request(browser.session_id, "GET", "https://example.com"))
16+
print("status", response.status_code)
17+
18+
with browsers.stream(browser.session_id, "GET", "https://example.com") as streamed:
19+
print("streamed-bytes", len(streamed.read()))
20+
finally:
21+
browsers.delete_by_id(browser.session_id)
22+
23+
24+
if __name__ == "__main__":
25+
main()

src/kernel/_client.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import os
6-
from typing import TYPE_CHECKING, Any, Dict, Mapping, cast
6+
from typing import TYPE_CHECKING, Any, Dict, Type, Mapping, cast
77
from typing_extensions import Self, Literal, override
88

99
import httpx
@@ -14,13 +14,15 @@
1414
Omit,
1515
Timeout,
1616
NotGiven,
17+
ResponseT,
1718
Transport,
1819
ProxiesTypes,
1920
RequestOptions,
2021
not_given,
2122
)
2223
from ._utils import is_given, get_async_library
2324
from ._compat import cached_property
25+
from ._models import FinalRequestOptions
2426
from ._version import __version__
2527
from ._streaming import Stream as Stream, AsyncStream as AsyncStream
2628
from ._exceptions import KernelError, APIStatusError
@@ -29,6 +31,15 @@
2931
SyncAPIClient,
3032
AsyncAPIClient,
3133
)
34+
from .lib.browser_routing.routing import (
35+
BrowserRouteCache,
36+
BrowserRoutingConfig,
37+
strip_direct_vm_auth,
38+
rewrite_direct_vm_options,
39+
browser_routing_config_from_env,
40+
maybe_evict_browser_route_from_response,
41+
maybe_populate_browser_route_cache_from_response,
42+
)
3243

3344
if TYPE_CHECKING:
3445
from .resources import (
@@ -79,8 +90,10 @@
7990
class Kernel(SyncAPIClient):
8091
# client options
8192
api_key: str
93+
browser_route_cache: BrowserRouteCache
8294

8395
_environment: Literal["production", "development"] | NotGiven
96+
_browser_routing: BrowserRoutingConfig
8497

8598
def __init__(
8699
self,
@@ -105,6 +118,7 @@ def __init__(
105118
# outlining your use-case to help us decide if it should be
106119
# part of our public interface in the future.
107120
_strict_response_validation: bool = False,
121+
_browser_route_cache: BrowserRouteCache | None = None,
108122
) -> None:
109123
"""Construct a new synchronous Kernel client instance.
110124
@@ -154,6 +168,8 @@ def __init__(
154168
custom_query=default_query,
155169
_strict_response_validation=_strict_response_validation,
156170
)
171+
self.browser_route_cache = _browser_route_cache or BrowserRouteCache()
172+
self._browser_routing = browser_routing_config_from_env()
157173

158174
@cached_property
159175
def deployments(self) -> DeploymentsResource:
@@ -266,6 +282,37 @@ def default_headers(self) -> dict[str, str | Omit]:
266282
**self._custom_headers,
267283
}
268284

285+
@override
286+
def _prepare_options(self, options: Any) -> Any:
287+
options = cast(Any, super()._prepare_options(options))
288+
return rewrite_direct_vm_options(options, cache=self.browser_route_cache, config=self._browser_routing)
289+
290+
@override
291+
def _prepare_request(self, request: httpx.Request) -> None:
292+
strip_direct_vm_auth(request, cache=self.browser_route_cache)
293+
294+
@override
295+
def _process_response(
296+
self,
297+
*,
298+
cast_to: Type[ResponseT],
299+
options: FinalRequestOptions,
300+
response: httpx.Response,
301+
stream: bool,
302+
stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
303+
retries_taken: int = 0,
304+
) -> ResponseT:
305+
maybe_populate_browser_route_cache_from_response(response, cache=self.browser_route_cache)
306+
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
307+
return super()._process_response(
308+
cast_to=cast_to,
309+
options=options,
310+
response=response,
311+
stream=stream,
312+
stream_cls=stream_cls,
313+
retries_taken=retries_taken,
314+
)
315+
269316
def copy(
270317
self,
271318
*,
@@ -279,6 +326,7 @@ def copy(
279326
set_default_headers: Mapping[str, str] | None = None,
280327
default_query: Mapping[str, object] | None = None,
281328
set_default_query: Mapping[str, object] | None = None,
329+
_browser_route_cache: BrowserRouteCache | None = None,
282330
_extra_kwargs: Mapping[str, Any] = {},
283331
) -> Self:
284332
"""
@@ -312,6 +360,7 @@ def copy(
312360
max_retries=max_retries if is_given(max_retries) else self.max_retries,
313361
default_headers=headers,
314362
default_query=params,
363+
_browser_route_cache=_browser_route_cache or self.browser_route_cache,
315364
**_extra_kwargs,
316365
)
317366

@@ -356,8 +405,10 @@ def _make_status_error(
356405
class AsyncKernel(AsyncAPIClient):
357406
# client options
358407
api_key: str
408+
browser_route_cache: BrowserRouteCache
359409

360410
_environment: Literal["production", "development"] | NotGiven
411+
_browser_routing: BrowserRoutingConfig
361412

362413
def __init__(
363414
self,
@@ -382,6 +433,7 @@ def __init__(
382433
# outlining your use-case to help us decide if it should be
383434
# part of our public interface in the future.
384435
_strict_response_validation: bool = False,
436+
_browser_route_cache: BrowserRouteCache | None = None,
385437
) -> None:
386438
"""Construct a new async AsyncKernel client instance.
387439
@@ -431,6 +483,8 @@ def __init__(
431483
custom_query=default_query,
432484
_strict_response_validation=_strict_response_validation,
433485
)
486+
self.browser_route_cache = _browser_route_cache or BrowserRouteCache()
487+
self._browser_routing = browser_routing_config_from_env()
434488

435489
@cached_property
436490
def deployments(self) -> AsyncDeploymentsResource:
@@ -543,6 +597,37 @@ def default_headers(self) -> dict[str, str | Omit]:
543597
**self._custom_headers,
544598
}
545599

600+
@override
601+
async def _prepare_options(self, options: Any) -> Any:
602+
options = cast(Any, await super()._prepare_options(options))
603+
return rewrite_direct_vm_options(options, cache=self.browser_route_cache, config=self._browser_routing)
604+
605+
@override
606+
async def _prepare_request(self, request: httpx.Request) -> None:
607+
strip_direct_vm_auth(request, cache=self.browser_route_cache)
608+
609+
@override
610+
async def _process_response(
611+
self,
612+
*,
613+
cast_to: Type[ResponseT],
614+
options: FinalRequestOptions,
615+
response: httpx.Response,
616+
stream: bool,
617+
stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
618+
retries_taken: int = 0,
619+
) -> ResponseT:
620+
maybe_populate_browser_route_cache_from_response(response, cache=self.browser_route_cache)
621+
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
622+
return await super()._process_response(
623+
cast_to=cast_to,
624+
options=options,
625+
response=response,
626+
stream=stream,
627+
stream_cls=stream_cls,
628+
retries_taken=retries_taken,
629+
)
630+
546631
def copy(
547632
self,
548633
*,
@@ -556,6 +641,7 @@ def copy(
556641
set_default_headers: Mapping[str, str] | None = None,
557642
default_query: Mapping[str, object] | None = None,
558643
set_default_query: Mapping[str, object] | None = None,
644+
_browser_route_cache: BrowserRouteCache | None = None,
559645
_extra_kwargs: Mapping[str, Any] = {},
560646
) -> Self:
561647
"""
@@ -589,6 +675,7 @@ def copy(
589675
max_retries=max_retries if is_given(max_retries) else self.max_retries,
590676
default_headers=headers,
591677
default_query=params,
678+
_browser_route_cache=_browser_route_cache or self.browser_route_cache,
592679
**_extra_kwargs,
593680
)
594681

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from __future__ import annotations
2+
3+
__all__: list[str] = []
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from __future__ import annotations
2+
3+
from typing import IO, Any, Union, Mapping, cast
4+
from contextlib import contextmanager, asynccontextmanager
5+
from collections.abc import Iterable, Iterator, AsyncIterator
6+
7+
import httpx
8+
9+
from .util import sanitize_curl_raw_params
10+
from .routing import BrowserRoute
11+
from ..._types import Body, Timeout, NotGiven, not_given
12+
from ..._models import FinalRequestOptions
13+
14+
BrowserRawContent = Union[bytes, bytearray, memoryview, str, IO[bytes], Iterable[bytes]]
15+
16+
17+
def request_via_browser_route(
18+
parent: Any,
19+
route: BrowserRoute,
20+
method: str,
21+
url: str,
22+
*,
23+
content: BrowserRawContent | None = None,
24+
json: Body | None = None,
25+
headers: Mapping[str, str] | None = None,
26+
params: Mapping[str, object] | None = None,
27+
timeout: float | Timeout | None | NotGiven = not_given,
28+
) -> httpx.Response:
29+
if json is not None and content is not None:
30+
raise TypeError("Passing both `json` and `content` is not supported")
31+
query: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url, "jwt": route.jwt}
32+
options = FinalRequestOptions.construct(
33+
method=method.upper(),
34+
url=route.base_url.rstrip("/") + "/curl/raw",
35+
params=query,
36+
headers=headers or {},
37+
content=_normalize_binary_content(content),
38+
json_data=json,
39+
timeout=_normalize_timeout(timeout),
40+
)
41+
return cast(httpx.Response, parent.request(httpx.Response, options))
42+
43+
44+
@contextmanager
45+
def stream_via_browser_route(
46+
parent: Any,
47+
route: BrowserRoute,
48+
method: str,
49+
url: str,
50+
*,
51+
content: BrowserRawContent | None = None,
52+
headers: Mapping[str, str] | None = None,
53+
params: Mapping[str, object] | None = None,
54+
timeout: float | Timeout | None | NotGiven = not_given,
55+
) -> Iterator[httpx.Response]:
56+
query: dict[str, Any] = sanitize_curl_raw_params(params)
57+
query["jwt"] = route.jwt
58+
query["url"] = url
59+
request_headers = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)}
60+
if content is None:
61+
request_headers.pop("Content-Type", None)
62+
if headers:
63+
request_headers.update(headers)
64+
request_headers.pop("Authorization", None)
65+
effective_timeout = parent.timeout if isinstance(timeout, NotGiven) else timeout
66+
with parent._client.stream(
67+
method.upper(),
68+
route.base_url.rstrip("/") + "/curl/raw",
69+
params=query,
70+
headers=request_headers,
71+
content=_normalize_binary_content(content),
72+
timeout=_normalize_timeout(effective_timeout),
73+
) as response:
74+
yield response
75+
76+
77+
async def async_request_via_browser_route(
78+
parent: Any,
79+
route: BrowserRoute,
80+
method: str,
81+
url: str,
82+
*,
83+
content: BrowserRawContent | None = None,
84+
json: Body | None = None,
85+
headers: Mapping[str, str] | None = None,
86+
params: Mapping[str, object] | None = None,
87+
timeout: float | Timeout | None | NotGiven = not_given,
88+
) -> httpx.Response:
89+
if json is not None and content is not None:
90+
raise TypeError("Passing both `json` and `content` is not supported")
91+
query: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url, "jwt": route.jwt}
92+
options = FinalRequestOptions.construct(
93+
method=method.upper(),
94+
url=route.base_url.rstrip("/") + "/curl/raw",
95+
params=query,
96+
headers=headers or {},
97+
content=_normalize_binary_content(content),
98+
json_data=json,
99+
timeout=_normalize_timeout(timeout),
100+
)
101+
return cast(httpx.Response, await parent.request(httpx.Response, options))
102+
103+
104+
@asynccontextmanager
105+
async def async_stream_via_browser_route(
106+
parent: Any,
107+
route: BrowserRoute,
108+
method: str,
109+
url: str,
110+
*,
111+
content: BrowserRawContent | None = None,
112+
headers: Mapping[str, str] | None = None,
113+
params: Mapping[str, object] | None = None,
114+
timeout: float | Timeout | None | NotGiven = not_given,
115+
) -> AsyncIterator[httpx.Response]:
116+
query: dict[str, Any] = sanitize_curl_raw_params(params)
117+
query["jwt"] = route.jwt
118+
query["url"] = url
119+
request_headers = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)}
120+
if content is None:
121+
request_headers.pop("Content-Type", None)
122+
if headers:
123+
request_headers.update(headers)
124+
request_headers.pop("Authorization", None)
125+
effective_timeout = parent.timeout if isinstance(timeout, NotGiven) else timeout
126+
async with parent._client.stream(
127+
method.upper(),
128+
route.base_url.rstrip("/") + "/curl/raw",
129+
params=query,
130+
headers=request_headers,
131+
content=_normalize_binary_content(content),
132+
timeout=_normalize_timeout(effective_timeout),
133+
) as response:
134+
yield response
135+
136+
137+
def _normalize_timeout(timeout: float | Timeout | None | NotGiven) -> float | Timeout | None:
138+
return None if isinstance(timeout, NotGiven) else timeout
139+
140+
141+
def _normalize_binary_content(content: BrowserRawContent | None) -> bytes | IO[bytes] | Iterable[bytes] | None:
142+
if content is None:
143+
return None
144+
if isinstance(content, str):
145+
return content.encode()
146+
if isinstance(content, bytearray):
147+
return bytes(content)
148+
if isinstance(content, memoryview):
149+
return content.tobytes()
150+
return content

0 commit comments

Comments
 (0)