Read the panel SSE body from response.content (task 4cbf2e96) #295
@@ -315,7 +315,10 @@ class NestQuestApiClient:
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
buffer = ""
|
||||
async for chunk in response:
|
||||
# The body is ``response.content`` (aiohttp's StreamReader,
|
||||
# yielding bytes); the response object itself — HA's
|
||||
# ``HassClientResponse`` included — is NOT async-iterable.
|
||||
async for chunk in response.content:
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
chunk = chunk.decode("utf-8")
|
||||
buffer += chunk
|
||||
|
||||
@@ -472,4 +472,88 @@ async def test_client_logs_nothing() -> None:
|
||||
await client.get_snapshot()
|
||||
finally:
|
||||
logging.getLogger().removeHandler(handler)
|
||||
assert records == []
|
||||
assert records == []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stream_events: the body is read from ``response.content``
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubStreamContent:
|
||||
"""A fake aiohttp ``StreamReader``: async-iterable over byte chunks."""
|
||||
|
||||
def __init__(self, chunks) -> None:
|
||||
self._chunks = list(chunks)
|
||||
|
||||
def __aiter__(self):
|
||||
return self._iterate()
|
||||
|
||||
async def _iterate(self):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class StubStreamResponse:
|
||||
"""A fake aiohttp/HA ``ClientResponse`` for a streaming body.
|
||||
|
||||
Like the real ``HassClientResponse`` it does NOT implement
|
||||
``__aiter__`` — the body is iterated through ``.content`` — so a
|
||||
client that iterates the response object itself raises the
|
||||
``'async for' requires an object with __aiter__ method`` TypeError.
|
||||
"""
|
||||
|
||||
def __init__(self, status: int, chunks) -> None:
|
||||
self.status = status
|
||||
self.content = _StubStreamContent(chunks)
|
||||
|
||||
|
||||
async def test_stream_events_reads_frames_from_response_content() -> None:
|
||||
"""Frames are parsed from ``response.content`` byte chunks, including
|
||||
a frame split across chunk boundaries; a trailing incomplete frame
|
||||
is discarded."""
|
||||
assert not hasattr(StubStreamResponse, "__aiter__")
|
||||
chunks = [
|
||||
b'event: nestquest_quest_completed\ndata: {"instance_id"',
|
||||
b': 7}\n',
|
||||
b'\nevent: nestquest_child_day_complete\n',
|
||||
b'data: {"child_id": 1}\n\n',
|
||||
b'event: nestquest_quest_missed\ndata: {"instance_id": 9}\n',
|
||||
]
|
||||
transport = StubTransport(StubStreamResponse(200, chunks))
|
||||
client = make_client(transport)
|
||||
|
||||
frames = [frame async for frame in client.stream_events()]
|
||||
|
||||
assert frames == [
|
||||
("nestquest_quest_completed", {"instance_id": 7}),
|
||||
("nestquest_child_day_complete", {"child_id": 1}),
|
||||
]
|
||||
assert transport.calls[0]["method"] == "GET"
|
||||
assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/events"
|
||||
|
||||
|
||||
async def test_stream_events_non_2xx_raises_typed_with_status() -> None:
|
||||
transport = StubTransport(StubStreamResponse(502, []))
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
async for _ in client.stream_events():
|
||||
pass
|
||||
|
||||
assert excinfo.value.status == 502
|
||||
assert not excinfo.value.is_transport_error
|
||||
|
||||
|
||||
async def test_stream_events_invalid_json_frame_raises_typed() -> None:
|
||||
transport = StubTransport(
|
||||
StubStreamResponse(200, [b"event: nestquest_quest_completed\n", b"data: {nope\n\n"])
|
||||
)
|
||||
client = make_client(transport)
|
||||
|
||||
with pytest.raises(NestQuestApiError) as excinfo:
|
||||
async for _ in client.stream_events():
|
||||
pass
|
||||
|
||||
assert excinfo.value.status == 200
|
||||
assert not excinfo.value.is_transport_error
|
||||
assert isinstance(excinfo.value.__cause__, ValueError)
|
||||
|
||||
@@ -165,19 +165,29 @@ MINIMAL_SNAPSHOT = {
|
||||
|
||||
|
||||
class _StreamingResponse:
|
||||
"""A fake aiohttp streaming response: ``status`` + chunk iteration.
|
||||
"""A fake aiohttp streaming response: ``status`` + ``.content``.
|
||||
|
||||
``chunks`` are the body pieces the stream yields in order; a chunk
|
||||
may be an exception, raised when the iteration reaches it (a
|
||||
mid-stream transport drop). ``hold_open`` keeps the stream open
|
||||
forever after the chunks run out, for cancellation tests.
|
||||
Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
|
||||
the response itself is NOT async-iterable; the body is iterated
|
||||
through ``.content``. ``chunks`` are the body pieces the stream
|
||||
yields in order; a chunk may be an exception, raised when the
|
||||
iteration reaches it (a mid-stream transport drop). ``hold_open``
|
||||
keeps the stream open forever after the chunks run out, for
|
||||
cancellation tests.
|
||||
"""
|
||||
|
||||
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
|
||||
self.status = status
|
||||
self.content = _StreamingContent(chunks, hold_open)
|
||||
self.closed = False
|
||||
|
||||
|
||||
class _StreamingContent:
|
||||
"""The fake response body: an async-iterable chunk stream."""
|
||||
|
||||
def __init__(self, chunks, hold_open: bool) -> None:
|
||||
self._chunks = list(chunks)
|
||||
self._hold_open = hold_open
|
||||
self.closed = False
|
||||
|
||||
def __aiter__(self):
|
||||
return self._iterate()
|
||||
|
||||
Reference in New Issue
Block a user