From f8cec7ed39f0620f734fd45e419a0cde91dc748b Mon Sep 17 00:00:00 2001 From: shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:26:25 +0530 Subject: [PATCH 1/2] Add interactive terminal support: pty.attach and persistent_pty.attach Implements the two WebSocket endpoints the generator omits (pty.connect and persistentPty.connect) as hand-written mixins on the generated resources, with sync and asyncio connections built on the optional websockets extra. --- .github/workflows/ci.yml | 2 +- README.md | 21 + codegen/render.py | 18 + examples/terminal.py | 55 +++ pyproject.toml | 6 + src/opencode/__init__.py | 4 + src/opencode/_pty.py | 586 +++++++++++++++++++++++++ src/opencode/resources/experimental.py | 5 +- src/opencode/resources/pty.py | 5 +- tests/test_live.py | 16 + tests/test_pty.py | 236 ++++++++++ uv.lock | 125 ++++++ 12 files changed, 1074 insertions(+), 5 deletions(-) create mode 100644 examples/terminal.py create mode 100644 src/opencode/_pty.py create mode 100644 tests/test_pty.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52b745..ae2447a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: with: python-version: "3.10" - run: >- - uv run --isolated --resolution lowest-direct --with-editable . + uv run --isolated --resolution lowest-direct --with-editable '.[pty]' --with pytest --with respx --with trio pytest -m "not live" -p no:cacheprovider diff --git a/README.md b/README.md index 4d01836..9fc75ba 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,27 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since they stay open between events. +## Terminals + +`client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra: +`pip install "opencode-python-sdk[pty]"`. + +```python +pty = client.pty.create(command="/bin/sh").data +with client.pty.attach(pty.id) as term: + term.resize(120, 40) + term.send("echo hi\n") + for chunk in term: # output bytes, until the shell exits + print(chunk.decode(errors="replace"), end="") +``` + +`recv(timeout=...)` reads one chunk and returns `None` once the terminal has ended. `term.cursor` holds the output +position to pass as `cursor=` when reattaching; `cursor=-1` skips the replay of earlier output. Persistent terminals +work the same way through `client.experimental.persistent_pty.attach(pty_id, role=..., takeover=...)`. The async +client returns `AsyncPtyConnection` (asyncio only): `async with await client.pty.attach(...) as term`. A connection +the server refuses or closes abnormally raises `PtyConnectionError`. See +[`examples/terminal.py`](examples/terminal.py) for a full TTY bridge. + ## Errors Every error derives from `opencode.OpencodeError`. Errors the server declares are raised as their own class, with a diff --git a/codegen/render.py b/codegen/render.py index dc22306..6eb622b 100644 --- a/codegen/render.py +++ b/codegen/render.py @@ -378,12 +378,29 @@ def _render_method(operation: Operation, errors: dict[str, dict[str, Any]], *, i return lines +# Hand-written methods mixed into generated resources: resource path -> (module, sync mixin, async mixin). +# The mixins subclass the resource base classes and cover endpoints the generator cannot express (WebSockets). +MIXINS: dict[tuple[str, ...], tuple[str, str, str]] = { + ("pty",): ("_pty", "PtyAttachMixin", "AsyncPtyAttachMixin"), + ("experimental", "persistentPty"): ("_pty", "PersistentPtyAttachMixin", "AsyncPersistentPtyAttachMixin"), +} + + +def _mixins(resource: Resource) -> list[tuple[str, str, str]]: + found = [MIXINS[tuple(resource.path)]] if tuple(resource.path) in MIXINS else [] + for child in resource.children.values(): + found += _mixins(child) + return found + + def _resource_classes(resource: Resource, errors: dict[str, dict[str, Any]], *, is_async: bool) -> list[str]: blocks: list[str] = [] for child in resource.children.values(): blocks += _resource_classes(child, errors, is_async=is_async) prefix = "Async" if is_async else "" base = "AsyncAPIResource" if is_async else "SyncAPIResource" + if tuple(resource.path) in MIXINS: + base = MIXINS[tuple(resource.path)][2 if is_async else 1] lines = [f"class {prefix}{resource.class_name}Resource({base}):"] title = ".".join(resource.path) lines.append(f' """Operations under ``client.{_attr_path(resource)}`` (OpenCode ``{title}``)."""\n') @@ -457,6 +474,7 @@ def render_resource_module( if any(op.kind == "page" for op in operations) else [] ), + *[f"from ..{module} import {', '.join(sorted({sync, async_}))}" for module, sync, async_ in _mixins(resource)], *([f"from .._qs import {', '.join(runtime)}"] if runtime else []), "from .._resource import AsyncAPIResource, SyncAPIResource", *(["from .._streaming import AsyncStream, Stream"] if any(op.kind == "stream" for op in operations) else []), diff --git a/examples/terminal.py b/examples/terminal.py new file mode 100644 index 0000000..66131f9 --- /dev/null +++ b/examples/terminal.py @@ -0,0 +1,55 @@ +"""Opens a shell on an OpenCode server and bridges it to this terminal. Exit the shell to quit. + +Usage: OPENCODE_BASE_URL=http://127.0.0.1:4096 OPENCODE_PASSWORD=... uv run python examples/terminal.py + +Needs the ``pty`` extra (``pip install "opencode-python-sdk[pty]"``) and a POSIX terminal. +""" + +from __future__ import annotations + +import os +import shutil +import signal +import sys +import termios +import threading +import tty +from typing import Any + +from opencode import Opencode, PtyConnection + + +def pump_input(term: PtyConnection) -> None: + """Forwards keystrokes to the remote shell.""" + fd = sys.stdin.fileno() + while data := os.read(fd, 1024): + term.send(data) + + +def main() -> None: + with Opencode() as client: + pty = client.pty.create(command=os.environ.get("SHELL", "/bin/sh"), title="examples/terminal.py").data + try: + with client.pty.attach(pty.id) as term: + size = shutil.get_terminal_size() + term.resize(size.columns, size.lines) + + def on_resize(*_: Any) -> None: + size = shutil.get_terminal_size() + term.resize(size.columns, size.lines) + + signal.signal(signal.SIGWINCH, on_resize) + saved = termios.tcgetattr(sys.stdin) + tty.setraw(sys.stdin) + try: + threading.Thread(target=pump_input, args=(term,), daemon=True).start() + for chunk in term: + os.write(sys.stdout.fileno(), chunk) + finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, saved) + finally: + client.pty.remove(pty.id) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 939b9c2..7ee4e8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,11 @@ Repository = "https://github.com/shrijayan/opencode-python-sdk" Issues = "https://github.com/shrijayan/opencode-python-sdk/issues" Changelog = "https://github.com/shrijayan/opencode-python-sdk/blob/main/CHANGELOG.md" +[project.optional-dependencies] +pty = [ + "websockets>=14,<17", +] + [dependency-groups] dev = [ "mypy>=1.18", @@ -48,6 +53,7 @@ dev = [ "respx>=0.22", "ruff>=0.14", "trio>=0.31", + "websockets>=14", ] docs = [ "mkdocs-material[imaging]>=9.6", diff --git a/src/opencode/__init__.py b/src/opencode/__init__.py index 469151e..6dd71e8 100644 --- a/src/opencode/__init__.py +++ b/src/opencode/__init__.py @@ -75,6 +75,7 @@ ) from ._models import BaseModel from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage +from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError from ._streaming import AsyncStream, Stream from ._types import NOT_GIVEN, NotGiven @@ -97,6 +98,7 @@ "AsyncOpencode", "AsyncOpencodeServer", "AsyncPaginator", + "AsyncPtyConnection", "AsyncStream", "AuthenticationError", "BadRequestError", @@ -111,6 +113,8 @@ "OpencodeError", "OpencodeServer", "PermissionDeniedError", + "PtyConnection", + "PtyConnectionError", "RateLimitError", "ServerStartError", "ServiceUnavailableError", diff --git a/src/opencode/_pty.py b/src/opencode/_pty.py new file mode 100644 index 0000000..3054d32 --- /dev/null +++ b/src/opencode/_pty.py @@ -0,0 +1,586 @@ +"""Interactive terminals: the PTY WebSocket endpoints. + +The generator skips ``pty.connect`` and ``persistentPty.connect`` because they are WebSockets, so this module +implements them by hand and the generated ``client.pty`` and ``client.experimental.persistent_pty`` resources +mix the ``attach`` methods in. It needs the optional ``websockets`` dependency:: + + pip install "opencode-python-sdk[pty]" + +Protocol (OpenCode 2.1.2): + +- Opening a socket takes a single-use ticket from ``POST .../connect-token`` (sent with + ``x-opencode-ticket: 1``), passed as the ``ticket`` query parameter. Tickets expire after 60 seconds. +- ``GET /api/pty/:ptyID/connect`` takes ``location[directory]`` and an optional ``cursor`` (``-1`` skips the + replay). The server replays buffered output, then sends one binary control frame, ``0x00`` followed by + ``{"cursor": n}`` JSON, then live output. Output frames are UTF-8 text. Input frames are UTF-8 text or binary; + invalid UTF-8 is dropped. Resizing goes through ``PUT /api/pty/:ptyID``. +- ``GET /api/experimental/persistent-pty/:ptyID/connect`` takes ``cursor`` (byte offset, default ``0``), + ``role`` (``controller`` or ``observer``), ``attachment_id``, ``takeover`` and ``input_protocol``. Output frames + are binary; text frames are JSON events: ``attached`` first, then ``replay_complete`` after the replay, and + later ones such as ``resized``. With ``input_protocol=1`` every input frame is binary: one type byte (``0`` + control, ``1`` input), big-endian ``uint16`` columns and rows, then the input bytes. A control frame claims + control at that size. +- Close code ``1000`` means the terminal ended; ``4404`` means it was not found, has exited or is unavailable. +""" + +from __future__ import annotations + +import json +import struct +import uuid +from typing import TYPE_CHECKING, Any, Literal, cast +from urllib.parse import urlencode + +from typing_extensions import Self + +from ._exceptions import OpencodeError +from ._qs import encode_path_segment, encode_query +from ._resource import AsyncAPIResource, SyncAPIResource +from ._types import NOT_GIVEN, NotGiven + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping + from types import TracebackType + + import httpx + from websockets.asyncio.client import ClientConnection as AsyncWebSocket + from websockets.sync.client import ClientConnection as SyncWebSocket + + from .types import Location, PtyID + +__all__ = [ + "AsyncPersistentPtyAttachMixin", + "AsyncPtyAttachMixin", + "AsyncPtyConnection", + "PersistentPtyAttachMixin", + "PtyAttachMixin", + "PtyConnection", + "PtyConnectionError", +] + +TICKET_HEADER = "x-opencode-ticket" +CLOSE_NORMAL = 1000 +CLOSE_NOT_FOUND = 4404 +_FRAME_CONTROL = 0 +_FRAME_INPUT = 1 + + +class PtyConnectionError(OpencodeError): + """The terminal WebSocket could not be opened or closed abnormally. + + Attributes: + status: HTTP status of a refused handshake (``403`` for a bad ticket, ``404`` for an unknown PTY). + code: WebSocket close code (``4404`` when the terminal is not found, exited or unavailable). + reason: WebSocket close reason. + """ + + def __init__(self, message: str, *, status: int | None = None, code: int | None = None, reason: str = "") -> None: + super().__init__(message) + self.status = status + self.code = code + self.reason = reason + + +def _missing() -> PtyConnectionError: + return PtyConnectionError( + 'Terminal support needs the websockets package. Install it with: pip install "opencode-python-sdk[pty]"' + ) + + +def _ws_url(base_url: httpx.URL, path: str, query: Mapping[str, object]) -> str: + url = base_url.join(path.lstrip("/")) + scheme = "wss" if url.scheme == "https" else "ws" + return str(url.copy_with(scheme=scheme, query=urlencode(encode_query(query)).encode("ascii"))) + + +def _headers(username: str, password: str | None, extra: Mapping[str, str] | None) -> dict[str, str]: + import base64 + + headers: dict[str, str] = {} + if password is not None: + headers["Authorization"] = "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + headers.update(extra or {}) + return headers + + +def _handshake_error(exc: Exception) -> PtyConnectionError: + response = getattr(exc, "response", None) + status = cast("int | None", getattr(response, "status_code", None)) + return PtyConnectionError(f"Could not open the terminal WebSocket: {exc}", status=status) + + +class _Protocol: + """Frame handling shared by the sync and async connections.""" + + def __init__(self, *, persistent: bool, cols: int, rows: int) -> None: + self.persistent = persistent + self.cols = cols + self.rows = rows + self.cursor: int | None = None + self.attached: dict[str, Any] | None = None + + def encode_input(self, data: bytes | str) -> bytes | str: + if not self.persistent: + return data + payload = data.encode() if isinstance(data, str) else data + return struct.pack(">BHH", _FRAME_INPUT, self.cols, self.rows) + payload + + def encode_control(self, cols: int, rows: int) -> bytes: + return struct.pack(">BHH", _FRAME_CONTROL, cols, rows) + + def decode(self, frame: bytes | str) -> tuple[bytes | None, dict[str, Any] | None]: + """Returns ``(output, event)``; control frames update the connection state and return no output.""" + if self.persistent: + if isinstance(frame, bytes): + # Output frames are the daemon's byte range, so the resume offset advances by their length. + if self.cursor is not None: + self.cursor += len(frame) + return frame, None + event = cast("dict[str, Any]", json.loads(frame)) + self._apply(event) + return None, event + if isinstance(frame, str): + return frame.encode(), None + if frame[:1] == b"\x00": + meta = cast("dict[str, Any]", json.loads(frame[1:])) + self.cursor = int(meta["cursor"]) + return None, meta + return frame, None + + def _apply(self, event: dict[str, Any]) -> None: + kind = event.get("type") + if kind == "attached": + self.attached = event + size = cast("dict[str, int]", event.get("info", {}).get("size") or {}) + self.cols = size.get("cols", self.cols) + self.rows = size.get("rows", self.rows) + self.cursor = cast("int | None", event.get("replay", {}).get("endOffset", self.cursor)) + elif kind == "replay_complete": + self.cursor = int(event["endOffset"]) + elif kind == "resized": + self.cols = int(event.get("cols", self.cols)) + self.rows = int(event.get("rows", self.rows)) + + +def _closed(code: int | None, reason: str) -> PtyConnectionError | None: + if code in (CLOSE_NORMAL, None): + return None + return PtyConnectionError( + f"Terminal connection closed with code {code}: {reason or 'no reason'}", code=code, reason=reason + ) + + +class PtyConnection: + """A synchronous connection to a remote terminal. + + Iterate over it, or call :meth:`recv`, for output bytes; it stops when the terminal ends. Control frames + are handled internally: :attr:`cursor` holds the latest output position the server reported, and + ``on_event`` (persistent terminals) receives every JSON event. + + Example:: + + with client.pty.attach(pty.id) as term: + term.send("echo hi\\n") + for chunk in term: + print(chunk.decode(errors="replace"), end="") + """ + + def __init__( + self, + ws: SyncWebSocket, + protocol: _Protocol, + resize: Callable[[int, int], object], + on_event: Callable[[dict[str, Any]], object] | None = None, + ) -> None: + self._ws = ws + self._protocol = protocol + self._resize = resize + self._on_event = on_event + + @property + def cursor(self) -> int | None: + """Output position to pass as ``cursor`` when reattaching, once the server has reported one.""" + return self._protocol.cursor + + @property + def attached(self) -> dict[str, Any] | None: + """The ``attached`` event of a persistent terminal (attachment id, role, info, replay offsets).""" + return self._protocol.attached + + @property + def close_code(self) -> int | None: + """The WebSocket close code once the connection has closed.""" + return self._ws.protocol.close_code + + def send(self, data: bytes | str) -> None: + """Writes input to the terminal. ``str`` is sent as UTF-8.""" + self._ws.send(self._protocol.encode_input(data)) + + def resize(self, cols: int, rows: int) -> None: + """Resizes the terminal.""" + if self._protocol.persistent: + self._protocol.cols, self._protocol.rows = cols, rows + self._ws.send(self._protocol.encode_control(cols, rows)) + else: + self._resize(cols, rows) + + def recv(self, timeout: float | None = None) -> bytes | None: + """Returns the next output chunk, or ``None`` once the terminal has ended. + + Raises: + TimeoutError: No output within ``timeout`` seconds. + PtyConnectionError: The server closed the connection abnormally (``4404``: terminal unavailable). + """ + while (output := self._next(timeout)) == b"": + pass + return output + + def _next(self, timeout: float | None) -> bytes | None: + """Reads one frame: output bytes, ``b""`` for a control frame, ``None`` after a normal close.""" + from websockets.exceptions import ConnectionClosed + + try: + frame = self._ws.recv(timeout) + except ConnectionClosed as exc: + error = _closed(exc.rcvd.code if exc.rcvd else None, exc.rcvd.reason if exc.rcvd else "") + if error is not None: + raise error from None + return None + output, event = self._protocol.decode(frame) + if event is not None and self._on_event is not None and self._protocol.persistent: + self._on_event(event) + return output or b"" + + def wait_attached(self, timeout: float | None) -> None: + """Reads frames until the ``attached`` event of a persistent terminal; ``attach`` already does this.""" + while self._protocol.attached is None: + if self._next(timeout) is None: + raise PtyConnectionError("Terminal connection closed before it was attached", code=self.close_code) + + def __iter__(self) -> Iterator[bytes]: + while (chunk := self.recv()) is not None: + yield chunk + + def close(self) -> None: + """Closes the connection. The terminal keeps running on the server.""" + self._ws.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None + ) -> None: + self.close() + + +class AsyncPtyConnection: + """An asyncio connection to a remote terminal. Same interface as :class:`PtyConnection`, with awaitables. + + Example:: + + async with await client.pty.attach(pty.id) as term: + await term.send("echo hi\\n") + async for chunk in term: + print(chunk.decode(errors="replace"), end="") + """ + + def __init__( + self, + ws: AsyncWebSocket, + protocol: _Protocol, + resize: Callable[[int, int], Awaitable[object]], + on_event: Callable[[dict[str, Any]], object] | None = None, + ) -> None: + self._ws = ws + self._protocol = protocol + self._resize = resize + self._on_event = on_event + + @property + def cursor(self) -> int | None: + """Output position to pass as ``cursor`` when reattaching, once the server has reported one.""" + return self._protocol.cursor + + @property + def attached(self) -> dict[str, Any] | None: + """The ``attached`` event of a persistent terminal (attachment id, role, info, replay offsets).""" + return self._protocol.attached + + @property + def close_code(self) -> int | None: + """The WebSocket close code once the connection has closed.""" + return self._ws.protocol.close_code + + async def send(self, data: bytes | str) -> None: + """Writes input to the terminal. ``str`` is sent as UTF-8.""" + await self._ws.send(self._protocol.encode_input(data)) + + async def resize(self, cols: int, rows: int) -> None: + """Resizes the terminal.""" + if self._protocol.persistent: + self._protocol.cols, self._protocol.rows = cols, rows + await self._ws.send(self._protocol.encode_control(cols, rows)) + else: + await self._resize(cols, rows) + + async def recv(self) -> bytes | None: + """Returns the next output chunk, or ``None`` once the terminal has ended. + + Raises: + PtyConnectionError: The server closed the connection abnormally (``4404``: terminal unavailable). + """ + while (output := await self._next()) == b"": + pass + return output + + async def _next(self) -> bytes | None: + """Reads one frame: output bytes, ``b""`` for a control frame, ``None`` after a normal close.""" + from websockets.exceptions import ConnectionClosed + + try: + frame = await self._ws.recv() + except ConnectionClosed as exc: + error = _closed(exc.rcvd.code if exc.rcvd else None, exc.rcvd.reason if exc.rcvd else "") + if error is not None: + raise error from None + return None + output, event = self._protocol.decode(frame) + if event is not None and self._on_event is not None and self._protocol.persistent: + self._on_event(event) + return output or b"" + + async def wait_attached(self) -> None: + """Reads frames until the ``attached`` event of a persistent terminal; ``attach`` already does this.""" + while self._protocol.attached is None: + if await self._next() is None: + raise PtyConnectionError("Terminal connection closed before it was attached", code=self.close_code) + + async def __aiter__(self) -> AsyncIterator[bytes]: + while (chunk := await self.recv()) is not None: + yield chunk + + async def close(self) -> None: + """Closes the connection. The terminal keeps running on the server.""" + await self._ws.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None + ) -> None: + await self.close() + + +def _open_sync(url: str, headers: dict[str, str], open_timeout: float | None) -> SyncWebSocket: + try: + from websockets.exceptions import InvalidHandshake + from websockets.sync.client import connect + except ImportError: + raise _missing() from None + try: + return connect(url, additional_headers=headers, max_size=None, open_timeout=open_timeout) + except InvalidHandshake as exc: + raise _handshake_error(exc) from exc + + +async def _open_async(url: str, headers: dict[str, str], open_timeout: float | None) -> AsyncWebSocket: + try: + from websockets.asyncio.client import connect + from websockets.exceptions import InvalidHandshake + except ImportError: + raise _missing() from None + try: + return await connect(url, additional_headers=headers, max_size=None, open_timeout=open_timeout) + except InvalidHandshake as exc: + raise _handshake_error(exc) from exc + + +def _require_websockets() -> None: + try: + import websockets # noqa: F401 # pyright: ignore[reportUnusedImport] + except ImportError: + raise _missing() from None + + +def _pty_query(location: object, cursor: int | None, ticket: str) -> dict[str, object]: + return {"location": location, "cursor": NOT_GIVEN if cursor is None else cursor, "ticket": ticket} + + +def _persistent_query( + cursor: int, role: Literal["controller", "observer"], attachment_id: str, takeover: bool, ticket: str +) -> dict[str, object]: + return { + "ticket": ticket, + "cursor": cursor, + "role": role, + "attachment_id": attachment_id, + "takeover": takeover, + "input_protocol": 1, + } + + +class PtyAttachMixin(SyncAPIResource): + """Adds :meth:`attach` to ``client.pty``.""" + + def attach( + self, + pty_id: PtyID, + *, + location: Location | NotGiven | None = NOT_GIVEN, + cursor: int | None = None, + open_timeout: float | None = 10, + extra_headers: Mapping[str, str] | None = None, + ) -> PtyConnection: + """Opens an interactive connection to a PTY created with :meth:`create`. + + Args: + pty_id: The PTY to attach to. + location: Project location; defaults to the client's ``directory``. + cursor: Output position to resume from (:attr:`PtyConnection.cursor` of an earlier connection). + ``None`` replays the whole buffer, ``-1`` skips the replay. + open_timeout: Seconds to wait for the WebSocket handshake. + extra_headers: Additional handshake headers. + + Raises: + PtyConnectionError: ``websockets`` is not installed, or the server refused the connection. + + ``GET /api/pty/:ptyID/connect`` (WebSocket) + """ + _require_websockets() + client = self._client + token = client.pty.connect.token(pty_id, location=location, x_opencode_ticket="1") + url = _ws_url( + client.base_url, + f"/api/pty/{encode_path_segment(pty_id)}/connect", + _pty_query(self._location(location), cursor, token.data.ticket), + ) + ws = _open_sync(url, _headers(client.username, client.password, extra_headers), open_timeout) + + def resize(cols: int, rows: int) -> object: + from .types import PtyUpdateSize + + return client.pty.update(pty_id, location=location, size=PtyUpdateSize(cols=cols, rows=rows)) + + return PtyConnection(ws, _Protocol(persistent=False, cols=0, rows=0), resize) + + +class AsyncPtyAttachMixin(AsyncAPIResource): + """Adds :meth:`attach` to ``client.pty``.""" + + async def attach( + self, + pty_id: PtyID, + *, + location: Location | NotGiven | None = NOT_GIVEN, + cursor: int | None = None, + open_timeout: float | None = 10, + extra_headers: Mapping[str, str] | None = None, + ) -> AsyncPtyConnection: + """Opens an interactive connection to a PTY. See :meth:`PtyAttachMixin.attach`. Needs asyncio. + + ``GET /api/pty/:ptyID/connect`` (WebSocket) + """ + _require_websockets() + client = self._client + token = await client.pty.connect.token(pty_id, location=location, x_opencode_ticket="1") + url = _ws_url( + client.base_url, + f"/api/pty/{encode_path_segment(pty_id)}/connect", + _pty_query(self._location(location), cursor, token.data.ticket), + ) + ws = await _open_async(url, _headers(client.username, client.password, extra_headers), open_timeout) + + async def resize(cols: int, rows: int) -> object: + from .types import PtyUpdateSize + + return await client.pty.update(pty_id, location=location, size=PtyUpdateSize(cols=cols, rows=rows)) + + return AsyncPtyConnection(ws, _Protocol(persistent=False, cols=0, rows=0), resize) + + +class PersistentPtyAttachMixin(SyncAPIResource): + """Adds :meth:`attach` to ``client.experimental.persistent_pty``.""" + + def attach( + self, + pty_id: PtyID, + *, + cursor: int = 0, + role: Literal["controller", "observer"] = "controller", + attachment_id: str | None = None, + takeover: bool = False, + on_event: Callable[[dict[str, Any]], object] | None = None, + open_timeout: float | None = 10, + extra_headers: Mapping[str, str] | None = None, + ) -> PtyConnection: + """Opens an interactive connection to a persistent terminal. *(experimental)* + + Args: + pty_id: The terminal to attach to. + cursor: Byte offset to replay from; ``0`` replays everything the server kept. + role: ``controller`` sends input and sets the size; ``observer`` only watches. + attachment_id: Identifies this client across reconnects; a random id by default. + takeover: Take control from the current controller. + on_event: Called with each JSON event (``attached``, ``replay_complete``, ``resized``, ...). + open_timeout: Seconds to wait for the WebSocket handshake. + extra_headers: Additional handshake headers. + + Raises: + PtyConnectionError: ``websockets`` is not installed, or the server refused the connection. + + ``GET /api/experimental/persistent-pty/:ptyID/connect`` (WebSocket) + """ + _require_websockets() + client = self._client + token = client.experimental.persistent_pty.connect_token(pty_id, x_opencode_ticket="1") + url = _ws_url( + client.base_url, + f"/api/experimental/persistent-pty/{encode_path_segment(pty_id)}/connect", + _persistent_query(cursor, role, attachment_id or str(uuid.uuid4()), takeover, token.ticket), + ) + ws = _open_sync(url, _headers(client.username, client.password, extra_headers), open_timeout) + protocol = _Protocol(persistent=True, cols=0, rows=0) + connection = PtyConnection(ws, protocol, lambda cols, rows: None, on_event) + connection.wait_attached(open_timeout) + return connection + + +class AsyncPersistentPtyAttachMixin(AsyncAPIResource): + """Adds :meth:`attach` to ``client.experimental.persistent_pty``.""" + + async def attach( + self, + pty_id: PtyID, + *, + cursor: int = 0, + role: Literal["controller", "observer"] = "controller", + attachment_id: str | None = None, + takeover: bool = False, + on_event: Callable[[dict[str, Any]], object] | None = None, + open_timeout: float | None = 10, + extra_headers: Mapping[str, str] | None = None, + ) -> AsyncPtyConnection: + """Opens an interactive connection to a persistent terminal. See + :meth:`PersistentPtyAttachMixin.attach`. Needs asyncio. *(experimental)* + + ``GET /api/experimental/persistent-pty/:ptyID/connect`` (WebSocket) + """ + _require_websockets() + client = self._client + token = await client.experimental.persistent_pty.connect_token(pty_id, x_opencode_ticket="1") + url = _ws_url( + client.base_url, + f"/api/experimental/persistent-pty/{encode_path_segment(pty_id)}/connect", + _persistent_query(cursor, role, attachment_id or str(uuid.uuid4()), takeover, token.ticket), + ) + ws = await _open_async(url, _headers(client.username, client.password, extra_headers), open_timeout) + protocol = _Protocol(persistent=True, cols=0, rows=0) + + async def resize(cols: int, rows: int) -> None: + return None + + connection = AsyncPtyConnection(ws, protocol, resize, on_event) + await connection.wait_attached() + return connection diff --git a/src/opencode/resources/experimental.py b/src/opencode/resources/experimental.py index 01ba58b..b334390 100644 --- a/src/opencode/resources/experimental.py +++ b/src/opencode/resources/experimental.py @@ -11,6 +11,7 @@ from typing import cast from .._base_client import RequestOptions +from .._pty import AsyncPersistentPtyAttachMixin, PersistentPtyAttachMixin from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout @@ -30,7 +31,7 @@ __all__ = ["ExperimentalResource", "AsyncExperimentalResource"] -class ExperimentalPersistentPtyResource(SyncAPIResource): +class ExperimentalPersistentPtyResource(PersistentPtyAttachMixin): """Operations under ``client.experimental.persistent_pty`` (OpenCode ``experimental.persistentPty``).""" def read( @@ -396,7 +397,7 @@ def persistent_pty(self) -> ExperimentalPersistentPtyResource: return ExperimentalPersistentPtyResource(self._client) -class AsyncExperimentalPersistentPtyResource(AsyncAPIResource): +class AsyncExperimentalPersistentPtyResource(AsyncPersistentPtyAttachMixin): """Operations under ``client.experimental.persistent_pty`` (OpenCode ``experimental.persistentPty``).""" async def read( diff --git a/src/opencode/resources/pty.py b/src/opencode/resources/pty.py index 6f5099a..3fa2bea 100644 --- a/src/opencode/resources/pty.py +++ b/src/opencode/resources/pty.py @@ -10,6 +10,7 @@ from typing import cast from .._base_client import RequestOptions +from .._pty import AsyncPtyAttachMixin, PtyAttachMixin from .._qs import encode_path_segment from .._resource import AsyncAPIResource, SyncAPIResource from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout @@ -72,7 +73,7 @@ def token( ) -class PtyResource(SyncAPIResource): +class PtyResource(PtyAttachMixin): """Operations under ``client.pty`` (OpenCode ``pty``).""" @cached_property @@ -318,7 +319,7 @@ async def token( ) -class AsyncPtyResource(AsyncAPIResource): +class AsyncPtyResource(AsyncPtyAttachMixin): """Operations under ``client.pty`` (OpenCode ``pty``).""" @cached_property diff --git a/tests/test_live.py b/tests/test_live.py index b749cd4..77b0ab0 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -87,3 +87,19 @@ async def test_async_server(tmp_path: Path) -> None: info = await client.server.info() assert info.version assert not running.running + + +def test_pty_attach_echo(client: Opencode) -> None: + pytest.importorskip("websockets") + pty = client.pty.create(command="/bin/sh").data + try: + with client.pty.attach(pty.id) as term: + term.resize(100, 30) + term.send("echo hi\n") + output = b"" + while b"hi\r\n" not in output.split(b"echo hi", 1)[-1]: + chunk = term.recv(timeout=15) + assert chunk is not None + output += chunk + finally: + client.pty.remove(pty.id) diff --git a/tests/test_pty.py b/tests/test_pty.py new file mode 100644 index 0000000..21da691 --- /dev/null +++ b/tests/test_pty.py @@ -0,0 +1,236 @@ +"""PTY WebSocket attach against an in-process fake server.""" + +from __future__ import annotations + +import json +import struct +import sys +import threading +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest +import respx +from websockets.datastructures import Headers +from websockets.http11 import Request, Response +from websockets.sync.server import Server, ServerConnection, serve + +from opencode import AsyncOpencode, Opencode, PtyConnectionError + +PASSWORD = "test-password" +TICKET = "ticket-1" + + +@dataclass +class Fake: + """Records what the client sent and decides how the fake server behaves.""" + + close_code: int = 1000 + refuse: bool = False + paths: list[str] = field(default_factory=list) + queries: list[dict[str, list[str]]] = field(default_factory=list) + headers: list[Headers] = field(default_factory=list) + received: list[bytes | str] = field(default_factory=list) + + +def _handler(fake: Fake) -> Any: + def handle(ws: ServerConnection) -> None: + assert ws.request is not None + path = urlsplit(ws.request.path).path + if fake.close_code != 1000: + ws.close(fake.close_code, "session not found") + return + if "persistent-pty" in path: + ws.send( + json.dumps({"type": "attached", "info": {"size": {"cols": 80, "rows": 24}}, "replay": {"endOffset": 0}}) + ) + ws.send(b"replay") + ws.send(json.dumps({"type": "replay_complete", "endOffset": 6})) + else: + ws.send("replay") + ws.send(b"\x00" + json.dumps({"cursor": 6}).encode()) + for message in ws: + fake.received.append(message) + if "persistent-pty" in path: + assert isinstance(message, bytes) + kind, cols, rows = struct.unpack(">BHH", message[:5]) + if kind == 0: + ws.send(json.dumps({"type": "resized", "cols": cols, "rows": rows})) + continue + payload = message[5:] + else: + payload = message.encode() if isinstance(message, str) else message + if payload == b"exit\n": + ws.close(1000) + return + ws.send(payload if "persistent-pty" in path else payload.decode()) + + return handle + + +def _process_request(fake: Fake) -> Any: + def process(ws: ServerConnection, request: Request) -> Response | None: + split = urlsplit(request.path) + fake.paths.append(split.path) + fake.queries.append(parse_qs(split.query)) + fake.headers.append(request.headers) + if fake.refuse: + return ws.respond(403, "forbidden\n") + return None + + return process + + +@pytest.fixture +def fake() -> Fake: + return Fake() + + +@pytest.fixture +def ws_server(fake: Fake) -> Iterator[str]: + server: Server = serve(_handler(fake), "127.0.0.1", 0, process_request=_process_request(fake)) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.socket.getsockname()[:2] + try: + yield f"http://{host}:{port}" + finally: + server.shutdown() + thread.join() + + +@pytest.fixture +def api(ws_server: str) -> Iterator[respx.MockRouter]: + with respx.mock(base_url=ws_server, assert_all_called=False) as router: + router.post("/api/pty/pty_1/connect-token").respond( + json={"location": {"directory": "/repo"}, "data": {"ticket": TICKET, "expires_in": 60}} + ) + router.post("/api/experimental/persistent-pty/pty_1/connect-token").respond( + json={"data": {"ticket": TICKET, "expires_in": 60}} + ) + yield router + + +def test_pty_attach_round_trip(ws_server: str, api: respx.MockRouter, fake: Fake) -> None: + update = api.put("/api/pty/pty_1").respond( + json={ + "location": {"directory": "/repo"}, + "data": { + "id": "pty_1", + "title": "t", + "command": "bash", + "args": [], + "cwd": "/repo", + "status": "running", + "pid": 1, + }, + } + ) + with ( + Opencode(ws_server, password=PASSWORD, directory="/repo") as client, + client.pty.attach("pty_1", cursor=3) as term, + ): + assert term.recv(timeout=5) == b"replay" + term.send("echo hi\n") + assert term.recv(timeout=5) == b"echo hi\n" + assert term.cursor == 6 + term.resize(120, 40) + term.send(b"exit\n") + assert list(term) == [] + assert term.close_code == 1000 + assert api.calls[0].request.headers["x-opencode-ticket"] == "1" + assert json.loads(update.calls[0].request.content) == {"size": {"rows": 40, "cols": 120}} + assert fake.paths == ["/api/pty/pty_1/connect"] + assert fake.queries[0] == {"location[directory]": ["/repo"], "cursor": ["3"], "ticket": [TICKET]} + assert fake.headers[0]["Authorization"].startswith("Basic ") + assert fake.received == ["echo hi\n", b"exit\n"] + + +def test_persistent_closed_before_attached(ws_server: str, api: respx.MockRouter, fake: Fake) -> None: + fake.close_code = 4404 + with Opencode(ws_server, password=PASSWORD) as client, pytest.raises(PtyConnectionError) as caught: + client.experimental.persistent_pty.attach("pty_1") + assert caught.value.code == 4404 + + +def test_persistent_pty_attach_frames(ws_server: str, api: respx.MockRouter, fake: Fake) -> None: + events: list[dict[str, Any]] = [] + with Opencode(ws_server, password=PASSWORD) as client: + term = client.experimental.persistent_pty.attach("pty_1", attachment_id="a1", on_event=events.append) + with term: + assert term.recv(timeout=5) == b"replay" + assert term.attached is not None + term.send("ls\n") + assert term.recv(timeout=5) == b"ls\n" + assert term.cursor == 9 + term.resize(132, 50) + term.send("exit\n") + assert list(term) == [] + assert [event["type"] for event in events] == ["attached", "replay_complete", "resized"] + assert fake.queries[0] == { + "ticket": [TICKET], + "cursor": ["0"], + "role": ["controller"], + "attachment_id": ["a1"], + "takeover": ["false"], + "input_protocol": ["1"], + } + # Input frames carry the size from the attached event, then the size set by resize. + assert fake.received == [ + struct.pack(">BHH", 1, 80, 24) + b"ls\n", + struct.pack(">BHH", 0, 132, 50), + struct.pack(">BHH", 1, 132, 50) + b"exit\n", + ] + + +def test_not_found_close_raises(ws_server: str, api: respx.MockRouter, fake: Fake) -> None: + fake.close_code = 4404 + with ( + Opencode(ws_server, password=PASSWORD) as client, + client.pty.attach("pty_1") as term, + pytest.raises(PtyConnectionError) as caught, + ): + term.recv(timeout=5) + assert caught.value.code == 4404 + assert caught.value.reason == "session not found" + + +def test_refused_handshake_raises(ws_server: str, api: respx.MockRouter, fake: Fake) -> None: + fake.refuse = True + with Opencode(ws_server, password=PASSWORD) as client, pytest.raises(PtyConnectionError) as caught: + client.pty.attach("pty_1") + assert caught.value.status == 403 + + +def test_missing_websockets(monkeypatch: pytest.MonkeyPatch, mock_api: respx.MockRouter, client: Opencode) -> None: + monkeypatch.setitem(sys.modules, "websockets", None) + with pytest.raises(PtyConnectionError, match=r"opencode-python-sdk\[pty\]"): + client.pty.attach("pty_1") + assert not mock_api.calls + + +@pytest.mark.anyio +async def test_async_pty_attach(ws_server: str, api: respx.MockRouter, fake: Fake) -> None: + async with AsyncOpencode(ws_server, password=PASSWORD) as client: + async with await client.pty.attach("pty_1") as term: + assert await term.recv() == b"replay" + await term.send("echo hi\n") + assert await term.recv() == b"echo hi\n" + await term.send("exit\n") + assert [chunk async for chunk in term] == [] + async with await client.experimental.persistent_pty.attach("pty_1") as persistent: + assert await persistent.recv() == b"replay" + await persistent.resize(90, 20) + await persistent.send(b"exit\n") + assert [chunk async for chunk in persistent] == [] + assert fake.received[-1] == struct.pack(">BHH", 1, 90, 20) + b"exit\n" + + +def test_wss_for_https() -> None: + from opencode._pty import _ws_url + + url = _ws_url(httpx.URL("https://example.com/base/"), "/api/pty/x/connect", {"ticket": "t"}) + assert url == "wss://example.com/base/api/pty/x/connect?ticket=t" diff --git a/uv.lock b/uv.lock index 4489fd5..d64b821 100644 --- a/uv.lock +++ b/uv.lock @@ -1309,6 +1309,11 @@ dependencies = [ { name = "typing-extensions" }, ] +[package.optional-dependencies] +pty = [ + { name = "websockets" }, +] + [package.dev-dependencies] dev = [ { name = "mypy" }, @@ -1318,6 +1323,7 @@ dev = [ { name = "respx" }, { name = "ruff" }, { name = "trio" }, + { name = "websockets" }, ] docs = [ { name = "mkdocs-material", extra = ["imaging"] }, @@ -1331,7 +1337,9 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27,<1" }, { name = "pydantic", specifier = ">=2.12,<3" }, { name = "typing-extensions", specifier = ">=4.12" }, + { name = "websockets", marker = "extra == 'pty'", specifier = ">=14,<17" }, ] +provides-extras = ["pty"] [package.metadata.requires-dev] dev = [ @@ -1342,6 +1350,7 @@ dev = [ { name = "respx", specifier = ">=0.22" }, { name = "ruff", specifier = ">=0.14" }, { name = "trio", specifier = ">=0.31" }, + { name = "websockets", specifier = ">=14" }, ] docs = [ { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.6" }, @@ -2200,3 +2209,119 @@ sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be5 wheels = [ { url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745, upload-time = "2026-08-15T14:22:56.31Z" }, ] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" }, + { url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" }, + { url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" }, + { url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" }, + { url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" }, + { url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] From 2f62c9fac52e215d34a816c93307665533312218 Mon Sep 17 00:00:00 2001 From: shrijayan <81805145+shrijayan@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:28:20 +0530 Subject: [PATCH 2/2] Document interactive terminals in the docs site --- docs/guides/index.md | 2 +- docs/guides/terminals.md | 58 +++++++++++++++++++++++++++++++++++++ docs/reference/terminals.md | 10 +++++++ mkdocs.yml | 2 ++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 docs/guides/terminals.md create mode 100644 docs/reference/terminals.md diff --git a/docs/guides/index.md b/docs/guides/index.md index 35d7142..678196a 100644 --- a/docs/guides/index.md +++ b/docs/guides/index.md @@ -26,7 +26,7 @@ entry; the common ones also have a guide. CI fails if a group on the client is m | `client.plugin` | List plugins | - | [`PluginResource`](../reference/resources.md#opencode.resources.plugin.PluginResource) | | `client.project` | Projects | - | [`ProjectResource`](../reference/resources.md#opencode.resources.project.ProjectResource) | | `client.provider` | Model providers | - | [`ProviderResource`](../reference/resources.md#opencode.resources.provider.ProviderResource) | -| `client.pty` | Terminals | - | [`PtyResource`](../reference/resources.md#opencode.resources.pty.PtyResource) | +| `client.pty` | Terminals | [Interactive terminals](terminals.md) | [`PtyResource`](../reference/resources.md#opencode.resources.pty.PtyResource) | | `client.reference` | References | - | [`ReferenceResource`](../reference/resources.md#opencode.resources.reference.ReferenceResource) | | `client.rpc` | Plugin RPC | - | [`RpcResource`](../reference/resources.md#opencode.resources.rpc.RpcResource) | | `client.server` | Server info and health | - | [`ServerResource`](../reference/resources.md#opencode.resources.server.ServerResource) | diff --git a/docs/guides/terminals.md b/docs/guides/terminals.md new file mode 100644 index 0000000..4966a13 --- /dev/null +++ b/docs/guides/terminals.md @@ -0,0 +1,58 @@ +# Interactive terminals + +`client.pty.attach()` opens an interactive connection to a server-side PTY over a WebSocket. It needs the `pty` +extra: + +```bash +pip install "opencode-python-sdk[pty]" +``` + +```python +pty = client.pty.create(command="/bin/sh").data +with client.pty.attach(pty.id) as term: # (1)! + term.resize(120, 40) + term.send("echo hi\n") + for chunk in term: # (2)! + print(chunk.decode(errors="replace"), end="") +``` + +1. Closing the connection leaves the PTY running; remove it with `client.pty.remove(pty.id)`. +2. Output arrives as `bytes` until the shell exits. + +`recv(timeout=...)` reads one chunk and returns `None` once the terminal has ended. `term.cursor` is the output +position to pass as `cursor=` when you reattach; `cursor=-1` skips the replay of earlier output. + +## Persistent terminals + +Persistent terminals belong to a session and survive server restarts *(experimental)*: + +```python +info = client.experimental.persistent_pty.create(session.id, command="/bin/sh", args=[], title="sh", env={}) +with client.experimental.persistent_pty.attach(info.id, role="controller", on_event=print) as term: + term.send("ls\n") + print(term.recv(timeout=5)) +``` + +`role="observer"` watches without sending input, `takeover=True` takes control from another controller, and +`attachment_id` identifies this client across reconnects. `on_event` receives the JSON events (`attached`, +`replay_complete`, `resized`, `controller_changed`, ...). + +## Async + +The async client returns `AsyncPtyConnection`, which needs asyncio: + +```python +async with await client.pty.attach(pty.id) as term: + await term.send("echo hi\n") + async for chunk in term: + ... +``` + +## Errors + +A refused handshake (bad ticket, unknown PTY) or an abnormal close raises `PtyConnectionError`, with `status` for the +HTTP status and `code` / `reason` for the close frame (`4404` means the terminal was not found, has exited or is +unavailable). The same error explains how to install the extra when `websockets` is missing. + +[`examples/terminal.py`](https://github.com/shrijayan/opencode-python-sdk/blob/main/examples/terminal.py) bridges +your local terminal to a remote shell. diff --git a/docs/reference/terminals.md b/docs/reference/terminals.md new file mode 100644 index 0000000..d8015c7 --- /dev/null +++ b/docs/reference/terminals.md @@ -0,0 +1,10 @@ +# Terminals + +Connections returned by `client.pty.attach()` and `client.experimental.persistent_pty.attach()`. See the +[terminals guide](../guides/terminals.md). + +::: opencode.PtyConnection + +::: opencode.AsyncPtyConnection + +::: opencode.PtyConnectionError diff --git a/mkdocs.yml b/mkdocs.yml index 2f7693a..8fd157f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -139,6 +139,7 @@ nav: - Models and unions: guides/models.md - Pagination: guides/pagination.md - Events and streaming: guides/events.md + - Interactive terminals: guides/terminals.md - Errors: guides/errors.md - Retries and timeouts: guides/retries.md - The project directory: guides/directory.md @@ -150,6 +151,7 @@ nav: - Server launcher: reference/server.md - Resources: reference/resources.md - Pagination and streams: reference/pagination.md + - Terminals: reference/terminals.md - Exceptions: reference/exceptions.md - Types: reference/types/index.md - Project: