Files
turnstone/tests/test_sdk_sse.py
T
Patrick Buckley 5ee539c983 Add Python and TypeScript client SDKs for server and console APIs (#19)
* Add Python and TypeScript client SDKs for server and console APIs

Python SDK (turnstone/sdk/) with sync + async clients for both server
and console APIs. Returns Pydantic models directly, streams SSE events
as typed dataclasses. 27 event types with registry-based deserialization.
High-level send_and_wait() for request-response patterns.

TypeScript SDK (sdk/typescript/) with zero browser dependencies. Uses
fetch + ReadableStream for SSE parsing. Discriminated union event types
with type guards. Same API surface as Python SDK.

63 Python tests, 21 TypeScript tests (vitest). Comprehensive docs at
docs/sdk.md with SDK architecture diagram.

* Address PR #19 review feedback + fix lint

- Fix consume_task leak in send_and_wait when send() raises (try/finally)
- Fix TS sendAndWait: open SSE before send, plumb AbortSignal for timeout
- Add signal param to TS streamSSE for cancellation support
- Fix SSE parser: join multi-line data: fields with \n per spec, handle CRLF
- Fix generate-types.py sys.path (parents[3] not parents[2])
- Document token ignored when httpx_client provided
- Document TS timeout units as milliseconds
- Fix stale docstring in test_sdk_sse.py
- Fix import sorting (ruff I001)
2026-03-03 21:22:24 -08:00

111 lines
3.6 KiB
Python

"""Tests for _BaseClient._stream_sse — SSE stream parsing."""
from __future__ import annotations
import httpx
import pytest
from turnstone.sdk._base import _BaseClient
def _sse_response(*events: str) -> httpx.Response:
"""Build a mock SSE response from data strings."""
body = ""
for event in events:
body += f"data: {event}\n\n"
return httpx.Response(
200,
content=body.encode(),
headers={"content-type": "text/event-stream"},
)
@pytest.mark.anyio
async def test_stream_sse_yields_json():
"""SSE stream with valid JSON payloads yields parsed dicts."""
def handler(request: httpx.Request) -> httpx.Response:
return _sse_response(
'{"type": "content", "text": "hello"}',
'{"type": "stream_end"}',
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = _BaseClient(httpx_client=hc)
events = []
async for data in client._stream_sse("/v1/api/events", params={"ws_id": "ws1"}):
events.append(data)
assert len(events) == 2
assert events[0]["type"] == "content"
assert events[1]["type"] == "stream_end"
@pytest.mark.anyio
async def test_stream_sse_skips_malformed_json():
"""Malformed JSON in SSE data is silently skipped."""
def handler(request: httpx.Request) -> httpx.Response:
return _sse_response(
"not-json",
'{"type": "content", "text": "ok"}',
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = _BaseClient(httpx_client=hc)
events = []
async for data in client._stream_sse("/test"):
events.append(data)
assert len(events) == 1
assert events[0]["type"] == "content"
@pytest.mark.anyio
async def test_stream_sse_skips_empty_data():
"""SSE frames with empty data field are skipped."""
def handler(request: httpx.Request) -> httpx.Response:
# Build response with an empty data line
body = 'data: \n\ndata: {"type": "info", "message": "ok"}\n\n'
return httpx.Response(
200,
content=body.encode(),
headers={"content-type": "text/event-stream"},
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = _BaseClient(httpx_client=hc)
events = []
async for data in client._stream_sse("/test"):
events.append(data)
# Empty " " data is not valid JSON, so skipped; only "info" event remains
assert len(events) == 1
assert events[0]["type"] == "info"
@pytest.mark.anyio
async def test_stream_sse_multiple_events():
"""Multiple SSE events are yielded in order."""
event_data = [
'{"type": "connected", "model": "gpt-5"}',
'{"type": "content", "text": "word1 "}',
'{"type": "content", "text": "word2"}',
'{"type": "status", "total_tokens": 10}',
'{"type": "stream_end"}',
]
def handler(request: httpx.Request) -> httpx.Response:
return _sse_response(*event_data)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
client = _BaseClient(httpx_client=hc)
events = []
async for data in client._stream_sse("/test"):
events.append(data)
assert len(events) == 5
types = [e["type"] for e in events]
assert types == ["connected", "content", "content", "status", "stream_end"]