Skip to content

feat(http-client-python): generate structured JSONL/SSE streaming - #11594

Open
Libba Lawrence (l0lawrence) wants to merge 17 commits into
mainfrom
l0lawrence-jsonl-sse-streaming-codegen
Open

feat(http-client-python): generate structured JSONL/SSE streaming#11594
Libba Lawrence (l0lawrence) wants to merge 17 commits into
mainfrom
l0lawrence-jsonl-sse-streaming-codegen

Conversation

@l0lawrence

@l0lawrence Libba Lawrence (l0lawrence) commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

  • Generate Azure-flavor client methods that return Stream[T] / AsyncStream[T] for JSONL (application/jsonl) and SSE (text/event-stream) response streams.
  • Use TCGC streamMetadata / sseMetadata to deserialize JSONL items and dispatch named SSE events to their concrete generated models, including terminal-event handling.
  • Vendor the JSONL/SSE streaming runtime in generated packages while preserving unbranded raw-byte iterator behavior.
  • Preserve registered stream item-type references across request overloads.
stream = client.receive()  # Stream[Thing]
for thing in stream:
    ...

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.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:client:python Issue for the Python client emitter: @typespec/http-client-python label Aug 7, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/http-client-python@11594

commit: aa76c0d

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

All changed packages have been documented.

  • @typespec/http-client-python
Show changes

@typespec/http-client-python - feature ✏️

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.,> ,> python,> stream = client.receive(),> for thing in stream:,> ...,>

@l0lawrence Libba Lawrence (l0lawrence) changed the title feat(http-client-python): generate structured JSONL/SSE streaming (Azure flavor) feat(http-client-python): generate structured JSONL/SSE streaming Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Python emitter diff

Baseline gh:6f61820603610b1306e462942d86f0fdd17d789a vs this PR.

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.

@azure-sdk-automation

azure-sdk-automation Bot commented Aug 7, 2026

Copy link
Copy Markdown

You can try these changes here

