feat(http-client-python): generate structured JSONL/SSE streaming - #11594
Conversation
commit: |
|
All changed packages have been documented.
Show changes
|
Python emitter diffBaseline Diff summary: 138 file(s), +16990 / -242 Rendered diff: inline on the run summary, or the emitter-diff-html artifact. Informational check (eng/emitter-diff); does not block the PR. |
|
You can try these changes here
|
4a36e0a to
d93a175
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds structured streaming support to the TypeSpec Python emitter/generator for the Azure flavor, emitting client methods that return Stream[T] / AsyncStream[T] for JSONL (application/jsonl) and SSE (text/event-stream) responses, using TCGC streaming metadata to drive per-item deserialization and SSE event dispatch. It also vendors a small streaming runtime into generated packages to avoid requiring an unreleased azure.core.streaming dependency.
Changes:
- Emit a
streamingblock in response YAML for structured JSONL/SSE streams (including SSE event/terminal metadata) and preserve it across request overloads. - Generate and write a vendored
_utils/streaming_base.pyruntime (Stream/AsyncStream + JSONL/SSE decoders) when needed, and update response/operation modeling + response handling to returnStream[T]/AsyncStream[T]. - Add emitter-side unit tests for structured-stream detection and update dependency versions to TCGC prereleases that expose
sseMetadata.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/http-client-python/README.md | Documents structured streaming behavior for Azure flavor and the vendored runtime. |
| packages/http-client-python/package.json | Updates dev dependencies to prereleases and adds an npm overrides entry for compiler version alignment. |
| packages/http-client-python/package-lock.json | Locks new prerelease dependency resolutions used by the emitter/generator tests. |
| packages/http-client-python/generator/pygen/preprocess/init.py | Preserves streaming metadata across generated overload YAML updates. |
| packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 | Adds vendored streaming runtime template (Stream/AsyncStream + JSONL/SSE decoding). |
| packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py | Adds serializer for the new streaming runtime template. |
| packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py | Emits response handling that returns Stream/AsyncStream with per-item deserialization callbacks. |
| packages/http-client-python/generator/pygen/codegen/serializers/init.py | Writes _utils/streaming_base.py when structured streaming is present. |
| packages/http-client-python/generator/pygen/codegen/models/response.py | Models structured stream metadata, stream return annotations, and imports needed for generated operations. |
| packages/http-client-python/generator/pygen/codegen/models/operation.py | Forces stream=True for structured streaming operations and exposes a structured-stream predicate. |
| packages/http-client-python/generator/pygen/codegen/models/code_model.py | Tracks whether any structured streaming exists to decide if vendored runtime must be emitted. |
| packages/http-client-python/emitter/test/streaming.test.ts | Adds emitter unit tests for structured-stream detection and kind inference. |
| packages/http-client-python/emitter/src/http.ts | Emits response streaming YAML using TCGC stream/sse metadata; adds structured stream detection helpers. |
| cspell.yaml | Adds streaming/runtime-related identifiers to the spellchecker dictionary. |
| .chronus/changes/structured-streaming-2026-0-0.md | Changelog entry describing the new Azure-flavor structured streaming support. |
Files not reviewed (1)
- packages/http-client-python/package-lock.json: Generated file
d93a175 to
c3924da
Compare
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e57edafe-9764-4b99-a1ae-0efd56e8729e
c3924da to
294b0c5
Compare
…e PR #48077 Adopt linear-time JSONL/SSE line framers (_JSONLLineFramer, _SSELineFramer) and aclosing/try-finally lifecycle from the refactored azure.core.streaming runtime. Preserve the emitter's terminal_event extension on Stream/AsyncStream (generated builder_serializer.py call site unchanged). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2a68a869-b075-46b2-87b4-2bbe62948e69
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e57edafe-9764-4b99-a1ae-0efd56e8729e
…o package root Structured JSONL/SSE streaming (and any cross-root model reference) could emit a package-escaping relative import such as `from .......search import models` when a payload model's client_namespace shares no top-level package component with the generated module's namespace (e.g. a `search` model referenced from an `azure.search.documents` package). Python rejects that at runtime with "attempted relative import beyond top-level package". CodeModel.get_relative_import_path now falls back to a valid absolute import when the common-prefix length is zero (idx == 0); in-package imports (which always share the package root, idx >= 1) are byte-identical to before. Stream/AsyncStream continue to be imported locally from `_utils.streaming_base`. Adds spec-agnostic regression tests for the helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e57edafe-9764-4b99-a1ae-0efd56e8729e
… share no package root" This reverts commit bf35b41.
…re and unbranded flavors
Make the vendored Stream/AsyncStream runtime flavor-aware: the functional
import and the docstrings in streaming_base.py now use
{{ code_model.core_library }}.rest, so the unbranded flavor targets
corehttp.rest instead of a hardcoded azure.core.rest (azure flavor output
is unchanged). Update the shared JSONL mock tests to consume Stream[Info]
(sync + async), and refresh the README and changelog to state that both
flavors emit structured streaming. The terminal_event extension and the
builder_serializer generated call site are preserved.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
…pe changelog Add JSDoc to StructuredStreamingInfo.itemType (aggregate stream element type used for the Stream[T]/AsyncStream[T] annotation) and StructuredStreamEvent.itemType (per-event payload type forming the runtime dispatch table), documenting the deliberate overlap for heterogeneous SSE. Remove a duplicated code block in the structured streaming changelog entry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
…ware
The vendored _utils/streaming_base.py header hardcoded "vendored from
azure-core (azure.core.streaming)". For the unbranded flavor this leaked
the word "azure" into generated output, failing the unbranded
test_sensitive_word check. Reword the banner to reference
{{ code_model.core_library }}.streaming (azure.core.streaming for azure,
corehttp.streaming for unbranded), consistent with the flavor-aware
import just below it.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
iscai-msft
left a comment
There was a problem hiding this comment.
overall design looks good, can you implement the spector streaming tests in the pr as well?
…ic export The vendored Stream/AsyncStream runtime is planned to move to azure-core and be removed from generated SDKs; exporting it from the package's base namespace now would make that removal a breaking change for anyone importing it. Keep it as an internal implementation detail - operations still return Stream[T]/AsyncStream[T] and import it from _utils.streaming_base. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
…ync + async, shared) Shared mock API tests for the SSE streaming spec covering homogeneous (unnamed), heterogeneous named events with a [DONE] terminal event, and a POST-body retrieve stream. Asserts deserialized model instances and terminal-event termination for both sync (for) and async (async for) iteration; runs in both azure and unbranded tox envs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
…nnect loop Stream._iter_results was closing self._response both at the end of every loop iteration and again in the outer finally block, double-closing the same HttpResponse in the non-reconnect (normal/terminal) case. Only close the response when preparing to reconnect; the finally block already handles the terminal/EOF path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
…-event
Previously the per-event _callback applied the cls response-shaping hook on
every streamed event, re-invoking it with the same pipeline_response each
time. Move the cls(pipeline_response, deserialized, {}) call outside the
_callback so it's applied exactly once, to the returned Stream/AsyncStream
object, matching the behavior of non-structured streaming responses.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:654
Stream.close()closesself._iterator, which triggers the generator'sfinallyin_iter_resultsand closes the underlying response, thenStream.close()closesself._responseagain. This double-close can be unsafe ifHttpResponse.close()isn't idempotent and is also redundant with the generator's cleanup.
def close(self) -> None:
try:
self._iterator.close()
finally:
self._response.close()
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:773
AsyncStream.close()awaitsself._iterator.aclose(), which will run the async generatorfinallyin_iter_resultsand close the underlying response, thenAsyncStream.close()closesself._responseagain. This double-close can be unsafe ifAsyncHttpResponse.close()isn't idempotent and is redundant with the generator's cleanup.
async def close(self) -> None:
try:
await self._iterator.aclose()
finally:
await self._response.close()
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:490
- SSE
retry:values are only observable byStream/AsyncStreamwhen an event with at least onedata:line is dispatched. If a server sendsretry:before the first event (or in a metadata-only block),_SSEEventBuilder._dispatch()returnsNoneand the stream never sees the updated retry, so auto-reconnect won’t trigger as documented.
def _dispatch(self) -> Optional[ServerSentEvent]:
if not self._data:
# No data accumulated: reset and dispatch nothing.
self._event_type = ""
return None
… entries Merge the SSE protocol (id/retry/reconnect) changelog entry into the main structured-streaming changelog file so there's a single entry for the feature. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
…t publicly exported The structured streaming README section claimed the generated package re-exports Stream/AsyncStream from its base namespace. That is not the case: Stream/AsyncStream are vendored internal implementation details and are not re-exported. Update the docs and example to reflect that callers only see an iterable stream object, not the Stream type itself. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
iscai-msft
left a comment
There was a problem hiding this comment.
can you send me the link for the generated code diff? not sure where to see it. thanks!
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- packages/http-client-python/package-lock.json: Generated file
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
packages/http-client-python/generator/pygen/preprocess/init.py:39
update_overload_sectionchecksoverload_s.get("streaming"), which will skip copying the originalstreamingsection when the key exists but its value is falsy (e.g. an empty dict). This makes the behavior dependent on truthiness rather than key presence and can leave overloads with mismatched streaming metadata.
if overload_s.get("type"):
overload_s["type"] = original_s["type"]
if overload_s.get("streaming"):
overload_s["streaming"] = original_s["streaming"]
packages/http-client-python/generator/pygen/codegen/models/response.py:228
Response.docstring_type()returns~<namespace>.Stream[...]/~<namespace>.AsyncStream[...], but the generated package__init__.pyonly exports clients and does not re-export these helper types. This makes the docstring type reference point at a non-existent symbol (Sphinx cross-reference will be wrong).
if self.is_structured_stream and self.type:
stream_class = self.stream_class_name(kwargs.get("async_mode", False))
item_type = (self.stream_item_type or self.type).docstring_type(**kwargs)
return f"~{self.code_model.namespace}.{stream_class}[{item_type}]"
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:759
AsyncStream.close()awaits_iterator.aclose()(which already closes the underlying response in_iter_results'sfinally) and then closes the response again. This results in a double-close of the sameAsyncHttpResponse.
async def close(self) -> None:
try:
await self._iterator.aclose()
finally:
await self._response.close()
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- packages/http-client-python/package-lock.json: Generated file
Suppressed comments (3)
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:54
- This docstring references
DecodedType_co, but that type variable doesn’t exist in this module (the protocol is parameterized byT_co). This makes the generated documentation misleading and can confuse type readers.
Use the protocol’s actual type parameter in the :rtype: field.
:param iter_bytes: An iterator of byte chunks.
:type iter_bytes: Iterator[bytes]
:return: An iterator of decoded data.
:rtype: Iterator[DecodedType_co]
"""
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:70
- This docstring references
DecodedType_co, but that type variable doesn’t exist in this module (the protocol is parameterized byT_co). This makes the generated documentation misleading.
Use the protocol’s actual type parameter in the :rtype: field.
:param iter_bytes: An asynchronous iterator of byte chunks.
:type iter_bytes: AsyncIterator[bytes]
:return: An asynchronous iterator of decoded data.
:rtype: AsyncIterator[DecodedType_co]
"""
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:646
Stream.close()closes the underlying generator (which already closes the response in_iter_results'sfinally) and then callsresponse.close()again. That double-close can be problematic if the underlyingHttpResponse.close()isn’t idempotent.
Consider relying on the generator’s finally for response cleanup and avoiding the second close.
def close(self) -> None:
try:
self._iterator.close()
finally:
self._response.close()
Stream[T]/AsyncStream[T]for JSONL (application/jsonl) and SSE (text/event-stream) response streamsSupports this spec
Missing support for : #11761
QUESTIONS/NOTES FOR NOW OR FUTURE REF:
@data(w/ w/o envelopes correctly -- python is just returning data aka Stream[T] not Stream[SSEEvent[T]] (like paging) so Stream[@datapayload])@retrynot used - both should be exposed on Stream) -- [http-client-python] Add SSE reconnection #11761EXAMPLES:
What search retrieveStream() looks like:
Stream(response=response, deserialization_callback=_callback, terminal_event_names=["error", "response.completed"])What search retrieveStream() output looks like:
the sample:
the output: