Skip to content

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

Merged
iscai-msft merged 36 commits into
mainfrom
l0lawrence-jsonl-sse-streaming-codegen
Aug 27, 2026
Merged

feat(http-client-python): generate structured JSONL/SSE streaming#11594
iscai-msft merged 36 commits into
mainfrom
l0lawrence-jsonl-sse-streaming-codegen

Conversation

@l0lawrence

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

Copy link
Copy Markdown
Member
  • Generate Azure-flavor client methods that return Stream[T] / AsyncStream[T] for JSONL (application/jsonl) and SSE (text/event-stream) response streams
stream = client.receive()  # Stream[Thing]
for thing in stream:
    ...

Supports this spec

Missing support for : #11761

QUESTIONS/NOTES FOR NOW OR FUTURE REF:

  • handling multiple unamed events correctly -- supporting @discriminator to figure out which event is which
  • handling @data (w/ w/o envelopes correctly -- python is just returning data aka Stream[T] not Stream[SSEEvent[T]] (like paging) so Stream[@data payload])
  • decoding payload content-type correctly -- use of isEventEnvelope*? -- don't currently use for python b/c of above design
  • Currently not supporting automatic reconnect (last-event-id can be passed in by user if needed, but @retry not used - both should be exposed on Stream) -- [http-client-python] Add SSE reconnection #11761
  • terminal events yielding -- [DONE] vs a terminal event that is a model and how we handle that /yield them or not

EXAMPLES:

What search retrieveStream() looks like:

image

Stream(response=response, deserialization_callback=_callback, terminal_event_names=["error", "response.completed"])

What search retrieveStream() output looks like:

the sample:

image

the output:

image

@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: 30fc41c

@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:,> ...,> ,> ,> For SSE streams, the most recently received event id and retry value (if provided by the server) are exposed via stream.last_event_id / stream.retry. Event envelopes yield the @Events.data payload using its payload type and content type. JSON payload media types (application/json and +json) are decoded as JSON, while other SSE payload media types remain UTF-8 text for type-specific deserialization. Pass last_event_id= to an SSE operation to send Last-Event-ID when manually resuming a 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:210f84c0dee6cfb8226ce95700562c9bf0922822 vs this PR.

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.

@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

@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
Comment thread packages/http-client-python/generator/pygen/codegen/models/operation.py Outdated
…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
…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
Copilot AI review requested due to automatic review settings August 24, 2026 17:52
@l0lawrence
Libba Lawrence (l0lawrence) marked this pull request as draft August 24, 2026 17:54
…-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

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 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() closes self._iterator, which triggers the generator's finally in _iter_results and closes the underlying response, then Stream.close() closes self._response again. This double-close can be unsafe if HttpResponse.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() awaits self._iterator.aclose(), which will run the async generator finally in _iter_results and close the underlying response, then AsyncStream.close() closes self._response again. This double-close can be unsafe if AsyncHttpResponse.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 by Stream/AsyncStream when an event with at least one data: line is dispatched. If a server sends retry: before the first event (or in a metadata-only block), _SSEEventBuilder._dispatch() returns None and 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

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

can you send me the link for the generated code diff? not sure where to see it. thanks!

Comment thread packages/http-client-python/emitter/src/http.ts Outdated
Comment thread packages/http-client-python/emitter/src/http.ts Outdated
Comment thread packages/http-client-python/generator/pygen/codegen/models/response.py Outdated
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
@l0lawrence
Libba Lawrence (l0lawrence) marked this pull request as ready for review August 25, 2026 22:29
Copilot AI review requested due to automatic review settings August 25, 2026 22:29

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 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_section checks overload_s.get("streaming"), which will skip copying the original streaming section 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__.py only 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's finally) and then closes the response again. This results in a double-close of the same AsyncHttpResponse.
    async def close(self) -> None:
        try:
            await self._iterator.aclose()
        finally:
            await self._response.close()

Copilot AI review requested due to automatic review settings August 25, 2026 22: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 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 by T_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 by T_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's finally) and then calls response.close() again. That double-close can be problematic if the underlying HttpResponse.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()

@iscai-msft
iscai-msft added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit a0b0ee0 Aug 27, 2026
42 of 43 checks passed
@iscai-msft
iscai-msft deleted the l0lawrence-jsonl-sse-streaming-codegen branch August 27, 2026 17:47
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