Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4834f19118 |
@@ -315,7 +315,10 @@ class NestQuestApiClient:
|
|||||||
event_type: str | None = None
|
event_type: str | None = None
|
||||||
data_lines: list[str] = []
|
data_lines: list[str] = []
|
||||||
buffer = ""
|
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)):
|
if isinstance(chunk, (bytes, bytearray)):
|
||||||
chunk = chunk.decode("utf-8")
|
chunk = chunk.decode("utf-8")
|
||||||
buffer += chunk
|
buffer += chunk
|
||||||
|
|||||||
@@ -473,3 +473,87 @@ async def test_client_logs_nothing() -> None:
|
|||||||
finally:
|
finally:
|
||||||
logging.getLogger().removeHandler(handler)
|
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:
|
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
|
Like aiohttp's ``ClientResponse`` (and HA's ``HassClientResponse``)
|
||||||
may be an exception, raised when the iteration reaches it (a
|
the response itself is NOT async-iterable; the body is iterated
|
||||||
mid-stream transport drop). ``hold_open`` keeps the stream open
|
through ``.content``. ``chunks`` are the body pieces the stream
|
||||||
forever after the chunks run out, for cancellation tests.
|
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:
|
def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None:
|
||||||
self.status = status
|
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._chunks = list(chunks)
|
||||||
self._hold_open = hold_open
|
self._hold_open = hold_open
|
||||||
self.closed = False
|
|
||||||
|
|
||||||
def __aiter__(self):
|
def __aiter__(self):
|
||||||
return self._iterate()
|
return self._iterate()
|
||||||
|
|||||||
Reference in New Issue
Block a user