From 7b3281208ec3de103c50733108314c95403569e5 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Tue, 26 May 2026 00:01:43 -0700 Subject: [PATCH] Add Python wire codec binding --- .github/workflows/ci.yml | 12 ++ README.md | 2 + bindings/python/README.md | 30 +++++ bindings/python/pyproject.toml | 15 +++ bindings/python/sessionrpc/__init__.py | 133 ++++++++++++++++++++++ bindings/python/tests/test_frame_codec.py | 33 ++++++ 6 files changed, 225 insertions(+) create mode 100644 bindings/python/README.md create mode 100644 bindings/python/pyproject.toml create mode 100644 bindings/python/sessionrpc/__init__.py create mode 100644 bindings/python/tests/test_frame_codec.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 314b6fd..a110596 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,15 @@ jobs: working-directory: bindings/typescript - run: npm test working-directory: bindings/typescript + + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m unittest discover -s tests + working-directory: bindings/python + env: + PYTHONPATH: . diff --git a/README.md b/README.md index 906fe6d..b6c82fe 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ This repository is in active bootstrap. The current crate includes: - `QuicFrameTransport` built on Quinn, including a 0-RTT resume path for flaky mobile reconnects. - TypeScript bindings with a browser `RTCDataChannel` transport. +- Python bindings for dependency-free wire codec clients and fixtures. - C ABI bindings for frame encode/decode at the wire boundary. ## Quick start @@ -84,4 +85,5 @@ See [docs/quic.md](docs/quic.md) for the QUIC transport. See [docs/k8s-sticky-routing.md](docs/k8s-sticky-routing.md) for the k8s sidecar route table. See [bindings/typescript](bindings/typescript) for the browser/WebRTC client. +See [bindings/python](bindings/python) for the Python wire codec. See [bindings/c](bindings/c) for the C ABI. diff --git a/bindings/python/README.md b/bindings/python/README.md new file mode 100644 index 0000000..30c0c7e --- /dev/null +++ b/bindings/python/README.md @@ -0,0 +1,30 @@ +# SessionRPC Python binding + +The Python binding is dependency-free and mirrors the Rust wire-frame codec. +It is useful for Python clients, integration tests, and protocol fixtures. + +```python +from uuid import UUID + +from sessionrpc import FrameKind, SessionRpcFrame, encode_frame, decode_frame + +frame = SessionRpcFrame( + session_id=UUID("2f8ad4ce-e85a-4ef9-b274-7c31c4a0b35d"), + stream_id=1, + seq=0, + lease_epoch=7, + kind=FrameKind.DATA, + payload=b"prompt bytes", + token_count=12, +) + +encoded = encode_frame(frame) +decoded = decode_frame(encoded) +assert decoded == frame +``` + +Run the tests: + +```bash +PYTHONPATH=. python -m unittest discover -s tests +``` diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml new file mode 100644 index 0000000..b5db184 --- /dev/null +++ b/bindings/python/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "sessionrpc" +version = "0.1.0" +description = "Python wire codec bindings for SessionRPC" +readme = "README.md" +requires-python = ">=3.9" +license = "Apache-2.0" +authors = [{ name = "EvalOps" }] + +[tool.setuptools.packages.find] +include = ["sessionrpc*"] diff --git a/bindings/python/sessionrpc/__init__.py b/bindings/python/sessionrpc/__init__.py new file mode 100644 index 0000000..3e7c756 --- /dev/null +++ b/bindings/python/sessionrpc/__init__.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import struct +from dataclasses import dataclass +from enum import IntEnum +from typing import Optional, Union +from uuid import UUID + +MAGIC = b"SRP1" +VERSION = 1 +HEADER_LEN = 60 +TOKEN_COUNT_NONE = (1 << 64) - 1 +HEADER = struct.Struct(">4sBBI16sQQQQH") + + +class FrameKind(IntEnum): + DATA = 0 + CANCEL = 1 + OPEN = 2 + END = 3 + PING = 4 + + +@dataclass(frozen=True) +class SessionRpcFrame: + session_id: UUID + stream_id: int + seq: int + lease_epoch: int + kind: FrameKind + payload: bytes = b"" + token_count: Optional[int] = None + trace_context: Optional[str] = None + + +def encode_frame(frame: SessionRpcFrame) -> bytes: + payload = bytes(frame.payload) if frame.kind == FrameKind.DATA else b"" + trace_context = ( + frame.trace_context.encode("utf-8") if frame.trace_context is not None else b"" + ) + if len(payload) > 0xFFFFFFFF: + raise ValueError(f"frame payload is too large: {len(payload)} bytes") + if len(trace_context) > 0xFFFF: + raise ValueError( + f"frame trace context is too large: {len(trace_context)} bytes" + ) + token_count = ( + TOKEN_COUNT_NONE if frame.token_count is None else _check_u64(frame.token_count) + ) + + return b"".join( + [ + HEADER.pack( + MAGIC, + VERSION, + int(frame.kind), + len(payload), + frame.session_id.bytes, + _check_u64(frame.stream_id), + _check_u64(frame.seq), + _check_u64(frame.lease_epoch), + token_count, + len(trace_context), + ), + trace_context, + payload, + ] + ) + + +def decode_frame(encoded: Union[bytes, bytearray, memoryview]) -> SessionRpcFrame: + encoded = bytes(encoded) + if len(encoded) < HEADER_LEN: + raise ValueError(f"truncated frame: need {HEADER_LEN}, got {len(encoded)}") + + ( + magic, + version, + kind_value, + payload_len, + session_bytes, + stream_id, + seq, + lease_epoch, + token_count, + trace_len, + ) = HEADER.unpack_from(encoded) + + if magic != MAGIC: + raise ValueError("invalid frame magic") + if version != VERSION: + raise ValueError(f"unsupported frame version {version}") + + try: + kind = FrameKind(kind_value) + except ValueError as exc: + raise ValueError(f"unknown frame kind {kind_value}") from exc + + needed = HEADER_LEN + trace_len + payload_len + if len(encoded) < needed: + raise ValueError(f"truncated frame: need {needed}, got {len(encoded)}") + + trace_start = HEADER_LEN + payload_start = trace_start + trace_len + trace_context = ( + encoded[trace_start:payload_start].decode("utf-8") if trace_len else None + ) + payload = encoded[payload_start:needed] if kind == FrameKind.DATA else b"" + + return SessionRpcFrame( + session_id=UUID(bytes=session_bytes), + stream_id=stream_id, + seq=seq, + lease_epoch=lease_epoch, + kind=kind, + payload=payload, + token_count=None if token_count == TOKEN_COUNT_NONE else token_count, + trace_context=trace_context, + ) + + +def _check_u64(value: int) -> int: + if value < 0 or value > TOKEN_COUNT_NONE: + raise ValueError(f"value is outside u64 range: {value}") + return value + + +__all__ = [ + "FrameKind", + "SessionRpcFrame", + "decode_frame", + "encode_frame", +] diff --git a/bindings/python/tests/test_frame_codec.py b/bindings/python/tests/test_frame_codec.py new file mode 100644 index 0000000..e20cf4c --- /dev/null +++ b/bindings/python/tests/test_frame_codec.py @@ -0,0 +1,33 @@ +import unittest +from uuid import UUID + +from sessionrpc import FrameKind, SessionRpcFrame, decode_frame, encode_frame + + +class FrameCodecTests(unittest.TestCase): + def test_roundtrips_payload_tokens_and_trace_context(self) -> None: + frame = SessionRpcFrame( + session_id=UUID("2f8ad4ce-e85a-4ef9-b274-7c31c4a0b35d"), + stream_id=9, + seq=3, + lease_epoch=11, + kind=FrameKind.DATA, + payload=b"hello from python", + token_count=5, + trace_context=( + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00" + ), + ) + + encoded = encode_frame(frame) + decoded = decode_frame(encoded) + + self.assertEqual(decoded, frame) + + def test_rejects_truncated_frames(self) -> None: + with self.assertRaisesRegex(ValueError, "truncated frame"): + decode_frame(b"SRP1") + + +if __name__ == "__main__": + unittest.main()