mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
"""Tests for SSE chunking and decoding in hanzoai._streaming."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import asyncio
|
|
from typing import Iterator, AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "pkg" / "hanzoai"))
|
|
|
|
from hanzoai._streaming import SSEDecoder, ServerSentEvent
|
|
|
|
|
|
def make_sync_iterator(chunks: list[bytes]) -> Iterator[bytes]:
|
|
yield from chunks
|
|
|
|
|
|
async def make_async_iterator(chunks: list[bytes]) -> AsyncIterator[bytes]:
|
|
for chunk in chunks:
|
|
yield chunk
|
|
|
|
|
|
class TestIterChunks:
|
|
"""Test that _iter_chunks correctly splits SSE frames."""
|
|
|
|
def _collect_chunks(self, raw_chunks: list[bytes]) -> list[bytes]:
|
|
decoder = SSEDecoder()
|
|
return list(decoder._iter_chunks(make_sync_iterator(raw_chunks)))
|
|
|
|
def test_simple_frame_lf(self):
|
|
"""Single frame terminated by \\n\\n."""
|
|
raw = [b"data: hello\n\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\n\n"]
|
|
|
|
def test_simple_frame_crlf(self):
|
|
"""Single frame terminated by \\r\\n\\r\\n."""
|
|
raw = [b"data: hello\r\n\r\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\r\n\r\n"]
|
|
|
|
def test_simple_frame_cr(self):
|
|
"""Single frame terminated by \\r\\r."""
|
|
raw = [b"data: hello\r\r"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\r\r"]
|
|
|
|
def test_multiple_frames(self):
|
|
"""Multiple frames in one byte chunk."""
|
|
raw = [b"data: one\n\ndata: two\n\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: one\n\n", b"data: two\n\n"]
|
|
|
|
def test_split_across_chunks(self):
|
|
"""Frame split across multiple byte chunks."""
|
|
raw = [b"data: hel", b"lo\n\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\n\n"]
|
|
|
|
def test_separator_split_across_chunks_lf(self):
|
|
"""\\n\\n separator split across chunk boundary."""
|
|
raw = [b"data: hello\n", b"\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\n\n"]
|
|
|
|
def test_separator_split_across_chunks_crlf(self):
|
|
"""\\r\\n\\r\\n separator split across chunk boundary at various points."""
|
|
# Split between \r\n and \r\n
|
|
raw = [b"data: hello\r\n", b"\r\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\r\n\r\n"]
|
|
|
|
def test_crlf_split_mid_separator(self):
|
|
"""The critical bug case: \\r\\n\\r\\n split so \\r and \\n land in different chunks."""
|
|
# This is the case splitlines(keepends=True) gets wrong:
|
|
# chunk1 ends with \r, chunk2 starts with \n\r\n
|
|
raw = [b"data: hello\r", b"\n\r\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\r\n\r\n"]
|
|
|
|
def test_crlf_split_three_ways(self):
|
|
"""\\r\\n\\r\\n split across three chunks."""
|
|
raw = [b"data: hello\r\n", b"\r", b"\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\r\n\r\n"]
|
|
|
|
def test_trailing_data_no_terminator(self):
|
|
"""Data without a trailing separator is yielded on stream end."""
|
|
raw = [b"data: hello"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello"]
|
|
|
|
def test_empty_input(self):
|
|
"""No data yields nothing."""
|
|
chunks = self._collect_chunks([])
|
|
assert chunks == []
|
|
|
|
def test_only_separator(self):
|
|
"""Just a separator yields it as a chunk."""
|
|
raw = [b"\n\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"\n\n"]
|
|
|
|
def test_multiple_frames_split(self):
|
|
"""Two frames, each split across chunks."""
|
|
raw = [b"data: one\n", b"\ndata: tw", b"o\n\n"]
|
|
chunks = self._collect_chunks(raw)
|
|
assert chunks == [b"data: one\n\n", b"data: two\n\n"]
|
|
|
|
|
|
class TestAiterChunks:
|
|
"""Test that _aiter_chunks matches _iter_chunks behavior."""
|
|
|
|
async def _collect_chunks(self, raw_chunks: list[bytes]) -> list[bytes]:
|
|
decoder = SSEDecoder()
|
|
result = []
|
|
async for chunk in decoder._aiter_chunks(make_async_iterator(raw_chunks)):
|
|
result.append(chunk)
|
|
return result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_simple_frame(self):
|
|
chunks = await self._collect_chunks([b"data: hello\n\n"])
|
|
assert chunks == [b"data: hello\n\n"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_crlf_split_mid_separator(self):
|
|
"""The critical bug case for async path."""
|
|
raw = [b"data: hello\r", b"\n\r\n"]
|
|
chunks = await self._collect_chunks(raw)
|
|
assert chunks == [b"data: hello\r\n\r\n"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_frames_split(self):
|
|
raw = [b"data: one\n", b"\ndata: tw", b"o\n\n"]
|
|
chunks = await self._collect_chunks(raw)
|
|
assert chunks == [b"data: one\n\n", b"data: two\n\n"]
|
|
|
|
|
|
class TestDoneAndPingSentinel:
|
|
"""Test that [DONE] and event: ping are skipped."""
|
|
|
|
def _collect_events(self, raw_chunks: list[bytes]) -> list[ServerSentEvent]:
|
|
decoder = SSEDecoder()
|
|
return list(decoder.iter_bytes(make_sync_iterator(raw_chunks)))
|
|
|
|
def test_done_sentinel_skipped(self):
|
|
"""data: [DONE] should not produce an event."""
|
|
raw = [b"data: {\"text\": \"hi\"}\n\n", b"data: [DONE]\n\n"]
|
|
events = self._collect_events(raw)
|
|
assert len(events) == 1
|
|
assert events[0].data == '{"text": "hi"}'
|
|
|
|
def test_ping_event_skipped(self):
|
|
"""event: ping should not produce an event."""
|
|
raw = [b"event: ping\ndata: \n\n", b"data: {\"text\": \"hi\"}\n\n"]
|
|
events = self._collect_events(raw)
|
|
assert len(events) == 1
|
|
assert events[0].data == '{"text": "hi"}'
|
|
|
|
def test_normal_events_pass_through(self):
|
|
"""Normal events should still work."""
|
|
raw = [b"data: {\"a\": 1}\n\n", b"data: {\"b\": 2}\n\n"]
|
|
events = self._collect_events(raw)
|
|
assert len(events) == 2
|
|
assert events[0].json() == {"a": 1}
|
|
assert events[1].json() == {"b": 2}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_done_sentinel_skipped_async(self):
|
|
"""data: [DONE] should not produce an event in async path."""
|
|
decoder = SSEDecoder()
|
|
raw = [b"data: {\"text\": \"hi\"}\n\n", b"data: [DONE]\n\n"]
|
|
events = []
|
|
async for sse in decoder.aiter_bytes(make_async_iterator(raw)):
|
|
events.append(sse)
|
|
assert len(events) == 1
|
|
assert events[0].data == '{"text": "hi"}'
|