[azure-core/corehttp] Add JSONL and SSE streaming support - #48077
[azure-core/corehttp] Add JSONL and SSE streaming support#48077Libba Lawrence (l0lawrence) wants to merge 19 commits into
Conversation
|
Azure Pipelines: Successfully started running 3 pipeline(s). 7 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
f10ef48 to
785e97e
Compare
Adds a stream-agnostic Stream/AsyncStream iterator plus JSONLDecoder/AsyncJSONLDecoder to both azure-core (azure.core.streaming) and corehttp (corehttp.streaming), kept in sync. The deserialization_callback receives the response and each decoded event so generated code can support the cls custom-deserializer pattern. Each package includes sync/async tests, test-server JSONL routes, a sample, CHANGELOG entry, and a README section (azure-core also bumps the version to 1.42.0). Based on the design in https://gist.github.com/kristapratico/d330af39962ea05b10384b865e37b36f and the original corehttp prototype in Azure#39478. Part of Azure#38806. Co-authored-by: Krista Pratico <krpratic@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
785e97e to
8fe667c
Compare
- iter_lines/aiter_lines split only on \n (tolerating \r\n) instead of str.splitlines(), preserving Unicode boundaries (\u2028/\u2029/\x85) inside JSONL record string values. - Stream.__iter__/AsyncStream.__aiter__ return self (typed Self). - Fix corehttp _stream.py docstring cross-refs to _decoders. - Add Server-Sent Events (SSE) support: ServerSentEvent, SSEDecoder, AsyncSSEDecoder in both packages, with server routes and sync/async tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08d2aa98-be13-48ab-89ea-531b494b9482
- Guard SSE retry parsing with isascii() so Unicode digits (e.g. superscript) that pass str.isdigit() but fail int() no longer crash the stream. - Restore azure-core test_stream_jsonl_basic, whose def line was accidentally removed while editing SSE test fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08d2aa98-be13-48ab-89ea-531b494b9482
SSE is always UTF-8 per the WHATWG spec. Switch the SSE line decoders to the incremental utf-8-sig decoder with errors=replace so that: - a single leading BOM is dropped only when present, and - invalid byte sequences become U+FFFD instead of crashing the stream. JSONL decoding is intentionally left strict (the JSONL spec forbids a BOM). Add server routes and sync/async tests covering a leading BOM, a BOM split across chunks, and invalid UTF-8 for both packages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08d2aa98-be13-48ab-89ea-531b494b9482
Move JSONL decoders into _jsonl.py and SSE decoders into _sse.py, keeping the shared StreamDecoder/AsyncStreamDecoder protocols in _decoders.py. Public API is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08d2aa98-be13-48ab-89ea-531b494b9482
An all-ASCII-digit retry value longer than CPython's int-string conversion limit would raise ValueError and abort the stream. Ignore such values per the WHATWG SSE spec's robustness intent. Adds sync + async regression tests in both packages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08d2aa98-be13-48ab-89ea-531b494b9482
Generalize the Stream/AsyncStream summaries (they consume any decoded event stream, not just JSONL) and point the decoder cross-references at the public StreamDecoder/AsyncStreamDecoder exports so both packages match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08d2aa98-be13-48ab-89ea-531b494b9482
…ample Add a parametrized chunk-size sweep (sync + async) that replays an SSE payload at chunk sizes down to a single byte, exercising line-separator and multibyte UTF-8 splits across arbitrary chunk boundaries. Also add sync/async SSE samples to sample_stream.py demonstrating OpenAI-style consumption, including the non-JSON `data: [DONE]` sentinel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fffeb0d7-6fcb-4485-8917-02315a708f72
Align the initial last-event ID with the WHATWG SSE processing model by using an empty string instead of None. Update the public event model, documentation, and sync/async expectations in both packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fffeb0d7-6fcb-4485-8917-02315a708f72
Select SSE decoding for text/event-stream responses and default other responses to JSONL when no decoder is supplied. Normalize media type parameters and casing while preserving explicit custom decoder overrides. Update samples and sync/async coverage in both packages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fffeb0d7-6fcb-4485-8917-02315a708f72
Limit the public streaming namespace to Stream and AsyncStream. Keep decoder protocols, concrete JSONL/SSE decoders, and ServerSentEvent as private implementation details, and update samples, tests, and documentation links to reflect the reduced API surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fffeb0d7-6fcb-4485-8917-02315a708f72
The streaming deserialization_callback receives a JSONLEvent or ServerSentEvent, so export both from the streaming package and add them to __all__ (decoders stay internal). Update the decoder-method docstring cross-references to point at the now-public streaming.<Type> paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08d2aa98-be13-48ab-89ea-531b494b9482
…rror Wrap streaming iteration in try/finally so the response is deterministically closed even when a caller iterates without a context manager and something raises mid-stream (e.g. malformed JSONL). Async paths also aclose child async generators via contextlib.aclosing to avoid RuntimeWarning on GC. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 08d2aa98-be13-48ab-89ea-531b494b9482
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 006b8a95-78b4-4e02-820e-debf125a5a6b
|
Azure Pipelines: Successfully started running 3 pipeline(s). 7 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
Adds mirrored JSONL and SSE streaming infrastructure to azure-core and corehttp.
Changes:
- Adds synchronous/asynchronous stream abstractions and decoders.
- Adds parser edge-case tests and test-server routes.
- Adds samples, documentation, release notes, and an
azure-coreversion bump.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
sdk/core/corehttp/tests/testserver_tests/coretestserver/coretestserver/test_routes/streams.py |
Adds streaming test endpoints. |
sdk/core/corehttp/tests/test_stream.py |
Adds synchronous streaming tests. |
sdk/core/corehttp/tests/async_tests/test_stream_async.py |
Adds asynchronous streaming tests. |
sdk/core/corehttp/samples/sample_stream.py |
Adds JSONL usage samples. |
sdk/core/corehttp/README.md |
Documents streaming usage. |
sdk/core/corehttp/corehttp/streaming/_stream.py |
Implements stream iterators. |
sdk/core/corehttp/corehttp/streaming/_sse.py |
Implements SSE decoding. |
sdk/core/corehttp/corehttp/streaming/_jsonl.py |
Implements JSONL decoding. |
sdk/core/corehttp/corehttp/streaming/_decoders.py |
Defines decoder protocols. |
sdk/core/corehttp/corehttp/streaming/__init__.py |
Defines public streaming exports. |
sdk/core/corehttp/CHANGELOG.md |
Records the feature. |
sdk/core/azure-core/tests/testserver_tests/coretestserver/coretestserver/test_routes/streams.py |
Adds streaming test endpoints. |
sdk/core/azure-core/tests/test_stream.py |
Adds synchronous streaming tests. |
sdk/core/azure-core/tests/async_tests/test_stream_async.py |
Adds asynchronous streaming tests. |
sdk/core/azure-core/samples/sample_stream.py |
Adds JSONL and SSE samples. |
sdk/core/azure-core/README.md |
Documents streaming usage. |
sdk/core/azure-core/CHANGELOG.md |
Records the preview feature. |
sdk/core/azure-core/azure/core/streaming/_stream.py |
Implements stream iterators. |
sdk/core/azure-core/azure/core/streaming/_sse.py |
Implements SSE decoding. |
sdk/core/azure-core/azure/core/streaming/_jsonl.py |
Implements JSONL decoding. |
sdk/core/azure-core/azure/core/streaming/_decoders.py |
Defines decoder protocols. |
sdk/core/azure-core/azure/core/streaming/__init__.py |
Defines public streaming exports. |
sdk/core/azure-core/azure/core/_version.py |
Sets version 1.43.0b1. |
Suppressed comments (4)
sdk/core/azure-core/samples/sample_stream.py:62
- The async JSONL decoder also passes a
JSONLEvent, so this annotation and return value are incorrect and the sample yields the wrapper instead of decoded JSON. Parseevent.json()as in the streaming tests.
def deserialize(response: AsyncHttpResponse, event: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
# Deserialize each decoded JSON object into a model. Here we just return it as-is.
return event
sdk/core/corehttp/samples/sample_stream.py:59
- The async JSONL decoder also passes a
JSONLEvent, so this annotation and return value are incorrect and the sample yields the wrapper instead of decoded JSON. Parseevent.json()as in the streaming tests.
def deserialize(response: AsyncHttpResponse, event: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
# Deserialize each decoded JSON object into a model. Here we just return it as-is.
return event
sdk/core/azure-core/azure/core/streaming/_jsonl.py:103
- The trailing split component is still incomplete, so stripping
\rfrom it before the next async chunk arrives can silently alter the JSON record. Strip CR only from completed components and preserve the final remainder until a following\nor EOF determines its role.
decoded_lines = [line[:-1] if line.endswith("\r") else line for line in decoded.split("\n")]
for line in decoded_lines[:-1]:
yield line
decoded = decoded_lines[-1]
sdk/core/corehttp/corehttp/streaming/_jsonl.py:103
- The trailing split component is still incomplete, so stripping
\rfrom it before the next async chunk arrives can silently alter the JSON record. Strip CR only from completed components and preserve the final remainder until a following\nor EOF determines its role.
decoded_lines = [line[:-1] if line.endswith("\r") else line for line in decoded.split("\n")]
for line in decoded_lines[:-1]:
yield line
decoded = decoded_lines[-1]
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| from ._stream import Stream, AsyncStream | ||
| from ._jsonl import JSONLEvent | ||
| from ._sse import ServerSentEvent | ||
|
|
||
|
|
||
| __all__ = [ | ||
| "Stream", | ||
| "AsyncStream", | ||
| "JSONLEvent", | ||
| "ServerSentEvent", | ||
| ] |
| from ._stream import Stream, AsyncStream | ||
| from ._jsonl import JSONLEvent | ||
| from ._sse import ServerSentEvent | ||
|
|
||
|
|
||
| __all__ = [ | ||
| "Stream", | ||
| "AsyncStream", | ||
| "JSONLEvent", | ||
| "ServerSentEvent", | ||
| ] |
| finally: | ||
| aclose = getattr(events, "aclose", None) | ||
| if aclose is not None: | ||
| await aclose() | ||
| await self._response.close() |
| finally: | ||
| aclose = getattr(events, "aclose", None) | ||
| if aclose is not None: | ||
| await aclose() | ||
| await self._response.close() |
|
|
||
| ### Streaming | ||
|
|
||
| `corehttp` provides a stream-agnostic `Stream` iterator for consuming streaming responses. Currently, JSON Lines (JSONL) streaming is supported via the `JSONLDecoder`. Pass the streamed response together with a decoder and a `deserialization_callback` that receives the response and each decoded event: |
| from corehttp.streaming import Stream | ||
|
|
||
| client: PipelineClient[HttpRequest, HttpResponse] = PipelineClient("https://example.com") | ||
| request = HttpRequest("GET", "https://example.com/stream") | ||
| response = client.send_request(request, stream=True) | ||
|
|
||
| def deserialize(response: HttpResponse, event: MutableMapping[str, Any]) -> MutableMapping[str, Any]: | ||
| # Deserialize each decoded JSON object into a model. Here we just return it as-is. | ||
| return event |
| __all__ = [ | ||
| "Stream", | ||
| "AsyncStream", | ||
| "JSONLEvent", | ||
| "ServerSentEvent", | ||
| ] |
| __all__ = [ | ||
| "Stream", | ||
| "AsyncStream", | ||
| "JSONLEvent", | ||
| "ServerSentEvent", | ||
| ] |
This comment has been minimized.
This comment has been minimized.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f257ae36-21ec-4fde-8c4e-d37d40c0e87d
…ders Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f257ae36-21ec-4fde-8c4e-d37d40c0e87d
[Pilot] PR Pipeline Failure AnalysisA CI pipeline failed on this pull request. Here is an automated analysis of what went wrong and how to get the build green. What failedThree Azure DevOps builds failed:
Recommended next steps
Raw pipeline analysis (azsdk ci analyze)
|
Adds streaming support to both azure-core (
azure.core.streaming) and corehttp (corehttp.streaming), kept in sync so the two implementations stay identical (only therest-path docstrings differ).What's included (per package)
Core
Stream/AsyncStream— decoder-agnostic iterators (__iter__/__aiter__returnself)deserialization_callbackreceives(response, event)so generated code can support theclscustom-deserializer patternJSONL
JSONLDecoder/AsyncJSONLDecoderwith incremental UTF-8 line decoding\n(with\r\ntolerated), notstr.splitlines(), so a JSON string value containing other Unicode line boundaries (e.g.\u2028,\x85) is not split mid-recordSSE (Server-Sent Events)
ServerSentEventvalue object (event,data,id,retry) plusSSEDecoder/AsyncSSEDecoder, conforming to the same decoder protocol as JSONL so they drop intoStream/AsyncStreamunchangedCR/LF/CRLFline splitting (incl. a lone\rsplit across chunks),:-comment lines ignored, one leading space stripped from values, multipledata:lines joined with\n,idpersisted across events (NUL rejected),retryparsed only when ASCII-digitsPer package also: sync + async tests, test-server JSONL/SSE routes, sample (
samples/sample_stream.py), CHANGELOG entry + README section (azure-core also bumps version to 1.42.0).Validation (both packages)
What client code to return a Stream() would look like:
