Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: .
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
30 changes: 30 additions & 0 deletions bindings/python/README.md
Original file line number Diff line number Diff line change
@@ -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
```
15 changes: 15 additions & 0 deletions bindings/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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*"]
133 changes: 133 additions & 0 deletions bindings/python/sessionrpc/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
33 changes: 33 additions & 0 deletions bindings/python/tests/test_frame_codec.py
Original file line number Diff line number Diff line change
@@ -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()
Loading