feat(http-client-python): generate structured JSONL/SSE streaming - #11594
feat(http-client-python): generate structured JSONL/SSE streaming#11594Libba Lawrence (l0lawrence) wants to merge 17 commits into
Conversation
commit: |
|
All changed packages have been documented.
Show changes
|
Python emitter diffBaseline Diff summary: 22 file(s), +3330 / -352 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
| def iter_events(self, iter_bytes: Iterator[bytes]) -> Iterator[T_co]: | ||
| """Iterate over events from a byte iterator. | ||
|
|
||
| :param iter_bytes: An iterator of byte chunks. | ||
| :type iter_bytes: Iterator[bytes] | ||
| :return: An iterator of decoded data. | ||
| :rtype: Iterator[DecodedType_co] | ||
| """ | ||
| ... | ||
|
|
||
|
|
||
| @runtime_checkable | ||
| class AsyncStreamDecoder(Protocol[T_co]): | ||
| """Protocol for async stream decoders.""" | ||
|
|
||
| # Why this isn't async def: https://mypy.readthedocs.io/en/stable/more_types.html#asynchronous-iterators | ||
| def aiter_events(self, iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[T_co]: | ||
| """Asynchronously iterate over events from a byte iterator. | ||
|
|
||
| :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] | ||
| """ | ||
| ... |
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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 21 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:53
- The StreamDecoder/AsyncStreamDecoder docstrings reference
DecodedType_co, but that TypeVar isn't defined (the type parameter isT_co). This looks like a vendoring typo and can confuse generated documentation/type readers; update the:rtype:lines to useT_co(or rename the TypeVar to match).
def iter_events(self, iter_bytes: Iterator[bytes]) -> Iterator[T_co]:
"""Iterate over events from a byte iterator.
: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/serializers/builder_serializer.py:1276
- handle_structured_stream_response hardcodes
_deserialize(...)for stream item deserialization, but other parts of the generator switch deserialization strategy based onmodels-mode(e.g.,self._deserialize(...)for msrest,_deserialize/_deserialize_xmlfor dpg). As-is, structured streaming will fail or deserialize incorrectly whenmodels-modeis not dpg. Consider reusing the existing response_deserialization strategy selection for stream items.
retval.append("def _callback(_http_response, _event):")
if response.streaming_kind == "sse": # type: ignore[attr-defined]
retval.append(" _event_json = json.loads(_event.data)")
named_events = [
(event_type, event_item_type)
packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py:1312
- In the structured streaming callback,
clsis invoked with an empty headers dict ({}), even if the response has headers. This drops header values for callers relying onclsand is inconsistent with the non-streaming path which passesresponse_headers. If structured stream responses can have headers, computeresponse_headersonce and pass it intocls(...)here.
retval.append(" if cls:")
retval.append(" return cls(pipeline_response, deserialized, {}) # type: ignore")
retval.append(" return deserialized")
The JSONL/SSE streaming mock API tests now capture the returned object and assert isinstance(stream, Stream) / isinstance(stream, AsyncStream) before iterating, verifying the generated method returns the streaming wrapper rather than a plain iterator or list. The concrete classes are imported from the internal _utils.streaming_base module (they are intentionally not part of the public API). JSONL element checks are strengthened with isinstance(item, Info) for parity with the SSE tests. 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 20 out of 21 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- packages/http-client-python/package-lock.json: Generated file
Suppressed comments (4)
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:69
AsyncStreamDecoder.aiter_eventsdocstring uses:rtype: AsyncIterator[DecodedType_co], butDecodedType_cois not defined in this template (the type parameter isT_co). Update the docstring to match the actual type parameter to avoid confusing generated-package docs.
: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]
"""
.chronus/changes/structured-streaming-2026-0-0.md:10
- The changelog states that
Stream/AsyncStreamare an internal implementation detail and not part of the public API, but the README and generated method signatures explicitly returnStream[T]/AsyncStream[T]and re-export these names from the package root. Please align the docs with the intended support policy (either treat them as public API, or avoid exposing/re-exporting them and avoid putting them in public signatures).
Generate structured streaming client methods: operations whose HTTP response is a JSONL (`application/jsonl`) or SSE (`text/event-stream`) stream now return `Stream[T]` / `AsyncStream[T]`, yielding deserialized model instances instead of raw bytes.
The `Stream` / `AsyncStream` runtime (plus the JSONL / SSE decoders) is vendored at `_utils/streaming_base.py` and depends only on the released core runtime for the flavor — `azure.core.rest` for the Azure flavor and `corehttp.rest` for the unbranded flavor. These types are an internal implementation detail and are not part of the package's public API.
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:52
StreamDecoder.iter_eventsdocstring uses:rtype: Iterator[DecodedType_co], butDecodedType_cois not defined in this template (the type parameter isT_co). Update the docstring to match the actual type parameter to avoid confusing generated-package docs.
: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/models/response.py:136
- For SSE streams where
terminal_eventis derived from a string-literal member of the item union (fallback path), iteration stops before yielding that terminal marker. However,stream_item_typereturns the full union (self.type), so the generated return type can still include the terminal literal even though it will never be yielded. Consider excluding the terminal-literal member from the stream item type/annotation when it is used only as the terminal marker.
@property
def stream_item_type(self) -> Optional[BaseType]:
if len(self.streaming_events) == 1:
return self.streaming_events[0][1]
return self.type
|
To fix #5778 |
- Remove redundant internal changeset for the SSE tests; the feature changelog already covers them. - Update test_streaming_init unit test to assert Stream/AsyncStream are never publicly exported (all sync/async + structured combos). - Remove the now-unnecessary @typespec/compiler package.json override. 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 no new comments.
Files not reviewed (1)
- packages/http-client-python/package-lock.json: Generated file
Suppressed comments (4)
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:53
- Docstring uses
DecodedType_coin the:rtype:but that type variable is not defined (the protocol is parameterized byT_co). This looks like a stale name and can confuse readers/tools that validate docstrings.
: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:69
- AsyncStreamDecoder docstring also references
DecodedType_coin the:rtype:even though the protocol is parameterized byT_coand noDecodedType_cois defined.
: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/README.md:172
- README claims generated packages re-export
Stream/AsyncStreamfrom the base namespace, but the generator now explicitly tests that these types are not publicly exported (tests/unit/test_streaming_init.py:46-58), and the changelog entry states they are an internal implementation detail (.chronus/changes/structured-streaming-2026-0-0.md:9). Please update this section/example to avoidfrom your_sdk import Streamand describe the intended (non-public) access/typing story instead.
Generated packages re-export `Stream` and `AsyncStream` from their base namespace:
```python
from your_sdk import Stream
from your_sdk.models import Thing
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:179
aiter_linesis an async generator overAsyncIterator[bytes], but the docstring still says:type iter_bytes: Iterator[bytes]and:rtype: Iterator[str]. Update these to the async equivalents to avoid misleading documentation.
"""Iterate over lines from a byte iterator.
:param iter_bytes: An iterator of byte chunks.
:type iter_bytes: Iterator[bytes]
:rtype: Iterator[str]
:return: An iterator of lines.
The test guarded the generated package __init__; with Stream/AsyncStream no longer publicly exported it no longer covers a supported behavior, so remove it. 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 17 out of 18 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- packages/http-client-python/package-lock.json: Generated file
Suppressed comments (4)
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:70
- In the Protocol docstrings, the declared
:rtype:referencesDecodedType_co, but that type variable isn’t defined in this module (the Protocol is parameterized byT_co). This is likely a copy/paste typo and can confuse generated docs/type readers; update the rtype toIterator[T_co]/AsyncIterator[T_co](orDecodedTypeif that’s what you intended).
@runtime_checkable
class StreamDecoder(Protocol[T_co]):
"""Protocol for stream decoders."""
def iter_events(self, iter_bytes: Iterator[bytes]) -> Iterator[T_co]:
"""Iterate over events from a byte iterator.
:param iter_bytes: An iterator of byte chunks.
:type iter_bytes: Iterator[bytes]
:return: An iterator of decoded data.
:rtype: Iterator[DecodedType_co]
"""
...
@runtime_checkable
class AsyncStreamDecoder(Protocol[T_co]):
"""Protocol for async stream decoders."""
# Why this isn't async def: https://mypy.readthedocs.io/en/stable/more_types.html#asynchronous-iterators
def aiter_events(self, iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[T_co]:
"""Asynchronously iterate over events from a byte iterator.
: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]
"""
...
.chronus/changes/structured-streaming-2026-0-0.md:10
- This changelog entry states
Stream/AsyncStreamare “not part of the package's public API”, but the README documents importingStreamfrom the generated package’s base namespace. Please reconcile the public-API stance (either document them as public/semipublic, or adjust docs/tests to avoid advertising direct imports).
Generate structured streaming client methods: operations whose HTTP response is a JSONL (`application/jsonl`) or SSE (`text/event-stream`) stream now return `Stream[T]` / `AsyncStream[T]`, yielding deserialized model instances instead of raw bytes.
The `Stream` / `AsyncStream` runtime (plus the JSONL / SSE decoders) is vendored at `_utils/streaming_base.py` and depends only on the released core runtime for the flavor — `azure.core.rest` for the Azure flavor and `corehttp.rest` for the unbranded flavor. These types are an internal implementation detail and are not part of the package's public API.
packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2:12
- The header comment says this file is vendored from
{{ code_model.core_library }}.streaming, but the README explicitly calls out that noazure.core.streamingruntime dependency is required. Consider rewording this comment to avoid implying that*.streamingis an existing/importable module (e.g., “vendored copy of the (unreleased) core streaming runtime”).
# --------------------------------------------------------------------------
# This file is vendored from the core streaming runtime
# ({{ code_model.core_library }}.streaming). It provides the Stream / AsyncStream
# helpers (plus the JSONL / SSE decoders and event types) used by generated
# structured-streaming operations, so the generated package does not take a hard
# dependency on a core runtime that ships the streaming helpers. Do not edit by hand.
# --------------------------------------------------------------------------
packages/http-client-python/README.md:176
- The README states generated packages re-export
Stream/AsyncStreamfrom the base namespace (from your_sdk import Stream), but the generator’smodel_init.py.jinja2/rest_init.py.jinja2templates currently only export clients/models/enums (noStream/AsyncStream). Either implement the promised re-export in the generated package__init__.pyfiles, or adjust this documentation (and any tests) to reflect that consumers must import from_utils.streaming_base.
Also, please reconcile this section with the changelog note that these types are “not part of the public API” if they are meant to be imported directly.
Generated packages re-export `Stream` and `AsyncStream` from their base namespace:
```python
from your_sdk import Stream
from your_sdk.models import Thing
stream: Stream[Thing] = client.receive()
for thing in stream:
...
The Stream / AsyncStream runtime (plus the JSONL and SSE decoders) is vendored into the generated package at _utils/streaming_base.py (alongside _utils/model_base.py). It depends only on the released core runtime for the flavor — azure.core.rest for the Azure flavor and corehttp.rest for the unbranded flavor — so no unreleased azure.core.streaming dependency is required at runtime.
SSE @events unions use TCGC event metadata to deserialize each named event into its corresponding generated model. Events marked with @terminalEvent stop iteration without being yielded.
</details>
|
Can a terminal event carry data? |
|
Libba Lawrence (@l0lawrence) I looked into this from three angles:
@events
union ResponseEvents {
@Events.contentType("application/json")
responseDelta: ResponseDelta,
@Events.contentType("text/plain")
@terminalEvent
"[DONE]",
}and the expected wire response includes: event: responseDelta
data: {"delta": "Hello"}
data: [DONE]So yes, in the existing pattern the terminal event does carry data: the
const terminalEventValue =
getStringConstantValue(terminalEvent.payloadType) ??
getStringConstantValue(terminalEvent.type);and the generated runtime stops by comparing the decoded SSE event data to that string: if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event:
breakSo my answer is: yes, terminal events can carry data in the supported Spector sense where the terminal event's event: done
data: {"reason": "complete", "usage": 123}For that case, we would need to decide different semantics: should the terminal event be yielded, should its payload be exposed somewhere, or should termination happen by event name regardless of payload? The current implementation filters terminal events out of the dispatch table and only preserves a string NOTE: before we make update for the PR, it is better to make it clear whether we need a new spector case for this scenario. CC iscai-msft |
The lock had been regenerated against the internal Azure SDK npm feed, rewriting all resolved URLs (registry.npmjs.org -> pkgs.dev.azure.com), downgrading integrity hashes from sha512 to sha1, and bumping a few unrelated dev-tooling transitive versions. package.json is byte-identical to main and no spec/core dependency changed, so restore the lock to main to keep the PR free of unnecessary churn. 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 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (3)
packages/http-client-python/generator/pygen/codegen/models/response.py:187
- The structured-stream docstring type reference (
~{namespace}.Stream[...]/AsyncStream[...]) assumesStreamis re-exported from the generated package root, but the generator currently importsStream/AsyncStreamfrom_utils.streaming_baseand the root__init__.pytemplate doesn't re-export these symbols. Either add the intended re-export to the generated package root (and include it in__all__), or updatedocstring_typeto reference the actual location (~{namespace}._utils.streaming_base.Stream[...]) to avoid broken Sphinx references.
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/README.md:172
- This section states that generated packages re-export
Stream/AsyncStreamfrom the base namespace, but the generator templates currently only vendor_utils/streaming_base.pyand import the types from there (no root re-export). This also conflicts with the changelog note that these types are an internal implementation detail. Please either implement the re-export in the generated package root or adjust the README (and changelog wording) to match the actual public surface.
Operations whose HTTP response is a JSONL (`application/jsonl`) or SSE (`text/event-stream`) stream generate client methods that return `Stream[T]` (sync) / `AsyncStream[T]` (async), yielding deserialized model instances instead of raw bytes. This is driven by the TCGC response stream metadata (the response stream type) — there is no opt-in emitter option. This applies to both the Azure and unbranded flavors.
For an operation returning `JsonlStream<Thing>`, the generated method returns `Stream[Thing]` (sync) / `AsyncStream[Thing]` (async), yielding deserialized `Thing` instances as each JSONL line arrives. Similarly, `SSEStream<Events>` produces a `Stream` / `AsyncStream` over the SSE event payloads.
Generated packages re-export `Stream` and `AsyncStream` from their base namespace:
```python
from your_sdk import Stream
from your_sdk.models import Thing
stream: Stream[Thing] = client.receive()
for thing in stream:
...
**packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py:1309**
* `handle_structured_stream_response` always emits `_deserialize(...)` calls inside the per-event callback, but `_deserialize` is only imported/available in `models-mode: dpg`. In `models-mode: none` (and the legacy msrest path), this will raise `NameError` at runtime when consuming the stream. The callback generation should branch like `response_deserialization` does: use `self._deserialize(...)` for msrest, `_deserialize(...)` for dpg, and fall back to returning the parsed JSON value for `models-mode: none` / typed-dict-only cases.
keyword = "if" if index == 0 else "elif"
retval.append(f" {keyword} _event.event == {event_type!r}:")
retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)")
retval.append(" else:")
if len(unnamed_events) == 1:
event_annotation = unnamed_events[0].type_annotation(
is_operation_file=True,
serialize_namespace=self.serialize_namespace,
)
retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)")
else:
retval.append(" deserialized = _event_json")
elif len(unnamed_events) == 1:
event_annotation = unnamed_events[0].type_annotation(
is_operation_file=True,
serialize_namespace=self.serialize_namespace,
)
retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)")
else:
retval.append(f" deserialized = _deserialize({item_annotation}, _event_json)")
else:
retval.append(" _event_json = _event.json()")
retval.append(f" deserialized = _deserialize({item_annotation}, _event_json)")
retval.append(" if cls:")
retval.append(" return cls(pipeline_response, deserialized, {}) # type: ignore")
retval.append(" return deserialized")
</details>
Structured SSE streaming previously recognized only a nameless string-constant `@terminalEvent` (e.g. `[DONE]`), which stops iteration without being yielded. Named / model `@terminalEvent`s (e.g. `error`, `response.completed`) are now deserialized and yielded like any other event, with iteration stopping immediately afterwards. Multiple terminal events are supported. The emitter partitions SSE events via a new pure `partitionSseEvents` helper: nameless string-constant terminals become the drop-and-stop sentinel, while named/model terminals stay in the dispatch table flagged `isTerminal`. The generator threads those names through to the vendored `Stream` / `AsyncStream` runtime, which breaks after yielding a matching event. Behavior for existing specs is unchanged. 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 17 out of 17 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py:1315
clsis currently applied inside the per-event_callback, which means a user-providedclswill be invoked once per streamed event (and see individual items), rather than once for the operation result. This diverges from the existingclscontract used elsewhere in generated operations and makes it hard/impossible to customize the returnedStreamitself.
retval.append(" if cls:")
retval.append(" return cls(pipeline_response, deserialized, {}) # type: ignore")
retval.append(" return deserialized")
retval.append("")
stream_kwargs = ["response=response", "deserialization_callback=_callback"]
packages/http-client-python/README.md:176
- The README says generated packages re-export
Stream/AsyncStreamfrom the base namespace (from your_sdk import Stream), but the generator templates don't currently export these symbols from the package__init__.py(only clients are exported). Also, terminal-event behavior is more nuanced: named/model@terminalEvents are yielded then stop, while a bare sentinel like[DONE]stops without yielding.
Generated packages re-export `Stream` and `AsyncStream` from their base namespace:
```python
from your_sdk import Stream
from your_sdk.models import Thing
.chronus/changes/structured-streaming-2026-0-0.md:9
- This changelog entry says
Stream/AsyncStreamare not part of the public API, but the feature is explicitly a new return type for generated client methods. Even if the runtime is vendored, callers will depend on theStream/AsyncStreamsurface (at least at runtime and in type hints), so this sentence is likely misleading.
The `Stream` / `AsyncStream` runtime (plus the JSONL / SSE decoders) is vendored at `_utils/streaming_base.py` and depends only on the released core runtime for the flavor — `azure.core.rest` for the Azure flavor and `corehttp.rest` for the unbranded flavor. These types are an internal implementation detail and are not part of the package's public API.
| def stream_value(self) -> Union[str, bool]: | ||
| # Structured streams (JSONL / SSE) must always run the pipeline with | ||
| # stream=True so the body can be consumed incrementally by Stream/AsyncStream. | ||
| if self.has_structured_stream_response: | ||
| return True |
Summary
Stream[T]/AsyncStream[T]for JSONL (application/jsonl) and SSE (text/event-stream) response streams.streamMetadata/sseMetadatato deserialize JSONL items and dispatch named SSE events to their concrete generated models, including terminal-event handling.Coverage
Notes
This uses the TCGC prerelease containing
sseMetadata. The external Azure mock API streaming test still asserts the previous raw-byte contract and is expected to remain failing until that test is aligned with the type-driven behavior.