Skip to content

Commit d93a175

Browse files
committed
feat(http-client-python): generate structured JSONL and SSE streams
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e57edafe-9764-4b99-a1ae-0efd56e8729e
1 parent d914a19 commit d93a175

15 files changed

Lines changed: 1230 additions & 253 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@typespec/http-client-python"
5+
---
6+
7+
Generate structured streaming client methods for the **Azure flavor**: 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.
8+
9+
10+
The `Stream` / `AsyncStream` runtime (plus the JSONL / SSE decoders) is vendored into the generated package at `_utils/streaming_base.py` (like `_utils/model_base.py`), so it depends only on the released `azure.core.rest` — not on an unreleased `azure.core.streaming`.
11+
12+
```python
13+
# For an operation returning JsonlStream<Thing> (Azure flavor):
14+
stream = client.receive() # -> Stream[Thing]
15+
for thing in stream: # deserialized model instances
16+
...
17+
```

cspell.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ dictionaries:
77
words:
88
- Ablack
99
- Adoptium
10+
- aenter
11+
- aexit
1012
- agentic
1113
- agentics
1214
- aiohttp
15+
- aiter
1316
- alzimmer
1417
- amqp
18+
- anext
1519
- AQID
1620
- Arize
1721
- arizeaiobservabilityeval
@@ -120,6 +124,7 @@ words:
120124
- intrinsics
121125
- ints
122126
- IOHTTP
127+
- isascii
123128
- isdigit
124129
- isinstance
125130
- issecret

