Skip to content

Commit 888659c

Browse files
committed
feat: add browser-scoped session client
Bind browser subresource calls to a browser session's base_url and expose raw HTTP through request and stream helpers so metro-routed access feels like normal httpx usage. Made-with: Cursor
1 parent e566aa5 commit 888659c

File tree

7 files changed

+566
-0
lines changed

7 files changed

+566
-0
lines changed

examples/browser_scoped.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Example: browser-scoped client for metro-backed process and raw HTTP."""
2+
3+
from kernel import Kernel
4+
5+
# After creating or loading a browser session (with base_url + cdp_ws_url from the API):
6+
# browser = client.browsers.create(...)
7+
# scoped = client.for_browser(browser)
8+
# scoped.process.exec(command="uname", args=["-a"])
9+
# r = scoped.request("GET", "https://example.com")
10+
# with scoped.stream("GET", "https://example.com") as resp:
11+
# print(resp.read())
12+
13+
14+
def main() -> None:
15+
_ = Kernel
16+
17+
18+
if __name__ == "__main__":
19+
main()

src/kernel/_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,12 @@ def copy(
319319
# client.with_options(timeout=10).foo.create(...)
320320
with_options = copy
321321

322+
def for_browser(self, browser: Any) -> Any:
323+
"""Return a browser-scoped client for session subresources and raw HTTP through the session base_url."""
324+
from .lib.browser_scoped.client import browser_scoped_from_browser
325+
326+
return browser_scoped_from_browser(self, browser)
327+
322328
@override
323329
def _make_status_error(
324330
self,
@@ -596,6 +602,12 @@ def copy(
596602
# client.with_options(timeout=10).foo.create(...)
597603
with_options = copy
598604

605+
def for_browser(self, browser: Any) -> Any:
606+
"""Return a browser-scoped client for session subresources and raw HTTP through the session base_url."""
607+
from .lib.browser_scoped.client import async_browser_scoped_from_browser
608+
609+
return async_browser_scoped_from_browser(self, browser)
610+
599611
@override
600612
def _make_status_error(
601613
self,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .client import BrowserScopedClient, AsyncBrowserScopedClient
2+
3+
__all__ = ["BrowserScopedClient", "AsyncBrowserScopedClient"]
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
"""Browser-scoped view over a session: metro-routed subresources and raw HTTP via /curl/raw."""
2+
3+
from __future__ import annotations
4+
5+
import inspect
6+
from typing import TYPE_CHECKING, Any, Mapping, cast
7+
from contextlib import contextmanager, asynccontextmanager
8+
from collections.abc import Iterator, AsyncIterator
9+
10+
import httpx
11+
12+
from .util import (
13+
jwt_from_cdp_ws_url,
14+
base_url_from_browser_like,
15+
cdp_ws_url_from_browser_like,
16+
session_id_from_browser_like,
17+
)
18+
from ..._types import Body, Timeout, NotGiven, BinaryTypes, not_given
19+
from ..._models import FinalRequestOptions
20+
from .metro_client import metro_kernel_from_browser, metro_async_kernel_from_browser
21+
22+
if TYPE_CHECKING:
23+
from ..._client import Kernel, AsyncKernel
24+
from ...resources.browsers.logs import LogsResource, AsyncLogsResource
25+
from ...resources.browsers.fs.fs import FsResource, AsyncFsResource
26+
from ...resources.browsers.process import ProcessResource, AsyncProcessResource
27+
from ...resources.browsers.replays import ReplaysResource, AsyncReplaysResource
28+
from ...resources.browsers.computer import ComputerResource, AsyncComputerResource
29+
from ...resources.browsers.playwright import PlaywrightResource, AsyncPlaywrightResource
30+
31+
32+
class _BoundBrowserSubresource:
33+
"""Delegates to a generated resource while defaulting `id` to the scoped session."""
34+
35+
def __init__(self, inner: Any, session_id: str) -> None:
36+
object.__setattr__(self, "_inner", inner)
37+
object.__setattr__(self, "_session_id", session_id)
38+
39+
def __getattr__(self, name: str) -> Any:
40+
if name.startswith("_"):
41+
raise AttributeError(name)
42+
attr = getattr(self._inner, name)
43+
if name.startswith("with_") or not callable(attr):
44+
return attr
45+
try:
46+
sig = inspect.signature(attr)
47+
except (TypeError, ValueError):
48+
return attr
49+
if "id" not in sig.parameters:
50+
return attr
51+
52+
def bound(*args: Any, **kwargs: Any) -> Any:
53+
kw = dict(kwargs)
54+
kw["id"] = self._session_id
55+
return attr(*args, **kw)
56+
57+
return bound
58+
59+
60+
class BrowserScopedClient:
61+
"""Session-scoped API: subresources without repeating session id; HTTP via browser /curl/raw."""
62+
63+
def __init__(self, parent: Kernel, *, session_id: str, metro_base_url: str, jwt: str) -> None:
64+
self._parent = parent
65+
self.session_id = session_id
66+
self._metro_base_url = metro_base_url
67+
self._jwt = jwt
68+
self._metro = metro_kernel_from_browser(parent, session_id=session_id, metro_base_url=metro_base_url, jwt=jwt)
69+
70+
@property
71+
def parent(self) -> Kernel:
72+
"""Control-plane client this view was created from (for future id remapping hooks)."""
73+
return self._parent
74+
75+
@property
76+
def base_url(self) -> str:
77+
return self._metro_base_url
78+
79+
@property
80+
def process(self) -> ProcessResource:
81+
from ...resources.browsers.process import ProcessResource
82+
83+
return cast(ProcessResource, _BoundBrowserSubresource(ProcessResource(self._metro), self.session_id))
84+
85+
@property
86+
def computer(self) -> ComputerResource:
87+
from ...resources.browsers.computer import ComputerResource
88+
89+
return cast(ComputerResource, _BoundBrowserSubresource(ComputerResource(self._metro), self.session_id))
90+
91+
@property
92+
def fs(self) -> FsResource:
93+
from ...resources.browsers.fs.fs import FsResource
94+
95+
return cast(FsResource, _BoundBrowserSubresource(FsResource(self._metro), self.session_id))
96+
97+
@property
98+
def logs(self) -> LogsResource:
99+
from ...resources.browsers.logs import LogsResource
100+
101+
return cast(LogsResource, _BoundBrowserSubresource(LogsResource(self._metro), self.session_id))
102+
103+
@property
104+
def playwright(self) -> PlaywrightResource:
105+
from ...resources.browsers.playwright import PlaywrightResource
106+
107+
return cast(PlaywrightResource, _BoundBrowserSubresource(PlaywrightResource(self._metro), self.session_id))
108+
109+
@property
110+
def replays(self) -> ReplaysResource:
111+
from ...resources.browsers.replays import ReplaysResource
112+
113+
return cast(ReplaysResource, _BoundBrowserSubresource(ReplaysResource(self._metro), self.session_id))
114+
115+
def request(
116+
self,
117+
method: str,
118+
url: str,
119+
*,
120+
content: BinaryTypes | None = None,
121+
json: Body | None = None,
122+
headers: Mapping[str, str] | None = None,
123+
params: Mapping[str, object] | None = None,
124+
timeout: float | Timeout | None | NotGiven = not_given,
125+
) -> httpx.Response:
126+
if json is not None and content is not None:
127+
raise TypeError("Passing both `json` and `content` is not supported")
128+
q: dict[str, object] = {"url": url}
129+
if params:
130+
q.update(dict(params))
131+
opts = FinalRequestOptions.construct(
132+
method=method.upper(),
133+
url="/curl/raw",
134+
params=q,
135+
headers=headers if headers is not None else not_given,
136+
content=content,
137+
json_data=json,
138+
timeout=timeout,
139+
)
140+
return self._metro.request(httpx.Response, opts)
141+
142+
@contextmanager
143+
def stream(
144+
self,
145+
method: str,
146+
url: str,
147+
*,
148+
content: BinaryTypes | None = None,
149+
headers: Mapping[str, str] | None = None,
150+
params: Mapping[str, object] | None = None,
151+
timeout: float | Timeout | None | NotGiven = not_given,
152+
) -> Iterator[httpx.Response]:
153+
q: dict[str, Any] = dict(self._metro.default_query)
154+
q["url"] = url
155+
if params:
156+
q.update(dict(params))
157+
h = {k: v for k, v in self._metro.default_headers.items() if isinstance(v, str)}
158+
if content is None:
159+
h.pop("Content-Type", None)
160+
if headers:
161+
h.update(headers)
162+
eff_timeout = self._metro.timeout if isinstance(timeout, NotGiven) else timeout
163+
cm = self._metro._client.stream(
164+
method.upper(),
165+
self._metro._prepare_url("/curl/raw"),
166+
params=q,
167+
headers=h,
168+
content=content,
169+
timeout=eff_timeout,
170+
)
171+
with cm as resp:
172+
yield resp
173+
174+
175+
class AsyncBrowserScopedClient:
176+
def __init__(self, parent: AsyncKernel, *, session_id: str, metro_base_url: str, jwt: str) -> None:
177+
self._parent = parent
178+
self.session_id = session_id
179+
self._metro_base_url = metro_base_url
180+
self._jwt = jwt
181+
self._metro = metro_async_kernel_from_browser(
182+
parent, session_id=session_id, metro_base_url=metro_base_url, jwt=jwt
183+
)
184+
185+
@property
186+
def parent(self) -> AsyncKernel:
187+
return self._parent
188+
189+
@property
190+
def base_url(self) -> str:
191+
return self._metro_base_url
192+
193+
@property
194+
def process(self) -> AsyncProcessResource:
195+
from ...resources.browsers.process import AsyncProcessResource
196+
197+
return cast(AsyncProcessResource, _BoundBrowserSubresource(AsyncProcessResource(self._metro), self.session_id))
198+
199+
@property
200+
def computer(self) -> AsyncComputerResource:
201+
from ...resources.browsers.computer import AsyncComputerResource
202+
203+
return cast(
204+
AsyncComputerResource, _BoundBrowserSubresource(AsyncComputerResource(self._metro), self.session_id)
205+
)
206+
207+
@property
208+
def fs(self) -> AsyncFsResource:
209+
from ...resources.browsers.fs.fs import AsyncFsResource
210+
211+
return cast(AsyncFsResource, _BoundBrowserSubresource(AsyncFsResource(self._metro), self.session_id))
212+
213+
@property
214+
def logs(self) -> AsyncLogsResource:
215+
from ...resources.browsers.logs import AsyncLogsResource
216+
217+
return cast(AsyncLogsResource, _BoundBrowserSubresource(AsyncLogsResource(self._metro), self.session_id))
218+
219+
@property
220+
def playwright(self) -> AsyncPlaywrightResource:
221+
from ...resources.browsers.playwright import AsyncPlaywrightResource
222+
223+
return cast(
224+
AsyncPlaywrightResource, _BoundBrowserSubresource(AsyncPlaywrightResource(self._metro), self.session_id)
225+
)
226+
227+
@property
228+
def replays(self) -> AsyncReplaysResource:
229+
from ...resources.browsers.replays import AsyncReplaysResource
230+
231+
return cast(AsyncReplaysResource, _BoundBrowserSubresource(AsyncReplaysResource(self._metro), self.session_id))
232+
233+
async def request(
234+
self,
235+
method: str,
236+
url: str,
237+
*,
238+
content: BinaryTypes | None = None,
239+
json: Body | None = None,
240+
headers: Mapping[str, str] | None = None,
241+
params: Mapping[str, object] | None = None,
242+
timeout: float | Timeout | None | NotGiven = not_given,
243+
) -> httpx.Response:
244+
if json is not None and content is not None:
245+
raise TypeError("Passing both `json` and `content` is not supported")
246+
q: dict[str, object] = {"url": url}
247+
if params:
248+
q.update(dict(params))
249+
opts = FinalRequestOptions.construct(
250+
method=method.upper(),
251+
url="/curl/raw",
252+
params=q,
253+
headers=headers if headers is not None else not_given,
254+
content=content,
255+
json_data=json,
256+
timeout=timeout,
257+
)
258+
return await self._metro.request(httpx.Response, opts)
259+
260+
@asynccontextmanager
261+
async def stream(
262+
self,
263+
method: str,
264+
url: str,
265+
*,
266+
content: BinaryTypes | None = None,
267+
headers: Mapping[str, str] | None = None,
268+
params: Mapping[str, object] | None = None,
269+
timeout: float | Timeout | None | NotGiven = not_given,
270+
) -> AsyncIterator[httpx.Response]:
271+
q: dict[str, Any] = dict(self._metro.default_query)
272+
q["url"] = url
273+
if params:
274+
q.update(dict(params))
275+
h = {k: v for k, v in self._metro.default_headers.items() if isinstance(v, str)}
276+
if content is None:
277+
h.pop("Content-Type", None)
278+
if headers:
279+
h.update(headers)
280+
eff_timeout = self._metro.timeout if isinstance(timeout, NotGiven) else timeout
281+
async with self._metro._client.stream(
282+
method.upper(),
283+
self._metro._prepare_url("/curl/raw"),
284+
params=q,
285+
headers=h,
286+
content=content,
287+
timeout=eff_timeout,
288+
) as resp:
289+
yield resp
290+
291+
292+
def browser_scoped_from_browser(parent: Kernel, browser: Any) -> BrowserScopedClient:
293+
session_id = session_id_from_browser_like(browser)
294+
metro = base_url_from_browser_like(browser)
295+
if not metro:
296+
raise ValueError("browser.base_url is required for a browser-scoped client")
297+
jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser))
298+
if not jwt:
299+
raise ValueError("could not parse jwt from browser.cdp_ws_url; required for metro requests")
300+
return BrowserScopedClient(parent, session_id=session_id, metro_base_url=metro, jwt=jwt)
301+
302+
303+
def async_browser_scoped_from_browser(parent: AsyncKernel, browser: Any) -> AsyncBrowserScopedClient:
304+
session_id = session_id_from_browser_like(browser)
305+
metro = base_url_from_browser_like(browser)
306+
if not metro:
307+
raise ValueError("browser.base_url is required for a browser-scoped client")
308+
jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser))
309+
if not jwt:
310+
raise ValueError("could not parse jwt from browser.cdp_ws_url; required for metro requests")
311+
return AsyncBrowserScopedClient(parent, session_id=session_id, metro_base_url=metro, jwt=jwt)

0 commit comments

Comments
 (0)