diff --git a/AGENTS.md b/AGENTS.md index a2927e4..263c281 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ Use this page as the quick orientation for the Python SDK repo. It mirrors the t | Path | Why it exists | | --- | --- | | `src/acp/` | Runtime package: agent/client bases, transports, helpers, schema bindings, contrib utilities | +| `src/acp/http/`, `src/acp/ws/` | Experimental remote transports (Streamable HTTP + WebSocket), client + server; opt-in via the `[http]` extra | | `schema/` | Upstream JSON schema sources (regenerate with `make gen-all`) | | `examples/` | Runnable scripts such as `echo_agent.py`, `client.py`, `gemini.py`, `duet.py` | | `tests/` | Pytest suite, including optional Gemini smoke tests in `tests/test_gemini_example.py` | diff --git a/docs/web-transport.md b/docs/web-transport.md new file mode 100644 index 0000000..e601988 --- /dev/null +++ b/docs/web-transport.md @@ -0,0 +1,108 @@ +# Web Transport (Streamable HTTP & WebSocket) + +> **Experimental.** The remote web transports are experimental and may change. +> They ship as an optional extra and are import-guarded. + +The SDK can run ACP over two remote connectivity profiles in addition to stdio: + +- **Streamable HTTP** — `POST` for client→server messages, long-lived `GET` SSE + streams for server→client messages (one connection-scoped stream plus one per + session), and `DELETE` to terminate. `initialize` returns `200 OK` with a JSON + body; all other POSTs return `202 Accepted`. **Requires HTTP/2.** +- **WebSocket** — a `GET` upgrade on the same endpoint carrying full-duplex + JSON-RPC text frames. + +Both reuse the existing JSON-RPC message format and ACP lifecycle +(`initialize` → session methods → close). + +## Installation + +```bash +pip install "agent-client-protocol[http]" +``` + +This pulls in `httpx[http2]` (HTTP/2 + SSE consumption) and `websockets`. + +## Client + +Both transports produce a message-level `Transport` that plugs into the existing +`connect_to_agent`: + +```python +from acp import connect_to_agent +from acp.http import create_http_stream +from acp.ws import create_websocket_stream + +# Streamable HTTP +transport = create_http_stream("http://localhost:8000/acp") +conn = connect_to_agent(my_client, transport) + +# ...or WebSocket +transport = await create_websocket_stream("ws://localhost:8000/acp") +conn = connect_to_agent(my_client, transport) + +init = await conn.initialize(protocol_version=1) +session = await conn.new_session(cwd="/tmp", mcp_servers=[]) +await conn.prompt(session_id=session.session_id, prompt=[...]) +await conn.close() +await transport.close() +``` + +The client sends `initialize` first, reads the `Acp-Connection-Id` response +header, then opens the connection-scoped SSE stream. When a new `sessionId` +appears it opens that session-scoped stream too. A single SSE attempt is made per +stream; reconnect/retry is the caller's responsibility (v1 of the RFD). + +## Server + +The server core is framework-agnostic; a thin ASGI adapter bridges it to your +web framework: + +```python +from acp.http.asgi import create_asgi_app + +# One agent instance is created per connection. +app = create_asgi_app(lambda conn: MyAgent()) +``` + +`app` is a standard ASGI 3.0 application handling `POST`/`GET`/`DELETE` and +WebSocket upgrades on the ACP endpoint. + +### HTTP/2 server requirement + +> ⚠️ **Uvicorn does not serve HTTP/2.** For a spec-compliant Streamable HTTP +> server, run an HTTP/2-capable ASGI server (**Hypercorn**, Daphne, Granian) or +> terminate HTTP/2 at a proxy. The WebSocket profile works on Uvicorn. + +```python +import asyncio +import hypercorn.asyncio +from hypercorn.config import Config + +config = Config() +config.bind = ["localhost:8000"] +config.alpn_protocols = ["h2", "http/1.1"] +asyncio.run(hypercorn.asyncio.serve(app, config)) +``` + +## Examples + +- [`examples/http_server.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/http_server.py) — serve an agent over HTTP + WS (Hypercorn). +- [`examples/http_client.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/http_client.py) — connect over Streamable HTTP. +- [`examples/ws_client.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/ws_client.py) — connect over WebSocket. + +## Identity model + +- `Acp-Connection-Id` (HTTP header) — returned by `initialize`; required on all + post-initialize HTTP requests and every GET stream. +- `Acp-Session-Id` (HTTP header) — required on session-scoped POSTs and the + session-scoped GET stream. +- `sessionId` (JSON-RPC field) — carried in params/results and used to route + messages to the correct stream. + +## Not yet supported (deferred to a future revision) + +- `Last-Event-ID` / SSE resumability and message sequencing. +- Client-side automatic reconnect/backoff. +- Batch JSON-RPC (the server returns `501`). +- `Acp-Protocol-Version` header enforcement. diff --git a/examples/http_client.py b/examples/http_client.py new file mode 100644 index 0000000..1843c01 --- /dev/null +++ b/examples/http_client.py @@ -0,0 +1,54 @@ +# /// script +# requires-python = ">=3.10,<3.15" +# dependencies = [ +# "agent-client-protocol[http]", +# ] +# /// +"""Connect to a remote ACP agent over Streamable HTTP (experimental). + +Start the server first (``uv run examples/http_server.py``), then run this. +""" + +import asyncio +from typing import Any + +from acp import connect_to_agent, text_block +from acp.http import create_http_stream +from acp.interfaces import Client + + +class ExampleClient(Client): + async def request_permission(self, session_id: str, tool_call: Any, options: Any, **kwargs: Any) -> Any: + # Auto-allow the first option. + return {"outcome": {"outcome": "selected", "optionId": options[0]["optionId"]}} + + async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: + content = getattr(update, "content", None) + text = getattr(content, "text", None) if content is not None else None + if text: + print(f"<< {text}") + + async def write_text_file(self, *args: Any, **kwargs: Any) -> None: + return None + + async def read_text_file(self, *args: Any, **kwargs: Any) -> Any: + return {"content": ""} + + +async def main() -> None: + transport = create_http_stream("http://localhost:8000/acp") + conn = connect_to_agent(ExampleClient(), transport) + try: + init = await conn.initialize(protocol_version=1) + print(f"initialized (protocol v{init.protocol_version})") + session = await conn.new_session(cwd=".", mcp_servers=[]) + print(f"session: {session.session_id}") + result = await conn.prompt(session_id=session.session_id, prompt=[text_block("hello over http")]) + print(f"stop reason: {result.stop_reason}") + finally: + await conn.close() + await transport.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/http_server.py b/examples/http_server.py new file mode 100644 index 0000000..98b4fc7 --- /dev/null +++ b/examples/http_server.py @@ -0,0 +1,81 @@ +# /// script +# requires-python = ">=3.10,<3.15" +# dependencies = [ +# "agent-client-protocol[http]", +# "hypercorn>=0.17", +# ] +# /// +"""Serve an ACP agent over Streamable HTTP + WebSocket (experimental). + +Run with an HTTP/2-capable ASGI server for spec-compliant Streamable HTTP. This +example uses Hypercorn; Uvicorn works for WebSocket but does not serve HTTP/2. + + uv run examples/http_server.py + # then, in another terminal: + uv run examples/http_client.py + uv run examples/ws_client.py +""" + +import asyncio +from typing import Any +from uuid import uuid4 + +from acp import ( + Agent, + InitializeResponse, + NewSessionResponse, + PromptResponse, + text_block, + update_agent_message, +) +from acp.http.asgi import create_asgi_app +from acp.interfaces import Client +from acp.schema import ClientCapabilities, Implementation + + +class EchoAgent(Agent): + _conn: Client + + def on_connect(self, conn: Client) -> None: + self._conn = conn + + async def initialize( + self, + protocol_version: int, + client_capabilities: ClientCapabilities | None = None, + client_info: Implementation | None = None, + **kwargs: Any, + ) -> InitializeResponse: + return InitializeResponse(protocol_version=protocol_version) + + async def new_session(self, cwd: str = "", **kwargs: Any) -> NewSessionResponse: + return NewSessionResponse(session_id=uuid4().hex) + + async def prompt(self, session_id: str, prompt: list[Any], **kwargs: Any) -> PromptResponse: + for block in prompt: + text = block.get("text", "") if isinstance(block, dict) else getattr(block, "text", "") + await self._conn.session_update( + session_id=session_id, + update=update_agent_message(text_block(f"echo: {text}")), + ) + return PromptResponse(stop_reason="end_turn") + + +# One agent instance per connection. +app = create_asgi_app(lambda conn: EchoAgent()) + + +async def main() -> None: + import hypercorn.asyncio + from hypercorn.config import Config + + config = Config() + config.bind = ["localhost:8000"] + # Enable HTTP/2 (Streamable HTTP requires it). Hypercorn negotiates h2c/h2. + config.alpn_protocols = ["h2", "http/1.1"] + print("Serving ACP agent on http://localhost:8000/acp (HTTP + WS)") + await hypercorn.asyncio.serve(app, config) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/ws_client.py b/examples/ws_client.py new file mode 100644 index 0000000..ac9c05c --- /dev/null +++ b/examples/ws_client.py @@ -0,0 +1,53 @@ +# /// script +# requires-python = ">=3.10,<3.15" +# dependencies = [ +# "agent-client-protocol[http]", +# ] +# /// +"""Connect to a remote ACP agent over WebSocket (experimental). + +Start the server first (``uv run examples/http_server.py``), then run this. +""" + +import asyncio +from typing import Any + +from acp import connect_to_agent, text_block +from acp.interfaces import Client +from acp.ws import create_websocket_stream + + +class ExampleClient(Client): + async def request_permission(self, session_id: str, tool_call: Any, options: Any, **kwargs: Any) -> Any: + return {"outcome": {"outcome": "selected", "optionId": options[0]["optionId"]}} + + async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: + content = getattr(update, "content", None) + text = getattr(content, "text", None) if content is not None else None + if text: + print(f"<< {text}") + + async def write_text_file(self, *args: Any, **kwargs: Any) -> None: + return None + + async def read_text_file(self, *args: Any, **kwargs: Any) -> Any: + return {"content": ""} + + +async def main() -> None: + transport = await create_websocket_stream("ws://localhost:8000/acp") + conn = connect_to_agent(ExampleClient(), transport) + try: + init = await conn.initialize(protocol_version=1) + print(f"initialized (protocol v{init.protocol_version})") + session = await conn.new_session(cwd=".", mcp_servers=[]) + print(f"session: {session.session_id}") + result = await conn.prompt(session_id=session.session_id, prompt=[text_block("hello over websocket")]) + print(f"stop reason: {result.stop_reason}") + finally: + await conn.close() + await transport.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mkdocs.yml b/mkdocs.yml index 09e3ea4..f7e1b6f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,7 @@ nav: - Home: index.md - Quick Start: quickstart.md - Use Cases: use-cases.md + - Web Transport (HTTP/WS): web-transport.md - Experimental Contrib: contrib.md - Releasing: releasing.md - 0.11 Migration Guide: migration-guide-0.11.md diff --git a/pyproject.toml b/pyproject.toml index 6432be2..2a76826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,10 +44,15 @@ dev = [ "mkdocstrings[python]>=0.26.1", "python-dotenv>=1.1.1", "prek>=0.2.17", + "httpx[http2]>=0.27", + "websockets>=12.0", + "uvicorn>=0.30", ] [project.optional-dependencies] logfire = ["logfire>=0.14", "opentelemetry-sdk>=1.28.0"] +# Experimental remote transports (Streamable HTTP + WebSocket), client + server. +http = ["httpx[http2]>=0.27", "websockets>=12.0"] [build-system] requires = ["pdm-backend"] diff --git a/src/acp/_cookies.py b/src/acp/_cookies.py new file mode 100644 index 0000000..a4b05d6 --- /dev/null +++ b/src/acp/_cookies.py @@ -0,0 +1,53 @@ +"""In-memory cookie store for the WebSocket handshake. + +The HTTP client relies on ``httpx``'s built-in cookie jar for session affinity, +but the WebSocket handshake needs a small, explicit store to collect +``Set-Cookie`` headers from the upgrade response and echo them back as a +``Cookie`` request header for the socket lifetime. + +This is intentionally minimal: it stores name→value pairs without attribute +parsing (domain/path/expiry), matching the affinity-only use case in the RFD. +""" + +from __future__ import annotations + +__all__ = ["MemoryAcpCookieStore"] + + +class MemoryAcpCookieStore: + """A tiny name→value cookie store keyed by cookie name.""" + + def __init__(self) -> None: + self._cookies: dict[str, str] = {} + + def store_set_cookie(self, header_value: str) -> None: + """Ingest a single ``Set-Cookie`` header value. + + Only the leading ``name=value`` pair is retained; cookie attributes + (``; Path=/``, ``; HttpOnly`` etc.) are ignored. + """ + first = header_value.split(";", 1)[0].strip() + if not first or "=" not in first: + return + name, _, value = first.partition("=") + name = name.strip() + if name: + self._cookies[name] = value.strip() + + def store_set_cookies(self, header_values: list[str]) -> None: + """Ingest multiple ``Set-Cookie`` header values.""" + for value in header_values: + self.store_set_cookie(value) + + def cookie_header(self) -> str | None: + """Render the stored cookies as a ``Cookie`` request header value.""" + if not self._cookies: + return None + return "; ".join(f"{name}={value}" for name, value in self._cookies.items()) + + def clear(self) -> None: + """Drop all stored cookies.""" + self._cookies.clear() + + def __len__(self) -> int: + return len(self._cookies) diff --git a/src/acp/_sse.py b/src/acp/_sse.py new file mode 100644 index 0000000..455a2d2 --- /dev/null +++ b/src/acp/_sse.py @@ -0,0 +1,84 @@ +"""Server-Sent Events (SSE) serialization + parsing building blocks. + +Minimal helpers shared by the Streamable HTTP client and server. We only need +the ``data:`` field (JSON-RPC payloads) plus keepalive comments; ``event:``, +``id:``, and ``retry:`` are not used by this transport (resumability is v2). +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +__all__ = [ + "parse_sse_stream", + "serialize_sse_event", + "serialize_sse_keepalive", +] + + +def serialize_sse_event(message: dict[str, Any]) -> bytes: + """Serialize a JSON-RPC message as an SSE ``data:`` event. + + The payload is JSON-encoded on a single line and terminated by a blank line, + per the SSE framing rules. + """ + data = json.dumps(message, separators=(",", ":")) + return f"data: {data}\n\n".encode() + + +def serialize_sse_keepalive() -> bytes: + """Serialize an SSE comment used to keep the connection alive.""" + return b": keepalive\n\n" + + +def _decode_event(data_lines: list[str]) -> dict[str, Any] | None: + """Decode buffered ``data:`` lines into a JSON object, or None to skip.""" + payload = "\n".join(data_lines) + if not payload: + return None + try: + return json.loads(payload) + except json.JSONDecodeError: + return None + + +def _append_field(line: str, data_lines: list[str]) -> None: + """Append a ``data:`` field's value to the buffer; ignore other fields.""" + field, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + if field == "data": + data_lines.append(value) + + +async def parse_sse_stream(chunks: AsyncIterator[bytes]) -> AsyncIterator[dict[str, Any]]: + """Parse an SSE byte stream, yielding decoded JSON-RPC ``data:`` payloads. + + Comments (lines starting with ``:``) and non-``data`` fields are ignored. + Multi-line ``data:`` fields are concatenated with newlines per the spec. A + blank line dispatches the buffered event. + """ + buffer = "" + data_lines: list[str] = [] + + async for chunk in chunks: + buffer += chunk.decode("utf-8") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.rstrip("\r") + if line == "": + event = _decode_event(data_lines) + data_lines = [] + if event is not None: + yield event + elif not line.startswith(":"): + _append_field(line, data_lines) + + # Flush a trailing event with no terminating blank line. + event = _decode_event(data_lines) + if event is not None: + yield event diff --git a/src/acp/_transport.py b/src/acp/_transport.py new file mode 100644 index 0000000..17c2815 --- /dev/null +++ b/src/acp/_transport.py @@ -0,0 +1,151 @@ +"""Message-level transport seam for :class:`acp.connection.Connection`. + +Historically the connection spoke directly to ``asyncio`` byte streams with +newline framing. To support message-oriented remote transports (Streamable +HTTP + WebSocket) we introduce a small :class:`Transport` protocol that moves +JSON-RPC *messages* (already-decoded ``dict`` payloads) instead of bytes. + +The existing stdio path is re-expressed on top of this seam via +:class:`NdjsonTransport`, which wraps the current byte-stream framing so there +is **zero behaviour change** for stdio users. :func:`memory_transport_pair` +gives two linked in-memory transports, used by the HTTP/WS server to bind an +``AgentSideConnection`` to its message pump. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Awaitable + + from .task import MessageSender + +__all__ = [ + "NdjsonTransport", + "Transport", + "memory_transport_pair", +] + + +@runtime_checkable +class Transport(Protocol): + """A bidirectional stream of JSON-RPC messages. + + Implementations move already-decoded ``dict`` payloads. ``receive`` returns + ``None`` to signal end-of-stream (EOF). ``send`` may raise if the message + could not be delivered (e.g. an HTTP POST failing before any JSON-RPC + response exists); callers correlate such failures with pending requests. + """ + + async def send(self, message: dict[str, Any]) -> None: ... + + async def receive(self) -> dict[str, Any] | None: ... + + async def close(self) -> None: ... + + +class NdjsonTransport: + """Transport backed by newline-delimited JSON over asyncio byte streams. + + This preserves the exact framing, buffer-limit-overrun handling, and receive + timeout semantics that previously lived inside ``Connection`` so that stdio + behaviour is byte-for-byte unchanged. + """ + + def __init__( + self, + reader: asyncio.StreamReader, + sender: MessageSender, + *, + receive_timeout: float | None = None, + ) -> None: + self._reader = reader + self._sender = sender + self._receive_timeout = receive_timeout + + async def send(self, message: dict[str, Any]) -> None: + await self._sender.send(message) + + async def receive(self) -> dict[str, Any] | None: + while True: + line = await self._read_line() + if not line: + return None + line = line.strip() + if not line: + continue + try: + message: dict[str, Any] = json.loads(line) + except Exception: + logging.exception("Error parsing JSON-RPC message") + continue + return message + + async def close(self) -> None: + await self._sender.close() + + async def _read_line(self) -> bytes: + chunks: list[bytes] = [] + try: + while True: + try: + line = await self._wait_for_reader(self._reader.readuntil(b"\n")) + except asyncio.LimitOverrunError as exc: + chunks.append(await self._wait_for_reader(self._reader.readexactly(exc.consumed))) + else: + chunks.append(line) + return b"".join(chunks) + except asyncio.IncompleteReadError as exc: + chunks.append(exc.partial) + return b"".join(chunks) + + async def _wait_for_reader(self, awaitable: Awaitable[bytes]) -> bytes: + return await asyncio.wait_for(awaitable, timeout=self._receive_timeout) + + +class _MemoryTransport: + """One end of an in-process :func:`memory_transport_pair`.""" + + def __init__( + self, + outbox: asyncio.Queue[dict[str, Any] | None], + inbox: asyncio.Queue[dict[str, Any] | None], + ) -> None: + self._outbox = outbox + self._inbox = inbox + self._closed = False + + async def send(self, message: dict[str, Any]) -> None: + if self._closed: + raise ConnectionError("Transport closed") + await self._outbox.put(dict(message)) + + async def receive(self) -> dict[str, Any] | None: + return await self._inbox.get() + + async def close(self) -> None: + if self._closed: + return + self._closed = True + with contextlib.suppress(Exception): + self._outbox.put_nowait(None) + + +def memory_transport_pair() -> tuple[Transport, Transport]: + """Return two linked in-memory transports. + + A message ``send`` on one end becomes available via ``receive`` on the + other. Closing an end enqueues an EOF (``None``) for its peer. This mirrors + the ``TransformStream`` pair the TypeScript SDK uses to bind a server-side + connection to its HTTP/WS message pump. + """ + a_to_b: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + b_to_a: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + left = _MemoryTransport(outbox=a_to_b, inbox=b_to_a) + right = _MemoryTransport(outbox=b_to_a, inbox=a_to_b) + return left, right diff --git a/src/acp/agent/connection.py b/src/acp/agent/connection.py index bd8c176..a3921e3 100644 --- a/src/acp/agent/connection.py +++ b/src/acp/agent/connection.py @@ -6,6 +6,7 @@ from pydantic import TypeAdapter +from .._transport import Transport from ..connection import Connection from ..interfaces import Agent, Client from ..meta import CLIENT_METHODS @@ -79,17 +80,24 @@ def __init__( self, to_agent: Callable[[Client], Agent] | Agent, input_stream: Any, - output_stream: Any, + output_stream: Any = None, listening: bool = True, *, use_unstable_protocol: bool = False, **connection_kwargs: Any, ) -> None: agent = to_agent(self) if callable(to_agent) else to_agent - if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): - raise TypeError(_AGENT_CONNECTION_ERROR) handler = build_agent_router(cast(Agent, agent), use_unstable_protocol=use_unstable_protocol) - self._conn = Connection(handler, input_stream, output_stream, listening=listening, **connection_kwargs) + if isinstance(input_stream, Transport): + if output_stream is not None: + raise TypeError(_AGENT_CONNECTION_ERROR) + self._conn = Connection(handler, input_stream, listening=listening, **connection_kwargs) + else: + if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance( + output_stream, asyncio.StreamReader + ): + raise TypeError(_AGENT_CONNECTION_ERROR) + self._conn = Connection(handler, input_stream, output_stream, listening=listening, **connection_kwargs) if on_connect := getattr(agent, "on_connect", None): on_connect(self) diff --git a/src/acp/client/connection.py b/src/acp/client/connection.py index 0f3b1cf..81f6769 100644 --- a/src/acp/client/connection.py +++ b/src/acp/client/connection.py @@ -4,6 +4,7 @@ from collections.abc import Callable from typing import Any, cast, final +from .._transport import Transport from ..connection import Connection from ..interfaces import Agent, Client from ..meta import AGENT_METHODS @@ -62,16 +63,23 @@ def __init__( self, to_client: Callable[[Agent], Client] | Client, input_stream: Any, - output_stream: Any, + output_stream: Any = None, *, use_unstable_protocol: bool = False, **connection_kwargs: Any, ) -> None: - if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): - raise TypeError(_CLIENT_CONNECTION_ERROR) client = to_client(self) if callable(to_client) else to_client handler = build_client_router(cast(Client, client), use_unstable_protocol=use_unstable_protocol) - self._conn = Connection(handler, input_stream, output_stream, **connection_kwargs) + if isinstance(input_stream, Transport): + if output_stream is not None: + raise TypeError(_CLIENT_CONNECTION_ERROR) + self._conn = Connection(handler, input_stream, **connection_kwargs) + else: + if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance( + output_stream, asyncio.StreamReader + ): + raise TypeError(_CLIENT_CONNECTION_ERROR) + self._conn = Connection(handler, input_stream, output_stream, **connection_kwargs) if on_connect := getattr(client, "on_connect", None): on_connect(self) diff --git a/src/acp/connection.py b/src/acp/connection.py index 41cdebc..cfd7b5c 100644 --- a/src/acp/connection.py +++ b/src/acp/connection.py @@ -8,10 +8,11 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import Enum -from typing import Any +from typing import Any, cast from pydantic import BaseModel, ValidationError +from ._transport import NdjsonTransport, Transport from .exceptions import RequestError from .task import ( DefaultMessageDispatcher, @@ -63,8 +64,8 @@ class Connection: def __init__( self, handler: MethodHandler, - writer: asyncio.StreamWriter, - reader: asyncio.StreamReader, + writer: asyncio.StreamWriter | Transport, + reader: asyncio.StreamReader | None = None, *, queue: MessageQueue | None = None, state_store: MessageStateStore | None = None, @@ -75,8 +76,6 @@ def __init__( receive_timeout: float | None = None, ) -> None: self._handler = handler - self._writer = writer - self._reader = reader self._next_request_id = 0 self._state = state_store or InMemoryMessageStateStore() self._tasks = TaskSupervisor(source="acp.Connection") @@ -84,9 +83,18 @@ def __init__( self._queue = queue or InMemoryMessageQueue() self._closed = False self._disconnected = False - self._sender = (sender_factory or self._default_sender_factory)(self._writer, self._tasks) + # Two construction forms: + # * message-level: ``Connection(handler, transport)`` (reader omitted) + # * byte-level: ``Connection(handler, writer, reader)`` (stdio path) + # We discriminate on ``reader`` rather than ``isinstance(writer, Transport)`` + # because a runtime-checkable Protocol would spuriously match duck-typed + # test doubles (e.g. ``MagicMock``). + if reader is None: + self._transport: Transport = cast("Transport", writer) + else: + sender = (sender_factory or self._default_sender_factory)(cast("asyncio.StreamWriter", writer), self._tasks) + self._transport = NdjsonTransport(reader, sender, receive_timeout=receive_timeout) self._observers: list[StreamObserver] = list(observers or []) - self._receive_timeout = receive_timeout if listening: self._recv_task = self._tasks.create( self._receive_loop(), @@ -111,7 +119,7 @@ async def close(self) -> None: return self._closed = True await self._dispatcher.stop() - await self._sender.close() + await self._transport.close() await self._tasks.shutdown() self._state.reject_all_outgoing(ConnectionError("Connection closed")) @@ -139,30 +147,29 @@ async def send_request(self, method: str, params: JsonValue | None = None) -> An self._next_request_id += 1 future = self._state.register_outgoing(request_id, method) payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} - await self._sender.send(payload) + try: + await self._transport.send(payload) + except Exception as exc: + # A synchronous send failure (e.g. HTTP POST rejected before any + # JSON-RPC response exists) must reject the correlated future so the + # caller gets a real, attributable error. + self._state.reject_outgoing(request_id, exc) + raise self._notify_observers(StreamDirection.OUTGOING, payload) return await future async def send_notification(self, method: str, params: JsonValue | None = None) -> None: self._raise_if_unavailable() payload = {"jsonrpc": "2.0", "method": method, "params": params} - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) async def _receive_loop(self) -> None: try: while True: - line = await self._read_line() - if not line: + message = await self._transport.receive() + if message is None: break - line = line.strip() - if not line: - continue - try: - message: dict[str, Any] = json.loads(line) - except Exception: - logging.exception("Error parsing JSON-RPC message") - continue self._notify_observers(StreamDirection.INCOMING, message) await self._process_message(message) except asyncio.CancelledError: @@ -171,24 +178,6 @@ async def _receive_loop(self) -> None: raise RequestError.internal_error({"details": "Agent timeout"}) from None self._disconnect() - async def _read_line(self) -> bytes: - chunks: list[bytes] = [] - try: - while True: - try: - line = await self._wait_for_reader(self._reader.readuntil(b"\n")) - except asyncio.LimitOverrunError as exc: - chunks.append(await self._wait_for_reader(self._reader.readexactly(exc.consumed))) - else: - chunks.append(line) - return b"".join(chunks) - except asyncio.IncompleteReadError as exc: - chunks.append(exc.partial) - return b"".join(chunks) - - async def _wait_for_reader(self, awaitable: Awaitable[bytes]) -> bytes: - return await asyncio.wait_for(awaitable, timeout=self._receive_timeout) - async def _process_message(self, message: dict[str, Any]) -> None: method = message.get("method") has_id = "id" in message @@ -239,18 +228,18 @@ async def _run_request(self, message: dict[str, Any]) -> Any: exclude_unset=True, ) payload["result"] = result if result is not None else None - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) return payload.get("result") except RequestError as exc: payload["error"] = exc.to_error_obj() - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) raise except ValidationError as exc: err = RequestError.invalid_params({"errors": exc.errors()}) payload["error"] = err.to_error_obj() - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) raise err from None except Exception as exc: @@ -265,7 +254,7 @@ async def _run_request(self, message: dict[str, Any]) -> Any: data = {"details": str(exc)} err = RequestError.internal_error(data) payload["error"] = err.to_error_obj() - await self._sender.send(payload) + await self._transport.send(payload) self._notify_observers(StreamDirection.OUTGOING, payload) raise err from None diff --git a/src/acp/core.py b/src/acp/core.py index 2d280c6..d42b164 100644 --- a/src/acp/core.py +++ b/src/acp/core.py @@ -79,17 +79,26 @@ async def run_agent( def connect_to_agent( client: Client, input_stream: Any, - output_stream: Any, + output_stream: Any = None, *, use_unstable_protocol: bool = False, **connection_kwargs: Any, ) -> ClientSideConnection: - """Create a ClientSideConnection to an ACP agent over the given input/output streams. + """Create a ClientSideConnection to an ACP agent. + + Two forms are supported: + + * **Byte streams (stdio):** pass ``input_stream`` (an ``asyncio.StreamWriter``) + and ``output_stream`` (an ``asyncio.StreamReader``). + * **Message transport (HTTP/WebSocket):** pass a single + :class:`~acp._transport.Transport` as ``input_stream`` and leave + ``output_stream`` as ``None``. Args: client: The client implementation to use. - input_stream: The (agent) input stream to write to (default: ``sys.stdin``). - output_stream: The (agent) output stream to read from (default: ``sys.stdout``). + input_stream: The agent input stream (``StreamWriter``) or a ``Transport``. + output_stream: The agent output stream (``StreamReader``), or ``None`` when + passing a ``Transport``. use_unstable_protocol: Whether to enable unstable protocol features. **connection_kwargs: Additional keyword arguments to pass to the :class:`ClientSideConnection` constructor. diff --git a/src/acp/http/__init__.py b/src/acp/http/__init__.py new file mode 100644 index 0000000..7529047 --- /dev/null +++ b/src/acp/http/__init__.py @@ -0,0 +1,30 @@ +"""Streamable HTTP transport for ACP (experimental). + +Public exports are import-guarded: the heavy client/server implementations pull +in optional dependencies (``httpx[http2]``). Importing a symbol without the +extra installed raises a friendly ``ImportError`` pointing at +``pip install agent-client-protocol[http]``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +__all__ = ["AcpServer", "create_http_stream"] + +if TYPE_CHECKING: + from .client import create_http_stream + from .server import AcpServer + + +def __getattr__(name: str) -> Any: + if name == "create_http_stream": + from .client import create_http_stream + + return create_http_stream + if name == "AcpServer": + from .server import AcpServer + + return AcpServer + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/src/acp/http/asgi.py b/src/acp/http/asgi.py new file mode 100644 index 0000000..7dbd5f8 --- /dev/null +++ b/src/acp/http/asgi.py @@ -0,0 +1,175 @@ +"""Thin ASGI adapter bridging Starlette/FastAPI/Hypercorn to :class:`AcpServer`. + +``create_asgi_app(agent_factory)`` returns an ASGI 3.0 application callable that +handles POST/GET/DELETE (and WebSocket upgrades) on the ACP endpoint. Users can +mount it directly or wrap it in their framework of choice. + +Note: for a spec-compliant Streamable HTTP server, run this under an +HTTP/2-capable ASGI server (Hypercorn, Daphne, Granian) or terminate HTTP/2 at a +proxy. Uvicorn does not serve HTTP/2 (WebSocket still works). +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from .protocol import CONNECTION_ID_HEADER, CONTENT_TYPE_SSE, SESSION_ID_HEADER +from .server import AcpServer + +if TYPE_CHECKING: + from .server import AgentFactory + +__all__ = ["AcpAsgiApp", "create_asgi_app"] + +_JSON_HEADERS = [(b"content-type", b"application/json")] + + +def _header_lookup(scope_headers: list[tuple[bytes, bytes]], name: str) -> str | None: + target = name.lower().encode() + for key, value in scope_headers: + if key.lower() == target: + return value.decode("latin-1") + return None + + +class AcpAsgiApp: + """ASGI application wrapping an :class:`AcpServer`.""" + + def __init__(self, server: AcpServer) -> None: + self._server = server + + async def __call__(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + scope_type = scope["type"] + if scope_type == "lifespan": + await self._handle_lifespan(receive, send) + return + if scope_type == "websocket": + await self._handle_websocket(scope, receive, send) + return + if scope_type != "http": + return + + method = scope["method"] + if method == "POST": + await self._handle_post(scope, receive, send) + elif method == "GET": + await self._handle_get(scope, receive, send) + elif method == "DELETE": + await self._handle_delete(scope, send) + else: + await self._send_json(send, 405, {"error": "Method not allowed"}) + + async def _handle_lifespan(self, receive: Callable, send: Callable) -> None: + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await self._server.close() + await send({"type": "lifespan.shutdown.complete"}) + return + + async def _read_body(self, receive: Callable) -> bytes: + chunks: list[bytes] = [] + while True: + message = await receive() + if message["type"] == "http.request": + chunks.append(message.get("body", b"")) + if not message.get("more_body", False): + break + elif message["type"] == "http.disconnect": + break + return b"".join(chunks) + + async def _handle_post(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + headers = scope["headers"] + content_type = _header_lookup(headers, "content-type") + connection_id = _header_lookup(headers, CONNECTION_ID_HEADER) + session_id = _header_lookup(headers, SESSION_ID_HEADER) + raw = await self._read_body(receive) + try: + message = json.loads(raw) if raw else None + except json.JSONDecodeError: + await self._send_json(send, 400, {"error": "Invalid JSON"}) + return + result = await self._server.handle_post( + message, + content_type=content_type, + connection_id=connection_id, + session_id=session_id, + ) + await self._send_json(send, result.status, result.body, extra_headers=result.headers) + + async def _handle_get(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + headers = scope["headers"] + upgrade = _header_lookup(headers, "upgrade") + if upgrade is not None and upgrade.lower() == "websocket": + # WebSocket upgrades arrive as scope type "websocket" in ASGI; a GET + # http scope with Upgrade is non-standard, so reject clearly. + await self._send_json(send, 400, {"error": "WebSocket upgrade must use the ws scope"}) + return + accept = _header_lookup(headers, "accept") or "" + if CONTENT_TYPE_SSE not in accept and "*/*" not in accept: + await self._send_json(send, 406, {"error": "Accept must include text/event-stream"}) + return + connection_id = _header_lookup(headers, CONNECTION_ID_HEADER) + session_id = _header_lookup(headers, SESSION_ID_HEADER) + error = self._server.validate_stream(connection_id=connection_id, session_id=session_id) + if error is not None: + await self._send_json(send, error.status, error.body) + return + if connection_id is None: # validated above, narrow for type-checker + await self._send_json(send, 400, {"error": "Missing connection id"}) + return + await send({ + "type": "http.response.start", + "status": 200, + "headers": [ + (b"content-type", CONTENT_TYPE_SSE.encode()), + (b"cache-control", b"no-cache"), + (b"connection", b"keep-alive"), + ], + }) + async for frame in self._server.open_stream(connection_id=connection_id, session_id=session_id): + await send({"type": "http.response.body", "body": frame, "more_body": True}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + async def _handle_delete(self, scope: dict[str, Any], send: Callable) -> None: + connection_id = _header_lookup(scope["headers"], CONNECTION_ID_HEADER) + result = await self._server.handle_delete(connection_id=connection_id) + await self._send_json(send, result.status, result.body, extra_headers=result.headers) + + async def _handle_websocket(self, scope: dict[str, Any], receive: Callable, send: Callable) -> None: + from ..ws.server import handle_asgi_websocket + + await handle_asgi_websocket(self._server, scope, receive, send) + + async def _send_json( + self, + send: Callable, + status: int, + body: dict[str, Any] | None, + *, + extra_headers: dict[str, str] | None = None, + ) -> None: + payload = json.dumps(body).encode() if body is not None else b"" + headers = list(_JSON_HEADERS) + if extra_headers: + headers.extend((k.encode("latin-1"), v.encode("latin-1")) for k, v in extra_headers.items()) + await send({"type": "http.response.start", "status": status, "headers": headers}) + await send({"type": "http.response.body", "body": payload}) + + +def create_asgi_app(agent_factory: AgentFactory) -> AcpAsgiApp: + """Create an ASGI app serving an ACP agent over Streamable HTTP + WebSocket. + + Args: + agent_factory: Called once per connection with the bound + ``AgentSideConnection`` to produce a per-connection ``Agent``. + + Returns: + An :class:`AcpAsgiApp` ASGI 3.0 application. + """ + return AcpAsgiApp(AcpServer(agent_factory)) diff --git a/src/acp/http/client.py b/src/acp/http/client.py new file mode 100644 index 0000000..b224167 --- /dev/null +++ b/src/acp/http/client.py @@ -0,0 +1,238 @@ +"""Streamable HTTP client transport (port of #155 ``http-stream.ts``, minus retry). + +``create_http_stream(url, ...)`` returns a :class:`~acp._transport.Transport` +that can be handed to :func:`acp.connect_to_agent`. The flow: + +* The first message MUST be ``initialize``: POSTed as ``application/json``, + expecting ``200 OK`` + an ``Acp-Connection-Id`` response header. The JSON body + is enqueued back into ``receive()`` so the core correlates it by ``id``. +* Subsequent messages are POSTed with the connection id (+ session id header for + session-scoped methods) and return ``202 Accepted``. +* After ``initialize`` the client opens the connection-scoped SSE stream (GET); + when it sees a new ``sessionId`` it opens that session-scoped SSE stream too. +* Server→client messages arrive on those SSE streams, merged into one + ``receive()`` feed. Order is preserved within a stream, interleaved across. +* A single SSE attempt is made per stream; on EOF/closure the reader surfaces + end-of-stream (no auto-retry — that is the caller's responsibility). +* ``close()`` aborts in-flight streams and DELETEs the connection. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from typing import TYPE_CHECKING, Any + +from .._sse import parse_sse_stream +from .protocol import ( + CONNECTION_ID_HEADER, + CONTENT_TYPE_JSON, + CONTENT_TYPE_SSE, + SESSION_ID_HEADER, + is_initialize_request, + method_requires_session_header, + session_id_from_message, +) + +try: + import httpx +except ImportError as exc: # pragma: no cover - exercised via import guard message + msg = "The Streamable HTTP transport requires the 'http' extra: pip install agent-client-protocol[http]" + raise ImportError(msg) from exc + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from .._transport import Transport + +__all__ = ["AcpHttpStatusError", "create_http_stream"] + +_EOF = object() + + +class AcpHttpStatusError(RuntimeError): + """Raised when an HTTP request returns an unexpected status code.""" + + def __init__(self, status_code: int, message: str) -> None: + super().__init__(f"HTTP {status_code}: {message}") + self.status_code = status_code + + +class _HttpStreamTransport: + """Streamable HTTP client transport implementing the :class:`Transport` protocol.""" + + def __init__( + self, + url: str, + *, + client: httpx.AsyncClient, + owns_client: bool, + headers: dict[str, str] | None = None, + ) -> None: + self._url = url + self._client = client + self._owns_client = owns_client + self._extra_headers = dict(headers or {}) + self._connection_id: str | None = None + self._closed = False + self._inbox: asyncio.Queue[Any] = asyncio.Queue() + self._stream_tasks: set[asyncio.Task[None]] = set() + self._session_streams: set[str] = set() + + # -- Transport protocol ------------------------------------------------- + + async def send(self, message: dict[str, Any]) -> None: + if self._closed: + raise ConnectionError("Transport closed") + if is_initialize_request(message): + await self._send_initialize(message) + return + await self._send_post(message) + + async def receive(self) -> dict[str, Any] | None: + item = await self._inbox.get() + if item is _EOF: + return None + return item + + async def close(self) -> None: + if self._closed: + return + self._closed = True + for task in list(self._stream_tasks): + task.cancel() + for task in list(self._stream_tasks): + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + self._stream_tasks.clear() + if self._connection_id is not None: + with contextlib.suppress(Exception): + await self._client.request( + "DELETE", + self._url, + headers={CONNECTION_ID_HEADER: self._connection_id, **self._extra_headers}, + ) + if self._owns_client: + with contextlib.suppress(Exception): + await self._client.aclose() + self._inbox.put_nowait(_EOF) + + # -- Internals ---------------------------------------------------------- + + async def _send_initialize(self, message: dict[str, Any]) -> None: + headers = {"Content-Type": CONTENT_TYPE_JSON, **self._extra_headers} + response = await self._client.post(self._url, json=message, headers=headers) + if response.status_code != 200: + raise AcpHttpStatusError(response.status_code, "initialize failed") + connection_id = response.headers.get(CONNECTION_ID_HEADER) + if not connection_id: + raise AcpHttpStatusError(response.status_code, f"missing {CONNECTION_ID_HEADER} header") + self._connection_id = connection_id + body = response.json() + # Enqueue the initialize result so the core correlates it by id. + self._inbox.put_nowait(body) + # Open the connection-scoped SSE stream. + self._open_stream(session_id=None) + + async def _send_post(self, message: dict[str, Any]) -> None: + if self._connection_id is None: + raise ConnectionError("Cannot send before initialize established a connection id") + headers = {"Content-Type": CONTENT_TYPE_JSON, CONNECTION_ID_HEADER: self._connection_id, **self._extra_headers} + method = message.get("method") + session_id = session_id_from_message(message) + if method_requires_session_header(method) and session_id is not None: + headers[SESSION_ID_HEADER] = session_id + response = await self._client.post(self._url, json=message, headers=headers) + if response.status_code not in (200, 202): + raise AcpHttpStatusError(response.status_code, f"POST {method} failed") + # Some servers may answer initialize-like 200 bodies; for 200 with a body enqueue it. + if response.status_code == 200 and response.content: + with contextlib.suppress(Exception): + self._inbox.put_nowait(response.json()) + + def _open_stream(self, *, session_id: str | None) -> None: + if self._closed: + return + if session_id is not None: + if session_id in self._session_streams: + return + self._session_streams.add(session_id) + task = asyncio.ensure_future(self._consume_stream(session_id=session_id)) + self._stream_tasks.add(task) + task.add_done_callback(self._stream_tasks.discard) + + async def _consume_stream(self, *, session_id: str | None) -> None: + if self._connection_id is None: + return + headers = { + "Accept": CONTENT_TYPE_SSE, + CONNECTION_ID_HEADER: self._connection_id, + **self._extra_headers, + } + if session_id is not None: + headers[SESSION_ID_HEADER] = session_id + try: + async with self._client.stream("GET", self._url, headers=headers) as response: + if response.status_code != 200: + return + async for event in parse_sse_stream(_aiter_raw(response)): + self._handle_incoming(event) + except (httpx.HTTPError, asyncio.CancelledError): + return + finally: + self._on_stream_closed(session_id) + + def _on_stream_closed(self, session_id: str | None) -> None: + """Handle a stream reader terminating (EOF, non-200, or error). + + A dropped/ended **connection-scoped** stream is the client's only channel + for connection-level server→client messages, so its loss is surfaced as + end-of-stream: ``receive()`` returns ``None``, the core's receive loop + exits, and pending requests are rejected instead of hanging forever. v1 + does not auto-reconnect — that is the host's responsibility — but the + disconnect must be observable. Session-scoped streams may legitimately + close, so their EOF is not treated as a connection-level disconnect. + """ + if session_id is not None: + self._session_streams.discard(session_id) + return + if not self._closed: + self._inbox.put_nowait(_EOF) + + def _handle_incoming(self, message: dict[str, Any]) -> None: + # Open a session-scoped stream when any message carries a new sessionId + # (e.g. a session/new or session/load result on the connection stream). + session_id = session_id_from_message(message) + if session_id is not None and session_id not in self._session_streams: + self._open_stream(session_id=session_id) + self._inbox.put_nowait(message) + + +async def _aiter_raw(response: httpx.Response) -> AsyncIterator[bytes]: + async for chunk in response.aiter_bytes(): + yield chunk + + +def create_http_stream( + url: str, + *, + client: httpx.AsyncClient | None = None, + headers: dict[str, str] | None = None, +) -> Transport: + """Create a Streamable HTTP client :class:`Transport`. + + Args: + url: The ACP endpoint URL (e.g. ``https://host/acp``). + client: An optional pre-configured ``httpx.AsyncClient``. If omitted, an + HTTP/2-enabled client with a cookie jar is created and owned by the + transport (closed on ``close()``). + headers: Extra headers sent on every request. + + Returns: + A :class:`Transport` usable with :func:`acp.connect_to_agent`. + """ + owns_client = client is None + if client is None: + # SSE GET streams are long-lived, so disable read timeouts by default. + client = httpx.AsyncClient(http2=True, timeout=httpx.Timeout(None)) + return _HttpStreamTransport(url, client=client, owns_client=owns_client, headers=headers) diff --git a/src/acp/http/protocol.py b/src/acp/http/protocol.py new file mode 100644 index 0000000..c15fdb4 --- /dev/null +++ b/src/acp/http/protocol.py @@ -0,0 +1,106 @@ +"""Protocol constants and JSON-RPC routing helpers for the HTTP/WS transport. + +Ports the small helpers from #155's ``protocol.ts`` + ``jsonrpc.ts``: header +names, MIME types, endpoint path, and pure functions that classify JSON-RPC +messages and extract routing keys (``id`` normalization, ``sessionId``). +""" + +from __future__ import annotations + +from typing import Any + +from ..meta import AGENT_METHODS + +__all__ = [ + "ACP_ENDPOINT_PATH", + "CONNECTION_ID_HEADER", + "CONTENT_TYPE_JSON", + "CONTENT_TYPE_SSE", + "INITIALIZE_METHOD", + "SESSION_ID_HEADER", + "is_initialize_request", + "is_response_message", + "message_id_key", + "method_requires_session_header", + "session_id_from_message", + "session_id_from_params", + "session_id_from_result", +] + +# Header names (case-insensitive on the wire; we normalize to these spellings). +CONNECTION_ID_HEADER = "Acp-Connection-Id" +SESSION_ID_HEADER = "Acp-Session-Id" + +# MIME types. +CONTENT_TYPE_JSON = "application/json" +CONTENT_TYPE_SSE = "text/event-stream" + +# Endpoint path used by docs/examples (the adapter itself is path-agnostic). +ACP_ENDPOINT_PATH = "/acp" + +INITIALIZE_METHOD = AGENT_METHODS["initialize"] + +# Agent methods that operate on an *already-established* session and therefore +# require the ``Acp-Session-Id`` header on POST + session-scoped routing of their +# response on GET. Methods that mint/attach a session id (``session/new``, +# ``session/load``, ``session/fork``, ``session/resume``) are deliberately +# excluded: per the RFD their responses come back on the connection-scoped stream +# because the client does not yet have the session-scoped stream open. +# ``session/list`` is connection-level. +_SESSION_SCOPED_METHODS = frozenset({ + AGENT_METHODS["session_set_mode"], + AGENT_METHODS["session_set_config_option"], + AGENT_METHODS["session_prompt"], + AGENT_METHODS["session_cancel"], + AGENT_METHODS["session_close"], +}) + + +def is_initialize_request(message: dict[str, Any]) -> bool: + """True if the message is an ``initialize`` JSON-RPC request.""" + return message.get("method") == INITIALIZE_METHOD and "id" in message + + +def is_response_message(message: dict[str, Any]) -> bool: + """True if the message is a JSON-RPC response (has ``id`` and no ``method``).""" + return "id" in message and "method" not in message + + +def method_requires_session_header(method: str | None) -> bool: + """True if a POST for ``method`` must carry the ``Acp-Session-Id`` header.""" + return method in _SESSION_SCOPED_METHODS + + +def message_id_key(message_id: Any) -> str | None: + """Normalize a JSON-RPC ``id`` (int or str) to a stable string key. + + The Python core assigns integer request ids while the wire may echo them as + ints or strings; routing tables must key consistently. Returns ``None`` for + a missing id. + """ + if message_id is None: + return None + return str(message_id) + + +def session_id_from_params(params: Any) -> str | None: + """Extract ``sessionId`` from a request's ``params`` object, if present.""" + if isinstance(params, dict): + session_id = params.get("sessionId") + if isinstance(session_id, str): + return session_id + return None + + +def session_id_from_result(result: Any) -> str | None: + """Extract ``sessionId`` from a response's ``result`` object, if present.""" + if isinstance(result, dict): + session_id = result.get("sessionId") + if isinstance(session_id, str): + return session_id + return None + + +def session_id_from_message(message: dict[str, Any]) -> str | None: + """Extract a ``sessionId`` from either a request's params or a response's result.""" + return session_id_from_params(message.get("params")) or session_id_from_result(message.get("result")) diff --git a/src/acp/http/server.py b/src/acp/http/server.py new file mode 100644 index 0000000..6c4f00f --- /dev/null +++ b/src/acp/http/server.py @@ -0,0 +1,461 @@ +"""Framework-agnostic Streamable HTTP server core (port of #155 server.ts + connection.ts). + +:class:`AcpServer` owns an in-memory :class:`ConnectionRegistry`. For each +``initialize`` POST it mints a connection, binds an ``AgentSideConnection`` to an +in-memory transport pair, and returns an ``Acp-Connection-Id``. Subsequent +server→client messages produced by the agent are fanned out to the correct SSE +stream (connection-scoped or session-scoped) based on their ``sessionId`` / +correlated request id. + +The core exposes small, transport-neutral entry points: + +* :meth:`AcpServer.handle_post` — returns a :class:`PostResult` (status + body). +* :meth:`AcpServer.open_stream` — returns an async byte iterator of SSE frames. +* :meth:`AcpServer.handle_delete` — terminates a connection. + +The ASGI adapter in :mod:`acp.http.asgi` maps these onto ASGI messages. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import uuid +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from .._sse import serialize_sse_event, serialize_sse_keepalive +from .._transport import memory_transport_pair +from ..agent.connection import AgentSideConnection +from .protocol import ( + CONNECTION_ID_HEADER, + is_initialize_request, + is_response_message, + message_id_key, + method_requires_session_header, + session_id_from_params, + session_id_from_result, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from ..interfaces import Agent + +__all__ = [ + "AcpServer", + "AgentFactory", + "ConnectionRegistry", + "ConnectionState", + "OutboundStream", + "PostResult", +] + +AgentFactory = Callable[[AgentSideConnection], "Agent"] + +# How long an idle SSE stream waits before emitting a keepalive comment. Kept in +# sync with the TypeScript reference (15s) so intermediaries do not time out an +# otherwise-healthy but quiet stream. +SSE_KEEPALIVE_INTERVAL_SECONDS = 15.0 + +# How long ``initialize`` waits for the agent's response before giving up. The +# response is returned synchronously in the HTTP body, so a hung agent must not +# block the POST forever. +INITIALIZE_TIMEOUT_SECONDS = 30.0 + + +@dataclass +class PostResult: + """Outcome of a POST request.""" + + status: int + body: dict[str, Any] | None = None + headers: dict[str, str] = field(default_factory=dict) + + +class OutboundStream: + """A backpressure-aware buffer for server→client messages. + + Messages pushed before a subscriber attaches are buffered (bounded) and + replayed when :meth:`iterate` is first awaited. When the buffer is full, + :meth:`push` *awaits* until the consumer drains rather than dropping the + message — dropping a JSON-RPC response would permanently hang the peer's + pending request. Awaiting propagates backpressure up to the agent's message + pump, mirroring the ``ReadableStream`` backpressure in the TypeScript SDK. + """ + + def __init__(self, *, capacity: int = 1024) -> None: + self._queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(maxsize=capacity) + self._closed = asyncio.Event() + + async def push(self, message: dict[str, Any]) -> None: + if self._closed.is_set(): + return + putter = asyncio.ensure_future(self._queue.put(message)) + closed = asyncio.ensure_future(self._closed.wait()) + try: + await asyncio.wait({putter, closed}, return_when=asyncio.FIRST_COMPLETED) + finally: + # If the stream closed while we were blocked on a full queue, abandon + # the put; otherwise ensure the close-waiter task is cleaned up. + for task in (putter, closed): + if not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + def close(self) -> None: + if self._closed.is_set(): + return + self._closed.set() + # Guarantee the consumer observes EOF even if the buffer is full: make + # room for the sentinel by dropping one buffered (tail) message, which is + # acceptable during teardown. + while not self._try_put_sentinel(): + with contextlib.suppress(asyncio.QueueEmpty): + self._queue.get_nowait() + + def _try_put_sentinel(self) -> bool: + try: + self._queue.put_nowait(None) + except asyncio.QueueFull: + return False + return True + + async def iterate(self) -> AsyncIterator[dict[str, Any]]: + while True: + message = await self._queue.get() + if message is None: + return + yield message + + +class ConnectionState: + """Owns an ``AgentSideConnection`` bound to an in-memory transport pair. + + The agent writes server→client messages onto the server end of the pair; a + pump task reads them and routes each to the connection-scoped stream or the + right session-scoped stream. + """ + + def __init__(self, connection_id: str, agent_factory: AgentFactory, *, multiplex: bool = False) -> None: + self.connection_id = connection_id + # ``server_side`` is what the AgentSideConnection talks over; ``pump_side`` + # is what we read agent→client traffic from and inject client→agent on. + server_side, pump_side = memory_transport_pair() + self._pump_side = pump_side + self._agent_conn = AgentSideConnection(agent_factory, server_side, listening=True) + self.connection_stream = OutboundStream() + self.session_streams: dict[str, OutboundStream] = {} + # WebSocket mode: multiplex *all* agent→client traffic onto one stream + # (the single socket) instead of splitting across SSE streams. + self._multiplex: OutboundStream | None = OutboundStream() if multiplex else None + # Maps a request id -> sessionId, so responses to session-scoped client + # requests route back onto the right session stream. + self._pending_routes: dict[str, str] = {} + # Request ids whose response should be captured (e.g. initialize) instead + # of being pushed to a stream. + self._response_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._pump_task: asyncio.Task[None] | None = None + + def start(self) -> None: + self._pump_task = asyncio.ensure_future(self._pump()) + + async def _pump(self) -> None: + try: + while True: + message = await self._pump_side.receive() + if message is None: + return + await self._route_outbound(message) + except asyncio.CancelledError: + return + + async def _route_outbound(self, message: dict[str, Any]) -> None: + """Route an agent→client message to the correct SSE stream. + + Rules (matching the RFD): + + * A response to a session-*establishing* request (``session/new`` / + ``session/load`` — result carries a ``sessionId``) goes on the + **connection-scoped** stream, because the client does not yet have the + session-scoped stream open. We register the session so its stream can + be opened on the next GET. + * A response to an already-session-scoped client request routes onto that + session's stream (looked up via ``_pending_routes`` by request id). + * A server→client message carrying a ``sessionId`` in params (a + notification or request) routes onto that session's stream. + * Everything else goes on the connection-scoped stream. + """ + if is_response_message(message): + await self._route_response(message) + return + # Requests/notifications: route by sessionId in params if present. + if self._multiplex is not None: + await self._multiplex.push(message) + return + session_id = session_id_from_params(message.get("params")) + if session_id is not None and session_id in self.session_streams: + await self.session_streams[session_id].push(message) + return + await self.connection_stream.push(message) + + async def _route_response(self, message: dict[str, Any]) -> None: + key = message_id_key(message.get("id")) + # A captured response (e.g. initialize) resolves its waiter instead of + # being pushed to any stream. + if key is not None and key in self._response_waiters: + waiter = self._response_waiters.pop(key) + if not waiter.done(): + waiter.set_result(message) + return + # Register any newly-established session so unknown-session validation + # succeeds regardless of transport. + established = session_id_from_result(message.get("result")) + if established is not None: + self.ensure_session_stream(established) + routed = self._pending_routes.pop(key, None) if key is not None else None + if self._multiplex is not None: + await self._multiplex.push(message) + return + # session/new | session/load results (``established``) go on the + # connection-scoped stream; already-session-scoped responses route to the + # session stream recorded when the request came in. + if established is None and routed is not None and routed in self.session_streams: + await self.session_streams[routed].push(message) + return + await self.connection_stream.push(message) + + async def deliver_to_agent(self, message: dict[str, Any]) -> None: + """Inject a client→server message into the agent connection.""" + # Track session-scoped client requests so their responses route back. + if "id" in message and "method" in message: + session_id = session_id_from_params(message.get("params")) + if session_id is not None: + key = message_id_key(message["id"]) + if key is not None: + self._pending_routes[key] = session_id + await self._pump_side.send(message) + + async def request_response(self, message: dict[str, Any]) -> dict[str, Any]: + """Send a request to the agent and await its correlated response. + + Used for the ``initialize`` POST, which is the one request whose response + is returned synchronously in the HTTP body rather than over an SSE stream. + """ + key = message_id_key(message.get("id")) + loop = asyncio.get_running_loop() + future: asyncio.Future[dict[str, Any]] = loop.create_future() + if key is not None: + self._response_waiters[key] = future + await self.deliver_to_agent(message) + return await asyncio.wait_for(future, timeout=INITIALIZE_TIMEOUT_SECONDS) + + def ensure_session_stream(self, session_id: str) -> OutboundStream: + stream = self.session_streams.get(session_id) + if stream is None: + stream = OutboundStream() + self.session_streams[session_id] = stream + return stream + + def has_session(self, session_id: str) -> bool: + return session_id in self.session_streams + + async def iter_all_outbound(self) -> AsyncIterator[dict[str, Any]]: + """Iterate every agent→client message (WebSocket multiplex mode).""" + if self._multiplex is None: + msg = "iter_all_outbound requires a multiplex connection (WebSocket)" + raise RuntimeError(msg) + async for message in self._multiplex.iterate(): + yield message + + async def close(self) -> None: + if self._pump_task is not None: + self._pump_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._pump_task + self.connection_stream.close() + for stream in self.session_streams.values(): + stream.close() + if self._multiplex is not None: + self._multiplex.close() + with contextlib.suppress(Exception): + await self._pump_side.close() + with contextlib.suppress(Exception): + await self._agent_conn.close() + + +class ConnectionRegistry: + """In-memory ``connectionId -> ConnectionState`` registry.""" + + def __init__(self) -> None: + self._connections: dict[str, ConnectionState] = {} + + def create(self, agent_factory: AgentFactory) -> ConnectionState: + connection_id = uuid.uuid4().hex + state = ConnectionState(connection_id, agent_factory) + state.start() + self._connections[connection_id] = state + return state + + def create_multiplex(self, agent_factory: AgentFactory) -> ConnectionState: + """Create a connection whose agent→client traffic is multiplexed onto one + stream (used by the WebSocket transport).""" + connection_id = uuid.uuid4().hex + state = ConnectionState(connection_id, agent_factory, multiplex=True) + state.start() + self._connections[connection_id] = state + return state + + def get(self, connection_id: str) -> ConnectionState | None: + return self._connections.get(connection_id) + + async def remove(self, connection_id: str) -> None: + state = self._connections.pop(connection_id, None) + if state is not None: + await state.close() + + async def close_all(self) -> None: + for connection_id in list(self._connections): + await self.remove(connection_id) + + +class AcpServer: + """Framework-agnostic Streamable HTTP + WebSocket server core. + + Args: + agent_factory: Called once per connection with the bound + ``AgentSideConnection`` to produce a per-connection ``Agent``. + """ + + def __init__(self, agent_factory: AgentFactory) -> None: + self._agent_factory = agent_factory + self._registry = ConnectionRegistry() + + @property + def registry(self) -> ConnectionRegistry: + return self._registry + + def create_websocket_connection(self) -> ConnectionState: + """Create a new multiplexed connection for a WebSocket upgrade.""" + return self._registry.create_multiplex(self._agent_factory) + + # -- POST --------------------------------------------------------------- + + async def handle_post( + self, + message: Any, + *, + content_type: str | None, + connection_id: str | None, + session_id: str | None, + ) -> PostResult: + if content_type is None or not content_type.lower().startswith("application/json"): + return PostResult(415, {"error": "Content-Type must be application/json"}) + if isinstance(message, list): + return PostResult(501, {"error": "Batch requests are not supported"}) + if not isinstance(message, dict): + return PostResult(400, {"error": "Invalid JSON-RPC message"}) + + if is_initialize_request(message): + return await self._handle_initialize(message) + + if connection_id is None: + return PostResult(400, {"error": "Missing connection id"}) + state = self._registry.get(connection_id) + if state is None: + return PostResult(404, {"error": "Unknown connection id"}) + + method = message.get("method") + if method_requires_session_header(method) and session_id is None: + return PostResult(400, {"error": "Missing session id header"}) + if session_id is not None and not state.has_session(session_id): + # A session-scoped POST references an unknown session. + return PostResult(404, {"error": "Unknown session id"}) + + await state.deliver_to_agent(message) + return PostResult(202) + + async def _handle_initialize(self, message: dict[str, Any]) -> PostResult: + state = self._registry.create(self._agent_factory) + # Deliver initialize to the agent and await its response so we can return + # the 200 body synchronously (initialize is the one blocking POST). If the + # agent never responds (timeout) or errors, tear the just-created + # connection down instead of leaking its pump task + agent connection. + try: + response = await state.request_response(message) + except TimeoutError: + await self._registry.remove(state.connection_id) + return PostResult(504, {"error": "initialize timed out"}) + except Exception: + await self._registry.remove(state.connection_id) + return PostResult(500, {"error": "initialize failed"}) + return PostResult(200, response, {CONNECTION_ID_HEADER: state.connection_id}) + + # -- GET / SSE ---------------------------------------------------------- + + def validate_stream(self, *, connection_id: str | None, session_id: str | None) -> PostResult | None: + """Validate a GET SSE request. Returns an error PostResult, or None if OK.""" + if connection_id is None: + return PostResult(400, {"error": "Missing connection id"}) + state = self._registry.get(connection_id) + if state is None: + return PostResult(404, {"error": "Unknown connection id"}) + if session_id is not None and not state.has_session(session_id): + return PostResult(404, {"error": "Unknown session id"}) + return None + + async def open_stream( + self, + *, + connection_id: str, + session_id: str | None, + ) -> AsyncIterator[bytes]: + """Yield SSE byte frames for a connection- or session-scoped stream. + + Emits a keepalive comment whenever the stream is idle for longer than + :data:`SSE_KEEPALIVE_INTERVAL_SECONDS` so that idle-timeout intermediaries + (proxies, load balancers) do not close an otherwise-healthy stream. + """ + state = self._registry.get(connection_id) + if state is None: + return + stream = state.ensure_session_stream(session_id) if session_id is not None else state.connection_stream + messages = stream.iterate() + pending: asyncio.Task[dict[str, Any]] | None = None + try: + while True: + if pending is None: + pending = asyncio.ensure_future(messages.__anext__()) + done, _ = await asyncio.wait({pending}, timeout=SSE_KEEPALIVE_INTERVAL_SECONDS) + if not done: + # Idle: emit a keepalive and keep awaiting the same message. + yield serialize_sse_keepalive() + continue + try: + message = pending.result() + except StopAsyncIteration: + return + finally: + pending = None + yield serialize_sse_event(message) + finally: + if pending is not None: + pending.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await pending + await messages.aclose() + + # -- DELETE ------------------------------------------------------------- + + async def handle_delete(self, *, connection_id: str | None) -> PostResult: + if connection_id is None: + return PostResult(400, {"error": "Missing connection id"}) + if self._registry.get(connection_id) is None: + return PostResult(404, {"error": "Unknown connection id"}) + await self._registry.remove(connection_id) + return PostResult(202) + + async def close(self) -> None: + await self._registry.close_all() diff --git a/src/acp/ws/__init__.py b/src/acp/ws/__init__.py new file mode 100644 index 0000000..51ab2a9 --- /dev/null +++ b/src/acp/ws/__init__.py @@ -0,0 +1,23 @@ +"""WebSocket transport for ACP (experimental). + +Public exports are import-guarded behind the ``http`` extra (which provides the +``websockets`` dependency). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +__all__ = ["create_websocket_stream"] + +if TYPE_CHECKING: + from .client import create_websocket_stream + + +def __getattr__(name: str) -> Any: + if name == "create_websocket_stream": + from .client import create_websocket_stream + + return create_websocket_stream + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/src/acp/ws/client.py b/src/acp/ws/client.py new file mode 100644 index 0000000..18acf5b --- /dev/null +++ b/src/acp/ws/client.py @@ -0,0 +1,117 @@ +"""WebSocket client transport (port of #155 ``ws-stream.ts``). + +``create_websocket_stream(url, ...)`` connects a WebSocket and returns a +:class:`~acp._transport.Transport`. Messages are JSON-RPC **text** frames; +binary frames are ignored. The client must still send ``initialize`` as the +first message over the socket. +""" + +from __future__ import annotations + +import contextlib +import json +from typing import TYPE_CHECKING, Any + +from .._cookies import MemoryAcpCookieStore + +try: + import websockets + from websockets.asyncio.client import connect as ws_connect +except ImportError as exc: # pragma: no cover - exercised via import guard message + msg = "The WebSocket transport requires the 'http' extra: pip install agent-client-protocol[http]" + raise ImportError(msg) from exc + +if TYPE_CHECKING: + from .._transport import Transport + +__all__ = ["MemoryAcpCookieStore", "create_websocket_stream"] + +# Case-insensitive header name a server uses to set connection-affinity cookies +# on the WebSocket upgrade response. +_SET_COOKIE_HEADER = "Set-Cookie" + + +class _WebSocketTransport: + """WebSocket client transport implementing the :class:`Transport` protocol.""" + + def __init__(self, connection: Any) -> None: + self._ws = connection + self._closed = False + + async def send(self, message: dict[str, Any]) -> None: + if self._closed: + raise ConnectionError("Transport closed") + await self._ws.send(json.dumps(message, separators=(",", ":"))) + + async def receive(self) -> dict[str, Any] | None: + while True: + try: + frame = await self._ws.recv() + except websockets.ConnectionClosed: + return None + # Ignore binary frames; only text JSON-RPC is meaningful. + if isinstance(frame, bytes): + continue + try: + return json.loads(frame) + except json.JSONDecodeError: + continue + + async def close(self) -> None: + if self._closed: + return + self._closed = True + with contextlib.suppress(Exception): + await self._ws.close() + + +async def create_websocket_stream( + url: str, + *, + headers: dict[str, str] | None = None, + cookie_store: MemoryAcpCookieStore | None = None, +) -> Transport: + """Connect a WebSocket and return a :class:`Transport`. + + Per the RFD, clients MUST accept, store, and return cookies on all HTTP-based + transports (including WebSocket) so servers can rely on cookies for session + affinity (e.g. sticky sessions behind a load balancer). A single WebSocket is + one long-lived connection, so cookie support matters across *reconnects*: + pass a caller-owned ``cookie_store`` reused between fresh streams. Any cookies + already in the store are sent as a ``Cookie`` header on the handshake, and any + ``Set-Cookie`` headers on the upgrade response are captured back into it. + + Args: + url: The ACP WebSocket endpoint (e.g. ``ws://host/acp``). + headers: Extra headers sent during the handshake. + cookie_store: Optional caller-owned affinity cookie store to reuse across + reconnects. If omitted, an ephemeral per-stream store is used. + + Returns: + A connected :class:`Transport` usable with :func:`acp.connect_to_agent`. + """ + store = cookie_store if cookie_store is not None else MemoryAcpCookieStore() + request_headers = dict(headers or {}) + cookie_header = store.cookie_header() + if cookie_header and not _has_header(request_headers, "Cookie"): + request_headers["Cookie"] = cookie_header + connection = await ws_connect(url, additional_headers=request_headers or None) + _capture_set_cookies(connection, store) + return _WebSocketTransport(connection) + + +def _has_header(headers: dict[str, str], name: str) -> bool: + lowered = name.lower() + return any(key.lower() == lowered for key in headers) + + +def _capture_set_cookies(connection: Any, store: MemoryAcpCookieStore) -> None: + """Store ``Set-Cookie`` headers from the WebSocket upgrade response.""" + response = getattr(connection, "response", None) + response_headers = getattr(response, "headers", None) + if response_headers is None: + return + get_all = getattr(response_headers, "get_all", None) + values = list(get_all(_SET_COOKIE_HEADER)) if get_all is not None else [] + if values: + store.store_set_cookies(values) diff --git a/src/acp/ws/server.py b/src/acp/ws/server.py new file mode 100644 index 0000000..e5ff43f --- /dev/null +++ b/src/acp/ws/server.py @@ -0,0 +1,79 @@ +"""WebSocket server handling for the ASGI adapter (port of #155 ws-server.ts). + +On upgrade we create a fresh :class:`~acp.http.server.ConnectionState` (bound to +its own ``AgentSideConnection``), accept the socket with an ``Acp-Connection-Id`` +header, then pump JSON-RPC text frames both directions. All server→client +traffic (across the connection- and every session-scoped stream) is multiplexed +onto the single socket. On disconnect the connection and its sessions are torn +down. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from ..http.protocol import CONNECTION_ID_HEADER + +if TYPE_CHECKING: + from ..http.server import AcpServer, ConnectionState + +__all__ = ["handle_asgi_websocket"] + + +async def handle_asgi_websocket( + server: AcpServer, + scope: dict[str, Any], + receive: Callable, + send: Callable, +) -> None: + """Handle an ASGI ``websocket`` scope by bridging it to a new ACP connection.""" + # Wait for the connect message. + message = await receive() + if message["type"] != "websocket.connect": + return + + state = server.create_websocket_connection() + await send({ + "type": "websocket.accept", + "headers": [(CONNECTION_ID_HEADER.lower().encode(), state.connection_id.encode())], + }) + + outbound_task = asyncio.ensure_future(_pump_outbound(state, send)) + try: + await _pump_inbound(state, receive) + finally: + outbound_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await outbound_task + await server.registry.remove(state.connection_id) + + +async def _pump_inbound(state: ConnectionState, receive: Callable) -> None: + """Read client→server text frames and deliver them to the agent.""" + while True: + message = await receive() + msg_type = message["type"] + if msg_type == "websocket.disconnect": + return + if msg_type != "websocket.receive": + continue + text = message.get("text") + if text is None: + # Ignore binary frames. + continue + try: + payload = json.loads(text) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + await state.deliver_to_agent(payload) + + +async def _pump_outbound(state: ConnectionState, send: Callable) -> None: + """Forward all agent→client messages onto the socket as text frames.""" + async for message in state.iter_all_outbound(): + await send({"type": "websocket.send", "text": json.dumps(message, separators=(",", ":"))}) diff --git a/tests/http/conftest.py b/tests/http/conftest.py new file mode 100644 index 0000000..f96355c --- /dev/null +++ b/tests/http/conftest.py @@ -0,0 +1,62 @@ +"""Shared fixtures for HTTP/WS loopback tests: run an ASGI app under uvicorn.""" + +from __future__ import annotations + +import asyncio +import contextlib +import socket +from collections.abc import AsyncIterator, Callable +from typing import Any + +import pytest_asyncio +import uvicorn + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class RunningServer: + def __init__(self, host: str, port: int) -> None: + self.host = host + self.port = port + + @property + def http_url(self) -> str: + return f"http://{self.host}:{self.port}/acp" + + @property + def ws_url(self) -> str: + return f"ws://{self.host}:{self.port}/acp" + + +@pytest_asyncio.fixture +async def serve_asgi() -> AsyncIterator[Callable[[Any], Any]]: + """Yield a factory that boots an ASGI app under uvicorn and returns a RunningServer.""" + servers: list[uvicorn.Server] = [] + tasks: list[asyncio.Task[Any]] = [] + + async def _start(app: Any) -> RunningServer: + host, port = "127.0.0.1", _free_port() + config = uvicorn.Config(app, host=host, port=port, log_level="warning", lifespan="on") + server = uvicorn.Server(config) + servers.append(server) + task = asyncio.ensure_future(server.serve()) + tasks.append(task) + # Wait until the server is up. + for _ in range(100): + if server.started: + break + await asyncio.sleep(0.02) + return RunningServer(host, port) + + try: + yield _start + finally: + for server in servers: + server.should_exit = True + for task in tasks: + with contextlib.suppress(asyncio.CancelledError, Exception): + await asyncio.wait_for(task, timeout=5) diff --git a/tests/http/test_cookies.py b/tests/http/test_cookies.py new file mode 100644 index 0000000..5bc0ae4 --- /dev/null +++ b/tests/http/test_cookies.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from acp._cookies import MemoryAcpCookieStore + + +def test_store_and_render_single_cookie() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("affinity=abc123; Path=/; HttpOnly") + assert store.cookie_header() == "affinity=abc123" + + +def test_store_multiple_cookies_preserves_all() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookies(["a=1; Path=/", "b=2; Secure"]) + assert store.cookie_header() == "a=1; b=2" + + +def test_later_value_overwrites_same_name() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("a=1") + store.store_set_cookie("a=2") + assert store.cookie_header() == "a=2" + assert len(store) == 1 + + +def test_empty_store_returns_none() -> None: + store = MemoryAcpCookieStore() + assert store.cookie_header() is None + + +def test_malformed_set_cookie_ignored() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("garbage") + store.store_set_cookie("") + assert store.cookie_header() is None + + +def test_clear_drops_all() -> None: + store = MemoryAcpCookieStore() + store.store_set_cookie("a=1") + store.clear() + assert store.cookie_header() is None diff --git a/tests/http/test_fixes.py b/tests/http/test_fixes.py new file mode 100644 index 0000000..2a8c307 --- /dev/null +++ b/tests/http/test_fixes.py @@ -0,0 +1,186 @@ +"""Regression tests for reliability fixes on the HTTP/WS transport. + +Covers: +* OutboundStream backpressure (no silent message drops under a full buffer). +* SSE keepalive emission on idle streams. +* HTTP client surfacing disconnect (EOF) when the connection-scoped SSE stream ends. +* Server cleanup of a leaked connection when ``initialize`` fails/times out. +* WebSocket client cookie support (send stored Cookie; capture Set-Cookie). +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx +import pytest + +import acp.http.server as server_mod +from acp.http.client import create_http_stream +from acp.http.protocol import CONNECTION_ID_HEADER, CONTENT_TYPE_JSON +from acp.http.server import AcpServer, OutboundStream + +CT_JSON = "application/json" + + +# -- Finding 1: OutboundStream backpressure ------------------------------------ + + +@pytest.mark.asyncio +async def test_outbound_stream_does_not_drop_beyond_capacity() -> None: + """Pushing more than ``capacity`` messages must not silently drop any. + + With a bounded queue and ``put_nowait``, the (capacity+1)-th message was + dropped. Backpressure-aware push blocks the producer until a consumer drains, + so every message is eventually delivered in order. + """ + stream = OutboundStream(capacity=2) + total = 5 + + async def produce() -> None: + for i in range(total): + await stream.push({"n": i}) + stream.close() + + producer = asyncio.ensure_future(produce()) + received = [msg async for msg in stream.iterate()] + await producer + assert received == [{"n": i} for i in range(total)] + + +@pytest.mark.asyncio +async def test_outbound_stream_push_blocks_when_full() -> None: + """push must not complete once the buffer is full and no consumer drains.""" + stream = OutboundStream(capacity=1) + await stream.push({"n": 0}) # fills the buffer + blocked = asyncio.ensure_future(stream.push({"n": 1})) + await asyncio.sleep(0.05) + assert not blocked.done() + # Draining one message unblocks the producer. + it = stream.iterate() + assert await it.__anext__() == {"n": 0} + await asyncio.wait_for(blocked, timeout=1) + stream.close() + + +# -- Finding 3: SSE keepalive -------------------------------------------------- + + +@pytest.mark.asyncio +async def test_open_stream_emits_keepalive_when_idle(monkeypatch: pytest.MonkeyPatch) -> None: + """An idle connection-scoped stream must emit periodic SSE keepalive frames.""" + monkeypatch.setattr(server_mod, "SSE_KEEPALIVE_INTERVAL_SECONDS", 0.05) + + server = AcpServer(lambda conn: _NoopAgent()) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + connection_id = result.headers[CONNECTION_ID_HEADER] + + frames: list[bytes] = [] + + async def drain() -> None: + async for frame in server.open_stream(connection_id=connection_id, session_id=None): + frames.append(frame) + + task = asyncio.ensure_future(drain()) + await asyncio.sleep(0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await server.close() + + assert any(frame == b": keepalive\n\n" for frame in frames) + + +# -- Finding 5: initialize failure cleanup ------------------------------------- + + +@pytest.mark.asyncio +async def test_initialize_timeout_cleans_up_connection(monkeypatch: pytest.MonkeyPatch) -> None: + """A hung ``initialize`` must not leak a registered connection.""" + monkeypatch.setattr(server_mod, "INITIALIZE_TIMEOUT_SECONDS", 0.1) + + server = AcpServer(lambda conn: _SilentInitAgent()) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + assert result.status >= 500 + # No connection should remain registered after a failed initialize. + assert server.registry.get(result.headers.get(CONNECTION_ID_HEADER, "")) is None + assert _registry_size(server) == 0 + await server.close() + + +# -- Finding 4: HTTP client surfaces disconnect on stream EOF ------------------ + + +@pytest.mark.asyncio +async def test_http_client_surfaces_eof_when_connection_stream_ends() -> None: + """When the connection-scoped SSE stream ends, receive() must return None.""" + conn_id = "conn-eof" + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + body = json.loads(request.content) + if body.get("method") == "initialize": + return httpx.Response( + 200, + headers={CONNECTION_ID_HEADER: conn_id, "Content-Type": CONTENT_TYPE_JSON}, + json={"jsonrpc": "2.0", "id": body["id"], "result": {}}, + ) + return httpx.Response(202) + if request.method == "GET": + # SSE stream that immediately ends (empty body -> EOF). + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"") + return httpx.Response(202) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + transport = create_http_stream("http://testserver/acp", client=client) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + # Drain the initialize result. + assert await asyncio.wait_for(transport.receive(), timeout=1) == {"jsonrpc": "2.0", "id": 0, "result": {}} + # The connection stream ends -> transport must surface EOF, not hang. + assert await asyncio.wait_for(transport.receive(), timeout=1) is None + finally: + await transport.close() + await client.aclose() + + +# -- Helpers ------------------------------------------------------------------- + + +def _registry_size(server: AcpServer) -> int: + return len(server.registry._connections) # type: ignore[attr-defined] + + +class _NoopAgent: + def __init__(self) -> None: + self._conn: Any = None + + def on_connect(self, conn: Any) -> None: + self._conn = conn + + async def initialize(self, protocol_version: int = 1, **kwargs: Any) -> Any: + from acp.schema import InitializeResponse + + return InitializeResponse(protocol_version=1) + + +class _SilentInitAgent: + """An agent whose initialize never returns, forcing a server-side timeout.""" + + def on_connect(self, conn: Any) -> None: + pass + + async def initialize(self, protocol_version: int = 1, **kwargs: Any) -> Any: + await asyncio.sleep(3600) diff --git a/tests/http/test_http_client.py b/tests/http/test_http_client.py new file mode 100644 index 0000000..cacb296 --- /dev/null +++ b/tests/http/test_http_client.py @@ -0,0 +1,213 @@ +"""Unit tests for the Streamable HTTP client transport (ported from http-stream.test.ts).""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx +import pytest + +from acp._sse import serialize_sse_event +from acp.http.client import AcpHttpStatusError, create_http_stream +from acp.http.protocol import CONNECTION_ID_HEADER, CONTENT_TYPE_JSON, SESSION_ID_HEADER + +CONN_ID = "conn-123" + + +class FakeServer: + """A minimal in-memory Streamable HTTP server backed by httpx.MockTransport.""" + + def __init__(self) -> None: + self.posts: list[dict[str, Any]] = [] + self.deleted = False + # Queues feeding the connection-scoped and session-scoped SSE streams. + self.conn_stream: asyncio.Queue[bytes | None] = asyncio.Queue() + self.session_streams: dict[str, asyncio.Queue[bytes | None]] = {} + + def handler(self, request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return self._handle_post(request) + if request.method == "GET": + return self._handle_get(request) + if request.method == "DELETE": + self.deleted = True + return httpx.Response(202) + return httpx.Response(405) + + def _handle_post(self, request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + self.posts.append(body) + if body.get("method") == "initialize": + return httpx.Response( + 200, + headers={CONNECTION_ID_HEADER: CONN_ID, "Content-Type": CONTENT_TYPE_JSON}, + json={"jsonrpc": "2.0", "id": body["id"], "result": {"protocolVersion": 1}}, + ) + return httpx.Response(202) + + def _handle_get(self, request: httpx.Request) -> httpx.Response: + session_id = request.headers.get(SESSION_ID_HEADER) + if session_id is not None: + queue = self.session_streams.setdefault(session_id, asyncio.Queue()) + else: + queue = self.conn_stream + + async def body() -> Any: + while True: + chunk = await queue.get() + if chunk is None: + return + yield chunk + + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, stream=_AsyncByteStream(body())) + + def push_conn(self, message: dict[str, Any]) -> None: + self.conn_stream.put_nowait(serialize_sse_event(message)) + + def push_session(self, session_id: str, message: dict[str, Any]) -> None: + queue = self.session_streams.setdefault(session_id, asyncio.Queue()) + queue.put_nowait(serialize_sse_event(message)) + + +class _AsyncByteStream(httpx.AsyncByteStream): + def __init__(self, iterator: Any) -> None: + self._iterator = iterator + + async def __aiter__(self) -> Any: + async for chunk in self._iterator: + yield chunk + + +def _make_transport(server: FakeServer): + client = httpx.AsyncClient(transport=httpx.MockTransport(server.handler)) + return create_http_stream("http://testserver/acp", client=client), client + + +@pytest.mark.asyncio +async def test_initialize_posts_and_reads_connection_id() -> None: + server = FakeServer() + transport, client = _make_transport(server) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + # The initialize result is enqueued back for the core to correlate. + result = await asyncio.wait_for(transport.receive(), timeout=1) + assert result == {"jsonrpc": "2.0", "id": 0, "result": {"protocolVersion": 1}} + assert server.posts[0]["method"] == "initialize" + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_initialize_failure_raises() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + transport = create_http_stream("http://testserver/acp", client=client) + try: + with pytest.raises(AcpHttpStatusError) as exc: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + assert exc.value.status_code == 500 # type: ignore[attr-defined] + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_connection_scoped_sse_delivers_new_session_result() -> None: + server = FakeServer() + transport, client = _make_transport(server) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) # drain initialize result + # session/new POST returns 202; the result comes over the connection stream. + await transport.send({"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}) + await asyncio.sleep(0.05) + server.push_conn({"jsonrpc": "2.0", "id": 1, "result": {"sessionId": "sess-1"}}) + msg = await asyncio.wait_for(transport.receive(), timeout=1) + assert msg == {"jsonrpc": "2.0", "id": 1, "result": {"sessionId": "sess-1"}} + assert any(p.get("method") == "session/new" for p in server.posts) + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_session_scoped_sse_opens_after_new_session() -> None: + server = FakeServer() + transport, client = _make_transport(server) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) + await transport.send({"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}) + await asyncio.sleep(0.05) + server.push_conn({"jsonrpc": "2.0", "id": 1, "result": {"sessionId": "sess-1"}}) + await asyncio.wait_for(transport.receive(), timeout=1) # session/new result + # Give the client time to open the session-scoped stream. + await asyncio.sleep(0.05) + assert "sess-1" in server.session_streams + # A session-scoped notification arrives on the merged feed. + server.push_session("sess-1", {"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "sess-1"}}) + msg = await asyncio.wait_for(transport.receive(), timeout=1) + assert msg["method"] == "session/update" + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_session_scoped_post_sends_session_header() -> None: + server = FakeServer() + transport, client = _make_transport(server) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) + await transport.send({"jsonrpc": "2.0", "id": 2, "method": "session/prompt", "params": {"sessionId": "sess-1"}}) + prompt_post = next(p for p in server.posts if p.get("method") == "session/prompt") + assert prompt_post["params"]["sessionId"] == "sess-1" + finally: + await transport.close() + await client.aclose() + + +@pytest.mark.asyncio +async def test_close_deletes_connection() -> None: + server = FakeServer() + transport, client = _make_transport(server) + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) + await transport.close() + assert server.deleted is True + # After close, receive() yields EOF. + assert await asyncio.wait_for(transport.receive(), timeout=1) is None + await client.aclose() + + +@pytest.mark.asyncio +async def test_post_error_status_raises() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + body = json.loads(request.content) + if body.get("method") == "initialize": + return httpx.Response( + 200, + headers={CONNECTION_ID_HEADER: CONN_ID}, + json={"jsonrpc": "2.0", "id": body["id"], "result": {}}, + ) + return httpx.Response(404) + return httpx.Response(200, headers={"Content-Type": "text/event-stream"}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + transport = create_http_stream("http://testserver/acp", client=client) + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + await asyncio.wait_for(transport.receive(), timeout=1) + with pytest.raises(AcpHttpStatusError) as exc: + await transport.send({"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}) + assert exc.value.status_code == 404 # type: ignore[attr-defined] + finally: + await transport.close() + await client.aclose() diff --git a/tests/http/test_http_server.py b/tests/http/test_http_server.py new file mode 100644 index 0000000..92f9290 --- /dev/null +++ b/tests/http/test_http_server.py @@ -0,0 +1,233 @@ +"""Tests for the framework-agnostic AcpServer core (ported from server*.test.ts).""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from acp.http.protocol import CONNECTION_ID_HEADER +from acp.http.server import AcpServer +from acp.schema import NewSessionResponse, PromptResponse +from tests.conftest import TestAgent + +CT_JSON = "application/json" + + +class _Agent(TestAgent): + """A test agent that streams a notification during prompt and can ask permission.""" + + def __init__(self) -> None: + super().__init__() + self._conn: Any = None + self.ask_permission = False + + def on_connect(self, conn: Any) -> None: + self._conn = conn + + async def new_session(self, cwd: str | None = None, mcp_servers: Any = None, **kwargs: Any) -> NewSessionResponse: + return NewSessionResponse(session_id="sess-1") + + async def prompt(self, session_id: str, prompt: Any = None, **kwargs: Any) -> PromptResponse: + # Emit a session-scoped notification back to the client. + await self._conn.session_update( + session_id=session_id, + update={"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hi"}}, + ) + if self.ask_permission: + await self._conn.request_permission( + session_id=session_id, + tool_call={"toolCallId": "t1", "title": "run"}, + options=[{"optionId": "allow", "name": "Allow", "kind": "allow_once"}], + ) + return PromptResponse(stop_reason="end_turn") + + +def _agent_factory(agent: _Agent): + return lambda conn: agent + + +async def _drain_stream(server: AcpServer, connection_id: str, session_id: str | None, out: list[bytes]) -> None: + async for frame in server.open_stream(connection_id=connection_id, session_id=session_id): + out.append(frame) + + +@pytest.mark.asyncio +async def test_post_wrong_content_type_returns_415() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}, + content_type="text/plain", + connection_id=None, + session_id=None, + ) + assert result.status == 415 + await server.close() + + +@pytest.mark.asyncio +async def test_batch_returns_501() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post([], content_type=CT_JSON, connection_id=None, session_id=None) + assert result.status == 501 + await server.close() + + +@pytest.mark.asyncio +async def test_initialize_creates_connection_and_returns_id() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1, "clientCapabilities": {}}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + assert result.status == 200 + assert CONNECTION_ID_HEADER in result.headers + assert result.body is not None + assert result.body["id"] == 0 + await server.close() + + +@pytest.mark.asyncio +async def test_missing_connection_id_returns_400() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + assert result.status == 400 + await server.close() + + +@pytest.mark.asyncio +async def test_unknown_connection_id_returns_404() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}}, + content_type=CT_JSON, + connection_id="nope", + session_id=None, + ) + assert result.status == 404 + await server.close() + + +async def _initialize(server: AcpServer) -> str: + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1, "clientCapabilities": {}}}, + content_type=CT_JSON, + connection_id=None, + session_id=None, + ) + return result.headers[CONNECTION_ID_HEADER] + + +@pytest.mark.asyncio +async def test_session_new_result_on_connection_stream() -> None: + server = AcpServer(_agent_factory(_Agent())) + conn_id = await _initialize(server) + frames: list[bytes] = [] + task = asyncio.ensure_future(_drain_stream(server, conn_id, None, frames)) + await asyncio.sleep(0.05) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {"cwd": ".", "mcpServers": []}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id=None, + ) + assert result.status == 202 + await asyncio.sleep(0.1) + joined = b"".join(frames).decode() + assert '"sessionId":"sess-1"' in joined + assert '"id":1' in joined + task.cancel() + await server.close() + + +@pytest.mark.asyncio +async def test_session_scoped_missing_session_header_returns_400() -> None: + server = AcpServer(_agent_factory(_Agent())) + conn_id = await _initialize(server) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 2, "method": "session/prompt", "params": {"sessionId": "sess-1"}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id=None, + ) + assert result.status == 400 + await server.close() + + +@pytest.mark.asyncio +async def test_prompt_streams_notification_on_session_stream() -> None: + server = AcpServer(_agent_factory(_Agent())) + conn_id = await _initialize(server) + # Create the session first. + await server.handle_post( + {"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {"cwd": ".", "mcpServers": []}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id=None, + ) + await asyncio.sleep(0.05) + session_frames: list[bytes] = [] + conn_frames: list[bytes] = [] + st = asyncio.ensure_future(_drain_stream(server, conn_id, "sess-1", session_frames)) + ct = asyncio.ensure_future(_drain_stream(server, conn_id, None, conn_frames)) + await asyncio.sleep(0.05) + result = await server.handle_post( + {"jsonrpc": "2.0", "id": 2, "method": "session/prompt", "params": {"sessionId": "sess-1", "prompt": []}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id="sess-1", + ) + assert result.status == 202 + await asyncio.sleep(0.15) + session_joined = b"".join(session_frames).decode() + # The agent_message_chunk notification is session-scoped. + assert "agent_message_chunk" in session_joined + # The prompt response (id 2) also routes to the session stream. + assert '"id":2' in session_joined + st.cancel() + ct.cancel() + await server.close() + + +@pytest.mark.asyncio +async def test_delete_terminates_connection() -> None: + server = AcpServer(_agent_factory(_Agent())) + conn_id = await _initialize(server) + result = await server.handle_delete(connection_id=conn_id) + assert result.status == 202 + # Subsequent use of the connection id 404s. + follow = await server.handle_post( + {"jsonrpc": "2.0", "id": 5, "method": "session/new", "params": {}}, + content_type=CT_JSON, + connection_id=conn_id, + session_id=None, + ) + assert follow.status == 404 + await server.close() + + +@pytest.mark.asyncio +async def test_delete_missing_connection_id_returns_400() -> None: + server = AcpServer(_agent_factory(_Agent())) + result = await server.handle_delete(connection_id=None) + assert result.status == 400 + await server.close() + + +@pytest.mark.asyncio +async def test_get_validation_errors() -> None: + server = AcpServer(_agent_factory(_Agent())) + assert server.validate_stream(connection_id=None, session_id=None).status == 400 # type: ignore[union-attr] + assert server.validate_stream(connection_id="nope", session_id=None).status == 404 # type: ignore[union-attr] + conn_id = await _initialize(server) + assert server.validate_stream(connection_id=conn_id, session_id="ghost").status == 404 # type: ignore[union-attr] + assert server.validate_stream(connection_id=conn_id, session_id=None) is None + await server.close() diff --git a/tests/http/test_loopback.py b/tests/http/test_loopback.py new file mode 100644 index 0000000..07b863d --- /dev/null +++ b/tests/http/test_loopback.py @@ -0,0 +1,131 @@ +"""End-to-end in-process loopback tests: Python client transport <-> ASGI server. + +Boots the ASGI app under a real uvicorn server (httpx's ASGITransport buffers +whole responses and cannot consume infinite SSE streams), then drives the full +ACP flow over both the Streamable HTTP and WebSocket transports. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from acp import connect_to_agent +from acp.http.asgi import create_asgi_app +from acp.http.client import create_http_stream +from acp.schema import InitializeResponse, NewSessionResponse, PromptResponse, RequestPermissionResponse +from acp.ws.client import create_websocket_stream +from tests.conftest import TestAgent, TestClient + + +class _LoopbackAgent(TestAgent): + def __init__(self) -> None: + super().__init__() + self._conn: Any = None + self.ask_permission = False + + def on_connect(self, conn: Any) -> None: + self._conn = conn + + async def initialize(self, protocol_version: int = 1, **kwargs: Any) -> InitializeResponse: + return InitializeResponse(protocol_version=1) + + async def new_session(self, cwd: str | None = None, mcp_servers: Any = None, **kwargs: Any) -> NewSessionResponse: + return NewSessionResponse(session_id="sess-loop") + + async def prompt(self, session_id: str, prompt: Any = None, **kwargs: Any) -> PromptResponse: + await self._conn.session_update( + session_id=session_id, + update={"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "hello"}}, + ) + if self.ask_permission: + await self._conn.request_permission( + session_id=session_id, + tool_call={"toolCallId": "t1", "title": "run"}, + options=[{"optionId": "allow", "name": "Allow", "kind": "allow_once"}], + ) + return PromptResponse(stop_reason="end_turn") + + +def _make_app(agent: _LoopbackAgent) -> Any: + return create_asgi_app(lambda conn: agent) + + +class _CapturingClient(TestClient): + def __init__(self) -> None: + super().__init__() + self.updates: list[Any] = [] + self.permission_requested = False + + async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: + self.updates.append(update) + + async def request_permission(self, session_id: str, tool_call: Any, options: Any, **kwargs: Any): + self.permission_requested = True + return RequestPermissionResponse.model_validate({"outcome": {"outcome": "selected", "optionId": "allow"}}) + + +# -- Streamable HTTP ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_http_loopback_initialize_and_new_session(serve_asgi) -> None: + agent = _LoopbackAgent() + server = await serve_asgi(_make_app(agent)) + transport = create_http_stream(server.http_url) + conn = connect_to_agent(_CapturingClient(), transport) + try: + init = await asyncio.wait_for(conn.initialize(protocol_version=1), timeout=10) + assert init.protocol_version == 1 + new = await asyncio.wait_for(conn.new_session(cwd=".", mcp_servers=[]), timeout=10) + assert new.session_id == "sess-loop" + finally: + await conn.close() + await transport.close() + + +@pytest.mark.asyncio +async def test_http_loopback_prompt_streams_and_permission(serve_asgi) -> None: + agent = _LoopbackAgent() + agent.ask_permission = True + server = await serve_asgi(_make_app(agent)) + transport = create_http_stream(server.http_url) + client = _CapturingClient() + conn = connect_to_agent(client, transport) + try: + await asyncio.wait_for(conn.initialize(protocol_version=1), timeout=10) + new = await asyncio.wait_for(conn.new_session(cwd=".", mcp_servers=[]), timeout=10) + result = await asyncio.wait_for(conn.prompt(session_id=new.session_id, prompt=[]), timeout=10) + assert result.stop_reason == "end_turn" + await asyncio.sleep(0.2) + assert client.updates, "expected a session/update notification over SSE" + assert client.permission_requested, "expected a server->client permission request" + finally: + await conn.close() + await transport.close() + + +# -- WebSocket ----------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ws_loopback_prompt_streams_and_permission(serve_asgi) -> None: + agent = _LoopbackAgent() + agent.ask_permission = True + server = await serve_asgi(_make_app(agent)) + transport = await create_websocket_stream(server.ws_url) + client = _CapturingClient() + conn = connect_to_agent(client, transport) + try: + await asyncio.wait_for(conn.initialize(protocol_version=1), timeout=10) + new = await asyncio.wait_for(conn.new_session(cwd=".", mcp_servers=[]), timeout=10) + result = await asyncio.wait_for(conn.prompt(session_id=new.session_id, prompt=[]), timeout=10) + assert result.stop_reason == "end_turn" + await asyncio.sleep(0.2) + assert client.updates, "expected a session/update notification over WS" + assert client.permission_requested, "expected a server->client permission request" + finally: + await conn.close() + await transport.close() diff --git a/tests/http/test_protocol.py b/tests/http/test_protocol.py new file mode 100644 index 0000000..8b58cba --- /dev/null +++ b/tests/http/test_protocol.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from acp.http.protocol import ( + INITIALIZE_METHOD, + is_initialize_request, + is_response_message, + message_id_key, + method_requires_session_header, + session_id_from_message, + session_id_from_params, + session_id_from_result, +) + + +def test_is_initialize_request() -> None: + assert is_initialize_request({"method": INITIALIZE_METHOD, "id": 1, "params": {}}) + assert not is_initialize_request({"method": "session/new", "id": 2}) + # A notification (no id) is not a request. + assert not is_initialize_request({"method": INITIALIZE_METHOD}) + + +def test_is_response_message() -> None: + assert is_response_message({"id": 1, "result": {}}) + assert is_response_message({"id": 1, "error": {"code": -1, "message": "x"}}) + assert not is_response_message({"id": 1, "method": "session/prompt"}) + assert not is_response_message({"method": "session/update", "params": {}}) + + +def test_method_requires_session_header() -> None: + assert method_requires_session_header("session/prompt") + assert method_requires_session_header("session/cancel") + assert method_requires_session_header("session/set_mode") + # Connection-level methods (and session-establishing ones) do not require the header. + assert not method_requires_session_header("initialize") + assert not method_requires_session_header("session/new") + assert not method_requires_session_header("session/load") + assert not method_requires_session_header("session/list") + assert not method_requires_session_header(None) + + +def test_message_id_key_normalizes_int_and_str() -> None: + assert message_id_key(1) == "1" + assert message_id_key("1") == "1" + assert message_id_key(1) == message_id_key("1") + assert message_id_key(None) is None + + +def test_session_id_extraction() -> None: + assert session_id_from_params({"sessionId": "s1"}) == "s1" + assert session_id_from_params({}) is None + assert session_id_from_params(None) is None + assert session_id_from_result({"sessionId": "s2"}) == "s2" + assert session_id_from_message({"params": {"sessionId": "s3"}}) == "s3" + assert session_id_from_message({"result": {"sessionId": "s4"}}) == "s4" + assert session_id_from_message({"result": {}}) is None diff --git a/tests/http/test_sse.py b/tests/http/test_sse.py new file mode 100644 index 0000000..2003c1b --- /dev/null +++ b/tests/http/test_sse.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest + +from acp._sse import parse_sse_stream, serialize_sse_event, serialize_sse_keepalive + + +def test_serialize_sse_event_frames_json_with_blank_line() -> None: + frame = serialize_sse_event({"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}) + assert frame == b'data: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n' + + +def test_serialize_sse_keepalive_is_a_comment() -> None: + assert serialize_sse_keepalive() == b": keepalive\n\n" + + +async def _aiter(chunks: list[bytes]) -> AsyncIterator[bytes]: + for chunk in chunks: + yield chunk + + +@pytest.mark.asyncio +async def test_parse_single_event() -> None: + stream = _aiter([b'data: {"id":1}\n\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 1}] + + +@pytest.mark.asyncio +async def test_parse_multiple_events_split_across_chunks() -> None: + stream = _aiter([b'data: {"id', b'":1}\n\ndata: {"id":2}', b"\n\n"]) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 1}, {"id": 2}] + + +@pytest.mark.asyncio +async def test_parse_ignores_comments_and_other_fields() -> None: + stream = _aiter([b': keepalive\n\nevent: message\ndata: {"id":7}\n\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 7}] + + +@pytest.mark.asyncio +async def test_parse_multiline_data_is_joined() -> None: + stream = _aiter([b'data: {"a":1,\ndata: "b":2}\n\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"a": 1, "b": 2}] + + +@pytest.mark.asyncio +async def test_parse_flushes_trailing_event_without_blank_line() -> None: + stream = _aiter([b'data: {"id":9}\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 9}] + + +@pytest.mark.asyncio +async def test_parse_skips_invalid_json() -> None: + stream = _aiter([b'data: not-json\n\ndata: {"id":1}\n\n']) + events = [event async for event in parse_sse_stream(stream)] + assert events == [{"id": 1}] diff --git a/tests/http/test_websocket.py b/tests/http/test_websocket.py new file mode 100644 index 0000000..cf21cf5 --- /dev/null +++ b/tests/http/test_websocket.py @@ -0,0 +1,182 @@ +"""WebSocket transport tests (client + ASGI server handler).""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest +from websockets.asyncio.server import serve + +from acp.http.protocol import CONNECTION_ID_HEADER +from acp.http.server import AcpServer +from acp.schema import NewSessionResponse, PromptResponse +from acp.ws.client import create_websocket_stream +from acp.ws.server import handle_asgi_websocket +from tests.conftest import TestAgent + + +class _Agent(TestAgent): + def __init__(self) -> None: + super().__init__() + self._conn: Any = None + + def on_connect(self, conn: Any) -> None: + self._conn = conn + + async def new_session(self, cwd: str | None = None, mcp_servers: Any = None, **kwargs: Any) -> NewSessionResponse: + return NewSessionResponse(session_id="sess-ws") + + async def prompt(self, session_id: str, prompt: Any = None, **kwargs: Any) -> PromptResponse: + await self._conn.session_update( + session_id=session_id, + update={"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "yo"}}, + ) + return PromptResponse(stop_reason="end_turn") + + +# -- Client transport against a plain echo websocket server -------------------- + + +@pytest.mark.asyncio +async def test_client_transport_send_receive_text_frames() -> None: + async def echo(ws: Any) -> None: + async for msg in ws: + data = json.loads(msg) + await ws.send(json.dumps({"echo": data})) + + async with serve(echo, "localhost", 0) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}") + try: + await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}) + received = await asyncio.wait_for(transport.receive(), timeout=1) + assert received == {"echo": {"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}}} + finally: + await transport.close() + + +@pytest.mark.asyncio +async def test_client_transport_receive_returns_none_on_close() -> None: + async def close_immediately(ws: Any) -> None: + await ws.close() + + async with serve(close_immediately, "localhost", 0) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}") + try: + assert await asyncio.wait_for(transport.receive(), timeout=1) is None + finally: + await transport.close() + + +@pytest.mark.asyncio +async def test_client_transport_ignores_binary_frames() -> None: + async def send_binary_then_text(ws: Any) -> None: + await ws.send(b"\x00\x01") + await ws.send(json.dumps({"ok": True})) + await ws.recv() + + async with serve(send_binary_then_text, "localhost", 0) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}") + try: + received = await asyncio.wait_for(transport.receive(), timeout=1) + assert received == {"ok": True} + finally: + await transport.close() + + +# -- ASGI websocket server handler --------------------------------------------- + + +class _FakeAsgiSocket: + """In-memory ASGI websocket double driving handle_asgi_websocket.""" + + def __init__(self) -> None: + self._incoming: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self.sent: list[dict[str, Any]] = [] + self.accepted_headers: list[tuple[bytes, bytes]] = [] + self._sent_event = asyncio.Event() + + def client_connect(self) -> None: + self._incoming.put_nowait({"type": "websocket.connect"}) + + def client_send_text(self, message: dict[str, Any]) -> None: + self._incoming.put_nowait({"type": "websocket.receive", "text": json.dumps(message)}) + + def client_disconnect(self) -> None: + self._incoming.put_nowait({"type": "websocket.disconnect", "code": 1000}) + + async def receive(self) -> dict[str, Any]: + return await self._incoming.get() + + async def send(self, message: dict[str, Any]) -> None: + if message["type"] == "websocket.accept": + self.accepted_headers = message.get("headers", []) + elif message["type"] == "websocket.send": + self.sent.append(json.loads(message["text"])) + self._sent_event.set() + + async def wait_for_send(self, predicate, timeout: float = 1.0) -> dict[str, Any]: + async def _poll() -> dict[str, Any]: + while True: + for item in self.sent: + if predicate(item): + return item + self._sent_event.clear() + await self._sent_event.wait() + + return await asyncio.wait_for(_poll(), timeout=timeout) + + +@pytest.mark.asyncio +async def test_asgi_websocket_handshake_returns_connection_id() -> None: + server = AcpServer(lambda conn: _Agent()) + socket = _FakeAsgiSocket() + socket.client_connect() + handler = asyncio.ensure_future(handle_asgi_websocket(server, {"type": "websocket"}, socket.receive, socket.send)) + await asyncio.sleep(0.05) + header_names = [k for k, _ in socket.accepted_headers] + assert CONNECTION_ID_HEADER.lower().encode() in header_names + socket.client_disconnect() + await asyncio.wait_for(handler, timeout=1) + await server.close() + + +@pytest.mark.asyncio +async def test_asgi_websocket_full_flow() -> None: + server = AcpServer(lambda conn: _Agent()) + socket = _FakeAsgiSocket() + socket.client_connect() + handler = asyncio.ensure_future(handle_asgi_websocket(server, {"type": "websocket"}, socket.receive, socket.send)) + await asyncio.sleep(0.05) + + socket.client_send_text({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1}}) + init_resp = await socket.wait_for_send(lambda m: m.get("id") == 0) + assert "result" in init_resp + + socket.client_send_text({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": {"cwd": "/", "mcpServers": []}, + }) + new_resp = await socket.wait_for_send(lambda m: m.get("id") == 1) + assert new_resp["result"]["sessionId"] == "sess-ws" + + socket.client_send_text({ + "jsonrpc": "2.0", + "id": 2, + "method": "session/prompt", + "params": {"sessionId": "sess-ws", "prompt": []}, + }) + notif = await socket.wait_for_send(lambda m: m.get("method") == "session/update") + assert notif["params"]["sessionId"] == "sess-ws" + prompt_resp = await socket.wait_for_send(lambda m: m.get("id") == 2) + assert prompt_resp["result"]["stopReason"] == "end_turn" + + socket.client_disconnect() + await asyncio.wait_for(handler, timeout=1) + await server.close() diff --git a/tests/http/test_ws_cookies.py b/tests/http/test_ws_cookies.py new file mode 100644 index 0000000..a5fdd89 --- /dev/null +++ b/tests/http/test_ws_cookies.py @@ -0,0 +1,55 @@ +"""WebSocket client cookie support (RFD §5: cookies MUST work on WS transport).""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from websockets.asyncio.server import serve + +from acp._cookies import MemoryAcpCookieStore +from acp.ws.client import create_websocket_stream + + +@pytest.mark.asyncio +async def test_ws_client_captures_set_cookie_from_handshake() -> None: + """A caller-owned cookie store must capture Set-Cookie from the upgrade response.""" + + def process_response(connection: Any, request: Any, response: Any) -> Any: + response.headers["Set-Cookie"] = "affinity=abc123; Path=/" + return response + + async def handler(ws: Any) -> None: + await ws.close() + + store = MemoryAcpCookieStore() + async with serve(handler, "localhost", 0, process_response=process_response) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}", cookie_store=store) + try: + assert store.cookie_header() == "affinity=abc123" + finally: + await transport.close() + + +@pytest.mark.asyncio +async def test_ws_client_sends_stored_cookie_on_handshake() -> None: + """Stored cookies must be echoed back as a Cookie header on the next handshake.""" + seen: dict[str, Any] = {} + + async def handler(ws: Any) -> None: + seen["cookie"] = ws.request.headers.get("Cookie") + await ws.close() + + store = MemoryAcpCookieStore() + store.store_set_cookie("affinity=abc123") + + async with serve(handler, "localhost", 0) as server: + port = server.sockets[0].getsockname()[1] + transport = await create_websocket_stream(f"ws://localhost:{port}", cookie_store=store) + try: + await asyncio.sleep(0.05) + assert seen.get("cookie") == "affinity=abc123" + finally: + await transport.close() diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 4bdc0a9..5e8a917 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -744,7 +744,7 @@ async def test_connection_init_under_eager_task_factory(server): # Regression: under asyncio.eager_task_factory the receive loop runs synchronously # up to its first await inside Connection.__init__, so every attribute it reads - # (e.g. _receive_timeout) must be assigned before _tasks.create(_receive_loop()). + # (i.e. the message transport) must be assigned before _tasks.create(_receive_loop()). loop = asyncio.get_running_loop() previous_factory = loop.get_task_factory() loop.set_task_factory(eager_task_factory) @@ -758,7 +758,7 @@ async def test_connection_init_under_eager_task_factory(server): finally: loop.set_task_factory(previous_factory) - assert conn._receive_timeout == 0.5 + assert conn._transport._receive_timeout == 0.5 # type: ignore[attr-defined] # Let the loop tick once so any deferred receive-task crash would land. await asyncio.sleep(0) assert conn._disconnected is False diff --git a/uv.lock b/uv.lock index 4e813f0..910d5cd 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,10 @@ dependencies = [ ] [package.optional-dependencies] +http = [ + { name = "httpx", extra = ["http2"] }, + { name = "websockets" }, +] logfire = [ { name = "logfire" }, { name = "opentelemetry-sdk" }, @@ -20,6 +24,7 @@ logfire = [ dev = [ { name = "datamodel-code-generator" }, { name = "deptry" }, + { name = "httpx", extra = ["http2"] }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, @@ -30,20 +35,25 @@ dev = [ { name = "ruff" }, { name = "tox-uv" }, { name = "ty" }, + { name = "uvicorn" }, + { name = "websockets" }, ] [package.metadata] requires-dist = [ + { name = "httpx", extras = ["http2"], marker = "extra == 'http'", specifier = ">=0.27" }, { name = "logfire", marker = "extra == 'logfire'", specifier = ">=0.14" }, { name = "opentelemetry-sdk", marker = "extra == 'logfire'", specifier = ">=1.28.0" }, { name = "pydantic", specifier = ">=2.7" }, + { name = "websockets", marker = "extra == 'http'", specifier = ">=12.0" }, ] -provides-extras = ["logfire"] +provides-extras = ["logfire", "http"] [package.metadata.requires-dev] dev = [ { name = "datamodel-code-generator", specifier = ">=0.25" }, { name = "deptry", specifier = ">=0.23.0" }, + { name = "httpx", extras = ["http2"], specifier = ">=0.27" }, { name = "mkdocs", specifier = ">=1.4.2" }, { name = "mkdocs-material", specifier = ">=8.5.10" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.26.1" }, @@ -54,6 +64,8 @@ dev = [ { name = "ruff", specifier = ">=0.11.5" }, { name = "tox-uv", specifier = ">=1.11.3" }, { name = "ty", specifier = ">=0.0.1a16" }, + { name = "uvicorn", specifier = ">=0.30" }, + { name = "websockets", specifier = ">=12.0" }, ] [[package]] @@ -65,6 +77,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + [[package]] name = "argcomplete" version = "3.6.3" @@ -352,7 +378,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -422,6 +448,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.15" @@ -1536,6 +1635,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" }, ] +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + [[package]] name = "virtualenv" version = "20.36.1" @@ -1583,6 +1696,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "websockets" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/31/cd11d2796b95c93645bac8e396b0f4bac0896a07a7b87d473bfc359f02c3/websockets-16.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de72a9c611178b15557d98eabd3101c9663c4d68938510478a6d162f99afd213", size = 179772, upload-time = "2026-07-10T06:30:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/34306d802f9b599eab041688a2086318037560cfae616a860234cca575b6/websockets-16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:37b0e4d726ffea3776670092d3d13e1cb605076f036a695fd1259de0d9b9fe02", size = 177457, upload-time = "2026-07-10T06:30:24.636Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/36ebbb978a7af70ff952afe5b22561264967164e9ad68b6734cae94efeb4/websockets-16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:00d50c0a27098fcb7ab47b3d99a1b1159b534dbcd959fbf05113ebc37e5f927b", size = 177737, upload-time = "2026-07-10T06:30:25.954Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/944f341d0d3c0450ffd3d171479531df1818cb1df1623af4065113999c44/websockets-16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1acb698bff1da1782b31aebd8d7a24d7d05453964abcd7d03dbf6e25893908e8", size = 186244, upload-time = "2026-07-10T06:30:27.235Z" }, + { url = "https://files.pythonhosted.org/packages/32/e5/a9b98fc49ef0214718a9c839c6c63856a921877256ec46f371be32decfa8/websockets-16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc2c453f3b5f99c56b16e233aad5299860558487d26adb2ed27a00c14ca24b8c", size = 187484, upload-time = "2026-07-10T06:30:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7a/a575b52ca090b1976ffbe4b5f0762d03f399dfcb48eab883101331be71a9/websockets-16.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1a9f08a0728b0835f1c6abe1d9b746ab3de49b7336a0e1919cf96be1e76273eb", size = 190143, upload-time = "2026-07-10T06:30:29.91Z" }, + { url = "https://files.pythonhosted.org/packages/7c/40/705fbbd5677242fd36f724e9a94103e6bbdcb7d71e8f4498bfc1a8a7d413/websockets-16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a089979d6173b27af18026c8d8b0077f83669a9169174482c4651e9f5739a5b6", size = 188004, upload-time = "2026-07-10T06:30:31.357Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0c/58227c8d66b1c4060c53bac8e066fb4fe2603060408e934f48660a448d72/websockets-16.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3c18dba232ec2b92a68579c9fed8ff5a18f853d1e09fc0b6ca3159e94f689fe", size = 186689, upload-time = "2026-07-10T06:30:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d0/5c1314782594aa347e0f18808ee277a61986a2a2f9f470df9893183995bd/websockets-16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c1eb7df4170d5068892a8834fb5c07b9552353deb0dbeb0bff3820481ae4792", size = 184559, upload-time = "2026-07-10T06:30:34.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e6/109c6f16850fd674b7e3d0e58b8987f05d3881abaa25f42a9faf5e85f097/websockets-16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c522bd48e625b6d557aa228967258d6d3da031c4cc21d3352fb302479aa9ba0a", size = 186997, upload-time = "2026-07-10T06:30:35.397Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ec/6afa1aebc59426438b85cf7a3868c53a89005e2250a648c99e99943b90a4/websockets-16.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d106396927a7f00b0f3a69215c3357f87bf0bca6844247121f7e8291e826a3b1", size = 185621, upload-time = "2026-07-10T06:30:36.88Z" }, + { url = "https://files.pythonhosted.org/packages/27/24/c038fe8682e9345bfa422d2cc5cc68b0491ab942c92e176bf8dfa6e8331f/websockets-16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d71bed12909b8039955536e192867d02d76cd3797cedfd0facf822e7668636c3", size = 187384, upload-time = "2026-07-10T06:30:38.096Z" }, + { url = "https://files.pythonhosted.org/packages/30/ca/dc0ef2be39c67394e24bc982a0af59cd6249bf2f4e4272813c5c505d0da9/websockets-16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9c1cf6f9a936b030b5bed0e800c5ee32069338129084546baf5ff5014dc62fa9", size = 185258, upload-time = "2026-07-10T06:30:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/3ffecd83ca3404b41fbdf8e9b178e55b529cd59bf64ea08b5a37b616b568/websockets-16.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3fd3e6a7af2c8fcdcf4ffbeaf7f54a567b91a83267204187797f31faaa2a4efa", size = 186050, upload-time = "2026-07-10T06:30:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/75/26/2e068497c78f31591a610ab7ef6d8d383ecadbe98f9121e1ebda77ef6d2b/websockets-16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dddd27175bf640acae5561fa79b77e8ec71fc445816200523e5c19b6a556fb72", size = 186273, upload-time = "2026-07-10T06:30:42.309Z" }, + { url = "https://files.pythonhosted.org/packages/44/ab/4dc049cb2c9e1be3a2c6fef77118f9c5049979e99cd56a97759d2f40f980/websockets-16.1-cp310-cp310-win32.whl", hash = "sha256:cce36c80b3f2fede7942f1756d3d885fa6fa086766c8c1bcf00695ab80f0d51a", size = 180157, upload-time = "2026-07-10T06:30:43.565Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/5e010ce5f66a8e5df380843f704ada508195a021c0c8a0f933639c9ee1c0/websockets-16.1-cp310-cp310-win_amd64.whl", hash = "sha256:115fc4695b94bb855995b23fb1abcb66099a5995575d3d5bc5605a616c58d0eb", size = 180458, upload-time = "2026-07-10T06:30:45.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d47429afcc2c28616c32640009c84ea3f95660dab805766345b9682468e0/websockets-16.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a9b1d7a63cba8e6b9b77e499a81eab29d31100298d090ad4507d1048c0b9cae0", size = 179770, upload-time = "2026-07-10T06:30:46.308Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/2f0a722039a1e0107be73ed672ba604449b4956e48733e8e6b8a005aea42/websockets-16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bedbc5efeb96621aa2921d2d92608246691399418cac22acba427eb11877ea1f", size = 177455, upload-time = "2026-07-10T06:30:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/43/6a/c26b0ae449e93d256ce5cdd50d5fe97b575a63e8dcd311a1faa972fd6bc6/websockets-16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd847ab82133015afe65d778e7966ab42dba16bd7ad2e5b8a7918db6539f3f94", size = 177731, upload-time = "2026-07-10T06:30:49.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/381550b344a02f0d2f84cda25e79b54575291bc7022128a41163fe8ba5b0/websockets-16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2fb33ccb16ee40a95cc676d7b0ff451a9a2632f11a0dbc2e666326892b2e1de", size = 187066, upload-time = "2026-07-10T06:30:50.505Z" }, + { url = "https://files.pythonhosted.org/packages/4a/87/5ab1ec2086910f23cfb9ec0c1c29fbcc24a9d190b5198b1557c00ce4a47e/websockets-16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f15b6d9ea9c2eaf6ccab964a082b09bfa6634a495bb0c2e9e7ee6943f58976", size = 188301, upload-time = "2026-07-10T06:30:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/bbbb8e6fac4cfc53d7aaa69a3d531bf10799354b0021f4b58914aced8c1a/websockets-16.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:638cf57c48b4ad8ac1ff1e453f4f97db2426b690ddc111e6da96b27b4a340bc3", size = 191594, upload-time = "2026-07-10T06:30:53.229Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/6c0c349443d6e999f481e3d9a0e57e7ac2956d75d6391bec24b92af3fe13/websockets-16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c1c85f61bc9d5eac57ce705d848dc2d2ce3680638300bf4e1da7d749e2cf4ce", size = 188862, upload-time = "2026-07-10T06:30:54.744Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ea/a368d37c010425a5451f42052fe804e754e23333e8448aef5d55c8a8d64f/websockets-16.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:eeab6d27f51c7e579023c971f5e6dff200deadf01faf6831beaecd32052dfaef", size = 187633, upload-time = "2026-07-10T06:30:56.055Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4e/2ecd59add10d0855ec03dbdedfcdacdbd1aaabcd44b7dcbeda27538662e9/websockets-16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ed64e5a97b0b97a0b66e18bfe281317a75fbbd5afe692f939ea8d14a4292f2c", size = 185089, upload-time = "2026-07-10T06:30:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/6f/eb/c6c3dcd7a01097bb0d42f4e9ef21a2c2a491d36b77cd0870ab59f9e8e77f/websockets-16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9b3b021d0ed4bc16eea9775f62c9fa71acdacba0fc790b38581754dedf29ca60", size = 187790, upload-time = "2026-07-10T06:30:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/775d36885d5e48ab8020aaf377de0ff5fbeb8bc2682a7e46419e4a14521c/websockets-16.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6eb604a4167f0a0d53c2243dfc667a29f0b43c3436057184e070bb82a1000fa2", size = 186381, upload-time = "2026-07-10T06:31:00.355Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/6305c00812a92e47d0582604c02bd759db0118bbafc13f707d712dbcf898/websockets-16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9a3f125e44c3e34d61d111652e608e0f5b85ce08c225c8d56ad0eb822fa40030", size = 188193, upload-time = "2026-07-10T06:31:01.677Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/96bf8302c81d961585b4d34a2ddd3f229782f9b8c57bc78bbf98f1b1a4ac/websockets-16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8fdf0b00d0d1f30d1f06a92cab46fe542eec3eb302a7aee7163f142d0780f216", size = 185771, upload-time = "2026-07-10T06:31:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1f/e8fe44b1d2dc417d740d9959d28fd2a846f268e7df38a686c04ac7dfe947/websockets-16.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67b56828712f5fa7852de4c0265c28827311a657a4d275b7312ed0d1a918bee4", size = 186803, upload-time = "2026-07-10T06:31:04.34Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/b07d3a4e1eb2ab03e94e7f53f0c7a628e85fde6ad86011f7afd08f27b985/websockets-16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39c7e7730be33b8f0cd6f0aa8e8c82f9cdd1813f159765e073b2ece65f4824b5", size = 187041, upload-time = "2026-07-10T06:31:05.567Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/e0abb8acc435642ac4a671490f6cf781c882f3fe682cdced9080ea455ab5/websockets-16.1-cp311-cp311-win32.whl", hash = "sha256:c54fe94fb2f11e11b48920c5f971e298cec73ac35db56efe57a49db63dfc95d4", size = 180158, upload-time = "2026-07-10T06:31:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/85574d9458d3b913090087b817df0cc47b68e9a01dd0ab6ac04b77f49b0a/websockets-16.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9f4fb9ae8b802e55609685db98382d48fd3feb1397804e1e774968dea0f28c7", size = 180456, upload-time = "2026-07-10T06:31:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/a1/52/748c014f07f4e0e170c8932de7e647a1511d5ab3049cd978797136aee577/websockets-16.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b6aa3f7ad345cf3862c21f4fbf2ef5e14d911348476c2845e137c091fe3a3f0b", size = 179798, upload-time = "2026-07-10T06:31:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5e/2a2e64d977d084e49d37c187c26c056daaff41965be7300cd5dbde6f8b07/websockets-16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b43fcfb521ac2f34ba80b7b8ea16303e4ad82dd8af667bf40839ad3a5d37b164", size = 177478, upload-time = "2026-07-10T06:31:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/12/5b85b4e75d697e548a94962ce5c036b05dd21cb9545759d555c5586422fc/websockets-16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bd3e12cd9afbe2baedae0b1eeade8ba64329b60fe2f9abdc966bd10fd2c2ef5", size = 177746, upload-time = "2026-07-10T06:31:12.386Z" }, + { url = "https://files.pythonhosted.org/packages/9d/62/79b1c8f0cee0da648b4899e1c5b0dbd3aa59846985136a54854db6827ab4/websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", size = 187345, upload-time = "2026-07-10T06:31:13.754Z" }, + { url = "https://files.pythonhosted.org/packages/25/34/b7c5c52c2f24280e1c017acb7ad491a566750a5cceca7f3cf999373bba21/websockets-16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a24d1f35aef07d794a16c853c688e74956c50239bec37b4f2de080056046419b", size = 188581, upload-time = "2026-07-10T06:31:15.075Z" }, + { url = "https://files.pythonhosted.org/packages/bc/37/604193bebcbeffe96fdf795960b83a15d600880c64dc17ec9c31c5b3427d/websockets-16.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c64c024ddf7a35331b21fcddb562a039c275d2c82e8c2d12939e7da23997270", size = 191362, upload-time = "2026-07-10T06:31:16.395Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b4/5ee27575b367d7110d4d13945e2a9de067ec84dc71e54b87f01e38550d9a/websockets-16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e99757f5baafe20fc598e202ea6f5b0b265186ad38d0a17bd8beca16296955", size = 189216, upload-time = "2026-07-10T06:31:17.776Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/3e2dcc78d85fc5d9d814895ce6d07d0dfacc0f6aaa1d151f2b8c8d772299/websockets-16.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:353f3bc6e058ac1ccab4b3588e8598837a8c04cfc8351233e6d523be675d844c", size = 187971, upload-time = "2026-07-10T06:31:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2f/cd271717b93d5ee19626cb5e38a85baab745c86e33db7c31a3ac729b31b8/websockets-16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0352f5b38b40e857b6428d468fa21dbb4dd4a567d933c26d9831b4efe1b92f43", size = 185381, upload-time = "2026-07-10T06:31:20.665Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/6ad6f2f1426317b5001bd490534208c7360636b35bac1dec2e0c22bfc40e/websockets-16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70bd789afab579602968c39f21cb925466505f3edff22f0ae852bca54978a4f9", size = 188015, upload-time = "2026-07-10T06:31:22.024Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6d/533733132ab4c07540efd4a8f0b9a435d3a5059b2f26cc476ace1abf7f45/websockets-16.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d0fb4b46f121eccd539353baebd1083a8767a9a351109453d1d1caecd1ba40c2", size = 186619, upload-time = "2026-07-10T06:31:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/08/73/16c059f3d73b3331eba10793704afa4faa9939234fb08ef7dca35794e8f0/websockets-16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c14b6634af01541e4efe2954fd8f263386f7aa6d37c01e55dd8109fd17661452", size = 188497, upload-time = "2026-07-10T06:31:25.024Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/9a8fae7dd2acdcfb1a8844c29fe42b518a04b64fce38a0923b6290e452f1/websockets-16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a58532c49a851bcb481e58c1be23b315c17fe2fbbed509d75aeea12f543d2c15", size = 186051, upload-time = "2026-07-10T06:31:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/b240c7dd6a0e0c59c1f68377cc3015263521080c327c15f5e753c1f6d378/websockets-16.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4e969170c3b08e1d8dabd990fef1fa702c4233aeaabec33f871806e444f6a0e4", size = 187029, upload-time = "2026-07-10T06:31:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/50/35/524e3fac40e47d6fdcf6c4b2c95ef1bc8a97e01593c90eff86621df7b716/websockets-16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff9b000064b88787ba9f7a3cb2af2b68a658ca5aad76458a46469e7124b678a0", size = 187308, upload-time = "2026-07-10T06:31:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/56840cf62c8859af6ba22b9529da937332468c80f32b598753e8a66d3990/websockets-16.1-cp312-cp312-win32.whl", hash = "sha256:b9f5d83f80f4d7c4bba6d97f3755ac05850c784dce0fd2ab371c4e41172f53ff", size = 180161, upload-time = "2026-07-10T06:31:30.316Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ff/87eb9eb44cb62424a8d729834f2b0515a47e2669fabec29820268f4d50a1/websockets-16.1-cp312-cp312-win_amd64.whl", hash = "sha256:6852c9f653966c16109d3b6f31181fd734f7914927e3f0fa1117af7a18c9aa21", size = 180462, upload-time = "2026-07-10T06:31:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" }, + { url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" }, + { url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" }, + { url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" }, + { url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" }, + { url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f4/84ef884775bbe77c46cce79bc7d705ea3bc6574cc00acf81af89754c077d/websockets-16.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7289d899c79e763e6221c8dcb8959361cb43274418538d7c7ad16a43b01d12f9", size = 177387, upload-time = "2026-07-10T06:32:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d9/6831ec6f65e1eeac770375f4f4b604f23df9bafaa1b47004bc5f9488d513/websockets-16.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e22e9e3719f5131bd62da4db63c8da63eb8c91cc99e16c1cbd122f130e1ae07a", size = 177663, upload-time = "2026-07-10T06:32:50.043Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/21d4922fa7fe855813a8b38f181a0ecf02a586e16c1f095fd05471f78cc2/websockets-16.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83bdabafef431247e6b11a9aab8a0893fd8e82e1ed95b32e0373625b03ffce4a", size = 178501, upload-time = "2026-07-10T06:32:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/87/7a0320df854dacd09507ca972cb04a4dc5aae279583cc5b80ad5f5819533/websockets-16.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b8d13ceabc5c60995f201b5211d76876e17e68706ebf5d3bc666b32eefff1a6", size = 179397, upload-time = "2026-07-10T06:32:52.892Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/0da1eb8c8da2ace7b578c8523d32618af85e62a9ebad56051d4a14a38a1c/websockets-16.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81495f9c0085361c582efbc3207fb877174cfe03370f17d9cd70624404aa526f", size = 180546, upload-time = "2026-07-10T06:32:54.619Z" }, + { url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" }, +] + [[package]] name = "wrapt" version = "1.17.3"