packages/http-client-python/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,20 @@ Whether to clear the output folder before generating the code. Defaults to `fals
153153
**Type:** `boolean`
154154

155155
Emit YAML code model only, without running Python generator. For batch processing.
156+
157+
## Structured streaming (JSONL / SSE)
158+
159+
For the **Azure flavor**, 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. For the unbranded flavor, streaming responses keep the existing raw byte-iterator behavior (`Iterator[bytes]` / `AsyncIterator[bytes]`).
160+
161+
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.
162+
163+
```python
164+
# For an operation returning JsonlStream<Thing> (Azure flavor):
165+
stream = client.receive() # -> Stream[Thing]
166+
for thing in stream: # deserialized model instances
167+
...
168+
```
169+
170+
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 `azure.core.rest`, so no unreleased `azure.core.streaming` dependency is required at runtime.
171+
172+
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.

packages/http-client-python/emitter/src/http.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,88 @@ export enum ReferredByOperationTypes {
4242
NonPagingOnly = 2,
4343
}
4444

45+
/**
46+
* Determine whether a stream's payload type is "structured" (a model or union
47+
* that can be deserialized into an item `T`), as opposed to a bare byte/string
48+
* stream that should keep the existing raw byte-iterator behavior.
49+
*/
50+
export function isStructuredStreamType(type: SdkType): boolean {
51+
switch (type.kind) {
52+
case "model":
53+
case "union":
54+
return true;
55+
case "nullable":
56+
return isStructuredStreamType(type.type);
57+
default:
58+
return false;
59+
}
60+
}
61+
62+
export function getStructuredStreamKind(
63+
response: SdkHttpResponse | SdkHttpErrorResponse,
64+
): "jsonl" | "sse" | undefined {
65+
if (response.sseMetadata) return "sse";
66+
67+
const contentTypes = response.streamMetadata?.contentTypes ?? response.contentTypes ?? [];
68+
for (const contentType of contentTypes) {
69+
const mediaType = contentType.split(";", 1)[0].trim().toLowerCase();
70+
if (mediaType === "text/event-stream") return "sse";
71+
if (mediaType === "application/jsonl") return "jsonl";
72+
}
73+
return undefined;
74+
}
75+
76+
function getStringConstantValue(type: SdkType): string | undefined {
77+
if (type.kind === "nullable") return getStringConstantValue(type.type);
78+
return type.kind === "constant" && typeof type.value === "string" ? type.value : undefined;
79+
}
80+
81+
/**
82+
* Build the `streaming` block for a response YAML when the response is a JSONL/SSE
83+
* stream with a structured payload type (driven by the TCGC stream metadata).
84+
*
85+
* Returns `undefined` when structured streaming should not apply, in which case
86+
* the existing raw byte-iterator behavior is preserved.
87+
*
88+
* For SSE, TCGC `sseMetadata` supplies each event's wire name, payload type, and
89+
* terminal marker. JSONL only needs the common stream item type.
90+
*/
91+
function getStreamingInfo(
92+
context: PythonSdkContext,
93+
response: SdkHttpResponse | SdkHttpErrorResponse,
94+
): Record<string, any> | undefined {
95+
const streamMetadata = response.streamMetadata;
96+
if (!streamMetadata) return undefined;
97+
if (!isStructuredStreamType(streamMetadata.streamType)) return undefined;
98+
const kind = getStructuredStreamKind(response);
99+
if (!kind) return undefined;
100+
101+
const streaming: Record<string, any> = {
102+
kind,
103+
itemType: getType(context, streamMetadata.streamType),
104+
};
105+
106+
if (kind === "sse" && response.sseMetadata) {
107+
const events: Record<string, any>[] = [];
108+
let terminalEvent: string | undefined;
109+
for (const event of response.sseMetadata.events) {
110+
if (event.isTerminalEvent) {
111+
terminalEvent =
112+
getStringConstantValue(event.payloadType) ?? getStringConstantValue(event.type);
113+
} else {
114+
events.push({
115+
eventType: event.eventType,
116+
itemType: getType(context, event.payloadType),
117+
});
118+
}
119+
}
120+
if (events.length > 0) streaming.events = events;
121+
if (terminalEvent !== undefined) streaming.terminalEvent = terminalEvent;
122+
}
123+
124+
return streaming;
125+
}
126+
45127
function isEtagType(type: SdkType): boolean {
46128
if (type.kind === "nullable") return isEtagType(type.type);
47129
const raw = type.__raw;
@@ -682,6 +764,7 @@ function emitHttpResponse(
682764
"invalid-lro-result",
683765
method,
684766
),
767+
streaming: isException ? undefined : getStreamingInfo(context, response),
685768
};
686769
}
687770

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { strictEqual } from "assert";
2+
import { describe, it } from "vitest";
3+
import { getStructuredStreamKind, isStructuredStreamType } from "../src/http.js";
4+
5+
describe("typespec-python: structured streaming", () => {
6+
it("treats model and union payloads as structured", () => {
7+
strictEqual(isStructuredStreamType({ kind: "model" } as any), true);
8+
strictEqual(isStructuredStreamType({ kind: "union" } as any), true);
9+
});
10+
11+
it("unwraps nullable payloads", () => {
12+
strictEqual(isStructuredStreamType({ kind: "nullable", type: { kind: "model" } } as any), true);
13+
strictEqual(
14+
isStructuredStreamType({ kind: "nullable", type: { kind: "bytes" } } as any),
15+
false,
16+
);
17+
});
18+
19+
it("treats bare byte/string payloads as unstructured", () => {
20+
strictEqual(isStructuredStreamType({ kind: "bytes" } as any), false);
21+
strictEqual(isStructuredStreamType({ kind: "string" } as any), false);
22+
});
23+
24+
it("detects the stream protocol explicitly", () => {
25+
strictEqual(getStructuredStreamKind({ sseMetadata: { events: [] } } as any), "sse");
26+
strictEqual(
27+
getStructuredStreamKind({
28+
streamMetadata: { contentTypes: ["text/event-stream; charset=utf-8"] },
29+
} as any),
30+
"sse",
31+
);
32+
strictEqual(
33+
getStructuredStreamKind({
34+
streamMetadata: { contentTypes: ["application/jsonl"] },
35+
} as any),
36+
"jsonl",
37+
);
38+
strictEqual(
39+
getStructuredStreamKind({
40+
streamMetadata: { contentTypes: ["application/json"] },
41+
} as any),
42+
undefined,
43+
);
44+
});
45+
});

packages/http-client-python/generator/pygen/codegen/models/code_model.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,12 +279,29 @@ def need_utils_folder(self, async_mode: bool, client_namespace: str) -> bool:
279279
self.need_utils_utils(async_mode, client_namespace)
280280
or self.need_utils_serialization
281281
or self.options["models-mode"] == "dpg"
282+
or self.need_streaming_base
282283
)
283284

284285
@property
285286
def need_utils_serialization(self) -> bool:
286287
return not self.options["client-side-validation"]
287288

289+
@property
290+
def has_structured_stream(self) -> bool:
291+
return any(
292+
op.has_structured_stream_response
293+
for client in self.clients
294+
for og in client.operation_groups
295+
for op in og.operations
296+
)
297+
298+
@property
299+
def need_streaming_base(self) -> bool:
300+
# Whether to emit the vendored ``_utils/streaming_base.py`` (Stream / AsyncStream
301+
# + JSONL / SSE decoders). Only needed when at least one operation returns a
302+
# structured stream.
303+
return self.has_structured_stream
304+
288305
def need_utils_utils(self, async_mode: bool, client_namespace: str) -> bool:
289306
return (
290307
self.need_utils_form_data(async_mode, client_namespace)

packages/http-client-python/generator/pygen/codegen/models/operation.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,21 @@ def exact_name_params(self) -> set[str]:
9898

9999
@property
100100
def stream_value(self) -> Union[str, bool]:
101+
# Structured streams (JSONL / SSE) must always run the pipeline with
102+
# stream=True so the body can be consumed incrementally by Stream/AsyncStream.
103+
if self.has_structured_stream_response:
104+
return True
101105
return (
102106
f'kwargs.pop("stream", {self.has_stream_response})'
103107
if self.expose_stream_keyword and self.has_response_body and "stream" not in self.exact_name_params
104108
else self.has_stream_response
105109
)
106110

111+
@property
112+
def has_structured_stream_response(self) -> bool:
113+
"""Whether any success response is a structured (JSONL / SSE) stream returning Stream[T]."""
114+
return any(getattr(r, "is_structured_stream", False) for r in self.responses)
115+
107116
@property
108117
def has_form_data_body(self):
109118
return self.parameters.has_form_data_body

0 commit comments

Comments
 (0)