Files
Emil Shanaty 1314314567 feat: conformance test runner + protocol fixes
- conformance.py: declarative scenario runner (subset matcher, $kind/$gt/
  $enum/$required assertions, call_id/session_id echo checks), reference
  bridge, CLI entry (python -m aicc.conformance)
- bridge.py: protocol version check before parsing (protocol_mismatch),
  session expiry handling (session_expired), malformed tool_call ->
  invalid_input, emit_event() for async events
- client.py: Self return type
- in_process.py: drain bridge event queue after response (event delivery),
  Self return type
- websocket.py: Self return type, best-effort shutdown
- tests: 14 passing (incl. conformance scenarios, 9/9 core scenarios)
- ruff: all checks pass
2026-08-08 03:27:14 +03:00

116 lines
3.8 KiB
Python

"""WebSocket transport for AICC: client and server."""
from __future__ import annotations
import json
from typing import Any, Self
from aicc.bridge import Bridge
from aicc.errors import ConnectionError_
class WebSocketClientTransport:
"""AICC client transport over WebSocket.
Example:
t = WebSocketClientTransport("ws://localhost:8765")
client = AICCClient(t)
"""
def __init__(self, uri: str):
self._uri = uri
self._ws = None # type: ignore[var-annotated]
async def connect(self) -> None:
try:
import websockets # type: ignore
except ImportError as e: # pragma: no cover
raise ConnectionError_(
"websockets is required for WebSocketClientTransport. "
"Install with: pip install aicc[ws] or pip install websockets"
) from e
self._ws = await websockets.connect(self._uri, max_size=64 * 1024 * 1024)
async def close(self) -> None:
if self._ws is not None:
await self._ws.close()
self._ws = None
async def send(self, message: dict[str, Any]) -> None:
if self._ws is None:
raise ConnectionError_("WebSocket not connected.")
await self._ws.send(json.dumps(message))
async def receive(self) -> dict[str, Any]:
if self._ws is None:
raise ConnectionError_("WebSocket not connected.")
raw = await self._ws.recv()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
return json.loads(raw)
class WebSocketServer:
"""AICC server transport: serves a Bridge over WebSocket.
Example:
bridge = Bridge(name="room")
async with WebSocketServer(bridge, port=8765):
await asyncio.Future() # run forever
"""
def __init__(self, bridge: Bridge, host: str = "127.0.0.1", port: int = 8765):
self._bridge = bridge
self._host = host
self._port = port
self._server = None # type: ignore[var-annotated]
self._connections: set[Any] = set()
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.stop()
async def start(self) -> None:
try:
import websockets # type: ignore
except ImportError as e: # pragma: no cover
raise ConnectionError_(
"websockets is required for WebSocketServer. "
"Install with: pip install websockets"
) from e
async def handler(ws) -> None: # type: ignore[no-untyped-def]
self._connections.add(ws)
session = self._bridge.open_session()
try:
manifest = session.manifest
if manifest is not None:
await ws.send(json.dumps(manifest.model_dump(by_alias=True)))
async for raw in ws:
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
msg = json.loads(raw)
response = await self._bridge.handle_message(session.session_id, msg)
if response is not None:
await ws.send(json.dumps(response))
finally:
self._bridge.close_session(session.session_id)
self._connections.discard(ws)
self._server = await websockets.serve(handler, self._host, self._port)
async def stop(self) -> None:
if self._server is not None:
self._server.close()
await self._server.wait_closed()
self._server = None
for ws in list(self._connections):
try:
await ws.close()
except Exception: # noqa: BLE001, S110 - best-effort shutdown
pass
self._connections.clear()