import asyncio
import json
import subprocess
from abc import ABC, abstractmethod
from typing import Any, Callable, List, Optional
from .errors import ConnectionError, ProtocolError
class Transport(ABC):
@abstractmethod
async def connect(self) -> None:
pass
@abstractmethod
async def send(self, message: str) -> None:
pass
@abstractmethod
async def receive(self) -> Optional[str]:
pass
@abstractmethod
async def close(self) -> None:
pass
@property
@abstractmethod
def is_connected(self) -> bool:
pass
class TcpTransport(Transport):
def __init__(self, host: str, port: int, timeout: float = 30.0):
self.host = host
self.port = port
self.timeout = timeout
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._connected = False
@classmethod
async def connect_to(cls, host: str, port: int, timeout: float = 30.0) -> "TcpTransport":
transport = cls(host, port, timeout)
await transport.connect()
return transport
async def connect(self) -> None:
try:
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port),
timeout=self.timeout
)
self._connected = True
except asyncio.TimeoutError:
raise ConnectionError(f"Connection to {self.host}:{self.port} timed out")
except OSError as e:
raise ConnectionError(f"Failed to connect to {self.host}:{self.port}: {e}")
async def send(self, message: str) -> None:
if not self._connected or self._writer is None:
raise ConnectionError("Not connected")
data = (message + "\n").encode("utf-8")
self._writer.write(data)
await self._writer.drain()
async def receive(self) -> Optional[str]:
if not self._connected or self._reader is None:
raise ConnectionError("Not connected")
try:
line = await self._reader.readline()
if not line:
return None
return line.decode("utf-8").strip()
except Exception as e:
raise ConnectionError(f"Failed to receive: {e}")
async def close(self) -> None:
if self._writer:
self._writer.close()
await self._writer.wait_closed()
self._connected = False
self._reader = None
self._writer = None
@property
def is_connected(self) -> bool:
return self._connected
class StdioTransport(Transport):
def __init__(self, command: List[str]):
self.command = command
self._process: Optional[subprocess.Popen] = None
self._connected = False
@classmethod
async def spawn(cls, command: List[str]) -> "StdioTransport":
transport = cls(command)
await transport.connect()
return transport
async def connect(self) -> None:
try:
self._process = await asyncio.create_subprocess_exec(
*self.command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
self._connected = True
except Exception as e:
raise ConnectionError(f"Failed to spawn process: {e}")
async def send(self, message: str) -> None:
if not self._connected or self._process is None or self._process.stdin is None:
raise ConnectionError("Not connected")
data = (message + "\n").encode("utf-8")
self._process.stdin.write(data)
await self._process.stdin.drain()
async def receive(self) -> Optional[str]:
if not self._connected or self._process is None or self._process.stdout is None:
raise ConnectionError("Not connected")
line = await self._process.stdout.readline()
if not line:
return None
return line.decode("utf-8").strip()
async def close(self) -> None:
if self._process:
self._process.terminate()
await self._process.wait()
self._connected = False
self._process = None
@property
def is_connected(self) -> bool:
return self._connected and self._process is not None
class SseTransport(Transport):
def __init__(self, url: str, timeout: float = 30.0):
self.url = url
self.timeout = timeout
self._connected = False
self._event_queue: asyncio.Queue[str] = asyncio.Queue()
self._session = None
self._sse_task: Optional[asyncio.Task] = None
self._last_event_id: Optional[str] = None
@classmethod
async def connect_to(cls, url: str, timeout: float = 30.0) -> "SseTransport":
transport = cls(url, timeout)
await transport.connect()
return transport
async def connect(self) -> None:
try:
import aiohttp
self._session = aiohttp.ClientSession()
self._sse_task = asyncio.create_task(self._listen_sse())
self._connected = True
except ImportError:
raise ConnectionError("aiohttp is required for SSE transport: pip install aiohttp")
except Exception as e:
raise ConnectionError(f"Failed to connect to SSE endpoint: {e}")
async def _listen_sse(self) -> None:
import aiohttp
headers = {}
if self._last_event_id:
headers["Last-Event-ID"] = self._last_event_id
events_url = self.url.rstrip("/") + "/events"
try:
async with self._session.get(events_url, headers=headers) as response:
async for line in response.content:
line = line.decode("utf-8").strip()
if line.startswith("data:"):
data = line[5:].strip()
await self._event_queue.put(data)
elif line.startswith("id:"):
self._last_event_id = line[3:].strip()
except Exception:
self._connected = False
async def send(self, message: str) -> None:
if not self._connected or self._session is None:
raise ConnectionError("Not connected")
post_url = self.url.rstrip("/") + "/message"
try:
async with self._session.post(
post_url,
data=message,
headers={"Content-Type": "application/json"}
) as response:
if response.status != 200:
raise ProtocolError(f"POST failed with status {response.status}")
except Exception as e:
raise ConnectionError(f"Failed to send message: {e}")
async def receive(self) -> Optional[str]:
if not self._connected:
raise ConnectionError("Not connected")
try:
return await asyncio.wait_for(self._event_queue.get(), timeout=self.timeout)
except asyncio.TimeoutError:
return None
async def close(self) -> None:
if self._sse_task:
self._sse_task.cancel()
try:
await self._sse_task
except asyncio.CancelledError:
pass
if self._session:
await self._session.close()
self._connected = False
self._session = None
self._sse_task = None
@property
def is_connected(self) -> bool:
return self._connected