initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

142
protocol.py Normal file
View File

@@ -0,0 +1,142 @@
"""Newline-delimited JSON protocol with base64 encoding for binary data."""
import asyncio
import base64
import binascii
import json
import os
def encode_binary(data: bytes) -> str:
"""Encode bytes to base64 string."""
return base64.b64encode(data).decode("ascii")
def decode_binary(data: str) -> bytes:
"""Decode base64 string to bytes."""
try:
return base64.b64decode(data, validate=True)
except (TypeError, binascii.Error) as e:
raise ValueError(f"Invalid base64: {e}")
VERSION = "0.8.5"
MIN_CLIENT_VERSION = "0.8.5" # server rejects clients older than this
def version_gte(version: str, minimum: str) -> bool:
"""Return True if version >= minimum (compares numeric tuples, e.g. '0.8.1' >= '0.8').
Returns False for malformed version strings (instead of silently treating them as 0).
"""
def _parse(v: str) -> tuple[int, ...] | None:
if not isinstance(v, str) or not v:
return None
parts = v.split(".")
try:
return tuple(int(x) for x in parts)
except (ValueError, AttributeError):
return None
parsed_ver = _parse(version)
parsed_min = _parse(minimum)
if parsed_ver is None or parsed_min is None:
return False
return parsed_ver >= parsed_min
MAX_MESSAGE_BYTES = int(os.getenv("MAX_MESSAGE_BYTES", "65536")) # 64 KiB default
MAX_IMAGE_BYTES = int(os.getenv("MAX_IMAGE_BYTES", str(5 * 1024 * 1024))) # 5 MiB default, 0 = no limit
MAX_FILE_BYTES = int(os.getenv("MAX_FILE_BYTES", str(50 * 1024 * 1024))) # 50 MiB default
IMAGE_CHUNK_SIZE = 32768 # 32 KiB raw chunk size for image upload/download
def build_request(msg_type: str, request_id: str | None = None, **kwargs) -> bytes:
"""Build a protocol message (newline-terminated JSON)."""
msg = {"type": msg_type, **kwargs}
if request_id:
msg["request_id"] = request_id
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
def build_response(
msg_type: str,
status: str,
data: dict | None = None,
request_id: str | None = None,
) -> bytes:
"""Build a server response."""
msg = {"type": msg_type, "status": status}
if data is not None:
msg["data"] = data
if request_id:
msg["request_id"] = request_id
return json.dumps(msg, ensure_ascii=False).encode("utf-8") + b"\n"
def parse_message(line: bytes) -> dict:
"""Parse a single protocol message from bytes."""
try:
return json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid message: {e}")
class ProtocolReader:
"""Read newline-delimited JSON messages from an asyncio StreamReader."""
def __init__(self, reader: asyncio.StreamReader):
self._reader = reader
async def read_message(self) -> dict | None:
"""Read and parse one message. Returns None on EOF."""
try:
line = await self._reader.readuntil(b"\n")
except (asyncio.IncompleteReadError, ConnectionError):
return None
except asyncio.LimitOverrunError as e:
# Message exceeded StreamReader limit — drain oversized data
# using public read() API (consumed=e.consumed bytes before limit).
# Read in chunks until newline found or EOF, then signal error.
remaining = e.consumed
while True:
chunk = await self._reader.read(max(remaining, 4096))
if not chunk:
return None # EOF while draining
if b"\n" in chunk:
break # found delimiter, oversized message fully drained
raise ValueError("Message exceeds maximum size")
if not line:
return None
return parse_message(line.strip())
class ProtocolWriter:
"""Write newline-delimited JSON messages to an asyncio StreamWriter."""
def __init__(self, writer: asyncio.StreamWriter):
self._writer = writer
async def send_request(self, msg_type: str, request_id: str | None = None, **kwargs):
"""Send a request message."""
payload = build_request(msg_type, request_id=request_id, **kwargs)
if len(payload) > MAX_MESSAGE_BYTES:
raise ValueError(f"Message exceeds limit ({len(payload)} > {MAX_MESSAGE_BYTES})")
self._writer.write(payload)
await self._writer.drain()
async def send_response(
self,
msg_type: str,
status: str,
data: dict | None = None,
request_id: str | None = None,
):
"""Send a response message."""
payload = build_response(msg_type, status, data, request_id=request_id)
if len(payload) > MAX_MESSAGE_BYTES:
raise ValueError(f"Message exceeds limit ({len(payload)} > {MAX_MESSAGE_BYTES})")
self._writer.write(payload)
await self._writer.drain()
def close(self):
self._writer.close()