🛝 Playground 🌐 Website 🛝 VSCode Extension

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 streaming block 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.py runtime (Stream/AsyncStream + JSONL/SSE decoders) when needed, and update response/operation modeling + response handling to return Stream[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

Comment on lines +44 to +68
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]
"""
...
@l0lawrence
Libba Lawrence (l0lawrence) force-pushed the l0lawrence-jsonl-sse-streaming-codegen branch from d93a175 to c3924da Compare August 7, 2026 19:14
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e57edafe-9764-4b99-a1ae-0efd56e8729e
@l0lawrence
Libba Lawrence (l0lawrence) force-pushed the l0lawrence-jsonl-sse-streaming-codegen branch from c3924da to 294b0c5 Compare August 7, 2026 19:22
…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
…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 iscai-msft left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall design looks good, can you implement the spector streaming tests in the pr as well?

Comment thread .chronus/changes/structured-streaming-2026-0-0.md Outdated
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is T_co). This looks like a vendoring typo and can confuse generated documentation/type readers; update the :rtype: lines to use T_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 on models-mode (e.g., self._deserialize(...) for msrest, _deserialize/_deserialize_xml for dpg). As-is, structured streaming will fail or deserialize incorrectly when models-mode is 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, cls is invoked with an empty headers dict ({}), even if the response has headers. This drops header values for callers relying on cls and is inconsistent with the non-streaming path which passes response_headers. If structured stream responses can have headers, compute response_headers once and pass it into cls(...) here.
        retval.append("    if cls:")
        retval.append("        return cls(pipeline_response, deserialized, {})  # type: ignore")
        retval.append("    return deserialized")

Comment thread packages/http-client-python/package-lock.json
Comment thread packages/http-client-python/tests/unit/test_streaming_init.py Outdated
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
Copilot AI review requested due to automatic review settings August 18, 2026 19:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_events docstring uses :rtype: AsyncIterator[DecodedType_co], but DecodedType_co is not defined in this template (the type parameter is T_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/AsyncStream are an internal implementation detail and not part of the public API, but the README and generated method signatures explicitly return Stream[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_events docstring uses :rtype: Iterator[DecodedType_co], but DecodedType_co is not defined in this template (the type parameter is T_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_event is derived from a string-literal member of the item union (fallback path), iteration stops before yielding that terminal marker. However, stream_item_type returns 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

@msyyc

Yuchao Yan (msyyc) commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

To fix #5778

Comment thread packages/http-client-python/package.json Outdated
Comment thread packages/http-client-python/tests/unit/test_streaming_init.py Outdated
Comment thread .chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md Outdated
- 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
Copilot AI review requested due to automatic review settings August 19, 2026 16:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_co in the :rtype: but that type variable is not defined (the protocol is parameterized by T_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_co in the :rtype: even though the protocol is parameterized by T_co and no DecodedType_co is 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/AsyncStream from 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 avoid from your_sdk import Stream and 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_lines is an async generator over AsyncIterator[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
Copilot AI review requested due to automatic review settings August 19, 2026 16:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: references DecodedType_co, but that type variable isn’t defined in this module (the Protocol is parameterized by T_co). This is likely a copy/paste typo and can confuse generated docs/type readers; update the rtype to Iterator[T_co] / AsyncIterator[T_co] (or DecodedType if 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/AsyncStream are “not part of the package's public API”, but the README documents importing Stream from 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 no azure.core.streaming runtime dependency is required. Consider rewording this comment to avoid implying that *.streaming is 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/AsyncStream from the base namespace (from your_sdk import Stream), but the generator’s model_init.py.jinja2/rest_init.py.jinja2 templates currently only export clients/models/enums (no Stream/AsyncStream). Either implement the promised re-export in the generated package __init__.py files, 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>

@l0lawrence

Copy link
Copy Markdown
Member Author

Can a terminal event carry data?

@msyyc

Yuchao Yan (msyyc) commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Libba Lawrence (@l0lawrence) I looked into this from three angles:

  1. The @typespec/sse docs define @terminalEvent as: "the presence of this event is a terminal event, and the client should disconnect from the server." The decorator has no parameter, so it marks a union variant as terminal rather than separately defining a stop value.

  2. The @typespec/sse examples and the Spector cases model terminal events as string-literal payloads with text/plain content. For example:

@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 data: payload is the terminal sentinel ([DONE]). The client should use that event to stop iteration and should not yield it as a normal stream item.

  1. The current PR implementation matches that string-sentinel interpretation. The emitter extracts a terminal value only if the terminal event payload/type is a string constant:
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:
    break

So my answer is: yes, terminal events can carry data in the supported Spector sense where the terminal event's data: payload is a string sentinel like [DONE]. What this PR does not currently support is a structured terminal payload, for example:

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 terminal_event marker, so structured terminal payloads are effectively out of scope unless we extend the metadata/runtime behavior.

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
Copilot AI review requested due to automatic review settings August 20, 2026 15:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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[...]) assumes Stream is re-exported from the generated package root, but the generator currently imports Stream/AsyncStream from _utils.streaming_base and the root __init__.py template doesn't re-export these symbols. Either add the intended re-export to the generated package root (and include it in __all__), or update docstring_type to 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/AsyncStream from the base namespace, but the generator templates currently only vendor _utils/streaming_base.py and 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
Copilot AI review requested due to automatic review settings August 20, 2026 20:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • cls is currently applied inside the per-event _callback, which means a user-provided cls will be invoked once per streamed event (and see individual items), rather than once for the operation result. This diverges from the existing cls contract used elsewhere in generated operations and makes it hard/impossible to customize the returned Stream itself.
        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/AsyncStream from 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/AsyncStream are 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 the Stream/AsyncStream surface (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.

Comment on lines 100 to +104
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:client:python Issue for the Python client emitter: @typespec/http-client-python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants