@typespec/http-client-python 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/emitter/emitter.d.ts.map +1 -1
  2. package/dist/emitter/emitter.js +1 -1
  3. package/dist/emitter/emitter.js.map +1 -1
  4. package/dist/emitter/external-process.d.ts +0 -14
  5. package/dist/emitter/external-process.d.ts.map +1 -1
  6. package/dist/emitter/external-process.js +1 -17
  7. package/dist/emitter/external-process.js.map +1 -1
  8. package/dist/emitter/http.d.ts +44 -1
  9. package/dist/emitter/http.d.ts.map +1 -1
  10. package/dist/emitter/http.js +96 -0
  11. package/dist/emitter/http.js.map +1 -1
  12. package/dist/emitter/yaml-utils.d.ts +19 -0
  13. package/dist/emitter/yaml-utils.d.ts.map +1 -0
  14. package/dist/emitter/yaml-utils.js +22 -0
  15. package/dist/emitter/yaml-utils.js.map +1 -0
  16. package/emitter/src/emitter.ts +1 -1
  17. package/emitter/src/external-process.ts +1 -18
  18. package/emitter/src/http.ts +154 -0
  19. package/emitter/src/yaml-utils.ts +22 -0
  20. package/emitter/temp/tsconfig.tsbuildinfo +1 -1
  21. package/emitter/test/streaming.test.ts +155 -0
  22. package/emitter/test/{external-process.test.ts → yaml-utils.test.ts} +2 -2
  23. package/eng/scripts/ci/regenerate-common.ts +1 -5
  24. package/eng/scripts/ci/run_pylint.py +1 -0
  25. package/eng/scripts/setup/__pycache__/package_manager.cpython-311.pyc +0 -0
  26. package/eng/scripts/setup/__pycache__/venvtools.cpython-311.pyc +0 -0
  27. package/generator/build/lib/pygen/codegen/models/code_model.py +22 -8
  28. package/generator/build/lib/pygen/codegen/models/enum_type.py +14 -2
  29. package/generator/build/lib/pygen/codegen/models/operation.py +6 -1
  30. package/generator/build/lib/pygen/codegen/models/response.py +152 -8
  31. package/generator/build/lib/pygen/codegen/serializers/__init__.py +7 -0
  32. package/generator/build/lib/pygen/codegen/serializers/builder_serializer.py +197 -58
  33. package/generator/build/lib/pygen/codegen/serializers/general_serializer.py +4 -0
  34. package/generator/build/lib/pygen/codegen/serializers/import_serializer.py +27 -0
  35. package/generator/build/lib/pygen/codegen/serializers/types_serializer.py +40 -3
  36. package/generator/build/lib/pygen/codegen/templates/streaming_base.py.jinja2 +767 -0
  37. package/generator/build/lib/pygen/preprocess/__init__.py +2 -0
  38. package/generator/component-detection-pip-report.json +5 -5
  39. package/generator/dist/pygen-0.1.0-py3-none-any.whl +0 -0
  40. package/generator/pygen/codegen/models/code_model.py +22 -8
  41. package/generator/pygen/codegen/models/enum_type.py +14 -2
  42. package/generator/pygen/codegen/models/operation.py +6 -1
  43. package/generator/pygen/codegen/models/response.py +152 -8
  44. package/generator/pygen/codegen/serializers/__init__.py +7 -0
  45. package/generator/pygen/codegen/serializers/builder_serializer.py +197 -58
  46. package/generator/pygen/codegen/serializers/general_serializer.py +4 -0
  47. package/generator/pygen/codegen/serializers/import_serializer.py +27 -0
  48. package/generator/pygen/codegen/serializers/types_serializer.py +40 -3
  49. package/generator/pygen/codegen/templates/streaming_base.py.jinja2 +767 -0
  50. package/generator/pygen/preprocess/__init__.py +2 -0
  51. package/generator/pygen.egg-info/SOURCES.txt +1 -0
  52. package/package.json +3 -3
@@ -0,0 +1,155 @@
1
+ import { strictEqual } from "assert";
2
+ import { describe, it } from "vitest";
3
+ import {
4
+ getStructuredStreamKind,
5
+ isStructuredStreamType,
6
+ partitionSSEEvents,
7
+ } from "../src/http.js";
8
+
9
+ describe("typespec-python: structured streaming", () => {
10
+ it("treats model and union payloads as structured", () => {
11
+ strictEqual(isStructuredStreamType({ kind: "model" } as any), true);
12
+ strictEqual(isStructuredStreamType({ kind: "union" } as any), true);
13
+ });
14
+
15
+ it("unwraps nullable payloads", () => {
16
+ strictEqual(isStructuredStreamType({ kind: "nullable", type: { kind: "model" } } as any), true);
17
+ strictEqual(
18
+ isStructuredStreamType({ kind: "nullable", type: { kind: "bytes" } } as any),
19
+ false,
20
+ );
21
+ });
22
+
23
+ it("treats bare byte/string payloads as unstructured", () => {
24
+ strictEqual(isStructuredStreamType({ kind: "bytes" } as any), false);
25
+ strictEqual(isStructuredStreamType({ kind: "string" } as any), false);
26
+ });
27
+
28
+ it("detects the stream protocol explicitly", () => {
29
+ strictEqual(getStructuredStreamKind({ sseMetadata: { events: [] } } as any), "sse");
30
+ strictEqual(
31
+ getStructuredStreamKind({
32
+ streamMetadata: { contentTypes: ["text/event-stream; charset=utf-8"] },
33
+ } as any),
34
+ "sse",
35
+ );
36
+ strictEqual(
37
+ getStructuredStreamKind({
38
+ streamMetadata: { contentTypes: ["application/jsonl"] },
39
+ } as any),
40
+ "jsonl",
41
+ );
42
+ strictEqual(
43
+ getStructuredStreamKind({
44
+ streamMetadata: { contentTypes: ["application/json"] },
45
+ } as any),
46
+ undefined,
47
+ );
48
+ });
49
+
50
+ describe("terminal-event partitioning", () => {
51
+ const identity = (payloadType: any) => payloadType;
52
+ const model = (name: string) => ({ kind: "model", name });
53
+ const constant = (value: string) => ({ kind: "constant", value });
54
+
55
+ it("keeps a nameless string-constant `[DONE]` as a drop-and-stop sentinel", () => {
56
+ const created = model("ResponseCreated");
57
+ const done = constant("[DONE]");
58
+ const { events, terminalEvent } = partitionSSEEvents(
59
+ [
60
+ {
61
+ eventType: "response.created",
62
+ isTerminalEvent: false,
63
+ type: created,
64
+ payloadType: created,
65
+ },
66
+ { eventType: undefined, isTerminalEvent: true, type: done, payloadType: done },
67
+ ] as any,
68
+ identity,
69
+ );
70
+ // The sentinel is NOT a dispatch event; it only sets `terminalEvent`.
71
+ strictEqual(terminalEvent, "[DONE]");
72
+ strictEqual(events.length, 1);
73
+ strictEqual(events[0].eventType, "response.created");
74
+ strictEqual(events[0].isTerminal, undefined);
75
+ });
76
+
77
+ it("keeps named / model `@terminalEvent`s in the dispatch table as yield-and-stop events", () => {
78
+ const created = model("ResponseCreated");
79
+ const completed = model("ResponseCompleted");
80
+ const errored = model("StreamError");
81
+ const { events, terminalEvent } = partitionSSEEvents(
82
+ [
83
+ {
84
+ eventType: "response.created",
85
+ isTerminalEvent: false,
86
+ type: created,
87
+ payloadType: created,
88
+ },
89
+ {
90
+ eventType: "response.completed",
91
+ isTerminalEvent: true,
92
+ type: completed,
93
+ payloadType: completed,
94
+ },
95
+ { eventType: "error", isTerminalEvent: true, type: errored, payloadType: errored },
96
+ ] as any,
97
+ identity,
98
+ );
99
+ // No bare sentinel: the two terminals carry payloads, so they stay in `events`.
100
+ strictEqual(terminalEvent, undefined);
101
+ strictEqual(events.length, 3);
102
+ strictEqual(events[0].isTerminal, undefined);
103
+ strictEqual(events[1].eventType, "response.completed");
104
+ strictEqual(events[1].isTerminal, true);
105
+ strictEqual(events[1].payloadType, completed);
106
+ strictEqual(events[2].eventType, "error");
107
+ strictEqual(events[2].isTerminal, true);
108
+ strictEqual(events[2].payloadType, errored);
109
+ });
110
+
111
+ it("supports a sentinel and named terminals together", () => {
112
+ const delta = model("ResponseDelta");
113
+ const completed = model("ResponseCompleted");
114
+ const done = constant("[DONE]");
115
+ const { events, terminalEvent } = partitionSSEEvents(
116
+ [
117
+ { eventType: "response.delta", isTerminalEvent: false, type: delta, payloadType: delta },
118
+ {
119
+ eventType: "response.completed",
120
+ isTerminalEvent: true,
121
+ type: completed,
122
+ payloadType: completed,
123
+ },
124
+ { eventType: undefined, isTerminalEvent: true, type: done, payloadType: done },
125
+ ] as any,
126
+ identity,
127
+ );
128
+ strictEqual(terminalEvent, "[DONE]");
129
+ strictEqual(events.length, 2);
130
+ strictEqual(events[0].isTerminal, undefined);
131
+ strictEqual(events[1].eventType, "response.completed");
132
+ strictEqual(events[1].isTerminal, true);
133
+ });
134
+
135
+ it("emits the payload metadata for event envelopes", () => {
136
+ const envelope = model("Envelope");
137
+ const payload = { kind: "string" };
138
+ const { events } = partitionSSEEvents(
139
+ [
140
+ {
141
+ eventType: "withEnvelope",
142
+ isTerminalEvent: false,
143
+ type: envelope,
144
+ payloadType: payload,
145
+ isEventEnvelope: true,
146
+ payloadContentType: "text/plain",
147
+ },
148
+ ] as any,
149
+ identity,
150
+ );
151
+ strictEqual(events[0].payloadType, payload);
152
+ strictEqual(events[0].payloadContentType, "text/plain");
153
+ });
154
+ });
155
+ });
@@ -1,9 +1,9 @@
1
1
  import { ok, strictEqual } from "assert";
2
2
  import { load } from "js-yaml";
3
3
  import { describe, it } from "vitest";
4
- import { dumpCodeModelToYaml } from "../src/external-process.js";
4
+ import { dumpCodeModelToYaml } from "../src/yaml-utils.js";
5
5
 
6
- describe("typespec-python: external-process", () => {
6
+ describe("typespec-python: yaml-utils", () => {
7
7
  // The Python generator parses the emitted YAML with PyYAML (YAML 1.1), where a plain
8
8
  // scalar such as `2020_01_01` is interpreted as the integer `20200101`. js-yaml dumps
9
9
  // using YAML 1.2 rules and would otherwise leave such string scalars unquoted, so we
@@ -69,11 +69,7 @@ export interface BuildTaskGroupsOptions {
69
69
 
70
70
  // ---- Public constants ----
71
71
 
72
- export const SKIP_SPECS: string[] = [
73
- "type/file",
74
- // LroPaging.postPagingLroWithBody generates begin_post_paging_lro_with_body with pylint R0915 (52/50).
75
- "azure/resource-manager/operation-templates",
76
- ];
72
+ export const SKIP_SPECS: string[] = ["type/file"];
77
73
 
78
74
  export const SpecialFlags: Record<string, Record<string, any>> = {
79
75
  azure: {
@@ -52,6 +52,7 @@ def _single_dir_pylint(mod):
52
52
  # narrow as possible: only this package, only the offending check.
53
53
  per_package_disables = {
54
54
  "azure-client-generator-core-exact-name": ["client-incorrect-naming-convention"],
55
+ "azure-resource-manager-operation-templates": ["too-many-statements"],
55
56
  }
56
57
  extra_disables = per_package_disables.get(mod.name)
57
58
  if extra_disables:
@@ -279,12 +279,22 @@ class CodeModel: # pylint: disable=too-many-public-methods, disable=too-many-in
279
279
  self.need_utils_utils(async_mode, client_namespace)
280
280
  or self.need_utils_serialization
281
281
  or self.options["models-mode"] == "dpg"
282
+ or self.has_structured_stream
282
283
  )
283
284
 
284
285
  @property
285
286
  def need_utils_serialization(self) -> bool:
286
287
  return not self.options["client-side-validation"]
287
288
 
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
+
288
298
  def need_utils_utils(self, async_mode: bool, client_namespace: str) -> bool:
289
299
  return (
290
300
  self.need_utils_form_data(async_mode, client_namespace)
@@ -395,22 +405,26 @@ class CodeModel: # pylint: disable=too-many-public-methods, disable=too-many-in
395
405
  def _sort_model_types_helper(
396
406
  self,
397
407
  current: ModelType,
398
- seen_schema_names: set[str],
408
+ seen_schema_keys: set[tuple[str, str]],
399
409
  seen_schema_yaml_ids: set[int],
400
410
  ):
401
411
  if current.id in seen_schema_yaml_ids:
402
412
  return []
403
- if current.name in seen_schema_names:
404
- raise ValueError(f"We have already generated a schema with name {current.name}")
413
+ current_schema_key = (current.client_namespace, current.name)
414
+ if current_schema_key in seen_schema_keys:
415
+ raise ValueError(
416
+ f"We have already generated a schema with name {current.name} "
417
+ f"in namespace {current.client_namespace}"
418
+ )
405
419
  ancestors = [current]
406
420
  if current.parents:
407
421
  for parent in current.parents:
408
422
  if parent.id in seen_schema_yaml_ids:
409
423
  continue
410
- seen_schema_names.add(current.name)
424
+ seen_schema_keys.add(current_schema_key)
411
425
  seen_schema_yaml_ids.add(current.id)
412
- ancestors = self._sort_model_types_helper(parent, seen_schema_names, seen_schema_yaml_ids) + ancestors
413
- seen_schema_names.add(current.name)
426
+ ancestors = self._sort_model_types_helper(parent, seen_schema_keys, seen_schema_yaml_ids) + ancestors
427
+ seen_schema_keys.add(current_schema_key)
414
428
  seen_schema_yaml_ids.add(current.id)
415
429
  return ancestors
416
430
 
@@ -420,11 +434,11 @@ class CodeModel: # pylint: disable=too-many-public-methods, disable=too-many-in
420
434
  :return: None
421
435
  :rtype: None
422
436
  """
423
- seen_schema_names: set[str] = set()
437
+ seen_schema_keys: set[tuple[str, str]] = set()
424
438
  seen_schema_yaml_ids: set[int] = set()
425
439
  sorted_object_schemas: list[ModelType] = []
426
440
  for schema in sorted(self.model_types, key=lambda x: x.name.lower()):
427
- sorted_object_schemas.extend(self._sort_model_types_helper(schema, seen_schema_names, seen_schema_yaml_ids))
441
+ sorted_object_schemas.extend(self._sort_model_types_helper(schema, seen_schema_keys, seen_schema_yaml_ids))
428
442
  self.model_types = sorted_object_schemas
429
443
 
430
444
  @property
@@ -203,6 +203,10 @@ class EnumType(BaseType):
203
203
  model_alias = self.code_model.get_unique_models_alias(serialize_namespace, self.client_namespace)
204
204
  module_name = f"{model_alias}."
205
205
  file_name = f"{self.code_model.enums_filename}." if self.internal else ""
206
+ if serialize_namespace_type == NamespaceType.TYPES_FILE:
207
+ # In types.py the enum symbol is imported directly (bare name), so no ``_enums.``
208
+ # module prefix even for internal enums — the prefix would be an undefined name.
209
+ file_name = ""
206
210
  model_name = module_name + file_name + self.name
207
211
  # we don't need quoted annotation in operation files, and need it in model folder files.
208
212
  if not kwargs.get("is_operation_file", False):
@@ -305,9 +309,17 @@ class EnumType(BaseType):
305
309
  typing_section=TypingSection.REGULAR,
306
310
  )
307
311
  elif serialize_namespace_type == NamespaceType.TYPES_FILE:
308
- # Import enum name directly to avoid dotted forward refs in TypedDict annotations
312
+ # Import the enum symbol directly to avoid dotted forward refs in TypedDict
313
+ # annotations. Internal enums are not re-exported from the public ``models``
314
+ # package, so import them from the private ``_enums`` submodule instead — the
315
+ # bare-symbol import (rather than the ``_enums`` module) also avoids name
316
+ # collisions when internal enums come from several sibling namespaces.
317
+ module_name = f"models.{self.code_model.enums_filename}" if self.internal else "models"
318
+ enums_module = self.code_model.get_relative_import_path(
319
+ serialize_namespace, self.client_namespace, module_name=module_name
320
+ )
309
321
  file_import.add_submodule_import(
310
- f"{relative_path}models" if relative_path != "." else ".models",
322
+ enums_module,
311
323
  self.name,
312
324
  ImportType.LOCAL,
313
325
  typing_section=TypingSection.TYPING,
@@ -104,6 +104,11 @@ class OperationBase( # pylint: disable=too-many-public-methods,too-many-instanc
104
104
  else self.has_stream_response
105
105
  )
106
106
 
107
+ @property
108
+ def has_structured_stream_response(self) -> bool:
109
+ """Whether any success response is a structured (JSONL / SSE) stream returning Stream[T]."""
110
+ return any(getattr(r, "is_structured_stream", False) for r in self.responses)
111
+
107
112
  @property
108
113
  def has_form_data_body(self):
109
114
  return self.parameters.has_form_data_body
@@ -506,7 +511,7 @@ class OperationBase( # pylint: disable=too-many-public-methods,too-many-instanc
506
511
 
507
512
  @property
508
513
  def has_stream_response(self) -> bool:
509
- return any(r.is_stream_response for r in self.responses)
514
+ return any(r.is_stream_response or r.is_structured_stream for r in self.responses)
510
515
 
511
516
  @classmethod
512
517
  def get_request_builder(cls, yaml_data: dict[str, Any], client: "Client"):
@@ -3,6 +3,7 @@
3
3
  # Licensed under the MIT License. See License.txt in the project root for
4
4
  # license information.
5
5
  # --------------------------------------------------------------------------
6
+ from dataclasses import dataclass
6
7
  from typing import Optional, Any, TYPE_CHECKING, Union
7
8
 
8
9
  from .base import BaseModel
@@ -17,6 +18,53 @@ from .combined_type import CombinedType
17
18
  if TYPE_CHECKING:
18
19
  from .code_model import CodeModel
19
20
 
21
+ DEFAULT_SSE_EVENT_TYPE = "message"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class StreamingEvent:
26
+ event_type: Optional[str]
27
+ payload_type: BaseType
28
+ payload_content_type: Optional[str]
29
+ is_terminal: bool
30
+
31
+
32
+ def get_streaming_event_discriminator(
33
+ events: list[StreamingEvent],
34
+ ) -> Optional[tuple[str, list[tuple[str, StreamingEvent]]]]:
35
+ discriminator_name: Optional[str] = None
36
+ variants: list[tuple[str, StreamingEvent]] = []
37
+ values: set[str] = set()
38
+ for event in events:
39
+ discriminator_property = getattr(event.payload_type, "discriminator_property", None)
40
+ discriminator_value = getattr(event.payload_type, "discriminator_value", None)
41
+ if discriminator_property is None or discriminator_value is None:
42
+ return None
43
+ if discriminator_name is None:
44
+ discriminator_name = discriminator_property.wire_name
45
+ elif discriminator_property.wire_name != discriminator_name:
46
+ return None
47
+ if discriminator_value in values:
48
+ return None
49
+ values.add(discriminator_value)
50
+ variants.append((discriminator_value, event))
51
+ if discriminator_name is None:
52
+ return None
53
+ return discriminator_name, variants
54
+
55
+
56
+ def _get_terminal_event_names(events: list[StreamingEvent]) -> list[str]:
57
+ unnamed_events = [event for event in events if event.event_type is None]
58
+ terminal_event_names: list[str] = []
59
+ for event in events:
60
+ if not event.is_terminal:
61
+ continue
62
+ if event.event_type is not None:
63
+ terminal_event_names.append(event.event_type)
64
+ elif len(unnamed_events) == 1:
65
+ terminal_event_names.append(DEFAULT_SSE_EVENT_TYPE)
66
+ return list(dict.fromkeys(terminal_event_names))
67
+
20
68
 
21
69
  class ResponseHeader(BaseModel):
22
70
  def __init__(
@@ -58,6 +106,25 @@ class Response(BaseModel):
58
106
  self.type = type
59
107
  self.nullable = yaml_data.get("nullable")
60
108
  self.default_content_type = yaml_data.get("defaultContentType")
109
+ streaming = yaml_data.get("streaming")
110
+ self.streaming_kind: Optional[str] = streaming["kind"] if streaming else None
111
+ self.streaming_events: list[StreamingEvent] = []
112
+ self.terminal_event: Optional[str] = streaming.get("terminalEvent") if streaming else None
113
+ # Named / model ``@terminalEvent`` events: deserialized and yielded like any other event,
114
+ # then iteration stops. The bare string-constant sentinel (``terminal_event``)
115
+ # stops WITHOUT yielding and is matched on event ``data`` instead of the event name.
116
+ self.terminal_event_names: list[str] = []
117
+ if streaming:
118
+ self.streaming_events = [
119
+ StreamingEvent(
120
+ event_type=event.get("eventType"),
121
+ payload_type=self.code_model.lookup_type(id(event["payloadType"])),
122
+ payload_content_type=event.get("payloadContentType"),
123
+ is_terminal=event.get("isTerminal", False),
124
+ )
125
+ for event in streaming.get("events", [])
126
+ ]
127
+ self.terminal_event_names = _get_terminal_event_names(self.streaming_events)
61
128
 
62
129
  @property
63
130
  def result_property(self) -> str:
@@ -92,12 +159,48 @@ class Response(BaseModel):
92
159
  )
93
160
  return retval
94
161
 
162
+ @property
163
+ def is_structured_stream(self) -> bool:
164
+ """Is the response a structured (JSONL / SSE) stream rendered as Stream[T] / AsyncStream[T]."""
165
+ return self.streaming_kind is not None
166
+
167
+ def stream_class_name(self, async_mode: bool) -> str:
168
+ return "AsyncStream" if async_mode else "Stream"
169
+
170
+ @property
171
+ def stream_item_type(self) -> Optional[BaseType]:
172
+ event_item_types = list(dict.fromkeys(event.payload_type for event in self.streaming_events))
173
+ if len(event_item_types) == 1:
174
+ return event_item_types[0]
175
+ if event_item_types:
176
+ return CombinedType({"type": "combined"}, self.code_model, event_item_types)
177
+ return self.type
178
+
95
179
  def serialization_type(self, **kwargs: Any) -> str:
96
180
  if self.type:
97
181
  return self.type.serialization_type(**kwargs)
98
182
  return "None"
99
183
 
184
+ def stream_item_annotation(self, **kwargs: Any) -> str:
185
+ """Valid type expression for a structured stream's item type.
186
+
187
+ A named ``CombinedType`` (``@events`` union) renders its ``type_annotation`` as the
188
+ ``_unions.<Name>`` alias, which is a module-level variable and therefore rejected by
189
+ pyright/mypy inside ``Stream[...]`` ("Variable not allowed in type expression"). Expand
190
+ the union inline (``Union[Model, ...]`` / the single member) so the annotation is a
191
+ valid type expression.
192
+ """
193
+ item_type = self.stream_item_type
194
+ if isinstance(item_type, CombinedType):
195
+ return item_type.type_definition(**kwargs)
196
+ return item_type.type_annotation(**kwargs) if item_type else "None"
197
+
100
198
  def type_annotation(self, **kwargs: Any) -> str:
199
+ if self.is_structured_stream and self.type:
200
+ kwargs["is_operation_file"] = True
201
+ kwargs["is_response"] = True
202
+ stream_class = self.stream_class_name(kwargs.get("async_mode", False))
203
+ return f"{stream_class}[{self.stream_item_annotation(**kwargs)}]"
101
204
  if self.type:
102
205
  kwargs["is_operation_file"] = True
103
206
  kwargs["is_response"] = True
@@ -109,30 +212,63 @@ class Response(BaseModel):
109
212
 
110
213
  def docstring_text(self, **kwargs: Any) -> str:
111
214
  kwargs["is_response"] = True
215
+ if self.is_structured_stream and self.type:
216
+ stream_class = self.stream_class_name(kwargs.get("async_mode", False))
217
+ item_type = self.stream_item_type or self.type
218
+ return f"An instance of {stream_class} that iterates over {item_type.docstring_text(**kwargs)}"
112
219
  if self.nullable and self.type:
113
220
  return f"{self.type.docstring_text(**kwargs)} or None"
114
221
  return self.type.docstring_text(**kwargs) if self.type else "None"
115
222
 
116
223
  def docstring_type(self, **kwargs: Any) -> str:
117
224
  kwargs["is_response"] = True
225
+ if self.is_structured_stream and self.type:
226
+ stream_class = self.stream_class_name(kwargs.get("async_mode", False))
227
+ item_type = (self.stream_item_type or self.type).docstring_type(**kwargs)
228
+ return f"~{self.code_model.namespace}.{stream_class}[{item_type}]"
118
229
  if self.nullable and self.type:
119
230
  return f"{self.type.docstring_type(**kwargs)} or None"
120
231
  return self.type.docstring_type(**kwargs) if self.type else "None"
121
232
 
122
233
  def imports(self, **kwargs: Any) -> FileImport:
123
234
  file_import = FileImport(self.code_model)
124
- if self.type:
125
- file_import.merge(self.type.imports(**kwargs))
235
+ item_type = self.stream_item_type if self.is_structured_stream else self.type
236
+ # For a structured stream whose item type is a named ``@events`` union, the annotation
237
+ # is expanded inline (see ``stream_item_annotation``), so import the union member types
238
+ # rather than the ``_unions`` alias.
239
+ if self.is_structured_stream and isinstance(item_type, CombinedType):
240
+ for member in item_type.types:
241
+ file_import.merge(member.imports(**kwargs))
242
+ # ``Union`` is only needed when the inline expansion actually yields a union of
243
+ # 2+ distinct member types (a single member collapses to that member; a union of
244
+ # only literals collapses to a single ``Literal[...]``).
245
+ distinct = list(dict.fromkeys(m.type_annotation(**kwargs) for m in item_type.types))
246
+ all_constant = all(t.type == "constant" for t in item_type.types)
247
+ if len(distinct) > 1 and not all_constant:
248
+ file_import.add_submodule_import("typing", "Union", ImportType.STDLIB)
249
+ elif item_type:
250
+ file_import.merge(item_type.imports(**kwargs))
251
+ if not self.is_structured_stream and isinstance(item_type, CombinedType) and item_type.name:
252
+ serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace)
253
+ file_import.add_submodule_import(
254
+ self.code_model.get_relative_import_path(serialize_namespace),
255
+ "_unions",
256
+ ImportType.LOCAL,
257
+ TypingSection.TYPING,
258
+ )
126
259
  if self.nullable:
127
260
  file_import.add_submodule_import("typing", "Optional", ImportType.STDLIB)
128
- if isinstance(self.type, CombinedType) and self.type.name:
261
+ if self.is_structured_stream:
262
+ stream_class = self.stream_class_name(kwargs.get("async_mode", False))
129
263
  serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace)
130
- file_import.add_submodule_import(
131
- self.code_model.get_relative_import_path(serialize_namespace),
132
- "_unions",
133
- ImportType.LOCAL,
134
- TypingSection.TYPING,
264
+ relative_path = self.code_model.get_relative_import_path(
265
+ serialize_namespace, module_name="_utils.streaming_base"
135
266
  )
267
+ file_import.add_submodule_import(relative_path, stream_class, ImportType.LOCAL)
268
+ if self.streaming_kind == "sse":
269
+ file_import.add_import("json", ImportType.STDLIB)
270
+ for event in self.streaming_events:
271
+ file_import.merge(event.payload_type.imports(**kwargs))
136
272
  return file_import
137
273
 
138
274
  def _get_import_type(self, input_path: str) -> ImportType:
@@ -143,6 +279,14 @@ class Response(BaseModel):
143
279
 
144
280
  @classmethod
145
281
  def from_yaml(cls, yaml_data: dict[str, Any], code_model: "CodeModel") -> "Response":
282
+ streaming = yaml_data.get("streaming")
283
+ if streaming:
284
+ return cls(
285
+ yaml_data=yaml_data,
286
+ code_model=code_model,
287
+ headers=[ResponseHeader.from_yaml(header, code_model) for header in yaml_data["headers"]],
288
+ type=code_model.lookup_type(id(streaming["itemType"])),
289
+ )
146
290
  type = code_model.lookup_type(id(yaml_data["type"])) if yaml_data.get("type") else None
147
291
  # use ByteIteratorType if we are returning a binary type
148
292
  default_content_type = yaml_data.get("defaultContentType", "application/json")
@@ -525,6 +525,13 @@ class JinjaSerializer(ReaderAndWriter):
525
525
  general_serializer.serialize_model_base_file(),
526
526
  )
527
527
 
528
+ # write _utils/streaming_base.py (vendored Stream/AsyncStream + JSONL/SSE decoders)
529
+ if self.code_model.has_structured_stream:
530
+ self.write_file(
531
+ utils_folder_path / Path("streaming_base.py"),
532
+ general_serializer.serialize_streaming_base_file(),
533
+ )
534
+
528
535
  def _serialize_and_write_top_level_folder(self, env: Environment, namespace: str) -> None:
529
536
  root_dir = self.code_model.get_root_dir()
530
537
  generation_dir = self.code_model.get_generation_dir(namespace)