diff --git a/pyproject.toml b/pyproject.toml index 1178d263e..12db063d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ dependencies = [ "distro>=1.7.0, <2", "sniffio", "uuid-utils>=0.11.0", + "websocket-client>=1.8.0, <2", + "websockets>=13.0, <16", ] requires-python = ">= 3.9" diff --git a/requirements-dev.lock b/requirements-dev.lock index 4def5ae9c..33d94df1a 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -15,6 +15,10 @@ certifi==2026.1.4 # httpx colorama==0.4.6 ; sys_platform == 'win32' # via pytest +coverage==7.10.7 ; python_full_version < '3.10' + # via pytest-cov +coverage==7.13.4 ; python_full_version >= '3.10' + # via pytest-cov dirty-equals==0.11 distro==1.9.0 # via runloop-api-client @@ -26,12 +30,18 @@ execnet==2.1.2 # via pytest-xdist h11==0.16.0 # via httpcore +h2==4.3.0 + # via httpx +hpack==4.1.0 + # via h2 httpcore==1.0.9 # via httpx httpx==0.28.1 # via # respx # runloop-api-client +hyperframe==6.1.0 + # via h2 idna==3.11 # via # anyio @@ -57,7 +67,9 @@ packaging==25.0 pathspec==1.0.3 # via mypy pluggy==1.6.0 - # via pytest + # via + # pytest + # pytest-cov pydantic==2.12.5 # via runloop-api-client pydantic-core==2.41.5 @@ -70,16 +82,25 @@ pyright==1.1.399 pytest==8.4.2 ; python_full_version < '3.10' # via # pytest-asyncio + # pytest-cov + # pytest-timeout # pytest-xdist pytest==9.0.2 ; python_full_version >= '3.10' # via # pytest-asyncio + # pytest-cov + # pytest-timeout # pytest-xdist pytest-asyncio==1.2.0 ; python_full_version < '3.10' pytest-asyncio==1.3.0 ; python_full_version >= '3.10' +pytest-cov==7.0.0 +pytest-timeout==2.4.0 pytest-xdist==3.8.0 python-dateutil==2.9.0.post0 ; python_full_version < '3.10' # via time-machine +python-frontmatter==1.1.0 +pyyaml==6.0.3 + # via python-frontmatter respx==0.22.0 rich==14.2.0 ruff==0.14.13 @@ -89,8 +110,9 @@ sniffio==1.3.1 # via runloop-api-client time-machine==2.19.0 ; python_full_version < '3.10' time-machine==3.2.0 ; python_full_version >= '3.10' -tomli==2.4.0 ; python_full_version < '3.11' +tomli==2.4.0 ; python_full_version <= '3.11' # via + # coverage # mypy # pytest typing-extensions==4.15.0 @@ -106,5 +128,11 @@ typing-extensions==4.15.0 # typing-inspection typing-inspection==0.4.2 # via pydantic +uuid-utils==0.14.1 + # via runloop-api-client +websocket-client==1.9.0 + # via runloop-api-client +websockets==15.0.1 + # via runloop-api-client zipp==3.23.0 # via importlib-metadata diff --git a/src/runloop_api_client/_client.py b/src/runloop_api_client/_client.py index 3d032dfd6..9e422721e 100644 --- a/src/runloop_api_client/_client.py +++ b/src/runloop_api_client/_client.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from .resources import ( + pty, axons, agents, apikeys, @@ -52,6 +53,7 @@ restricted_keys, network_policies, ) + from .resources.pty import PtyResource, AsyncPtyResource from .resources.agents import AgentsResource, AsyncAgentsResource from .resources.apikeys import ApikeysResource, AsyncApikeysResource from .resources.objects import ObjectsResource, AsyncObjectsResource @@ -184,6 +186,12 @@ def devboxes(self) -> DevboxesResource: return DevboxesResource(self) + @cached_property + def pty(self) -> PtyResource: + from .resources.pty import PtyResource + + return PtyResource(self) + @cached_property def scenarios(self) -> ScenariosResource: from .resources.scenarios import ScenariosResource @@ -466,6 +474,12 @@ def devboxes(self) -> AsyncDevboxesResource: return AsyncDevboxesResource(self) + @cached_property + def pty(self) -> AsyncPtyResource: + from .resources.pty import AsyncPtyResource + + return AsyncPtyResource(self) + @cached_property def scenarios(self) -> AsyncScenariosResource: from .resources.scenarios import AsyncScenariosResource @@ -683,6 +697,12 @@ def devboxes(self) -> devboxes.DevboxesResourceWithRawResponse: return DevboxesResourceWithRawResponse(self._client.devboxes) + @cached_property + def pty(self) -> pty.PtyResourceWithRawResponse: + from .resources.pty import PtyResourceWithRawResponse + + return PtyResourceWithRawResponse(self._client.pty) + @cached_property def scenarios(self) -> scenarios.ScenariosResourceWithRawResponse: from .resources.scenarios import ScenariosResourceWithRawResponse @@ -780,6 +800,12 @@ def devboxes(self) -> devboxes.AsyncDevboxesResourceWithRawResponse: return AsyncDevboxesResourceWithRawResponse(self._client.devboxes) + @cached_property + def pty(self) -> pty.AsyncPtyResourceWithRawResponse: + from .resources.pty import AsyncPtyResourceWithRawResponse + + return AsyncPtyResourceWithRawResponse(self._client.pty) + @cached_property def scenarios(self) -> scenarios.AsyncScenariosResourceWithRawResponse: from .resources.scenarios import AsyncScenariosResourceWithRawResponse @@ -877,6 +903,12 @@ def devboxes(self) -> devboxes.DevboxesResourceWithStreamingResponse: return DevboxesResourceWithStreamingResponse(self._client.devboxes) + @cached_property + def pty(self) -> pty.PtyResourceWithStreamingResponse: + from .resources.pty import PtyResourceWithStreamingResponse + + return PtyResourceWithStreamingResponse(self._client.pty) + @cached_property def scenarios(self) -> scenarios.ScenariosResourceWithStreamingResponse: from .resources.scenarios import ScenariosResourceWithStreamingResponse @@ -974,6 +1006,12 @@ def devboxes(self) -> devboxes.AsyncDevboxesResourceWithStreamingResponse: return AsyncDevboxesResourceWithStreamingResponse(self._client.devboxes) + @cached_property + def pty(self) -> pty.AsyncPtyResourceWithStreamingResponse: + from .resources.pty import AsyncPtyResourceWithStreamingResponse + + return AsyncPtyResourceWithStreamingResponse(self._client.pty) + @cached_property def scenarios(self) -> scenarios.AsyncScenariosResourceWithStreamingResponse: from .resources.scenarios import AsyncScenariosResourceWithStreamingResponse diff --git a/src/runloop_api_client/resources/__init__.py b/src/runloop_api_client/resources/__init__.py index dd27ba9e4..9737e048d 100644 --- a/src/runloop_api_client/resources/__init__.py +++ b/src/runloop_api_client/resources/__init__.py @@ -1,5 +1,13 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from .pty import ( + PtyResource, + AsyncPtyResource, + PtyResourceWithRawResponse, + AsyncPtyResourceWithRawResponse, + PtyResourceWithStreamingResponse, + AsyncPtyResourceWithStreamingResponse, +) from .axons import ( AxonsResource, AsyncAxonsResource, @@ -206,6 +214,12 @@ "AsyncApikeysResourceWithRawResponse", "ApikeysResourceWithStreamingResponse", "AsyncApikeysResourceWithStreamingResponse", + "PtyResource", + "AsyncPtyResource", + "PtyResourceWithRawResponse", + "AsyncPtyResourceWithRawResponse", + "PtyResourceWithStreamingResponse", + "AsyncPtyResourceWithStreamingResponse", "RestrictedKeysResource", "AsyncRestrictedKeysResource", "RestrictedKeysResourceWithRawResponse", diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py index 888369e98..c0359bd45 100644 --- a/src/runloop_api_client/resources/devboxes/devboxes.py +++ b/src/runloop_api_client/resources/devboxes/devboxes.py @@ -777,6 +777,31 @@ def enable_tunnel( cast_to=TunnelView, ) + def create_pty_tunnel( + self, + id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> TunnelView: + """Create an authenticated tunnel for high-level PTY access.""" + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._post( + path_template("/v1/devboxes/{id}/create_pty_tunnel", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=TunnelView, + ) + def execute( self, id: str, @@ -2395,6 +2420,31 @@ async def enable_tunnel( cast_to=TunnelView, ) + async def create_pty_tunnel( + self, + id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> TunnelView: + """Create an authenticated tunnel for high-level PTY access.""" + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return await self._post( + path_template("/v1/devboxes/{id}/create_pty_tunnel", id=id), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=TunnelView, + ) + async def execute( self, id: str, @@ -3388,6 +3438,9 @@ def __init__(self, devboxes: DevboxesResource) -> None: self.enable_tunnel = to_raw_response_wrapper( devboxes.enable_tunnel, ) + self.create_pty_tunnel = to_raw_response_wrapper( + devboxes.create_pty_tunnel, + ) self.execute = to_raw_response_wrapper( devboxes.execute, ) @@ -3481,6 +3534,9 @@ def __init__(self, devboxes: AsyncDevboxesResource) -> None: self.enable_tunnel = async_to_raw_response_wrapper( devboxes.enable_tunnel, ) + self.create_pty_tunnel = async_to_raw_response_wrapper( + devboxes.create_pty_tunnel, + ) self.execute = async_to_raw_response_wrapper( devboxes.execute, ) @@ -3574,6 +3630,9 @@ def __init__(self, devboxes: DevboxesResource) -> None: self.enable_tunnel = to_streamed_response_wrapper( devboxes.enable_tunnel, ) + self.create_pty_tunnel = to_streamed_response_wrapper( + devboxes.create_pty_tunnel, + ) self.execute = to_streamed_response_wrapper( devboxes.execute, ) @@ -3667,6 +3726,9 @@ def __init__(self, devboxes: AsyncDevboxesResource) -> None: self.enable_tunnel = async_to_streamed_response_wrapper( devboxes.enable_tunnel, ) + self.create_pty_tunnel = async_to_streamed_response_wrapper( + devboxes.create_pty_tunnel, + ) self.execute = async_to_streamed_response_wrapper( devboxes.execute, ) diff --git a/src/runloop_api_client/resources/pty.py b/src/runloop_api_client/resources/pty.py new file mode 100644 index 000000000..0c8c09cd1 --- /dev/null +++ b/src/runloop_api_client/resources/pty.py @@ -0,0 +1,219 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional +from typing_extensions import Literal + +import httpx + +from ..types import pty_connect_params, pty_control_params +from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from .._utils import maybe_transform, async_maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from .._base_client import make_request_options +from ..types.pty_connect_view import PtyConnectView + +__all__ = [ + "PtyResource", + "AsyncPtyResource", + "PtyResourceWithRawResponse", + "AsyncPtyResourceWithRawResponse", + "PtyResourceWithStreamingResponse", + "AsyncPtyResourceWithStreamingResponse", +] + + +class PtyResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> PtyResourceWithRawResponse: + return PtyResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> PtyResourceWithStreamingResponse: + return PtyResourceWithStreamingResponse(self) + + def connect( + self, + *, + command: Optional[str] | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Optional[Dict[str, str]] | Omit = omit, + cols: Optional[int] | Omit = omit, + rows: Optional[int] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> PtyConnectView: + return self._post( + "/pty/connect", + body=maybe_transform( + { + "command": command, + "cwd": cwd, + "env": env, + "cols": cols, + "rows": rows, + }, + pty_connect_params.PtyConnectParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=PtyConnectView, + ) + + def control( + self, + *, + action: Literal["resize", "signal", "close"], + cols: Optional[int] | Omit = omit, + rows: Optional[int] | Omit = omit, + signal: Optional[str] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> object: + return self._post( + "/pty/control", + body=maybe_transform( + { + "action": action, + "cols": cols, + "rows": rows, + "signal": signal, + }, + pty_control_params.PtyControlParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=object, + ) + + +class AsyncPtyResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncPtyResourceWithRawResponse: + return AsyncPtyResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncPtyResourceWithStreamingResponse: + return AsyncPtyResourceWithStreamingResponse(self) + + async def connect( + self, + *, + command: Optional[str] | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Optional[Dict[str, str]] | Omit = omit, + cols: Optional[int] | Omit = omit, + rows: Optional[int] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> PtyConnectView: + return await self._post( + "/pty/connect", + body=await async_maybe_transform( + { + "command": command, + "cwd": cwd, + "env": env, + "cols": cols, + "rows": rows, + }, + pty_connect_params.PtyConnectParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=PtyConnectView, + ) + + async def control( + self, + *, + action: Literal["resize", "signal", "close"], + cols: Optional[int] | Omit = omit, + rows: Optional[int] | Omit = omit, + signal: Optional[str] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + idempotency_key: str | None = None, + ) -> object: + return await self._post( + "/pty/control", + body=await async_maybe_transform( + { + "action": action, + "cols": cols, + "rows": rows, + "signal": signal, + }, + pty_control_params.PtyControlParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + idempotency_key=idempotency_key, + ), + cast_to=object, + ) + + +class PtyResourceWithRawResponse: + def __init__(self, pty: PtyResource) -> None: + self._pty = pty + self.connect = to_raw_response_wrapper(pty.connect) + self.control = to_raw_response_wrapper(pty.control) + + +class AsyncPtyResourceWithRawResponse: + def __init__(self, pty: AsyncPtyResource) -> None: + self._pty = pty + self.connect = async_to_raw_response_wrapper(pty.connect) + self.control = async_to_raw_response_wrapper(pty.control) + + +class PtyResourceWithStreamingResponse: + def __init__(self, pty: PtyResource) -> None: + self._pty = pty + self.connect = to_streamed_response_wrapper(pty.connect) + self.control = to_streamed_response_wrapper(pty.control) + + +class AsyncPtyResourceWithStreamingResponse: + def __init__(self, pty: AsyncPtyResource) -> None: + self._pty = pty + self.connect = async_to_streamed_response_wrapper(pty.connect) + self.control = async_to_streamed_response_wrapper(pty.control) diff --git a/src/runloop_api_client/sdk/__init__.py b/src/runloop_api_client/sdk/__init__.py index 38b3d0e41..6dfd21b5d 100644 --- a/src/runloop_api_client/sdk/__init__.py +++ b/src/runloop_api_client/sdk/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations +from .pty import DevboxPtyOps, DevboxPtyProcess, DevboxPtySession from .axon import Axon, AxonSqlOps from .sync import ( AxonOps, @@ -45,6 +46,7 @@ from .secret import Secret from .scenario import Scenario from .snapshot import Snapshot +from .async_pty import AsyncDevboxPtyOps, AsyncDevboxPtyProcess, AsyncDevboxPtySession from .benchmark import Benchmark from .blueprint import Blueprint from .execution import Execution @@ -149,4 +151,10 @@ "AsyncGatewayConfig", "NamedShell", "AsyncNamedShell", + "DevboxPtyOps", + "DevboxPtySession", + "DevboxPtyProcess", + "AsyncDevboxPtyOps", + "AsyncDevboxPtySession", + "AsyncDevboxPtyProcess", ] diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py index bed785a7d..9c145a871 100644 --- a/src/runloop_api_client/sdk/async_devbox.py +++ b/src/runloop_api_client/sdk/async_devbox.py @@ -33,6 +33,7 @@ from .._types import omit from .._client import AsyncRunloop from ._helpers import filter_params +from .async_pty import AsyncDevboxPtyOps from .._streaming import AsyncStream from ..lib.polling import PollingConfig from ..types.devboxes import ExecutionUpdateChunk @@ -74,6 +75,7 @@ def __init__(self, client: AsyncRunloop, devbox_id: str) -> None: self._client = client self._id = devbox_id self._logger = logging.getLogger(__name__) + self.pty = AsyncDevboxPtyOps(client, devbox_id) @override def __repr__(self) -> str: diff --git a/src/runloop_api_client/sdk/async_pty.py b/src/runloop_api_client/sdk/async_pty.py new file mode 100644 index 000000000..0d921eb76 --- /dev/null +++ b/src/runloop_api_client/sdk/async_pty.py @@ -0,0 +1,392 @@ +"""High-level asynchronous PTY support for devboxes.""" + +from __future__ import annotations + +import uuid +import codecs +import asyncio +import inspect +from typing import Any, Union, Callable, Awaitable, AsyncIterator, cast +from importlib import import_module +from urllib.parse import urljoin, urlparse, urlunparse + +from .._types import omit +from .._client import AsyncRunloop +from .._exceptions import APIStatusError + +AsyncBytesCallback = Callable[[bytes], Union[None, Awaitable[None]]] +AsyncTextCallback = Callable[[str], Union[None, Awaitable[None]]] +_CLOSE_SENTINEL = object() +_RETRY_STATUS_CODES = {502, 503} + + +async def _sleep_for_attempt(attempt: int) -> None: + await asyncio.sleep(min(0.25 * (2**attempt), 2.0)) + + +def _is_retryable_status_error(exc: BaseException) -> bool: + return isinstance(exc, APIStatusError) and exc.status_code in _RETRY_STATUS_CODES + + +def _is_retryable_ws_error(exc: BaseException) -> bool: + status = getattr(exc, "status_code", None) or getattr(exc, "status", None) + if status in _RETRY_STATUS_CODES: + return True + response = getattr(exc, "response", None) + return getattr(response, "status_code", None) in _RETRY_STATUS_CODES + + +def _base_domain(client: AsyncRunloop) -> str: + host = client.base_url.host + return host[4:] if host.startswith("api.") else host + + +def _tunnel_base_url(client: AsyncRunloop, tunnel_key: str) -> str: + return f"https://13-{tunnel_key}.tunnel.{_base_domain(client)}" + + +def _websocket_url(base_url: str, connect_url: str) -> str: + parsed = urlparse(urljoin(f"{base_url.rstrip('/')}/", connect_url.lstrip("/"))) + scheme = "wss" if parsed.scheme == "https" else "ws" + return urlunparse(parsed._replace(scheme=scheme)) + + +async def _maybe_await(result: None | Awaitable[None]) -> None: + if inspect.isawaitable(result): + await result + + +class AsyncDevboxPtyOps: + """High-level PTY operations for an async devbox.""" + + def __init__(self, client: AsyncRunloop, devbox_id: str) -> None: + self._client = client + self._devbox_id = devbox_id + + async def open( + self, + command: str | None = None, + *, + cols: int = 80, + rows: int = 24, + cwd: str | None = None, + env: dict[str, str] | None = None, + retry_attempts: int = 5, + ) -> "AsyncDevboxPtySession": + tunnel = await self._create_tunnel(retry_attempts) + if not tunnel.auth_token: + raise RuntimeError("PTY tunnel did not include an auth token") + + base_url = _tunnel_base_url(self._client, tunnel.tunnel_key) + tunnel_client = AsyncRunloop(base_url=base_url, bearer_token=tunnel.auth_token) + connect = await self._connect(tunnel_client, command, cols, rows, cwd, env, retry_attempts) + ws_url = _websocket_url(base_url, connect.connect_url) + ws = await self._attach(ws_url, tunnel.auth_token, retry_attempts) + return AsyncDevboxPtySession(tunnel_client, ws) + + async def exec( + self, + command: str, + *, + cols: int = 80, + rows: int = 24, + cwd: str | None = None, + env: dict[str, str] | None = None, + retry_attempts: int = 5, + ) -> "AsyncDevboxPtyProcess": + marker = f"__RUNLOOP_PTY_EXIT_{uuid.uuid4().hex}__" + session = await self.open("/bin/sh", cols=cols, rows=rows, cwd=cwd, env=env, retry_attempts=retry_attempts) + wrapped = ( + f"{command}\n" + "__runloop_status=$?\n" + f"printf '\\n{marker}:%s\\n' \"$__runloop_status\"\n" + "exit \"$__runloop_status\"\n" + ) + process = AsyncDevboxPtyProcess(session, marker) + await session.send(wrapped) + return process + + async def _create_tunnel(self, retry_attempts: int) -> Any: + for attempt in range(retry_attempts): + try: + return await self._client.devboxes.create_pty_tunnel(self._devbox_id) + except Exception as exc: + if attempt == retry_attempts - 1 or not _is_retryable_status_error(exc): + raise + await _sleep_for_attempt(attempt) + raise RuntimeError("unreachable") + + async def _connect( + self, + tunnel_client: AsyncRunloop, + command: str | None, + cols: int, + rows: int, + cwd: str | None, + env: dict[str, str] | None, + retry_attempts: int, + ) -> Any: + for attempt in range(retry_attempts): + try: + return await tunnel_client.pty.connect( + command=command if command is not None else omit, + cols=cols, + rows=rows, + cwd=cwd if cwd is not None else omit, + env=env if env is not None else omit, + ) + except Exception as exc: + if attempt == retry_attempts - 1 or not _is_retryable_status_error(exc): + raise + await _sleep_for_attempt(attempt) + raise RuntimeError("unreachable") + + async def _attach(self, ws_url: str, auth_token: str, retry_attempts: int) -> Any: + websockets = import_module("websockets") + for attempt in range(retry_attempts): + try: + try: + return await websockets.connect(ws_url, additional_headers={"Authorization": f"Bearer {auth_token}"}) + except TypeError: + return await websockets.connect(ws_url, extra_headers={"Authorization": f"Bearer {auth_token}"}) + except Exception as exc: + if attempt == retry_attempts - 1 or not _is_retryable_ws_error(exc): + raise + await _sleep_for_attempt(attempt) + raise RuntimeError("unreachable") + + +class AsyncDevboxPtySession: + """Attached async PTY session.""" + + def __init__(self, tunnel_client: AsyncRunloop, websocket: Any) -> None: + self._client = tunnel_client + self._ws = websocket + self._raw_callbacks: list[AsyncBytesCallback] = [] + self._text_callbacks: list[AsyncTextCallback] = [] + self._queue: asyncio.Queue[bytes | object] = asyncio.Queue() + self._closed = asyncio.Event() + self._decoder = codecs.getincrementaldecoder("utf-8")() + self._reader = asyncio.create_task(self._read_loop()) + + @property + def raw_output(self) -> AsyncIterator[bytes]: + return self._raw_iter() + + async def _raw_iter(self) -> AsyncIterator[bytes]: + while True: + item = await self._queue.get() + if item is _CLOSE_SENTINEL: + return + yield item # type: ignore[misc] + + @property + def output(self) -> AsyncIterator[str]: + return self._text_iter() + + async def _text_iter(self) -> AsyncIterator[str]: + decoder = codecs.getincrementaldecoder("utf-8")() + async for chunk in self.raw_output: + text = decoder.decode(chunk) + if text: + yield text + tail = decoder.decode(b"", final=True) + if tail: + yield tail + + def on_data(self, callback: AsyncBytesCallback) -> None: + self._raw_callbacks.append(callback) + + def on_output(self, callback: AsyncTextCallback) -> None: + self._text_callbacks.append(callback) + + async def send(self, data: str | bytes) -> None: + payload = data.encode("utf-8") if isinstance(data, str) else data + await self._send(payload) + + async def resize(self, cols: int, rows: int) -> None: + if cols <= 0 or rows <= 0: + raise ValueError("cols and rows must be positive") + await self._client.pty.control(action="resize", cols=cols, rows=rows) + + async def signal(self, signal: str) -> None: + if not signal: + raise ValueError("signal must be non-empty") + await self._client.pty.control(action="signal", signal=signal) + + async def detach(self) -> None: + await self._close_websocket() + await self.wait_for_close() + + async def close(self) -> None: + await self.detach() + + async def terminate(self) -> None: + try: + await self._client.pty.control(action="close") + finally: + await self.detach() + + async def wait_for_close(self, timeout: float | None = None) -> bool: + try: + if timeout is None: + await self._closed.wait() + else: + await asyncio.wait_for(self._closed.wait(), timeout) + return True + except asyncio.TimeoutError: + return False + + async def _send(self, payload: bytes) -> None: + send = getattr(self._ws, "send", None) + if send is None: + await self._ws.send_bytes(payload) + else: + await send(payload) + + async def _recv(self) -> bytes | str | None: + recv = getattr(self._ws, "recv", None) + if recv is None: + return cast(Union[bytes, str, None], await self._ws.receive()) + return cast(Union[bytes, str, None], await recv()) + + async def _close_websocket(self) -> None: + close = getattr(self._ws, "close", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + + async def _read_loop(self) -> None: + try: + while True: + data = await self._recv() + if data is None or data == b"" or data == "": + break + chunk = data.encode("utf-8") if isinstance(data, str) else bytes(data) + await self._emit(chunk) + except Exception: + pass + finally: + self._closed.set() + await self._queue.put(_CLOSE_SENTINEL) + + async def _emit(self, chunk: bytes) -> None: + await self._queue.put(chunk) + for raw_callback in list(self._raw_callbacks): + await _maybe_await(raw_callback(chunk)) + text = self._decoder.decode(chunk) + if text: + for text_callback in list(self._text_callbacks): + await _maybe_await(text_callback(text)) + + +class AsyncDevboxPtyProcess: + """PTY-backed async command process.""" + + def __init__(self, session: AsyncDevboxPtySession, exit_marker: str) -> None: + self._session = session + self._exit_marker = exit_marker.encode("ascii") + self._raw_callbacks: list[AsyncBytesCallback] = [] + self._text_callbacks: list[AsyncTextCallback] = [] + self._queue: asyncio.Queue[bytes | object] = asyncio.Queue() + self._decoder = codecs.getincrementaldecoder("utf-8")() + self._pending = b"" + self._exit_code: int | None = None + self._done = asyncio.Event() + session.on_data(self._handle_data) + + @property + def raw_output(self) -> AsyncIterator[bytes]: + return self._raw_iter() + + async def _raw_iter(self) -> AsyncIterator[bytes]: + while True: + item = await self._queue.get() + if item is _CLOSE_SENTINEL: + return + yield item # type: ignore[misc] + + @property + def output(self) -> AsyncIterator[str]: + return self._text_iter() + + async def _text_iter(self) -> AsyncIterator[str]: + decoder = codecs.getincrementaldecoder("utf-8")() + async for chunk in self.raw_output: + text = decoder.decode(chunk) + if text: + yield text + tail = decoder.decode(b"", final=True) + if tail: + yield tail + + @property + def exit_code(self) -> int | None: + return self._exit_code + + def on_data(self, callback: AsyncBytesCallback) -> None: + self._raw_callbacks.append(callback) + + def on_output(self, callback: AsyncTextCallback) -> None: + self._text_callbacks.append(callback) + + async def write(self, chars: str | bytes) -> None: + await self._session.send(chars) + + async def resize(self, cols: int, rows: int) -> None: + await self._session.resize(cols, rows) + + async def interrupt(self) -> None: + await self._session.signal("SIGINT") + + async def close(self) -> None: + await self._session.terminate() + + async def wait(self, timeout: float | None = None) -> int | None: + try: + if timeout is None: + await self._done.wait() + else: + await asyncio.wait_for(self._done.wait(), timeout) + except asyncio.TimeoutError: + return None + return self._exit_code + + async def _handle_data(self, chunk: bytes) -> None: + if self._done.is_set(): + return + self._pending += chunk + marker_index = self._pending.find(self._exit_marker) + if marker_index >= 0: + await self._publish(self._pending[:marker_index]) + rest = self._pending[marker_index + len(self._exit_marker) :] + if rest.startswith(b":"): + digits = bytearray() + for byte in rest[1:]: + if 48 <= byte <= 57: + digits.append(byte) + else: + break + if digits: + self._exit_code = int(digits.decode("ascii")) + self._pending = b"" + self._done.set() + await self._queue.put(_CLOSE_SENTINEL) + return + + keep = len(self._exit_marker) + 32 + if len(self._pending) > keep: + await self._publish(self._pending[:-keep]) + self._pending = self._pending[-keep:] + + async def _publish(self, chunk: bytes) -> None: + if not chunk: + return + await self._queue.put(chunk) + for raw_callback in list(self._raw_callbacks): + await _maybe_await(raw_callback(chunk)) + text = self._decoder.decode(chunk) + if text: + for text_callback in list(self._text_callbacks): + await _maybe_await(text_callback(text)) diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py index c97ea2682..049153df3 100644 --- a/src/runloop_api_client/sdk/devbox.py +++ b/src/runloop_api_client/sdk/devbox.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence from typing_extensions import Unpack, override +from .pty import DevboxPtyOps from ..types import ( DevboxView, TunnelView, @@ -73,6 +74,7 @@ def __init__(self, client: Runloop, devbox_id: str) -> None: self._client = client self._id = devbox_id self._logger = logging.getLogger(__name__) + self.pty = DevboxPtyOps(client, devbox_id) @override def __repr__(self) -> str: diff --git a/src/runloop_api_client/sdk/pty.py b/src/runloop_api_client/sdk/pty.py new file mode 100644 index 000000000..d99d715d9 --- /dev/null +++ b/src/runloop_api_client/sdk/pty.py @@ -0,0 +1,354 @@ +"""High-level synchronous PTY support for devboxes.""" + +from __future__ import annotations + +import time +import uuid +import queue +import codecs +import threading +from typing import Any, Callable, Iterator +from importlib import import_module +from urllib.parse import urljoin, urlparse, urlunparse + +from .._types import omit +from .._client import Runloop +from .._exceptions import APIStatusError + +BytesCallback = Callable[[bytes], None] +TextCallback = Callable[[str], None] +_CLOSE_SENTINEL = object() +_RETRY_STATUS_CODES = {502, 503} +_ACCEPTABLE_CLOSE_CODES = {1000, 1006, 4000} + + +def _sleep_for_attempt(attempt: int) -> None: + time.sleep(min(0.25 * (2**attempt), 2.0)) + + +def _is_retryable_status_error(exc: BaseException) -> bool: + return isinstance(exc, APIStatusError) and exc.status_code in _RETRY_STATUS_CODES + + +def _is_retryable_ws_error(exc: BaseException) -> bool: + status = getattr(exc, "status_code", None) or getattr(exc, "status", None) + if status in _RETRY_STATUS_CODES: + return True + response = getattr(exc, "response", None) + return getattr(response, "status_code", None) in _RETRY_STATUS_CODES + + +def _base_domain(client: Runloop) -> str: + host = client.base_url.host + return host[4:] if host.startswith("api.") else host + + +def _tunnel_base_url(client: Runloop, tunnel_key: str) -> str: + return f"https://13-{tunnel_key}.tunnel.{_base_domain(client)}" + + +def _websocket_url(base_url: str, connect_url: str) -> str: + parsed = urlparse(urljoin(f"{base_url.rstrip('/')}/", connect_url.lstrip("/"))) + scheme = "wss" if parsed.scheme == "https" else "ws" + return urlunparse(parsed._replace(scheme=scheme)) + + +def _close_websocket(ws: Any) -> None: + try: + ws.close() + except Exception: + pass + + +class DevboxPtyOps: + """High-level PTY operations for a synchronous devbox.""" + + def __init__(self, client: Runloop, devbox_id: str) -> None: + self._client = client + self._devbox_id = devbox_id + + def open( + self, + command: str | None = None, + *, + cols: int = 80, + rows: int = 24, + cwd: str | None = None, + env: dict[str, str] | None = None, + retry_attempts: int = 5, + ) -> "DevboxPtySession": + tunnel = self._create_tunnel(retry_attempts) + if not tunnel.auth_token: + raise RuntimeError("PTY tunnel did not include an auth token") + + base_url = _tunnel_base_url(self._client, tunnel.tunnel_key) + tunnel_client = Runloop(base_url=base_url, bearer_token=tunnel.auth_token) + connect = self._connect(tunnel_client, command, cols, rows, cwd, env, retry_attempts) + ws_url = _websocket_url(base_url, connect.connect_url) + ws = self._attach(ws_url, tunnel.auth_token, retry_attempts) + return DevboxPtySession(tunnel_client, ws) + + def exec( + self, + command: str, + *, + cols: int = 80, + rows: int = 24, + cwd: str | None = None, + env: dict[str, str] | None = None, + retry_attempts: int = 5, + ) -> "DevboxPtyProcess": + marker = f"__RUNLOOP_PTY_EXIT_{uuid.uuid4().hex}__" + session = self.open("/bin/sh", cols=cols, rows=rows, cwd=cwd, env=env, retry_attempts=retry_attempts) + wrapped = ( + f"{command}\n" + "__runloop_status=$?\n" + f"printf '\\n{marker}:%s\\n' \"$__runloop_status\"\n" + "exit \"$__runloop_status\"\n" + ) + process = DevboxPtyProcess(session, marker) + session.send(wrapped) + return process + + def _create_tunnel(self, retry_attempts: int) -> Any: + for attempt in range(retry_attempts): + try: + return self._client.devboxes.create_pty_tunnel(self._devbox_id) + except Exception as exc: + if attempt == retry_attempts - 1 or not _is_retryable_status_error(exc): + raise + _sleep_for_attempt(attempt) + raise RuntimeError("unreachable") + + def _connect( + self, + tunnel_client: Runloop, + command: str | None, + cols: int, + rows: int, + cwd: str | None, + env: dict[str, str] | None, + retry_attempts: int, + ) -> Any: + for attempt in range(retry_attempts): + try: + return tunnel_client.pty.connect( + command=command if command is not None else omit, + cols=cols, + rows=rows, + cwd=cwd if cwd is not None else omit, + env=env if env is not None else omit, + ) + except Exception as exc: + if attempt == retry_attempts - 1 or not _is_retryable_status_error(exc): + raise + _sleep_for_attempt(attempt) + raise RuntimeError("unreachable") + + def _attach(self, ws_url: str, auth_token: str, retry_attempts: int) -> Any: + websocket = import_module("websocket") + for attempt in range(retry_attempts): + try: + return websocket.create_connection(ws_url, header=[f"Authorization: Bearer {auth_token}"]) + except Exception as exc: + if attempt == retry_attempts - 1 or not _is_retryable_ws_error(exc): + raise + _sleep_for_attempt(attempt) + raise RuntimeError("unreachable") + + +class DevboxPtySession: + """Attached PTY session.""" + + def __init__(self, tunnel_client: Runloop, websocket: Any) -> None: + self._client = tunnel_client + self._ws = websocket + self._raw_callbacks: list[BytesCallback] = [] + self._text_callbacks: list[TextCallback] = [] + self._queue: queue.Queue[bytes | object] = queue.Queue() + self._closed = threading.Event() + self._decoder = codecs.getincrementaldecoder("utf-8")() + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + @property + def raw_output(self) -> Iterator[bytes]: + while True: + item = self._queue.get() + if item is _CLOSE_SENTINEL: + return + yield item # type: ignore[misc] + + @property + def output(self) -> Iterator[str]: + decoder = codecs.getincrementaldecoder("utf-8")() + for chunk in self.raw_output: + text = decoder.decode(chunk) + if text: + yield text + tail = decoder.decode(b"", final=True) + if tail: + yield tail + + def on_data(self, callback: BytesCallback) -> None: + self._raw_callbacks.append(callback) + + def on_output(self, callback: TextCallback) -> None: + self._text_callbacks.append(callback) + + def send(self, data: str | bytes) -> None: + payload = data.encode("utf-8") if isinstance(data, str) else data + self._ws.send_binary(payload) + + def resize(self, cols: int, rows: int) -> None: + if cols <= 0 or rows <= 0: + raise ValueError("cols and rows must be positive") + self._client.pty.control(action="resize", cols=cols, rows=rows) + + def signal(self, signal: str) -> None: + if not signal: + raise ValueError("signal must be non-empty") + self._client.pty.control(action="signal", signal=signal) + + def detach(self) -> None: + _close_websocket(self._ws) + self.wait_for_close() + + def close(self) -> None: + self.detach() + + def terminate(self) -> None: + try: + self._client.pty.control(action="close") + finally: + self.detach() + + def wait_for_close(self, timeout: float | None = None) -> bool: + closed = self._closed.wait(timeout) + if closed and self._reader.is_alive() and threading.current_thread() is not self._reader: + self._reader.join(timeout=0) + return closed + + def _read_loop(self) -> None: + try: + while True: + data = self._ws.recv() + if data in (None, b"", ""): + break + chunk = data.encode("utf-8") if isinstance(data, str) else bytes(data) + self._emit(chunk) + except Exception: + pass + finally: + self._closed.set() + self._queue.put(_CLOSE_SENTINEL) + + def _emit(self, chunk: bytes) -> None: + self._queue.put(chunk) + for raw_callback in list(self._raw_callbacks): + raw_callback(chunk) + text = self._decoder.decode(chunk) + if text: + for text_callback in list(self._text_callbacks): + text_callback(text) + + +class DevboxPtyProcess: + """PTY-backed command process.""" + + def __init__(self, session: DevboxPtySession, exit_marker: str) -> None: + self._session = session + self._exit_marker = exit_marker.encode("ascii") + self._raw_callbacks: list[BytesCallback] = [] + self._text_callbacks: list[TextCallback] = [] + self._queue: queue.Queue[bytes | object] = queue.Queue() + self._decoder = codecs.getincrementaldecoder("utf-8")() + self._pending = b"" + self._exit_code: int | None = None + self._done = threading.Event() + session.on_data(self._handle_data) + + @property + def raw_output(self) -> Iterator[bytes]: + while True: + item = self._queue.get() + if item is _CLOSE_SENTINEL: + return + yield item # type: ignore[misc] + + @property + def output(self) -> Iterator[str]: + decoder = codecs.getincrementaldecoder("utf-8")() + for chunk in self.raw_output: + text = decoder.decode(chunk) + if text: + yield text + tail = decoder.decode(b"", final=True) + if tail: + yield tail + + @property + def exit_code(self) -> int | None: + return self._exit_code + + def on_data(self, callback: BytesCallback) -> None: + self._raw_callbacks.append(callback) + + def on_output(self, callback: TextCallback) -> None: + self._text_callbacks.append(callback) + + def write(self, chars: str | bytes) -> None: + self._session.send(chars) + + def resize(self, cols: int, rows: int) -> None: + self._session.resize(cols, rows) + + def interrupt(self) -> None: + self._session.signal("SIGINT") + + def close(self) -> None: + self._session.terminate() + + def wait(self, timeout: float | None = None) -> int | None: + self._done.wait(timeout) + if self._exit_code is None: + self._session.wait_for_close(timeout=0) + return self._exit_code + + def _handle_data(self, chunk: bytes) -> None: + if self._done.is_set(): + return + self._pending += chunk + marker_index = self._pending.find(self._exit_marker) + if marker_index >= 0: + self._publish(self._pending[:marker_index]) + rest = self._pending[marker_index + len(self._exit_marker) :] + if rest.startswith(b":"): + digits = bytearray() + for byte in rest[1:]: + if 48 <= byte <= 57: + digits.append(byte) + else: + break + if digits: + self._exit_code = int(digits.decode("ascii")) + self._pending = b"" + self._done.set() + self._queue.put(_CLOSE_SENTINEL) + return + + keep = len(self._exit_marker) + 32 + if len(self._pending) > keep: + self._publish(self._pending[:-keep]) + self._pending = self._pending[-keep:] + + def _publish(self, chunk: bytes) -> None: + if not chunk: + return + self._queue.put(chunk) + for raw_callback in list(self._raw_callbacks): + raw_callback(chunk) + text = self._decoder.decode(chunk) + if text: + for text_callback in list(self._text_callbacks): + text_callback(text) diff --git a/src/runloop_api_client/types/__init__.py b/src/runloop_api_client/types/__init__.py index 01130880d..db49cc15f 100644 --- a/src/runloop_api_client/types/__init__.py +++ b/src/runloop_api_client/types/__init__.py @@ -30,6 +30,7 @@ from .axon_list_params import AxonListParams as AxonListParams from .devbox_list_view import DevboxListView as DevboxListView from .object_list_view import ObjectListView as ObjectListView +from .pty_connect_view import PtyConnectView as PtyConnectView from .scope_entry_view import ScopeEntryView as ScopeEntryView from .scoring_contract import ScoringContract as ScoringContract from .scoring_function import ScoringFunction as ScoringFunction @@ -41,6 +42,8 @@ from .benchmark_run_view import BenchmarkRunView as BenchmarkRunView from .devbox_list_params import DevboxListParams as DevboxListParams from .object_list_params import ObjectListParams as ObjectListParams +from .pty_connect_params import PtyConnectParams as PtyConnectParams +from .pty_control_params import PtyControlParams as PtyControlParams from .secret_list_params import SecretListParams as SecretListParams from .agent_create_params import AgentCreateParams as AgentCreateParams from .axon_publish_params import AxonPublishParams as AxonPublishParams diff --git a/src/runloop_api_client/types/pty_connect_params.py b/src/runloop_api_client/types/pty_connect_params.py new file mode 100644 index 000000000..c2f529451 --- /dev/null +++ b/src/runloop_api_client/types/pty_connect_params.py @@ -0,0 +1,24 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Dict, Optional, TypedDict + +__all__ = ["PtyConnectParams"] + + +class PtyConnectParams(TypedDict, total=False): + command: Optional[str] + """Optional command to start in the PTY.""" + + cwd: Optional[str] + """Optional working directory.""" + + env: Optional[Dict[str, str]] + """Environment variables to set for the PTY process.""" + + cols: Optional[int] + """Terminal width in columns.""" + + rows: Optional[int] + """Terminal height in rows.""" diff --git a/src/runloop_api_client/types/pty_connect_view.py b/src/runloop_api_client/types/pty_connect_view.py new file mode 100644 index 000000000..cdb4464f4 --- /dev/null +++ b/src/runloop_api_client/types/pty_connect_view.py @@ -0,0 +1,15 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional + +from .._models import BaseModel + +__all__ = ["PtyConnectView"] + + +class PtyConnectView(BaseModel): + connect_url: str + """WebSocket URL path used to attach to the PTY stream.""" + + session_id: Optional[str] = None + """Optional server-side PTY session identifier.""" diff --git a/src/runloop_api_client/types/pty_control_params.py b/src/runloop_api_client/types/pty_control_params.py new file mode 100644 index 000000000..891cc5fc8 --- /dev/null +++ b/src/runloop_api_client/types/pty_control_params.py @@ -0,0 +1,22 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Optional, TypedDict +from typing_extensions import Literal + +__all__ = ["PtyControlParams"] + + +class PtyControlParams(TypedDict, total=False): + action: Literal["resize", "signal", "close"] + """Control action to apply to the PTY session.""" + + cols: Optional[int] + """Terminal width in columns for resize actions.""" + + rows: Optional[int] + """Terminal height in rows for resize actions.""" + + signal: Optional[str] + """Signal name for signal actions.""" diff --git a/tests/api_resources/test_pty.py b/tests/api_resources/test_pty.py new file mode 100644 index 000000000..c3beb2588 --- /dev/null +++ b/tests/api_resources/test_pty.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json + +import httpx + +from tests.utils import assert_matches_type +from runloop_api_client import Runloop, AsyncRunloop +from runloop_api_client.types import PtyConnectView + + +def test_method_connect() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"connect_url": "/pty/ws/session", "session_id": "pty_123"}) + + client = Runloop( + bearer_token="token", + base_url="https://13-key.tunnel.runloop.ai", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + response = client.pty.connect(command="/bin/sh", cols=100, rows=40) + + assert_matches_type(PtyConnectView, response, path=["response"]) + assert response.connect_url == "/pty/ws/session" + assert requests[0].url == "https://13-key.tunnel.runloop.ai/pty/connect" + assert json.loads(requests[0].content) == {"command": "/bin/sh", "cols": 100, "rows": 40} + + +def test_method_control() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={}) + + client = Runloop( + bearer_token="token", + base_url="https://13-key.tunnel.runloop.ai", + http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + response = client.pty.control(action="resize", cols=120, rows=30) + + assert response == {} + assert requests[0].url == "https://13-key.tunnel.runloop.ai/pty/control" + assert json.loads(requests[0].content) == {"action": "resize", "cols": 120, "rows": 30} + + +async def test_async_method_connect() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"connect_url": "/pty/ws/session"}) + + client = AsyncRunloop( + bearer_token="token", + base_url="https://13-key.tunnel.runloop.ai", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + response = await client.pty.connect(command="/bin/sh") + + assert_matches_type(PtyConnectView, response, path=["response"]) + assert requests[0].url == "https://13-key.tunnel.runloop.ai/pty/connect" diff --git a/tests/sdk/test_pty.py b/tests/sdk/test_pty.py new file mode 100644 index 000000000..3a52037ce --- /dev/null +++ b/tests/sdk/test_pty.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import re +import asyncio +from types import SimpleNamespace +from unittest.mock import Mock, AsyncMock, patch + +import pytest + +from runloop_api_client import Runloop, AsyncRunloop +from runloop_api_client.sdk.pty import DevboxPtyProcess, DevboxPtySession +from runloop_api_client.sdk.devbox import Devbox +from runloop_api_client.sdk.async_pty import AsyncDevboxPtyProcess, AsyncDevboxPtySession + + +class FakeWebSocket: + def __init__(self, messages: list[bytes | str | None] | None = None) -> None: + self.messages = list(messages or []) + self.sent: list[bytes] = [] + self.closed = False + + def recv(self) -> bytes | str | None: + if self.messages: + return self.messages.pop(0) + return None + + def send_binary(self, payload: bytes) -> None: + self.sent.append(payload) + + def close(self) -> None: + self.closed = True + + +class AsyncFakeWebSocket: + def __init__(self) -> None: + self.messages: asyncio.Queue[bytes | str | None] = asyncio.Queue() + self.sent: list[bytes] = [] + self.closed = False + + async def recv(self) -> bytes | str | None: + return await self.messages.get() + + async def send(self, payload: bytes) -> None: + self.sent.append(payload) + + async def close(self) -> None: + self.closed = True + await self.messages.put(None) + + +def test_devbox_exposes_pty() -> None: + client = Runloop(bearer_token="token", base_url="https://api.runloop.ai") + devbox = Devbox(client, "dbx_123") + + assert devbox.pty is not None + + +def test_open_creates_tunnel_client_and_attaches_websocket() -> None: + client = Runloop(bearer_token="root", base_url="https://api.runloop.ai") + tunnel = SimpleNamespace(tunnel_key="abc123", auth_token="tunnel-token") + client.devboxes.create_pty_tunnel = Mock(return_value=tunnel) # type: ignore[method-assign] + tunnel_client = Mock() + tunnel_client.pty.connect.return_value = SimpleNamespace(connect_url="/pty/ws/session") + ws = FakeWebSocket([b"hello", None]) + websocket_module = SimpleNamespace(create_connection=Mock(return_value=ws)) + + with patch("runloop_api_client.sdk.pty.Runloop", Mock(return_value=tunnel_client)) as runloop_cls: + with patch("runloop_api_client.sdk.pty.import_module", Mock(return_value=websocket_module)): + session = Devbox(client, "dbx_123").pty.open(command="/bin/bash", cols=100, rows=40) + + assert list(session.raw_output) == [b"hello"] + client.devboxes.create_pty_tunnel.assert_called_once_with("dbx_123") + runloop_cls.assert_called_once_with(base_url="https://13-abc123.tunnel.runloop.ai", bearer_token="tunnel-token") + _, kwargs = tunnel_client.pty.connect.call_args + assert kwargs["command"] == "/bin/bash" + assert kwargs["cols"] == 100 + assert kwargs["rows"] == 40 + + +def test_open_connect_kwargs_and_websocket_headers() -> None: + client = Runloop(bearer_token="root", base_url="https://api.runloop.ai") + client.devboxes.create_pty_tunnel = Mock( # type: ignore[method-assign] + return_value=SimpleNamespace(tunnel_key="abc123", auth_token="tunnel-token") + ) + tunnel_client = Mock() + tunnel_client.pty.connect.return_value = SimpleNamespace(connect_url="pty/ws/session") + ws = FakeWebSocket([None]) + websocket_module = SimpleNamespace(create_connection=Mock(return_value=ws)) + + with patch("runloop_api_client.sdk.pty.Runloop", Mock(return_value=tunnel_client)): + with patch("runloop_api_client.sdk.pty.import_module", Mock(return_value=websocket_module)): + Devbox(client, "dbx_123").pty.open(command="/bin/bash", cols=100, rows=40, cwd="/work", env={"A": "B"}) + + _, kwargs = tunnel_client.pty.connect.call_args + assert kwargs == {"command": "/bin/bash", "cols": 100, "rows": 40, "cwd": "/work", "env": {"A": "B"}} + websocket_module.create_connection.assert_called_once_with( + "wss://13-abc123.tunnel.runloop.ai/pty/ws/session", + header=["Authorization: Bearer tunnel-token"], + ) + + +def test_session_close_detaches_and_terminate_sends_close_control() -> None: + tunnel_client = Mock() + ws = FakeWebSocket([None]) + session = DevboxPtySession(tunnel_client, ws) + + session.close() + + assert ws.closed is True + tunnel_client.pty.control.assert_not_called() + + ws2 = FakeWebSocket([None]) + session2 = DevboxPtySession(tunnel_client, ws2) + session2.terminate() + + tunnel_client.pty.control.assert_called_with(action="close") + assert ws2.closed is True + + +def test_resize_signal_and_process_helpers() -> None: + tunnel_client = Mock() + ws = FakeWebSocket([]) + session = DevboxPtySession(tunnel_client, ws) + process = DevboxPtyProcess(session, "__RUNLOOP_TEST__") + + session.resize(120, 30) + session.signal("SIGTERM") + process.write("hello") + process.interrupt() + process.close() + + assert ws.sent == [b"hello"] + tunnel_client.pty.control.assert_any_call(action="resize", cols=120, rows=30) + tunnel_client.pty.control.assert_any_call(action="signal", signal="SIGTERM") + tunnel_client.pty.control.assert_any_call(action="signal", signal="SIGINT") + tunnel_client.pty.control.assert_any_call(action="close") + + +def test_process_detects_exit_marker_and_filters_output() -> None: + tunnel_client = Mock() + ws = FakeWebSocket([]) + session = DevboxPtySession(tunnel_client, ws) + process = DevboxPtyProcess(session, "__RUNLOOP_TEST__") + + session._emit(b"out") # pyright: ignore[reportPrivateUsage] + session._emit(b"put\n__RUNLOOP_TEST__:7\n") # pyright: ignore[reportPrivateUsage] + + assert list(process.raw_output) == [b"output\n"] + assert process.wait(timeout=0) == 7 + + +def test_exec_wraps_command_and_sends_marker() -> None: + client = Runloop(bearer_token="root", base_url="https://api.runloop.ai") + client.devboxes.create_pty_tunnel = Mock( # type: ignore[method-assign] + return_value=SimpleNamespace(tunnel_key="abc123", auth_token="tunnel-token") + ) + tunnel_client = Mock() + tunnel_client.pty.connect.return_value = SimpleNamespace(connect_url="/pty/ws/session") + ws = FakeWebSocket([None]) + + with patch("runloop_api_client.sdk.pty.Runloop", Mock(return_value=tunnel_client)): + websocket_module = SimpleNamespace(create_connection=Mock(return_value=ws)) + with patch("runloop_api_client.sdk.pty.import_module", Mock(return_value=websocket_module)): + process = Devbox(client, "dbx_123").pty.exec("echo hi") + + sent = ws.sent[0].decode("utf-8") + assert "echo hi\n__runloop_status=$?" in sent + assert re.search(r"__RUNLOOP_PTY_EXIT_[a-f0-9]{32}__:%s", sent) + assert process.exit_code is None + + +@pytest.mark.asyncio +async def test_async_session_and_process() -> None: + tunnel_client = AsyncMock() + ws = AsyncFakeWebSocket() + session = AsyncDevboxPtySession(tunnel_client, ws) + process = AsyncDevboxPtyProcess(session, "__RUNLOOP_TEST__") + + await ws.messages.put(b"hello") + await ws.messages.put(b"\n__RUNLOOP_TEST__:3\n") + + assert [chunk async for chunk in process.raw_output] == [b"hello\n"] + assert await process.wait(timeout=1) == 3 + + await process.write("x") + await process.resize(90, 20) + await process.interrupt() + await process.close() + + assert ws.sent == [b"x"] + tunnel_client.pty.control.assert_any_await(action="resize", cols=90, rows=20) + tunnel_client.pty.control.assert_any_await(action="signal", signal="SIGINT") + tunnel_client.pty.control.assert_any_await(action="close") + + +@pytest.mark.asyncio +async def test_async_open_attaches_with_authorization_header() -> None: + client = AsyncRunloop(bearer_token="root", base_url="https://api.runloop.ai") + client.devboxes.create_pty_tunnel = AsyncMock( # type: ignore[method-assign] + return_value=SimpleNamespace(tunnel_key="abc123", auth_token="tunnel-token") + ) + tunnel_client = AsyncMock() + tunnel_client.pty.connect.return_value = SimpleNamespace(connect_url="/pty/ws/session") + ws = AsyncFakeWebSocket() + connect = AsyncMock(return_value=ws) + + with patch("runloop_api_client.sdk.async_pty.AsyncRunloop", Mock(return_value=tunnel_client)): + with patch("runloop_api_client.sdk.async_pty.import_module", Mock(return_value=SimpleNamespace(connect=connect))): + from runloop_api_client.sdk.async_devbox import AsyncDevbox + + await AsyncDevbox(client, "dbx_123").pty.open(command="/bin/sh") + + connect.assert_awaited_once_with( + "wss://13-abc123.tunnel.runloop.ai/pty/ws/session", + additional_headers={"Authorization": "Bearer tunnel-token"}, + ) diff --git a/uv.lock b/uv.lock index a35165b2c..f32aebd74 100644 --- a/uv.lock +++ b/uv.lock @@ -2422,7 +2422,7 @@ wheels = [ [[package]] name = "runloop-api-client" -version = "1.20.2" +version = "1.20.3" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -2432,6 +2432,8 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, { name = "uuid-utils" }, + { name = "websocket-client" }, + { name = "websockets" }, ] [package.optional-dependencies] @@ -2486,6 +2488,8 @@ requires-dist = [ { name = "sniffio" }, { name = "typing-extensions", specifier = ">=4.14,<5" }, { name = "uuid-utils", specifier = ">=0.11.0" }, + { name = "websocket-client", specifier = ">=1.8.0,<2" }, + { name = "websockets", specifier = ">=13.0,<16" }, ] provides-extras = ["aiohttp"] @@ -3294,6 +3298,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424, upload-time = "2025-03-05T20:02:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077, upload-time = "2025-03-05T20:02:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324, upload-time = "2025-03-05T20:02:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094, upload-time = "2025-03-05T20:03:01.827Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094, upload-time = "2025-03-05T20:03:03.123Z" }, + { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397, upload-time = "2025-03-05T20:03:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794, upload-time = "2025-03-05T20:03:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194, upload-time = "2025-03-05T20:03:08.844Z" }, + { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164, upload-time = "2025-03-05T20:03:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381, upload-time = "2025-03-05T20:03:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841, upload-time = "2025-03-05T20:03:14.367Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106, upload-time = "2025-03-05T20:03:29.404Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339, upload-time = "2025-03-05T20:03:30.755Z" }, + { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597, upload-time = "2025-03-05T20:03:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205, upload-time = "2025-03-05T20:03:33.731Z" }, + { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150, upload-time = "2025-03-05T20:03:35.757Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877, upload-time = "2025-03-05T20:03:37.199Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + [[package]] name = "yarl" version = "1.22.0"