From 294b0c57a7b2473422f1559d0298af5c15667406 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 7 Aug 2026 10:58:36 -0700 Subject: [PATCH 01/29] 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 --- .../changes/structured-streaming-2026-0-0.md | 17 + cspell.yaml | 5 + packages/http-client-python/README.md | 21 + .../http-client-python/emitter/src/http.ts | 85 +++ .../emitter/test/streaming.test.ts | 45 ++ .../pygen/codegen/models/code_model.py | 17 + .../pygen/codegen/models/operation.py | 9 + .../pygen/codegen/models/response.py | 119 +++- .../pygen/codegen/serializers/__init__.py | 7 + .../codegen/serializers/builder_serializer.py | 91 ++- .../codegen/serializers/general_serializer.py | 17 + .../pygen/codegen/templates/init.py.jinja2 | 9 + .../templates/streaming_base.py.jinja2 | 562 ++++++++++++++++++ .../generator/pygen/preprocess/__init__.py | 2 + packages/http-client-python/package-lock.json | 482 ++++++++------- packages/http-client-python/package.json | 23 +- .../tests/unit/test_streaming_init.py | 57 ++ 17 files changed, 1315 insertions(+), 253 deletions(-) create mode 100644 .chronus/changes/structured-streaming-2026-0-0.md create mode 100644 packages/http-client-python/emitter/test/streaming.test.ts create mode 100644 packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 create mode 100644 packages/http-client-python/tests/unit/test_streaming_init.py diff --git a/.chronus/changes/structured-streaming-2026-0-0.md b/.chronus/changes/structured-streaming-2026-0-0.md new file mode 100644 index 00000000000..af07b4d5cb9 --- /dev/null +++ b/.chronus/changes/structured-streaming-2026-0-0.md @@ -0,0 +1,17 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client-python" +--- + +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. + +`Stream` and `AsyncStream` are available from the generated package's base namespace. Their runtime (plus the JSONL / SSE decoders) is vendored at `_utils/streaming_base.py`, so it depends only on the released `azure.core.rest`. + +```python +from your_sdk import Stream + +stream: Stream[Thing] = client.receive() +for thing in stream: + ... +``` diff --git a/cspell.yaml b/cspell.yaml index 4fdbe6d4b2f..6273e27f277 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -7,11 +7,15 @@ dictionaries: words: - Ablack - Adoptium + - aenter + - aexit - agentic - agentics - aiohttp + - aiter - alzimmer - amqp + - anext - AQID - Arize - arizeaiobservabilityeval @@ -120,6 +124,7 @@ words: - intrinsics - ints - IOHTTP + - isascii - isdigit - isinstance - issecret diff --git a/packages/http-client-python/README.md b/packages/http-client-python/README.md index 0c3335df5fa..6e5a6df1cd8 100644 --- a/packages/http-client-python/README.md +++ b/packages/http-client-python/README.md @@ -153,3 +153,24 @@ Whether to clear the output folder before generating the code. Defaults to `fals **Type:** `boolean` Emit YAML code model only, without running Python generator. For batch processing. + +## Structured streaming (JSONL / SSE) + +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]`). + +For an operation returning `JsonlStream`, the generated method returns `Stream[Thing]` (sync) / `AsyncStream[Thing]` (async), yielding deserialized `Thing` instances as each JSONL line arrives. Similarly, `SSEStream` 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: + ... +``` + +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. + +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. diff --git a/packages/http-client-python/emitter/src/http.ts b/packages/http-client-python/emitter/src/http.ts index 31d970d4b65..51743d17e58 100644 --- a/packages/http-client-python/emitter/src/http.ts +++ b/packages/http-client-python/emitter/src/http.ts @@ -42,6 +42,90 @@ export enum ReferredByOperationTypes { NonPagingOnly = 2, } +type StructuredStreamKind = "jsonl" | "sse"; +type EmittedType = ReturnType; + +interface StructuredStreamEvent { + eventType: string | undefined; + itemType: EmittedType; +} + +interface StructuredStreamingInfo { + kind: StructuredStreamKind; + itemType: EmittedType; + events?: StructuredStreamEvent[]; + terminalEvent?: string; +} + +/** Whether pygen can deserialize the stream item type. */ +export function isStructuredStreamType(type: SdkType): boolean { + switch (type.kind) { + case "model": + case "union": + return true; + case "nullable": + return isStructuredStreamType(type.type); + default: + return false; + } +} + +export function getStructuredStreamKind( + response: SdkHttpResponse | SdkHttpErrorResponse, +): StructuredStreamKind | undefined { + if (response.sseMetadata) return "sse"; + + const contentTypes = response.streamMetadata?.contentTypes ?? response.contentTypes ?? []; + for (const contentType of contentTypes) { + const mediaType = contentType.split(";", 1)[0].trim().toLowerCase(); + if (mediaType === "text/event-stream") return "sse"; + if (mediaType === "application/jsonl") return "jsonl"; + } + return undefined; +} + +function getStringConstantValue(type: SdkType): string | undefined { + if (type.kind === "nullable") return getStringConstantValue(type.type); + return type.kind === "constant" && typeof type.value === "string" ? type.value : undefined; +} + +function emitStructuredStreamingInfo( + context: PythonSdkContext, + response: SdkHttpResponse | SdkHttpErrorResponse, +): StructuredStreamingInfo | undefined { + const streamMetadata = response.streamMetadata; + if (!streamMetadata || !isStructuredStreamType(streamMetadata.streamType)) return undefined; + + const kind = getStructuredStreamKind(response); + if (!kind) return undefined; + + const streaming: StructuredStreamingInfo = { + kind, + itemType: getType(context, streamMetadata.streamType), + }; + + const sseMetadata = response.sseMetadata; + if (!sseMetadata) return streaming; + + const events = sseMetadata.events + .filter((event) => !event.isTerminalEvent) + .map((event) => ({ + eventType: event.eventType, + itemType: getType(context, event.payloadType), + })); + if (events.length > 0) streaming.events = events; + + const terminalEvent = sseMetadata.events.find((event) => event.isTerminalEvent); + if (terminalEvent) { + const terminalEventValue = + getStringConstantValue(terminalEvent.payloadType) ?? + getStringConstantValue(terminalEvent.type); + if (terminalEventValue !== undefined) streaming.terminalEvent = terminalEventValue; + } + + return streaming; +} + function isEtagType(type: SdkType): boolean { if (type.kind === "nullable") return isEtagType(type.type); const raw = type.__raw; @@ -682,6 +766,7 @@ function emitHttpResponse( "invalid-lro-result", method, ), + streaming: isException ? undefined : emitStructuredStreamingInfo(context, response), }; } diff --git a/packages/http-client-python/emitter/test/streaming.test.ts b/packages/http-client-python/emitter/test/streaming.test.ts new file mode 100644 index 00000000000..491bc04b098 --- /dev/null +++ b/packages/http-client-python/emitter/test/streaming.test.ts @@ -0,0 +1,45 @@ +import { strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { getStructuredStreamKind, isStructuredStreamType } from "../src/http.js"; + +describe("typespec-python: structured streaming", () => { + it("treats model and union payloads as structured", () => { + strictEqual(isStructuredStreamType({ kind: "model" } as any), true); + strictEqual(isStructuredStreamType({ kind: "union" } as any), true); + }); + + it("unwraps nullable payloads", () => { + strictEqual(isStructuredStreamType({ kind: "nullable", type: { kind: "model" } } as any), true); + strictEqual( + isStructuredStreamType({ kind: "nullable", type: { kind: "bytes" } } as any), + false, + ); + }); + + it("treats bare byte/string payloads as unstructured", () => { + strictEqual(isStructuredStreamType({ kind: "bytes" } as any), false); + strictEqual(isStructuredStreamType({ kind: "string" } as any), false); + }); + + it("detects the stream protocol explicitly", () => { + strictEqual(getStructuredStreamKind({ sseMetadata: { events: [] } } as any), "sse"); + strictEqual( + getStructuredStreamKind({ + streamMetadata: { contentTypes: ["text/event-stream; charset=utf-8"] }, + } as any), + "sse", + ); + strictEqual( + getStructuredStreamKind({ + streamMetadata: { contentTypes: ["application/jsonl"] }, + } as any), + "jsonl", + ); + strictEqual( + getStructuredStreamKind({ + streamMetadata: { contentTypes: ["application/json"] }, + } as any), + undefined, + ); + }); +}); diff --git a/packages/http-client-python/generator/pygen/codegen/models/code_model.py b/packages/http-client-python/generator/pygen/codegen/models/code_model.py index 73cd410eb84..c1e0bb1ee6b 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/code_model.py +++ b/packages/http-client-python/generator/pygen/codegen/models/code_model.py @@ -279,12 +279,29 @@ def need_utils_folder(self, async_mode: bool, client_namespace: str) -> bool: self.need_utils_utils(async_mode, client_namespace) or self.need_utils_serialization or self.options["models-mode"] == "dpg" + or self.need_streaming_base ) @property def need_utils_serialization(self) -> bool: return not self.options["client-side-validation"] + @property + def has_structured_stream(self) -> bool: + return any( + op.has_structured_stream_response + for client in self.clients + for og in client.operation_groups + for op in og.operations + ) + + @property + def need_streaming_base(self) -> bool: + # Whether to emit the vendored ``_utils/streaming_base.py`` (Stream / AsyncStream + # + JSONL / SSE decoders). Only needed when at least one operation returns a + # structured stream. + return self.has_structured_stream + def need_utils_utils(self, async_mode: bool, client_namespace: str) -> bool: return ( self.need_utils_form_data(async_mode, client_namespace) diff --git a/packages/http-client-python/generator/pygen/codegen/models/operation.py b/packages/http-client-python/generator/pygen/codegen/models/operation.py index 3c6d525f122..b2fb243fef9 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -98,12 +98,21 @@ def exact_name_params(self) -> set[str]: @property 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 return ( f'kwargs.pop("stream", {self.has_stream_response})' if self.expose_stream_keyword and self.has_response_body and "stream" not in self.exact_name_params else self.has_stream_response ) + @property + def has_structured_stream_response(self) -> bool: + """Whether any success response is a structured (JSONL / SSE) stream returning Stream[T].""" + return any(getattr(r, "is_structured_stream", False) for r in self.responses) + @property def has_form_data_body(self): return self.parameters.has_form_data_body diff --git a/packages/http-client-python/generator/pygen/codegen/models/response.py b/packages/http-client-python/generator/pygen/codegen/models/response.py index 99a90481319..c102ffd8802 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/response.py +++ b/packages/http-client-python/generator/pygen/codegen/models/response.py @@ -58,6 +58,15 @@ def __init__( self.type = type self.nullable = yaml_data.get("nullable") self.default_content_type = yaml_data.get("defaultContentType") + streaming = yaml_data.get("streaming") + self.streaming_kind: Optional[str] = streaming["kind"] if streaming else None + self.streaming_events: list[tuple[Optional[str], BaseType]] = [] + self._streaming_terminal_event: Optional[str] = streaming.get("terminalEvent") if streaming else None + if streaming: + self.streaming_events = [ + (event.get("eventType"), self.code_model.lookup_type(id(event["itemType"]))) + for event in streaming.get("events", []) + ] @property def result_property(self) -> str: @@ -92,12 +101,65 @@ def is_stream_response(self) -> bool: ) return retval + @property + def is_structured_stream(self) -> bool: + """Is the response a structured (JSONL / SSE) stream rendered as Stream[T] / AsyncStream[T].""" + return self.streaming_kind is not None + + @property + def terminal_event(self) -> Optional[str]: + """Terminal event marker for a heterogeneous SSE stream, if any. + + TCGC ``sseMetadata`` supplies this marker directly. For compatibility with + older metadata, a string-literal member of the item union is used as a fallback. + """ + if self.streaming_kind != "sse": + return None + if self._streaming_terminal_event is not None: + return self._streaming_terminal_event + if not isinstance(self.type, CombinedType): + return None + from .constant_type import ConstantType + + for member in self.type.types: + if isinstance(member, ConstantType) and isinstance(member.value, str): + return member.value + return None + + def stream_class_name(self, async_mode: bool) -> str: + return "AsyncStream" if async_mode else "Stream" + + @property + def stream_item_type(self) -> Optional[BaseType]: + if len(self.streaming_events) == 1: + return self.streaming_events[0][1] + return self.type + def serialization_type(self, **kwargs: Any) -> str: if self.type: return self.type.serialization_type(**kwargs) return "None" + def stream_item_annotation(self, **kwargs: Any) -> str: + """Valid type expression for a structured stream's item type. + + A named ``CombinedType`` (``@events`` union) renders its ``type_annotation`` as the + ``_unions.`` alias, which is a module-level variable and therefore rejected by + pyright/mypy inside ``Stream[...]`` ("Variable not allowed in type expression"). Expand + the union inline (``Union[Model, ...]`` / the single member) so the annotation is a + valid type expression. + """ + item_type = self.stream_item_type + if isinstance(item_type, CombinedType): + return item_type.type_definition(**kwargs) + return item_type.type_annotation(**kwargs) if item_type else "None" + def type_annotation(self, **kwargs: Any) -> str: + if self.is_structured_stream and self.type: + kwargs["is_operation_file"] = True + kwargs["is_response"] = True + stream_class = self.stream_class_name(kwargs.get("async_mode", False)) + return f"{stream_class}[{self.stream_item_annotation(**kwargs)}]" if self.type: kwargs["is_operation_file"] = True kwargs["is_response"] = True @@ -109,30 +171,63 @@ def type_annotation(self, **kwargs: Any) -> str: def docstring_text(self, **kwargs: Any) -> str: kwargs["is_response"] = True + 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 + return f"An instance of {stream_class} that iterates over {item_type.docstring_text(**kwargs)}" if self.nullable and self.type: return f"{self.type.docstring_text(**kwargs)} or None" return self.type.docstring_text(**kwargs) if self.type else "None" def docstring_type(self, **kwargs: Any) -> str: kwargs["is_response"] = True + 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}]" if self.nullable and self.type: return f"{self.type.docstring_type(**kwargs)} or None" return self.type.docstring_type(**kwargs) if self.type else "None" def imports(self, **kwargs: Any) -> FileImport: file_import = FileImport(self.code_model) - if self.type: - file_import.merge(self.type.imports(**kwargs)) + item_type = self.stream_item_type if self.is_structured_stream else self.type + # For a structured stream whose item type is a named ``@events`` union, the annotation + # is expanded inline (see ``stream_item_annotation``), so import the union member types + # rather than the ``_unions`` alias. + if self.is_structured_stream and isinstance(item_type, CombinedType): + for member in item_type.types: + file_import.merge(member.imports(**kwargs)) + # ``Union`` is only needed when the inline expansion actually yields a union of + # 2+ distinct member types (a single member collapses to that member; a union of + # only literals collapses to a single ``Literal[...]``). + distinct = list(dict.fromkeys(m.type_annotation(**kwargs) for m in item_type.types)) + all_constant = all(t.type == "constant" for t in item_type.types) + if len(distinct) > 1 and not all_constant: + file_import.add_submodule_import("typing", "Union", ImportType.STDLIB) + elif item_type: + file_import.merge(item_type.imports(**kwargs)) + if not self.is_structured_stream and isinstance(item_type, CombinedType) and item_type.name: + serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace) + file_import.add_submodule_import( + self.code_model.get_relative_import_path(serialize_namespace), + "_unions", + ImportType.LOCAL, + TypingSection.TYPING, + ) if self.nullable: file_import.add_submodule_import("typing", "Optional", ImportType.STDLIB) - if isinstance(self.type, CombinedType) and self.type.name: + if self.is_structured_stream: + stream_class = self.stream_class_name(kwargs.get("async_mode", False)) serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace) - file_import.add_submodule_import( - self.code_model.get_relative_import_path(serialize_namespace), - "_unions", - ImportType.LOCAL, - TypingSection.TYPING, + relative_path = self.code_model.get_relative_import_path( + serialize_namespace, module_name="_utils.streaming_base" ) + file_import.add_submodule_import(relative_path, stream_class, ImportType.LOCAL) + if self.streaming_kind == "sse": + file_import.add_import("json", ImportType.STDLIB) + for _event_type, event_item_type in self.streaming_events: + file_import.merge(event_item_type.imports(**kwargs)) return file_import def _get_import_type(self, input_path: str) -> ImportType: @@ -143,6 +238,14 @@ def _get_import_type(self, input_path: str) -> ImportType: @classmethod def from_yaml(cls, yaml_data: dict[str, Any], code_model: "CodeModel") -> "Response": + streaming = yaml_data.get("streaming") + if streaming: + return cls( + yaml_data=yaml_data, + code_model=code_model, + headers=[ResponseHeader.from_yaml(header, code_model) for header in yaml_data["headers"]], + type=code_model.lookup_type(id(streaming["itemType"])), + ) type = code_model.lookup_type(id(yaml_data["type"])) if yaml_data.get("type") else None # use ByteIteratorType if we are returning a binary type default_content_type = yaml_data.get("defaultContentType", "application/json") diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py index c5d786a4a6e..0321dc17d91 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -525,6 +525,13 @@ def _serialize_and_write_utils_folder(self, env: Environment, namespace: str): general_serializer.serialize_model_base_file(), ) + # write _utils/streaming_base.py (vendored Stream/AsyncStream + JSONL/SSE decoders) + if self.code_model.need_streaming_base: + self.write_file( + utils_folder_path / Path("streaming_base.py"), + general_serializer.serialize_streaming_base_file(), + ) + def _serialize_and_write_top_level_folder(self, env: Environment, namespace: str) -> None: root_dir = self.code_model.get_root_dir() generation_dir = self.code_model.get_generation_dir(namespace) diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index 152b840d260..04c3a266530 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -1256,15 +1256,75 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran ) return retval - def handle_response(self, builder: OperationType) -> list[str]: - retval: list[str] = ["response = pipeline_response.http_response"] - retval.append("") - retval.extend(self.handle_error_response(builder)) + def handle_structured_stream_response(self, builder: OperationType) -> list[str]: + """Emit the body for an operation returning a structured (JSONL / SSE) stream. + + Produces a per-event deserialization callback and returns a ``Stream`` / + ``AsyncStream`` wrapping the streamed HTTP response. + """ + response = next(r for r in builder.responses if getattr(r, "is_structured_stream", False)) + item_annotation = response.type.type_annotation( # type: ignore[union-attr] + is_operation_file=True, serialize_namespace=self.serialize_namespace + ) + stream_class = response.stream_class_name(self.async_mode) # type: ignore[attr-defined] + terminal_event = getattr(response, "terminal_event", None) + streaming_events = getattr(response, "streaming_events", []) + retval: list[str] = [] + 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) + for event_type, event_item_type in streaming_events + if event_type is not None + ] + unnamed_events = [event_item_type for event_type, event_item_type in streaming_events if event_type is None] + if named_events: + for index, (event_type, event_item_type) in enumerate(named_events): + event_annotation = event_item_type.type_annotation( + is_operation_file=True, + serialize_namespace=self.serialize_namespace, + ) + 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") retval.append("") - if builder.has_optional_return_type: - retval.append("deserialized = None") - if builder.any_response_has_headers: - retval.append("response_headers = {}") + if terminal_event is not None: + retval.append( + f"return {stream_class}(response=response, deserialization_callback=_callback, " + f"terminal_event={terminal_event!r}) # type: ignore" + ) + else: + retval.append( + f"return {stream_class}(response=response, deserialization_callback=_callback) # type: ignore" + ) + return retval + + def _handle_response_body(self, builder: OperationType) -> list[str]: + retval: list[str] = [] if builder.has_response_body or builder.any_response_has_headers: # pylint: disable=too-many-nested-blocks if len(builder.responses) > 1: status_codes, res_headers, res_deserialization = [], [], [] @@ -1299,6 +1359,21 @@ def handle_response(self, builder: OperationType) -> list[str]: else: retval.extend(self.response_headers_and_deserialization(builder, builder.responses[0])) retval.append("") + return retval + + def handle_response(self, builder: OperationType) -> list[str]: + retval: list[str] = ["response = pipeline_response.http_response"] + retval.append("") + retval.extend(self.handle_error_response(builder)) + retval.append("") + if builder.has_structured_stream_response: + retval.extend(self.handle_structured_stream_response(builder)) + return retval + if builder.has_optional_return_type: + retval.append("deserialized = None") + if builder.any_response_has_headers: + retval.append("response_headers = {}") + retval.extend(self._handle_response_body(builder)) if ( builder.has_optional_return_type or self.code_model.options["models-mode"] diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py index d44dbc8bc02..fd54fcc9260 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py @@ -200,11 +200,24 @@ def serialize_pkgutil_init_file(self) -> str: def serialize_init_file(self, clients: list[Client]) -> str: template = self.env.get_template("init.py.jinja2") + expose_streaming_types = ( + not self.async_mode + and self.code_model.need_streaming_base + and self.code_model.is_top_namespace(self.client_namespace) + ) return template.render( code_model=self.code_model, clients=clients, async_mode=self.async_mode, serialize_namespace=self.serialize_namespace, + streaming_import_path=( + self.code_model.get_relative_import_path( + self.serialize_namespace, + module_name="_utils.streaming_base", + ) + if expose_streaming_types + else None + ), ) def serialize_service_client_file(self, clients: list[Client]) -> str: @@ -318,6 +331,10 @@ def serialize_model_base_file(self) -> str: template = self.env.get_template("model_base.py.jinja2") return template.render(code_model=self.code_model, file_import=FileImport(self.code_model)) + def serialize_streaming_base_file(self) -> str: + template = self.env.get_template("streaming_base.py.jinja2") + return template.render(code_model=self.code_model, file_import=FileImport(self.code_model)) + def serialize_validation_file(self) -> str: template = self.env.get_template("validation.py.jinja2") return template.render( diff --git a/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 index 007009e4bc9..69176f27529 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 @@ -9,6 +9,9 @@ from .{{ client.filename }} import {{ client.name }} # type: ignore {% endfor %} {% endif %} +{% if streaming_import_path %} +from {{ streaming_import_path }} import AsyncStream, Stream +{% endif %} {% if not async_mode and code_model.options.get("package-version") %} from {{ code_model.get_relative_import_path(serialize_namespace, module_name="_version") }} import VERSION @@ -17,9 +20,15 @@ __version__ = VERSION {{ keywords.patch_imports(try_except=True) }} __all__ = [ + {% if streaming_import_path %} + "AsyncStream", + {% endif %} {% for client in clients %} {{ keywords.escape_str(client.name) }}, {% endfor %} + {% if streaming_import_path %} + "Stream", + {% endif %} ] {{ keywords.extend_all }} diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 new file mode 100644 index 00000000000..1c422a7a391 --- /dev/null +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -0,0 +1,562 @@ +# coding=utf-8 +{% if code_model.license_header %} +{{ code_model.license_header }} +{% endif %} +# pylint: disable=line-too-long,useless-suppression,unnecessary-ellipsis +# -------------------------------------------------------------------------- +# This file is vendored from azure-core (azure.core.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 an azure-core version that ships +# azure.core.streaming. Do not edit by hand. +# -------------------------------------------------------------------------- +import codecs +import json +from types import TracebackType +from typing import ( + Any, + AsyncIterator, + Callable, + Iterator, + List, + Optional, + Protocol, + Tuple, + Type, + TypeVar, + cast, + runtime_checkable, +) + +from typing_extensions import Self + +from azure.core.rest import AsyncHttpResponse, HttpResponse + +DecodedType = TypeVar("DecodedType") +ReturnType_co = TypeVar("ReturnType_co", covariant=True) +T_co = TypeVar("T_co", covariant=True) + + +@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] + """ + ... + + +class JSONLEvent: + """A single JSON Lines (JSONL) event. + + :ivar data: The raw JSONL record. + :vartype data: str or None + :keyword data: The raw JSONL record. + :paramtype data: str or None + """ + + def __init__( + self, + *, + data: Optional[str] = None, + ) -> None: + self.data = data + + def json(self) -> Any: + """Parse the event data as JSON. + + :return: The parsed JSON value. + :rtype: Any + """ + return json.loads(cast(str, self.data)) + + +def iter_lines(iter_bytes: Iterator[bytes]) -> Iterator[str]: + """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. + """ + decoder = codecs.getincrementaldecoder("utf-8")() + + # Split only on "\n" (tolerating "\r\n") rather than using str.splitlines(), + # which would also break on other Unicode boundaries (\v, \f, \x1c-\x1e, \x85, + # \u2028, \u2029) that are valid inside a JSONL record's string value. + decoded = "" + for chunk in iter_bytes: + decoded += decoder.decode(chunk) + if decoded: + decoded_lines = [line[:-1] if line.endswith("\r") else line for line in decoded.split("\n")] + yield from decoded_lines[:-1] + decoded = decoded_lines[-1] + + decoded += decoder.decode(b"", final=True) + if decoded: + yield decoded[:-1] if decoded.endswith("\r") else decoded + + +async def aiter_lines(iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[str]: + """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. + """ + decoder = codecs.getincrementaldecoder("utf-8")() + + # Split only on "\n" (tolerating "\r\n") rather than using str.splitlines(), + # which would also break on other Unicode boundaries (\v, \f, \x1c-\x1e, \x85, + # \u2028, \u2029) that are valid inside a JSONL record's string value. + decoded = "" + async for chunk in iter_bytes: + decoded += decoder.decode(chunk) + if decoded: + decoded_lines = [line[:-1] if line.endswith("\r") else line for line in decoded.split("\n")] + for line in decoded_lines[:-1]: + yield line + decoded = decoded_lines[-1] + + decoded += decoder.decode(b"", final=True) + if decoded: + yield decoded[:-1] if decoded.endswith("\r") else decoded + + +class JSONLDecoder: + """Decoder for JSON Lines (JSONL) format. https://jsonlines.org/""" + + def iter_events(self, iter_bytes: Iterator[bytes]) -> Iterator[JSONLEvent]: + """Iterate over JSONL events from a byte iterator. + + :param iter_bytes: An iterator of byte chunks. + :type iter_bytes: Iterator[bytes] + :rtype: Iterator[JSONLEvent] + :return: An iterator of JSONL events. + """ + + yield from (JSONLEvent(data=line) for line in iter_lines(iter_bytes)) + + +class AsyncJSONLDecoder: + """Asynchronous decoder for JSON Lines (JSONL) format. https://jsonlines.org/""" + + # pylint: disable=invalid-overridden-method + async def aiter_events(self, iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[JSONLEvent]: + """Asynchronously iterate over JSONL events from a byte iterator. + + :param iter_bytes: An asynchronous iterator of byte chunks. + :type iter_bytes: AsyncIterator[bytes] + :rtype: AsyncIterator[JSONLEvent] + :return: An asynchronous iterator of JSONL events. + """ + + async for line in aiter_lines(iter_bytes): + yield JSONLEvent(data=line) + + +class ServerSentEvent: + """A single Server-Sent Event (SSE). + + https://html.spec.whatwg.org/multipage/server-sent-events.html + + :ivar event: The event type. Defaults to ``"message"`` when the stream does not + specify one. + :vartype event: str + :ivar data: The event payload. Multiple ``data`` lines are joined with ``"\\n"``. + Left as a raw string; the caller is responsible for any further parsing. + :vartype data: str + :ivar id: The last event ID. Defaults to an empty string until the stream + provides one. + :vartype id: str + :ivar retry: The reconnection time in milliseconds, if the stream provided one. + :vartype retry: int or None + :keyword event: The event type. Defaults to ``"message"`` when the stream does not + specify one. + :paramtype event: str + :keyword data: The event payload. Multiple ``data`` lines are joined with ``"\\n"``. + Left as a raw string; the caller is responsible for any further parsing. + :paramtype data: str + :keyword id: The last event ID. Defaults to an empty string until the stream + provides one. + :paramtype id: str + :keyword retry: The reconnection time in milliseconds, if the stream provided one. + :paramtype retry: int or None + """ + + def __init__( + self, + *, + event: str = "message", + data: str = "", + id: str = "", # pylint: disable=redefined-builtin + retry: Optional[int] = None, + ) -> None: + self.event = event + self.data = data + self.id = id + self.retry = retry + + def __repr__(self) -> str: + return f"ServerSentEvent(event={self.event!r}, data={self.data!r}, " f"id={self.id!r}, retry={self.retry!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ServerSentEvent): + return NotImplemented + return (self.event, self.data, self.id, self.retry) == ( + other.event, + other.data, + other.id, + other.retry, + ) + + +def _split_sse_lines(buf: str) -> Tuple[List[str], str]: + """Split ``buf`` into complete SSE lines plus a trailing remainder. + + Per the SSE spec, lines may be separated by ``\\r\\n``, ``\\r`` or ``\\n``. A lone + trailing ``\\r`` is kept in the remainder because it may be the first half of a + ``\\r\\n`` that arrives in a later chunk. + + :param buf: The buffered, already UTF-8 decoded text. + :type buf: str + :return: A tuple of ``(complete_lines, remainder)`` where ``remainder`` is the + unterminated tail (never containing a line separator, except a single trailing + ``\\r`` awaiting a possible ``\\n``). + :rtype: tuple[list[str], str] + """ + lines: List[str] = [] + start = 0 + i = 0 + n = len(buf) + while i < n: + char = buf[i] + if char == "\n": + lines.append(buf[start:i]) + i += 1 + start = i + elif char == "\r": + if i + 1 < n: + lines.append(buf[start:i]) + i += 2 if buf[i + 1] == "\n" else 1 + start = i + else: + # Trailing lone "\r": ambiguous, defer until the next chunk. + break + else: + i += 1 + return lines, buf[start:] + + +def _iter_sse_lines(iter_bytes: Iterator[bytes]) -> Iterator[str]: + """Iterate over SSE lines (line separators stripped) from a byte iterator. + + :param iter_bytes: An iterator of byte chunks. + :type iter_bytes: Iterator[bytes] + :rtype: Iterator[str] + :return: An iterator of decoded lines. + """ + # SSE is always UTF-8 (WHATWG spec). Use utf-8-sig to drop one leading BOM and + # errors="replace" so invalid byte sequences become U+FFFD instead of crashing. + decoder = codecs.getincrementaldecoder("utf-8-sig")(errors="replace") + + buf = "" + for chunk in iter_bytes: + buf += decoder.decode(chunk) + lines, buf = _split_sse_lines(buf) + yield from lines + + buf += decoder.decode(b"", final=True) + lines, remainder = _split_sse_lines(buf) + yield from lines + if remainder: + yield remainder[:-1] if remainder.endswith("\r") else remainder + + +async def _aiter_sse_lines(iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[str]: + """Asynchronously iterate over SSE lines (separators stripped) from a byte iterator. + + :param iter_bytes: An asynchronous iterator of byte chunks. + :type iter_bytes: AsyncIterator[bytes] + :rtype: AsyncIterator[str] + :return: An asynchronous iterator of decoded lines. + """ + # SSE is always UTF-8 (WHATWG spec). Use utf-8-sig to drop one leading BOM and + # errors="replace" so invalid byte sequences become U+FFFD instead of crashing. + decoder = codecs.getincrementaldecoder("utf-8-sig")(errors="replace") + + buf = "" + async for chunk in iter_bytes: + buf += decoder.decode(chunk) + lines, buf = _split_sse_lines(buf) + for line in lines: + yield line + + buf += decoder.decode(b"", final=True) + lines, remainder = _split_sse_lines(buf) + for line in lines: + yield line + if remainder: + yield remainder[:-1] if remainder.endswith("\r") else remainder + + +class _SSEEventBuilder: + """Accumulates SSE field lines and builds :class:`ServerSentEvent` instances.""" + + def __init__(self) -> None: + self._data: List[str] = [] + self._event_type = "" + self._last_id = "" + self._retry: Optional[int] = None + + def add_line(self, line: str) -> Optional[ServerSentEvent]: + """Process a single SSE line, dispatching an event on a blank line. + + :param line: A single SSE line with its terminator already stripped. + :type line: str + :return: A :class:`ServerSentEvent` when ``line`` is blank and an event is + pending, otherwise ``None``. + :rtype: ServerSentEvent or None + """ + if line == "": + return self._dispatch() + if line.startswith(":"): + # Comment line, ignored. + return None + + field, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + + if field == "event": + self._event_type = value + elif field == "data": + self._data.append(value) + elif field == "id": + if "\x00" not in value: + self._last_id = value + elif field == "retry": + if value.isascii() and value.isdigit(): + try: + self._retry = int(value) + except ValueError: + # All ASCII digits but too long for int() (CPython's int-string + # conversion limit). Ignore rather than crashing the stream. + pass + # Unknown fields are ignored per spec. + return None + + def _dispatch(self) -> Optional[ServerSentEvent]: + if not self._data: + # No data accumulated: reset and dispatch nothing. + self._event_type = "" + return None + event = ServerSentEvent( + event=self._event_type or "message", + data="\n".join(self._data), + id=self._last_id, + retry=self._retry, + ) + self._data = [] + self._event_type = "" + return event + + +class SSEDecoder: + """Decoder for Server-Sent Events (SSE). + + https://html.spec.whatwg.org/multipage/server-sent-events.html + """ + + def iter_events(self, iter_bytes: Iterator[bytes]) -> Iterator[ServerSentEvent]: + """Iterate over SSE events from a byte iterator. + + :param iter_bytes: An iterator of byte chunks. + :type iter_bytes: Iterator[bytes] + :rtype: Iterator[ServerSentEvent] + :return: An iterator of server-sent events. + """ + builder = _SSEEventBuilder() + for line in _iter_sse_lines(iter_bytes): + event = builder.add_line(line) + if event is not None: + yield event + + +class AsyncSSEDecoder: + """Asynchronous decoder for Server-Sent Events (SSE). + + https://html.spec.whatwg.org/multipage/server-sent-events.html + """ + + # pylint: disable=invalid-overridden-method + async def aiter_events(self, iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]: + """Asynchronously iterate over SSE events from a byte iterator. + + :param iter_bytes: An asynchronous iterator of byte chunks. + :type iter_bytes: AsyncIterator[bytes] + :rtype: AsyncIterator[ServerSentEvent] + :return: An asynchronous iterator of server-sent events. + """ + builder = _SSEEventBuilder() + async for line in _aiter_sse_lines(iter_bytes): + event = builder.add_line(line) + if event is not None: + yield event + + +class Stream(Iterator[ReturnType_co]): + """Stream class for consuming a decoded event stream (e.g. JSONL or SSE). + + :keyword response: The response object. + :paramtype response: ~azure.core.rest.HttpResponse + :keyword decoder: A decoder to use for the stream. If omitted, the decoder is + inferred from the response ``Content-Type`` header. + :paramtype decoder: StreamDecoder + :keyword deserialization_callback: A callback that takes the response and the decoded event and + returns a deserialized object. + :paramtype deserialization_callback: Callable[[~azure.core.rest.HttpResponse, Any], ReturnType] + :keyword terminal_event: Optional event ``data`` value that terminates the stream (e.g. + ``"[DONE]"``). When an event's ``data`` equals this value, iteration stops and the + event is not passed to ``deserialization_callback``. + :paramtype terminal_event: str or None + """ + + def __init__( + self, + *, + response: HttpResponse, + deserialization_callback: Callable[[HttpResponse, DecodedType], ReturnType_co], + decoder: Optional[StreamDecoder[DecodedType]] = None, + terminal_event: Optional[str] = None, + ) -> None: + self._response = response + content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + self._decoder: StreamDecoder[Any] = ( + decoder if decoder is not None else (SSEDecoder() if content_type == "text/event-stream" else JSONLDecoder()) + ) + self._deserialization_callback = deserialization_callback + self._terminal_event = terminal_event + self._iterator = self._iter_results() + + def __next__(self) -> ReturnType_co: + return self._iterator.__next__() + + def __iter__(self) -> Self: + return self + + def _iter_results(self) -> Iterator[ReturnType_co]: + for event in self._decoder.iter_events(self._response.iter_bytes()): + if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: + break + result = self._deserialization_callback(self._response, event) + yield result + + def __exit__( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + self.close() + + def __enter__(self) -> Self: + return self + + def close(self) -> None: + self._response.close() + + +class AsyncStream(AsyncIterator[ReturnType_co]): + """AsyncStream class for asynchronously consuming a decoded event stream (e.g. JSONL or SSE). + + :keyword response: The response object. + :paramtype response: ~azure.core.rest.AsyncHttpResponse + :keyword decoder: A decoder to use for the stream. If omitted, the decoder is + inferred from the response ``Content-Type`` header. + :paramtype decoder: AsyncStreamDecoder + :keyword deserialization_callback: A callback that takes the response and the decoded event and + returns a deserialized object. + :paramtype deserialization_callback: Callable[[~azure.core.rest.AsyncHttpResponse, Any], ReturnType] + :keyword terminal_event: Optional event ``data`` value that terminates the stream (e.g. + ``"[DONE]"``). When an event's ``data`` equals this value, iteration stops and the + event is not passed to ``deserialization_callback``. + :paramtype terminal_event: str or None + """ + + def __init__( + self, + *, + response: AsyncHttpResponse, + deserialization_callback: Callable[[AsyncHttpResponse, DecodedType], ReturnType_co], + decoder: Optional[AsyncStreamDecoder[DecodedType]] = None, + terminal_event: Optional[str] = None, + ) -> None: + self._response = response + content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + self._decoder: AsyncStreamDecoder[Any] = ( + decoder + if decoder is not None + else (AsyncSSEDecoder() if content_type == "text/event-stream" else AsyncJSONLDecoder()) + ) + self._deserialization_callback = deserialization_callback + self._terminal_event = terminal_event + self._iterator = self._iter_results() + + async def __anext__(self) -> ReturnType_co: + return await self._iterator.__anext__() + + def __aiter__(self) -> Self: + return self + + async def _iter_results(self) -> AsyncIterator[ReturnType_co]: + async for event in self._decoder.aiter_events(self._response.iter_bytes()): + if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: + break + result = self._deserialization_callback(self._response, event) + yield result + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + await self.close() + + async def __aenter__(self) -> Self: + return self + + async def close(self) -> None: + await self._response.close() + + +__all__ = [ + "Stream", + "AsyncStream", + "JSONLEvent", + "ServerSentEvent", +] diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index ff3d6f094e3..d79fcb42235 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -35,6 +35,8 @@ def update_overload_section( for overload_s, original_s in zip(overload[section], yaml_data[section]): if overload_s.get("type"): overload_s["type"] = original_s["type"] + if overload_s.get("streaming"): + overload_s["streaming"] = original_s["streaming"] if overload_s.get("headers"): for overload_h, original_h in zip(overload_s["headers"], original_s["headers"]): if overload_h.get("type"): diff --git a/packages/http-client-python/package-lock.json b/packages/http-client-python/package-lock.json index 41dd54e4b17..4533f7e1f3e 100644 --- a/packages/http-client-python/package-lock.json +++ b/packages/http-client-python/package-lock.json @@ -18,22 +18,22 @@ }, "devDependencies": { "@azure-tools/azure-http-specs": "0.1.0-alpha.43", - "@azure-tools/typespec-autorest": "~0.70.0", + "@azure-tools/typespec-autorest": "0.71.0-dev.4", "@azure-tools/typespec-azure-core": "~0.70.0", "@azure-tools/typespec-azure-resource-manager": "~0.70.0", - "@azure-tools/typespec-azure-rulesets": "~0.70.0", - "@azure-tools/typespec-client-generator-core": "~0.70.0", + "@azure-tools/typespec-azure-rulesets": "0.71.0-dev.5", + "@azure-tools/typespec-client-generator-core": "0.71.0-dev.11", "@types/js-yaml": "~4.0.5", "@types/node": "~25.0.2", "@types/semver": "7.5.8", - "@typespec/compiler": "^1.14.0", + "@typespec/compiler": "1.15.0-dev.17", "@typespec/events": "~0.84.0", "@typespec/http": "^1.14.0", - "@typespec/http-specs": "0.1.0-alpha.39", + "@typespec/http-specs": "0.1.0-alpha.40", "@typespec/openapi": "^1.14.0", "@typespec/rest": "~0.84.0", "@typespec/spec-api": "0.1.0-alpha.15", - "@typespec/spector": "0.1.0-alpha.26", + "@typespec/spector": "0.1.0-alpha.27", "@typespec/sse": "~0.84.0", "@typespec/streams": "~0.84.0", "@typespec/versioning": "~0.84.0", @@ -50,12 +50,12 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-autorest": ">=0.70.0 <1.0.0", + "@azure-tools/typespec-autorest": ">=0.70.0 <1.0.0 || >=0.71.0-dev.4 <0.71.0", "@azure-tools/typespec-azure-core": ">=0.70.0 <1.0.0", "@azure-tools/typespec-azure-resource-manager": ">=0.70.0 <1.0.0", - "@azure-tools/typespec-azure-rulesets": ">=0.70.0 <1.0.0", - "@azure-tools/typespec-client-generator-core": ">=0.70.0 <1.0.0", - "@typespec/compiler": "^1.14.0", + "@azure-tools/typespec-azure-rulesets": ">=0.70.0 <1.0.0 || >=0.71.0-dev.5 <0.71.0", + "@azure-tools/typespec-client-generator-core": ">=0.70.0 <1.0.0 || >=0.71.0-dev.11 <0.71.0", + "@typespec/compiler": "^1.14.0 || >=1.15.0-dev.17 <1.15.0", "@typespec/events": ">=0.84.0 <1.0.0", "@typespec/http": "^1.14.0", "@typespec/openapi": "^1.14.0", @@ -89,9 +89,9 @@ } }, "node_modules/@azure-tools/typespec-autorest": { - "version": "0.70.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-autorest/-/typespec-autorest-0.70.0.tgz", - "integrity": "sha512-OaxLkgMcuOXAbaqTNpezmFF24jtkiIH1+2PBwAeRo3ZG7C1r7Hf8xZwCK6KVtBEgMbqnrd5eCqxsPl1zy3y9/Q==", + "version": "0.71.0-dev.4", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-autorest/-/typespec-autorest-0.71.0-dev.4.tgz", + "integrity": "sha1-p920uaoYRzfFVIeEUDW6+uUri1w=", "dev": true, "license": "MIT", "dependencies": { @@ -101,9 +101,9 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.70.0", - "@azure-tools/typespec-azure-resource-manager": "^0.70.0", - "@azure-tools/typespec-client-generator-core": "^0.70.0", + "@azure-tools/typespec-azure-core": "^0.70.0 || >= 0.71.0-dev.3", + "@azure-tools/typespec-azure-resource-manager": "^0.70.0 || >= 0.71.0-dev.10", + "@azure-tools/typespec-client-generator-core": "^0.70.0 || >= 0.71.0-dev.11", "@typespec/compiler": "^1.14.0", "@typespec/http": "^1.14.0", "@typespec/openapi": "^1.14.0", @@ -119,8 +119,8 @@ }, "node_modules/@azure-tools/typespec-azure-core": { "version": "0.70.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-azure-core/-/typespec-azure-core-0.70.0.tgz", - "integrity": "sha512-8MojHWRtTLKycJJ98IMoXX/5b9tTo3F0d3Iu20OKoCsORnSDG2NfjOWHJVW63oxA2t8VTlqC6J8BDcnRihygQQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-core/-/typespec-azure-core-0.70.0.tgz", + "integrity": "sha1-k3VYRkt7yNAA1kiBElz71YbZH7A=", "dev": true, "license": "MIT", "engines": { @@ -134,8 +134,8 @@ }, "node_modules/@azure-tools/typespec-azure-resource-manager": { "version": "0.70.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-azure-resource-manager/-/typespec-azure-resource-manager-0.70.0.tgz", - "integrity": "sha512-hVrbbsOhU3EQ2yQTppCqsGQwY/HcVZPOINtFkoUo+PUVBmCFXyqLkTO4jvUbsp/LvJEwoQ8aEA8Y35f7VWT5uw==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-resource-manager/-/typespec-azure-resource-manager-0.70.0.tgz", + "integrity": "sha1-+Skz0cnW1LADE60g/JnqYk0nixg=", "dev": true, "license": "MIT", "dependencies": { @@ -155,25 +155,25 @@ } }, "node_modules/@azure-tools/typespec-azure-rulesets": { - "version": "0.70.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-azure-rulesets/-/typespec-azure-rulesets-0.70.0.tgz", - "integrity": "sha512-Uxxl/18oryDwk2S+aYx6cIqiyjmoMeFDGmjuQ72a+aw6u8mZjgahMxNsY0ShvGLSchjsDqsVGaUlazXGXakVrw==", + "version": "0.71.0-dev.5", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-azure-rulesets/-/typespec-azure-rulesets-0.71.0-dev.5.tgz", + "integrity": "sha1-bpzvEq9boKSAhUwBc+EKXvxNOwI=", "dev": true, "license": "MIT", "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.70.0", - "@azure-tools/typespec-azure-resource-manager": "^0.70.0", - "@azure-tools/typespec-client-generator-core": "^0.70.0", + "@azure-tools/typespec-azure-core": "^0.70.0 || >= 0.71.0-dev.4", + "@azure-tools/typespec-azure-resource-manager": "^0.70.0 || >= 0.71.0-dev.11", + "@azure-tools/typespec-client-generator-core": "^0.70.0 || >= 0.71.0-dev.11", "@typespec/compiler": "^1.14.0" } }, "node_modules/@azure-tools/typespec-client-generator-core": { - "version": "0.70.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-client-generator-core/-/typespec-client-generator-core-0.70.0.tgz", - "integrity": "sha512-8yxOYJfID3wp3FLQYNIa3kbmR5YLWjYtpB+i4u66quHTTQWWANHV1/o9f8xymAf+8fO9jbLo5tw1JerumxISWg==", + "version": "0.71.0-dev.11", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@azure-tools/typespec-client-generator-core/-/typespec-client-generator-core-0.71.0-dev.11.tgz", + "integrity": "sha1-uBy0avXoMit1nkolVuXXLg2gbmw=", "dev": true, "license": "MIT", "dependencies": { @@ -185,7 +185,7 @@ "node": ">=22.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.70.0", + "@azure-tools/typespec-azure-core": "^0.70.0 || >= 0.71.0-dev.3", "@typespec/compiler": "^1.14.0", "@typespec/events": "^0.84.0", "@typespec/http": "^1.14.0", @@ -999,8 +999,8 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha1-8p4iBXrVMWzyODbO6aNMgf/8t+Y=", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1014,9 +1014,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", "dev": true, "license": "MIT", "peer": true, @@ -1027,8 +1027,8 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", "dev": true, "license": "ISC", "peer": true, @@ -1041,8 +1041,8 @@ }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha1-G9AGzut+LlWyt3OrMY0wDhpmrto=", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1055,8 +1055,8 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha1-dyJYIEE9lhdQnak0IZCiAZ54dhw=", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1068,9 +1068,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha1-0iv9azp9jh8sCy8ubeERtT7G4T4=", "dev": true, "license": "MIT", "peer": true, @@ -1081,7 +1081,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1093,9 +1093,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha1-B+mCx0YmFnqnoklcU4F4ktcTlJI=", "dev": true, "license": "MIT", "peer": true, @@ -1111,9 +1111,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", "dev": true, "license": "MIT", "peer": true, @@ -1124,16 +1124,16 @@ }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha1-afaofZUTq4u4/mO9sJecRI5oRmA=", "dev": true, "license": "MIT", "peer": true }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", "dev": true, "license": "ISC", "peer": true, @@ -1145,9 +1145,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha1-by+8/3VQDSKdU14KlJrhNHLIR4c=", "dev": true, "license": "MIT", "peer": true, @@ -1160,8 +1160,8 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha1-biEmoTR+hqTe34cG7Gf/jhB+u60=", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1171,8 +1171,8 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha1-l3nj/Zt+4zVxpXQ1z0M1oXlKbLI=", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1185,48 +1185,52 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha1-qCcsoDsqz0kmcCIrIyC2xCG/3mA=", "dev": true, + "license": "Apache-2.0", "peer": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.8", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha1-j4AMzME/T4zTEW4tnAqUk52j4+0=", "dev": true, + "license": "Apache-2.0", "peer": true, "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha1-8qCfYgEjkLK/8/xvskjd7IwJoJA=", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha1-r1smkaIrRL6EewyoFkHF+2rQFyw=", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=12.22" @@ -1237,10 +1241,11 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "version": "0.4.3", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha1-wrnS43TuYsWG062+qHGZsdenpro=", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=18.18" @@ -2061,8 +2066,8 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha1-WWoXRyM2lNUPatinhp/Lb1bPWEE=", "dev": true, "license": "MIT", "peer": true @@ -2327,9 +2332,9 @@ } }, "node_modules/@typespec/compiler": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@typespec/compiler/-/compiler-1.14.0.tgz", - "integrity": "sha512-RRN0LGVDlonG/IbB2b4mvRjdCo6LywwB9/J8lOp6UaH7vtaFnKe5FL+rpxhof4rXx/zI/4OWnQO6c01bTCz4/Q==", + "version": "1.15.0-dev.17", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/compiler/-/compiler-1.15.0-dev.17.tgz", + "integrity": "sha1-hrlL0q3kmaR0fWNddCfI7LA2BCY=", "dev": true, "license": "MIT", "dependencies": { @@ -2341,7 +2346,7 @@ "is-unicode-supported": "^2.1.0", "mustache": "^4.2.0", "picocolors": "^1.1.1", - "prettier": "^3.8.1", + "prettier": "^3.9.5", "semver": "^7.7.4", "tar": "^7.5.13", "temporal-polyfill": "^1.0.1", @@ -2441,8 +2446,8 @@ }, "node_modules/@typespec/events": { "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@typespec/events/-/events-0.84.0.tgz", - "integrity": "sha512-UroDIu6t6Z+cOLyX8I+GJWhSFmYGrp1L93F7ZVt0Ypmj0ndmC9YYa4cpeEyS5PDDIC8u49WfCIwfGegxt4rPVQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/events/-/events-0.84.0.tgz", + "integrity": "sha1-U6JW0cqeb0+n5fCjTbaW3DlOYPk=", "dev": true, "license": "MIT", "engines": { @@ -2454,8 +2459,8 @@ }, "node_modules/@typespec/http": { "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@typespec/http/-/http-1.14.0.tgz", - "integrity": "sha512-W+heCzu8K63AVcoX8MachVWaRxSAMFWOI1yBTc2Kq8QHaJeDiLL5JbU8VfTZ4tL/6EoGSdKfIT5ZNRW7oVCzhg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/http/-/http-1.14.0.tgz", + "integrity": "sha1-La9yB2Ny8FhnXSBbst9wYQBiOq4=", "dev": true, "license": "MIT", "engines": { @@ -2472,30 +2477,32 @@ } }, "node_modules/@typespec/http-specs": { - "version": "0.1.0-alpha.39", - "resolved": "https://registry.npmjs.org/@typespec/http-specs/-/http-specs-0.1.0-alpha.39.tgz", - "integrity": "sha512-x3ORyF/qLSLt+QDyievKT90STB0tT9J4s0Yu/Isio8zK16U+eRbCFHi69T7FZ94ZrRpPDWJ2u9+dEZv/SCPj+w==", + "version": "0.1.0-alpha.40", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typespec/http-specs/-/http-specs-0.1.0-alpha.40.tgz", + "integrity": "sha1-Jbgrft+poBvuGMqqGR+shaf07Kk=", "dev": true, "license": "MIT", "dependencies": { "@typespec/spec-api": "^0.1.0-alpha.15", - "@typespec/spector": "^0.1.0-alpha.26" + "@typespec/spector": "^0.1.0-alpha.27" }, "engines": { "node": ">=22.0.0" }, "peerDependencies": { "@typespec/compiler": "^1.14.0", + "@typespec/events": "^0.84.0", "@typespec/http": "^1.14.0", "@typespec/rest": "^0.84.0", + "@typespec/sse": "^0.84.0", "@typespec/versioning": "^0.84.0", "@typespec/xml": "^0.84.0" } }, "node_modules/@typespec/openapi": { "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@typespec/openapi/-/openapi-1.14.0.tgz", - "integrity": "sha512-KL7kImPhCXRmxpHVt1k7TWaa4bb3NbSeUx2rxyxeq7lYZFllI6/NYRCTOI/5JOrbElWmmSxrajU9K9IAKI6PkQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/openapi/-/openapi-1.14.0.tgz", + "integrity": "sha1-uwjWOV3VwWP59UXlR2xVUuzyCGc=", "dev": true, "license": "MIT", "engines": { @@ -2508,8 +2515,8 @@ }, "node_modules/@typespec/rest": { "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@typespec/rest/-/rest-0.84.0.tgz", - "integrity": "sha512-9s5dDfRoHRPdtbVvkBasUx/RnMvwWMTuXRieSQDEji4gWGgxVu4Zt4MiEEKSfQrkMr3Aw0QjRCSxBxjMCHIOmA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/rest/-/rest-0.84.0.tgz", + "integrity": "sha1-kMLB39G8geZbiA3EEdQBaqteuuA=", "dev": true, "license": "MIT", "engines": { @@ -2582,9 +2589,9 @@ "license": "MIT" }, "node_modules/@typespec/spector": { - "version": "0.1.0-alpha.26", - "resolved": "https://registry.npmjs.org/@typespec/spector/-/spector-0.1.0-alpha.26.tgz", - "integrity": "sha512-WtaWIJE+Xh80G11Bi/3zC1GIrK4EVYBSCnkmSvm9bEYcBMH8QuryPDbUeTXYUyozcCrMYXaKnBboHN91IEi8ug==", + "version": "0.1.0-alpha.27", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@typespec/spector/-/spector-0.1.0-alpha.27.tgz", + "integrity": "sha1-aA4cucLEbAIR6ZLH0llZuaVOMMY=", "dev": true, "license": "MIT", "dependencies": { @@ -2682,8 +2689,8 @@ }, "node_modules/@typespec/sse": { "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@typespec/sse/-/sse-0.84.0.tgz", - "integrity": "sha512-9joNgVisRCWDFfV1d79iTAuR1W/6r+AKJrKUfcjsaTrq5A8OWW3v5TTsfxbHAZArn7n2WxQkqhNGgNyc8LjEng==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/sse/-/sse-0.84.0.tgz", + "integrity": "sha1-dkP/3P+tvI4Q2SV3oRz/F2JMVXo=", "dev": true, "license": "MIT", "engines": { @@ -2698,8 +2705,8 @@ }, "node_modules/@typespec/streams": { "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@typespec/streams/-/streams-0.84.0.tgz", - "integrity": "sha512-SDneR8+zY+ueOpzg9yJtttfDe/ikB99JgddZSXKPwiDPlAIEeEvI8auipcYfB58EEOB21h8Oq0tEm8HqiAAWdQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/streams/-/streams-0.84.0.tgz", + "integrity": "sha1-Bm3D76chBKlcou2jj913xFfTBI4=", "dev": true, "license": "MIT", "engines": { @@ -2726,8 +2733,8 @@ }, "node_modules/@typespec/versioning": { "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@typespec/versioning/-/versioning-0.84.0.tgz", - "integrity": "sha512-ZoDasTDj4z0mgFK+0cJL2+7DduCaTjvICHL2nQ/RBWc7nLgObaIYCjvXLno8WneDXnpxCAr7larN4/nlHEv9fg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/versioning/-/versioning-0.84.0.tgz", + "integrity": "sha1-YS06C7uMMWXKp7vwuy8qV8kjr38=", "dev": true, "license": "MIT", "engines": { @@ -2739,8 +2746,8 @@ }, "node_modules/@typespec/xml": { "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@typespec/xml/-/xml-0.84.0.tgz", - "integrity": "sha512-3x0spgIrr4u3azkYaOxrlumtjoqPiUnJ/G5RwGBmUCAeE5F413MHf/AeIkmZ2ULT1gY3myabfZp8bOijTbMk7A==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/xml/-/xml-0.84.0.tgz", + "integrity": "sha1-aP99jZ3+wHS7f9mMJbEUT4Py7+g=", "dev": true, "license": "MIT", "engines": { @@ -2878,9 +2885,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha1-T68BstbTJr/u2XrqH1IiC19MGUA=", "dev": true, "license": "MIT", "peer": true, @@ -2893,8 +2900,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha1-ftW7VZCLOy8bxVxq8WU7rafweTc=", "dev": true, "license": "MIT", "peer": true, @@ -3216,8 +3223,8 @@ }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha1-s2MKvYlDQy9Us/BRkjjjPNffL3M=", "dev": true, "license": "MIT", "peer": true, @@ -3235,6 +3242,24 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha1-qsTit3NKdAhnrrFr8CqtVWoeegE=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -3342,8 +3367,8 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "license": "MIT", "peer": true @@ -3448,9 +3473,10 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha1-pvLc5hL63S7x9Rm3NVHxfoUZmDE=", "dev": true, + "license": "MIT", "peer": true }, "node_modules/default-browser": { @@ -3685,9 +3711,10 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha1-FLqDpdNz49MR5a/KKc9b+tllvzQ=", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=10" @@ -3697,9 +3724,9 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha1-Kk48iw91MZbvrpQ8j/qocw/Go/o=", "dev": true, "license": "MIT", "peer": true, @@ -3709,8 +3736,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3759,8 +3786,8 @@ }, "node_modules/eslint-scope": { "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha1-iOZGogf61hQ2/6OetQUUcgBlXII=", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3789,9 +3816,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha1-B+mCx0YmFnqnoklcU4F4ktcTlJI=", "dev": true, "license": "MIT", "peer": true, @@ -3807,9 +3834,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", "dev": true, "license": "MIT", "peer": true, @@ -3818,35 +3845,18 @@ "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha1-afaofZUTq4u4/mO9sJecRI5oRmA=", "dev": true, "license": "MIT", "peer": true }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", "dev": true, "license": "ISC", "peer": true, @@ -3859,8 +3869,8 @@ }, "node_modules/espree": { "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/espree/-/espree-10.4.0.tgz", + "integrity": "sha1-1U9JSdRikAWh+haNk3w/8ffiqDc=", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3877,10 +3887,11 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha1-CNBI8mHw3e21uulfRoCUY9nJSW0=", "dev": true, + "license": "BSD-3-Clause", "peer": true, "dependencies": { "estraverse": "^5.1.0" @@ -3891,8 +3902,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha1-eteWTWeauyi+5yzsY3WLHF0smSE=", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3905,9 +3916,10 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha1-LupSkHAvJquP5TcDcP+GyWXSESM=", "dev": true, + "license": "BSD-2-Clause", "peer": true, "engines": { "node": ">=4.0" @@ -3925,9 +3937,10 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha1-dNLrTeC42hKTcRkQ1Qd1ubcQ72Q=", "dev": true, + "license": "BSD-2-Clause", "peer": true, "engines": { "node": ">=0.10.0" @@ -4015,17 +4028,18 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha1-h0v2nG9ATCtdmcSBNBOZ/VWJJjM=", "dev": true, "license": "MIT", "peer": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true, + "license": "MIT", "peer": true }, "node_modules/fast-string-truncated-width": { @@ -4115,9 +4129,10 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha1-d4e93PETG/+5JjbGlFe7wO3W2B8=", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "flat-cache": "^4.0.0" @@ -4179,9 +4194,10 @@ }, "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha1-Ds45/LFO4BL0sEEL0z3ZwfAREnw=", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "flatted": "^3.2.9", @@ -4192,9 +4208,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha1-ruyipQYwPwzuYcWebJ8qiNLyn8Y=", "dev": true, "license": "ISC", "peer": true @@ -4350,9 +4366,10 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha1-bSN9mQg5UMeSkPJMdkKj3poo+eM=", "dev": true, + "license": "ISC", "peer": true, "dependencies": { "is-glob": "^4.0.3" @@ -4402,8 +4419,8 @@ }, "node_modules/globals": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/globals/-/globals-14.0.0.tgz", + "integrity": "sha1-iY10E8Kbq89rr+Vvyt3thYrack4=", "dev": true, "license": "MIT", "peer": true, @@ -4536,8 +4553,8 @@ }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha1-PNQOcp82Q/2HywTlC/DrcivFlvU=", "dev": true, "license": "MIT", "peer": true, @@ -4547,8 +4564,8 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha1-nOy1ZQPAraHydB271lRuSxO1fM8=", "dev": true, "license": "MIT", "peer": true, @@ -4565,8 +4582,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true, "license": "MIT", "peer": true, @@ -4609,9 +4626,10 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=0.10.0" @@ -4628,9 +4646,10 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha1-ZPYeQsu7LuwgcanawLKLoeZdUIQ=", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "is-extglob": "^2.1.1" @@ -4779,9 +4798,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha1-0ZAFcqf3zwtfVAyDZz5gutNDZZI=", "funding": [ { "type": "github", @@ -4802,9 +4821,10 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha1-kziAKjDTtmBfvgYT4JQAjKjAWhM=", "dev": true, + "license": "MIT", "peer": true }, "node_modules/json-schema-traverse": { @@ -4816,9 +4836,10 @@ }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true, + "license": "MIT", "peer": true }, "node_modules/jsonwebtoken": { @@ -4869,9 +4890,10 @@ }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha1-qHmpnilFL5QkOfKkBeOvizHU3pM=", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "json-buffer": "3.0.1" @@ -4879,9 +4901,10 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/levn/-/levn-0.4.1.tgz", + "integrity": "sha1-rkViwAdHO5MqYgDUAyaN0v/8at4=", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "prelude-ls": "^1.2.1", @@ -5211,9 +5234,10 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha1-VYqlO0O2YeGSWgr9+japoQhf5Xo=", "dev": true, + "license": "MIT", "peer": true }, "node_modules/lodash.once": { @@ -5625,9 +5649,10 @@ }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha1-fqHBpdkddk+yghOciP4R4YKjpzQ=", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "deep-is": "^0.1.3", @@ -5679,8 +5704,8 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha1-aR0nCeeMefrjoVZiJFLQB2LKqqI=", "dev": true, "license": "MIT", "peer": true, @@ -5829,9 +5854,10 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha1-3rxkidem5rDnYRiIzsiAM30xY5Y=", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">= 0.8.0" @@ -5869,8 +5895,8 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha1-AnQi4vrsCyXhVJw+G9gwm5EztuU=", "dev": true, "license": "MIT", "peer": true, @@ -5972,8 +5998,8 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha1-SrzYUq0y3Xuqv+m0DgCjbbXzkuY=", "dev": true, "license": "MIT", "peer": true, @@ -6452,8 +6478,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha1-MfEoGzgyYwQ0gxwxDAHMzajL4AY=", "dev": true, "license": "MIT", "peer": true, @@ -6744,9 +6770,10 @@ }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha1-B7ggO/pwVsBlcFDjzNLDdzC6uPE=", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "prelude-ls": "^1.2.1" @@ -6852,8 +6879,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha1-mxpSWVIlhZ5V9mnZKPiMbFfyp34=", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -7158,9 +7185,10 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha1-0sRcbdT7zmIaZvE2y+Mor9BBCzQ=", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=0.10.0" diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json index d3d837fcad5..1074cb3e58d 100644 --- a/packages/http-client-python/package.json +++ b/packages/http-client-python/package.json @@ -66,12 +66,12 @@ "emitter" ], "peerDependencies": { - "@azure-tools/typespec-autorest": ">=0.70.0 <1.0.0", + "@azure-tools/typespec-autorest": ">=0.70.0 <1.0.0 || >=0.71.0-dev.4 <0.71.0", "@azure-tools/typespec-azure-core": ">=0.70.0 <1.0.0", "@azure-tools/typespec-azure-resource-manager": ">=0.70.0 <1.0.0", - "@azure-tools/typespec-azure-rulesets": ">=0.70.0 <1.0.0", - "@azure-tools/typespec-client-generator-core": ">=0.70.0 <1.0.0", - "@typespec/compiler": "^1.14.0", + "@azure-tools/typespec-azure-rulesets": ">=0.70.0 <1.0.0 || >=0.71.0-dev.5 <0.71.0", + "@azure-tools/typespec-client-generator-core": ">=0.70.0 <1.0.0 || >=0.71.0-dev.11 <0.71.0", + "@typespec/compiler": "^1.14.0 || >=1.15.0-dev.17 <1.15.0", "@typespec/http": "^1.14.0", "@typespec/openapi": "^1.14.0", "@typespec/rest": ">=0.84.0 <1.0.0", @@ -104,24 +104,24 @@ "tsx": "^4.21.0" }, "devDependencies": { - "@azure-tools/typespec-autorest": "~0.70.0", + "@azure-tools/typespec-autorest": "0.71.0-dev.4", "@azure-tools/typespec-azure-core": "~0.70.0", "@azure-tools/typespec-azure-resource-manager": "~0.70.0", - "@azure-tools/typespec-azure-rulesets": "~0.70.0", - "@azure-tools/typespec-client-generator-core": "~0.70.0", + "@azure-tools/typespec-azure-rulesets": "0.71.0-dev.5", + "@azure-tools/typespec-client-generator-core": "0.71.0-dev.11", "@azure-tools/azure-http-specs": "0.1.0-alpha.43", - "@typespec/compiler": "^1.14.0", + "@typespec/compiler": "1.15.0-dev.17", "@typespec/http": "^1.14.0", "@typespec/openapi": "^1.14.0", "@typespec/rest": "~0.84.0", "@typespec/versioning": "~0.84.0", "@typespec/events": "~0.84.0", - "@typespec/spector": "0.1.0-alpha.26", + "@typespec/spector": "0.1.0-alpha.27", "@typespec/spec-api": "0.1.0-alpha.15", "@typespec/sse": "~0.84.0", "@typespec/streams": "~0.84.0", "@typespec/xml": "~0.84.0", - "@typespec/http-specs": "0.1.0-alpha.39", + "@typespec/http-specs": "0.1.0-alpha.40", "@types/js-yaml": "~4.0.5", "@types/node": "~25.0.2", "@types/semver": "7.5.8", @@ -132,5 +132,8 @@ "typescript-eslint": "^8.49.0", "vitest": "^4.0.15", "prettier": "^3.9.5" + }, + "overrides": { + "@typespec/compiler": "$@typespec/compiler" } } diff --git a/packages/http-client-python/tests/unit/test_streaming_init.py b/packages/http-client-python/tests/unit/test_streaming_init.py new file mode 100644 index 00000000000..1d83e492c5a --- /dev/null +++ b/packages/http-client-python/tests/unit/test_streaming_init.py @@ -0,0 +1,57 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from types import SimpleNamespace + +import pytest +from jinja2 import Environment, PackageLoader + +from pygen.codegen.serializers.general_serializer import GeneralSerializer + + +def _env() -> Environment: + return Environment( + loader=PackageLoader("pygen.codegen", "templates"), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, + ) + + +def _render_init(*, has_structured_stream: bool, async_mode: bool = False) -> str: + code_model = SimpleNamespace( + namespace="sample", + license_header="", + options={"package-version": None}, + need_streaming_base=has_structured_stream, + get_serialize_namespace=lambda namespace, async_mode: f"{namespace}.aio" if async_mode else namespace, + get_relative_import_path=lambda _namespace, module_name: f".{module_name}", + is_top_namespace=lambda namespace: namespace == "sample", + ) + client = SimpleNamespace(filename="_client", name="SampleClient") + return GeneralSerializer(code_model=code_model, env=_env(), async_mode=async_mode).serialize_init_file([client]) + + +def test_exports_stream_types_from_base_namespace(): + init_file = _render_init(has_structured_stream=True) + + assert "from ._utils.streaming_base import AsyncStream, Stream" in init_file + assert '"AsyncStream",' in init_file + assert '"Stream",' in init_file + + +@pytest.mark.parametrize( + "has_structured_stream,async_mode", + [ + (False, False), + (True, True), + ], +) +def test_does_not_export_stream_types_outside_structured_base_namespace(has_structured_stream, async_mode): + init_file = _render_init(has_structured_stream=has_structured_stream, async_mode=async_mode) + + assert "streaming_base import" not in init_file + assert '"AsyncStream",' not in init_file + assert '"Stream",' not in init_file From 851b4a2c106bcd9af6a45d36141f541395516141 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 11 Aug 2026 12:52:22 -0700 Subject: [PATCH 02/29] feat(http-client-python): sync vendored streaming_base with azure-core 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 --- .../templates/streaming_base.py.jinja2 | 322 ++++++++++++------ 1 file changed, 225 insertions(+), 97 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 index 1c422a7a391..d89a051d1eb 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -12,16 +12,18 @@ # -------------------------------------------------------------------------- import codecs import json +from contextlib import aclosing from types import TracebackType from typing import ( Any, + AsyncGenerator, AsyncIterator, Callable, + Generator, Iterator, List, Optional, Protocol, - Tuple, Type, TypeVar, cast, @@ -93,6 +95,64 @@ class JSONLEvent: return json.loads(cast(str, self.data)) +class _JSONLLineFramer: + """Incremental JSONL line framer with linear-time behavior. + + JSONL records are separated only by ``"\\n"`` (tolerating ``"\\r\\n"``). Unlike + ``str.splitlines()``, other Unicode boundaries (``\\v``, ``\\f``, ``\\x1c``-``\\x1e``, + ``\\x85``, ``\\u2028``, ``\\u2029``) are preserved because they are valid inside a JSONL + record's string value. + + Rather than re-concatenating and re-splitting the whole pending record on every network chunk + (which is O(n^2) for a single long record fragmented across many chunks), the unfinished record + is held as a list of fragments and joined only when a terminator arrives (or at EOF). Only the + newly decoded text is scanned per chunk, giving O(total) behavior. + """ + + def __init__(self) -> None: + # Fragments of the current, not-yet-terminated record. Never contains a "\n". + self._parts: List[str] = [] + + def push(self, text: str) -> List[str]: + """Feed newly decoded text and return any completed records. + + :param text: Newly decoded text from a single chunk. + :type text: str + :return: Completed records produced by this chunk (may be empty). + :rtype: list[str] + """ + if not text: + return [] + + segments = text.split("\n") + # Fast path: no line terminator, so this is a continuation of the current record. Stash the + # fragment without joining or rescanning the accumulated tail. + if len(segments) == 1: + self._parts.append(text) + return [] + + first = "".join(self._parts) + segments[0] + # All but the final segment are complete records (terminated by "\n"). Strip a trailing "\r" + # to tolerate "\r\n" line endings. + completed = [line[:-1] if line.endswith("\r") else line for line in [first, *segments[1:-1]]] + self._parts = [segments[-1]] + return completed + + def flush(self, extra: str = "") -> List[str]: + """Return the final unterminated record, if any, at end of stream. + + :param extra: Trailing text from finalizing the incremental decoder. + :type extra: str + :return: The final record, if non-empty. + :rtype: list[str] + """ + tail = "".join(self._parts) + extra + self._parts = [] + if not tail: + return [] + return [tail[:-1] if tail.endswith("\r") else tail] + + def iter_lines(iter_bytes: Iterator[bytes]) -> Iterator[str]: """Iterate over lines from a byte iterator. @@ -102,24 +162,15 @@ def iter_lines(iter_bytes: Iterator[bytes]) -> Iterator[str]: :return: An iterator of lines. """ decoder = codecs.getincrementaldecoder("utf-8")() + framer = _JSONLLineFramer() - # Split only on "\n" (tolerating "\r\n") rather than using str.splitlines(), - # which would also break on other Unicode boundaries (\v, \f, \x1c-\x1e, \x85, - # \u2028, \u2029) that are valid inside a JSONL record's string value. - decoded = "" for chunk in iter_bytes: - decoded += decoder.decode(chunk) - if decoded: - decoded_lines = [line[:-1] if line.endswith("\r") else line for line in decoded.split("\n")] - yield from decoded_lines[:-1] - decoded = decoded_lines[-1] + yield from framer.push(decoder.decode(chunk)) - decoded += decoder.decode(b"", final=True) - if decoded: - yield decoded[:-1] if decoded.endswith("\r") else decoded + yield from framer.flush(decoder.decode(b"", final=True)) -async def aiter_lines(iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[str]: +async def aiter_lines(iter_bytes: AsyncIterator[bytes]) -> AsyncGenerator[str, None]: """Iterate over lines from a byte iterator. :param iter_bytes: An iterator of byte chunks. @@ -128,22 +179,19 @@ async def aiter_lines(iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[str]: :return: An iterator of lines. """ decoder = codecs.getincrementaldecoder("utf-8")() + framer = _JSONLLineFramer() - # Split only on "\n" (tolerating "\r\n") rather than using str.splitlines(), - # which would also break on other Unicode boundaries (\v, \f, \x1c-\x1e, \x85, - # \u2028, \u2029) that are valid inside a JSONL record's string value. - decoded = "" - async for chunk in iter_bytes: - decoded += decoder.decode(chunk) - if decoded: - decoded_lines = [line[:-1] if line.endswith("\r") else line for line in decoded.split("\n")] - for line in decoded_lines[:-1]: + try: + async for chunk in iter_bytes: + for line in framer.push(decoder.decode(chunk)): yield line - decoded = decoded_lines[-1] + finally: + aclose = getattr(iter_bytes, "aclose", None) + if aclose is not None: + await aclose() - decoded += decoder.decode(b"", final=True) - if decoded: - yield decoded[:-1] if decoded.endswith("\r") else decoded + for line in framer.flush(decoder.decode(b"", final=True)): + yield line class JSONLDecoder: @@ -174,8 +222,9 @@ class AsyncJSONLDecoder: :return: An asynchronous iterator of JSONL events. """ - async for line in aiter_lines(iter_bytes): - yield JSONLEvent(data=line) + async with aclosing(aiter_lines(iter_bytes)) as lines: + async for line in lines: + yield JSONLEvent(data=line) class ServerSentEvent: @@ -234,41 +283,110 @@ class ServerSentEvent: ) -def _split_sse_lines(buf: str) -> Tuple[List[str], str]: - """Split ``buf`` into complete SSE lines plus a trailing remainder. +class _SSELineFramer: + """Incremental SSE line framer with linear-time behavior. - Per the SSE spec, lines may be separated by ``\\r\\n``, ``\\r`` or ``\\n``. A lone - trailing ``\\r`` is kept in the remainder because it may be the first half of a - ``\\r\\n`` that arrives in a later chunk. + Per the SSE spec, lines may be separated by ``"\\r\\n"``, ``"\\r"`` or ``"\\n"``. A lone trailing + ``"\\r"`` at a chunk boundary is ambiguous (it may be the first half of a ``"\\r\\n"``) and its + resolution is deferred until the next chunk (or EOF). - :param buf: The buffered, already UTF-8 decoded text. - :type buf: str - :return: A tuple of ``(complete_lines, remainder)`` where ``remainder`` is the - unterminated tail (never containing a line separator, except a single trailing - ``\\r`` awaiting a possible ``\\n``). - :rtype: tuple[list[str], str] + Rather than re-concatenating and re-scanning the whole pending line on every network chunk + (which is O(n^2) for a single long line fragmented across many chunks), the unfinished line is + held as a list of fragments and joined only when a terminator arrives (or at EOF). Only the + newly decoded text is scanned per chunk, giving O(total) behavior. """ - lines: List[str] = [] - start = 0 - i = 0 - n = len(buf) - while i < n: - char = buf[i] - if char == "\n": - lines.append(buf[start:i]) - i += 1 - start = i - elif char == "\r": - if i + 1 < n: - lines.append(buf[start:i]) - i += 2 if buf[i + 1] == "\n" else 1 + + def __init__(self) -> None: + # Fragments of the current, not-yet-terminated line. Never contains a separator. + self._parts: List[str] = [] + # True when the previous chunk ended with a lone "\r" whose "\r\n" status is still unknown. + self._pending_cr = False + + def _emit_current(self) -> str: + line = "".join(self._parts) + self._parts = [] + return line + + def push(self, text: str) -> List[str]: + """Feed newly decoded text and return any completed lines. + + :param text: Newly decoded text from a single chunk. + :type text: str + :return: Completed lines (separators stripped) produced by this chunk. + :rtype: list[str] + """ + if not text: + return [] + + out: List[str] = [] + if self._pending_cr: + # The deferred "\r" terminates the current line now that more data is available. + out.append(self._emit_current()) + self._pending_cr = False + # A leading "\n" is the second half of that "\r\n" pair: consume it. + if text[:1] == "\n": + text = text[1:] + + n = len(text) + start = 0 + i = 0 + while i < n: + char = text[i] + if char == "\n": + self._parts.append(text[start:i]) + out.append(self._emit_current()) + i += 1 start = i + elif char == "\r": + if i + 1 < n: + self._parts.append(text[start:i]) + out.append(self._emit_current()) + i += 2 if text[i + 1] == "\n" else 1 + start = i + else: + # Trailing lone "\r": defer resolution until the next chunk. + self._parts.append(text[start:i]) + self._pending_cr = True + start = n + break else: - # Trailing lone "\r": ambiguous, defer until the next chunk. - break + i += 1 + + if start < n: + self._parts.append(text[start:n]) + return out + + def flush(self, extra: str = "") -> List[str]: + """Return any remaining lines at end of stream. + + A lone trailing ``"\\r"`` is treated as a terminator (its ``"\\r\\n"`` half never arrives), + and a non-empty final unterminated line is emitted; an empty tail is not, so no blank line + (and therefore no spurious event) is invented at EOF. + + :param extra: Trailing text from finalizing the incremental decoder. + :type extra: str + :return: The remaining lines, if any. + :rtype: list[str] + """ + out: List[str] = [] + if self._pending_cr: + out.append(self._emit_current()) + self._pending_cr = False + if extra[:1] == "\n": + extra = extra[1:] + + if extra: + out.extend(self.push(extra)) + + if self._pending_cr: + # 'extra' ended in a lone "\r": at EOF it is a terminator; emit the preceding content. + out.append(self._emit_current()) + self._pending_cr = False else: - i += 1 - return lines, buf[start:] + tail = self._emit_current() + if tail: + out.append(tail) + return out def _iter_sse_lines(iter_bytes: Iterator[bytes]) -> Iterator[str]: @@ -282,21 +400,15 @@ def _iter_sse_lines(iter_bytes: Iterator[bytes]) -> Iterator[str]: # SSE is always UTF-8 (WHATWG spec). Use utf-8-sig to drop one leading BOM and # errors="replace" so invalid byte sequences become U+FFFD instead of crashing. decoder = codecs.getincrementaldecoder("utf-8-sig")(errors="replace") + framer = _SSELineFramer() - buf = "" for chunk in iter_bytes: - buf += decoder.decode(chunk) - lines, buf = _split_sse_lines(buf) - yield from lines + yield from framer.push(decoder.decode(chunk)) - buf += decoder.decode(b"", final=True) - lines, remainder = _split_sse_lines(buf) - yield from lines - if remainder: - yield remainder[:-1] if remainder.endswith("\r") else remainder + yield from framer.flush(decoder.decode(b"", final=True)) -async def _aiter_sse_lines(iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[str]: +async def _aiter_sse_lines(iter_bytes: AsyncIterator[bytes]) -> AsyncGenerator[str, None]: """Asynchronously iterate over SSE lines (separators stripped) from a byte iterator. :param iter_bytes: An asynchronous iterator of byte chunks. @@ -307,20 +419,19 @@ async def _aiter_sse_lines(iter_bytes: AsyncIterator[bytes]) -> AsyncIterator[st # SSE is always UTF-8 (WHATWG spec). Use utf-8-sig to drop one leading BOM and # errors="replace" so invalid byte sequences become U+FFFD instead of crashing. decoder = codecs.getincrementaldecoder("utf-8-sig")(errors="replace") + framer = _SSELineFramer() - buf = "" - async for chunk in iter_bytes: - buf += decoder.decode(chunk) - lines, buf = _split_sse_lines(buf) - for line in lines: - yield line + try: + async for chunk in iter_bytes: + for line in framer.push(decoder.decode(chunk)): + yield line + finally: + aclose = getattr(iter_bytes, "aclose", None) + if aclose is not None: + await aclose() - buf += decoder.decode(b"", final=True) - lines, remainder = _split_sse_lines(buf) - for line in lines: + for line in framer.flush(decoder.decode(b"", final=True)): yield line - if remainder: - yield remainder[:-1] if remainder.endswith("\r") else remainder class _SSEEventBuilder: @@ -422,10 +533,11 @@ class AsyncSSEDecoder: :return: An asynchronous iterator of server-sent events. """ builder = _SSEEventBuilder() - async for line in _aiter_sse_lines(iter_bytes): - event = builder.add_line(line) - if event is not None: - yield event + async with aclosing(_aiter_sse_lines(iter_bytes)) as lines: + async for line in lines: + event = builder.add_line(line) + if event is not None: + yield event class Stream(Iterator[ReturnType_co]): @@ -468,12 +580,15 @@ class Stream(Iterator[ReturnType_co]): def __iter__(self) -> Self: return self - def _iter_results(self) -> Iterator[ReturnType_co]: - for event in self._decoder.iter_events(self._response.iter_bytes()): - if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: - break - result = self._deserialization_callback(self._response, event) - yield result + def _iter_results(self) -> Generator[ReturnType_co, None, None]: + try: + for event in self._decoder.iter_events(self._response.iter_bytes()): + if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: + break + result = self._deserialization_callback(self._response, event) + yield result + finally: + self._response.close() def __exit__( self, @@ -487,7 +602,10 @@ class Stream(Iterator[ReturnType_co]): return self def close(self) -> None: - self._response.close() + try: + self._iterator.close() + finally: + self._response.close() class AsyncStream(AsyncIterator[ReturnType_co]): @@ -532,12 +650,19 @@ class AsyncStream(AsyncIterator[ReturnType_co]): def __aiter__(self) -> Self: return self - async def _iter_results(self) -> AsyncIterator[ReturnType_co]: - async for event in self._decoder.aiter_events(self._response.iter_bytes()): - if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: - break - result = self._deserialization_callback(self._response, event) - yield result + async def _iter_results(self) -> AsyncGenerator[ReturnType_co, None]: + events = self._decoder.aiter_events(self._response.iter_bytes()) + try: + async for event in events: + if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: + break + result = self._deserialization_callback(self._response, event) + yield result + finally: + aclose = getattr(events, "aclose", None) + if aclose is not None: + await aclose() + await self._response.close() async def __aexit__( self, @@ -551,7 +676,10 @@ class AsyncStream(AsyncIterator[ReturnType_co]): return self async def close(self) -> None: - await self._response.close() + try: + await self._iterator.aclose() + finally: + await self._response.close() __all__ = [ From f5db6370e74af5ae6b160791da04bd525cc6bf28 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 11 Aug 2026 12:57:10 -0700 Subject: [PATCH 03/29] chore: add async streaming terms to cspell Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e57edafe-9764-4b99-a1ae-0efd56e8729e --- cspell.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cspell.yaml b/cspell.yaml index 6273e27f277..0140694c651 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -5,6 +5,8 @@ dictionaries: - node - typescript words: + - aclose + - aclosing - Ablack - Adoptium - aenter From bf35b418d50d4cf82e492a176ecfac08a5ec64b1 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Wed, 12 Aug 2026 11:01:33 -0700 Subject: [PATCH 04/29] fix(http-client-python): emit absolute import when namespaces share no 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 --- .../pygen/codegen/models/code_model.py | 13 ++- .../tests/unit/test_relative_import_path.py | 95 +++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 packages/http-client-python/tests/unit/test_relative_import_path.py diff --git a/packages/http-client-python/generator/pygen/codegen/models/code_model.py b/packages/http-client-python/generator/pygen/codegen/models/code_model.py index c1e0bb1ee6b..4ce1d86bbc8 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/code_model.py +++ b/packages/http-client-python/generator/pygen/codegen/models/code_model.py @@ -145,9 +145,16 @@ def get_relative_import_path( if serialize_namespace_split[idx] != imported_namespace_split[idx]: break idx += 1 - self._relative_import_path[key] = "." * (len(serialize_namespace_split[idx:]) + 1) + ".".join( - imported_namespace_split[idx:] - ) + if idx == 0: + # The two namespaces share no top-level package, so a relative import would + # climb above the package root (e.g. ``from .......search import models``), + # which Python rejects at runtime ("attempted relative import beyond top-level + # package"). Emit a valid absolute import of the foreign namespace instead. + self._relative_import_path[key] = ".".join(imported_namespace_split) + else: + self._relative_import_path[key] = "." * (len(serialize_namespace_split[idx:]) + 1) + ".".join( + imported_namespace_split[idx:] + ) result = self._relative_import_path[key] if module_name is None: return result diff --git a/packages/http-client-python/tests/unit/test_relative_import_path.py b/packages/http-client-python/tests/unit/test_relative_import_path.py new file mode 100644 index 00000000000..13d95ccfada --- /dev/null +++ b/packages/http-client-python/tests/unit/test_relative_import_path.py @@ -0,0 +1,95 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +"""Tests for ``CodeModel.get_relative_import_path``. + +A structured (JSONL / SSE) streaming operation can reference a payload model +whose ``client_namespace`` shares no top-level package with the operation being +generated (for example an SSE event payload that TCGC reports under a ``search`` +namespace while the package root is ``azure.search.documents``). Emitting a +relative import in that case climbs above the package root +(``from .......search import models``), which Python rejects at runtime with +``attempted relative import beyond top-level package``. The generator must fall +back to a valid absolute import instead. In-package imports must stay relative +(byte-identical to before). +""" + +import pytest + +from pygen.codegen.models import CodeModel + + +def _code_model() -> CodeModel: + return CodeModel( + { + "clients": [ + { + "name": "client", + "namespace": "azure.search.documents", + "moduleName": "azure.search.documents", + "parameters": [], + "url": "", + "operationGroups": [], + } + ], + "namespace": "azure.search.documents", + }, + options={ + "show-send-request": True, + "builders-visibility": "public", + "show-operations": True, + "models-mode": "dpg", + "only-path-and-body-params-positional": True, + }, + ) + + +@pytest.mark.parametrize( + "serialize_namespace,imported_namespace,expected", + [ + # In-package: shares a top-level package -> relative import (unchanged). + ("azure.test.operations", "azure.test", ".."), + ("azure.test.operations", "azure", "..."), + ("azure.test.subtest.aio.operations", "azure.test", "...."), + ("azure.search.documents.operations", "azure.search.documents", ".."), + ], +) +def test_in_package_stays_relative(serialize_namespace, imported_namespace, expected): + assert _code_model().get_relative_import_path(serialize_namespace, imported_namespace) == expected + + +@pytest.mark.parametrize( + "serialize_namespace,imported_namespace,expected", + [ + # No shared top-level package -> valid absolute import (no leading dots). + ("azure.search.documents.grp.aio.operations", "search", "search"), + ("azure.search.documents.aio.operations", "search", "search"), + ("search.aio.operations", "azure.search.documents", "azure.search.documents"), + ], +) +def test_cross_root_uses_absolute_import(serialize_namespace, imported_namespace, expected): + result = _code_model().get_relative_import_path(serialize_namespace, imported_namespace) + assert result == expected + assert not result.startswith("."), "cross-root import must be absolute, not a package-escaping relative import" + + +def test_cross_root_absolute_import_with_module_name(): + # The Stream / AsyncStream import points at ``._utils.streaming_base``. When an + # operation lives outside the package root, that import must also be absolute-valid. + result = _code_model().get_relative_import_path( + "search.aio.operations", + "azure.search.documents", + module_name="_utils.streaming_base", + ) + assert result == "azure.search.documents._utils.streaming_base" + + +def test_in_package_module_name_stays_relative(): + result = _code_model().get_relative_import_path( + "azure.search.documents.grp.aio.operations", + "azure.search.documents", + module_name="_utils.streaming_base", + ) + assert result == "...._utils.streaming_base" From d9f7aa257b35ff5026d44998e673d55878321f91 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Wed, 12 Aug 2026 12:55:28 -0700 Subject: [PATCH 05/29] Revert "fix(http-client-python): emit absolute import when namespaces share no package root" This reverts commit bf35b418d50d4cf82e492a176ecfac08a5ec64b1. --- .../pygen/codegen/models/code_model.py | 13 +-- .../tests/unit/test_relative_import_path.py | 95 ------------------- 2 files changed, 3 insertions(+), 105 deletions(-) delete mode 100644 packages/http-client-python/tests/unit/test_relative_import_path.py diff --git a/packages/http-client-python/generator/pygen/codegen/models/code_model.py b/packages/http-client-python/generator/pygen/codegen/models/code_model.py index 4ce1d86bbc8..c1e0bb1ee6b 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/code_model.py +++ b/packages/http-client-python/generator/pygen/codegen/models/code_model.py @@ -145,16 +145,9 @@ def get_relative_import_path( if serialize_namespace_split[idx] != imported_namespace_split[idx]: break idx += 1 - if idx == 0: - # The two namespaces share no top-level package, so a relative import would - # climb above the package root (e.g. ``from .......search import models``), - # which Python rejects at runtime ("attempted relative import beyond top-level - # package"). Emit a valid absolute import of the foreign namespace instead. - self._relative_import_path[key] = ".".join(imported_namespace_split) - else: - self._relative_import_path[key] = "." * (len(serialize_namespace_split[idx:]) + 1) + ".".join( - imported_namespace_split[idx:] - ) + self._relative_import_path[key] = "." * (len(serialize_namespace_split[idx:]) + 1) + ".".join( + imported_namespace_split[idx:] + ) result = self._relative_import_path[key] if module_name is None: return result diff --git a/packages/http-client-python/tests/unit/test_relative_import_path.py b/packages/http-client-python/tests/unit/test_relative_import_path.py deleted file mode 100644 index 13d95ccfada..00000000000 --- a/packages/http-client-python/tests/unit/test_relative_import_path.py +++ /dev/null @@ -1,95 +0,0 @@ -# ------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- -"""Tests for ``CodeModel.get_relative_import_path``. - -A structured (JSONL / SSE) streaming operation can reference a payload model -whose ``client_namespace`` shares no top-level package with the operation being -generated (for example an SSE event payload that TCGC reports under a ``search`` -namespace while the package root is ``azure.search.documents``). Emitting a -relative import in that case climbs above the package root -(``from .......search import models``), which Python rejects at runtime with -``attempted relative import beyond top-level package``. The generator must fall -back to a valid absolute import instead. In-package imports must stay relative -(byte-identical to before). -""" - -import pytest - -from pygen.codegen.models import CodeModel - - -def _code_model() -> CodeModel: - return CodeModel( - { - "clients": [ - { - "name": "client", - "namespace": "azure.search.documents", - "moduleName": "azure.search.documents", - "parameters": [], - "url": "", - "operationGroups": [], - } - ], - "namespace": "azure.search.documents", - }, - options={ - "show-send-request": True, - "builders-visibility": "public", - "show-operations": True, - "models-mode": "dpg", - "only-path-and-body-params-positional": True, - }, - ) - - -@pytest.mark.parametrize( - "serialize_namespace,imported_namespace,expected", - [ - # In-package: shares a top-level package -> relative import (unchanged). - ("azure.test.operations", "azure.test", ".."), - ("azure.test.operations", "azure", "..."), - ("azure.test.subtest.aio.operations", "azure.test", "...."), - ("azure.search.documents.operations", "azure.search.documents", ".."), - ], -) -def test_in_package_stays_relative(serialize_namespace, imported_namespace, expected): - assert _code_model().get_relative_import_path(serialize_namespace, imported_namespace) == expected - - -@pytest.mark.parametrize( - "serialize_namespace,imported_namespace,expected", - [ - # No shared top-level package -> valid absolute import (no leading dots). - ("azure.search.documents.grp.aio.operations", "search", "search"), - ("azure.search.documents.aio.operations", "search", "search"), - ("search.aio.operations", "azure.search.documents", "azure.search.documents"), - ], -) -def test_cross_root_uses_absolute_import(serialize_namespace, imported_namespace, expected): - result = _code_model().get_relative_import_path(serialize_namespace, imported_namespace) - assert result == expected - assert not result.startswith("."), "cross-root import must be absolute, not a package-escaping relative import" - - -def test_cross_root_absolute_import_with_module_name(): - # The Stream / AsyncStream import points at ``._utils.streaming_base``. When an - # operation lives outside the package root, that import must also be absolute-valid. - result = _code_model().get_relative_import_path( - "search.aio.operations", - "azure.search.documents", - module_name="_utils.streaming_base", - ) - assert result == "azure.search.documents._utils.streaming_base" - - -def test_in_package_module_name_stays_relative(): - result = _code_model().get_relative_import_path( - "azure.search.documents.grp.aio.operations", - "azure.search.documents", - module_name="_utils.streaming_base", - ) - assert result == "...._utils.streaming_base" From dffe9014d8c6d5a37a9fadef987e985be5e95952 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 17 Aug 2026 12:24:50 -0700 Subject: [PATCH 06/29] feat(http-client-python): structured JSONL/SSE streaming for both azure 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 --- .chronus/changes/structured-streaming-2026-0-0.md | 12 ++++++++++-- packages/http-client-python/README.md | 4 ++-- .../codegen/templates/streaming_base.py.jinja2 | 14 +++++++------- .../asynctests/test_streaming_jsonl_async.py | 3 ++- .../tests/mock_api/shared/test_streaming_jsonl.py | 2 +- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.chronus/changes/structured-streaming-2026-0-0.md b/.chronus/changes/structured-streaming-2026-0-0.md index af07b4d5cb9..877bc896b98 100644 --- a/.chronus/changes/structured-streaming-2026-0-0.md +++ b/.chronus/changes/structured-streaming-2026-0-0.md @@ -4,9 +4,17 @@ packages: - "@typespec/http-client-python" --- -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. +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. -`Stream` and `AsyncStream` are available from the generated package's base namespace. Their runtime (plus the JSONL / SSE decoders) is vendored at `_utils/streaming_base.py`, so it depends only on the released `azure.core.rest`. +`Stream` and `AsyncStream` are available from the generated package's base namespace. Their 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. + +```python +from your_sdk import Stream + +stream: Stream[Thing] = client.receive() +for thing in stream: + ... +``` ```python from your_sdk import Stream diff --git a/packages/http-client-python/README.md b/packages/http-client-python/README.md index 6e5a6df1cd8..c896aa6a990 100644 --- a/packages/http-client-python/README.md +++ b/packages/http-client-python/README.md @@ -156,7 +156,7 @@ Emit YAML code model only, without running Python generator. For batch processin ## Structured streaming (JSONL / SSE) -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]`). +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`, the generated method returns `Stream[Thing]` (sync) / `AsyncStream[Thing]` (async), yielding deserialized `Thing` instances as each JSONL line arrives. Similarly, `SSEStream` produces a `Stream` / `AsyncStream` over the SSE event payloads. @@ -171,6 +171,6 @@ 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 `azure.core.rest`, so no unreleased `azure.core.streaming` dependency is required at runtime. +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. diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 index d89a051d1eb..d94b376effa 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -7,8 +7,8 @@ # This file is vendored from azure-core (azure.core.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 an azure-core version that ships -# azure.core.streaming. Do not edit by hand. +# does not take a hard dependency on a core runtime that ships the streaming +# helpers. Do not edit by hand. # -------------------------------------------------------------------------- import codecs import json @@ -32,7 +32,7 @@ from typing import ( from typing_extensions import Self -from azure.core.rest import AsyncHttpResponse, HttpResponse +from {{ code_model.core_library }}.rest import AsyncHttpResponse, HttpResponse DecodedType = TypeVar("DecodedType") ReturnType_co = TypeVar("ReturnType_co", covariant=True) @@ -544,13 +544,13 @@ class Stream(Iterator[ReturnType_co]): """Stream class for consuming a decoded event stream (e.g. JSONL or SSE). :keyword response: The response object. - :paramtype response: ~azure.core.rest.HttpResponse + :paramtype response: ~{{ code_model.core_library }}.rest.HttpResponse :keyword decoder: A decoder to use for the stream. If omitted, the decoder is inferred from the response ``Content-Type`` header. :paramtype decoder: StreamDecoder :keyword deserialization_callback: A callback that takes the response and the decoded event and returns a deserialized object. - :paramtype deserialization_callback: Callable[[~azure.core.rest.HttpResponse, Any], ReturnType] + :paramtype deserialization_callback: Callable[[~{{ code_model.core_library }}.rest.HttpResponse, Any], ReturnType] :keyword terminal_event: Optional event ``data`` value that terminates the stream (e.g. ``"[DONE]"``). When an event's ``data`` equals this value, iteration stops and the event is not passed to ``deserialization_callback``. @@ -612,13 +612,13 @@ class AsyncStream(AsyncIterator[ReturnType_co]): """AsyncStream class for asynchronously consuming a decoded event stream (e.g. JSONL or SSE). :keyword response: The response object. - :paramtype response: ~azure.core.rest.AsyncHttpResponse + :paramtype response: ~{{ code_model.core_library }}.rest.AsyncHttpResponse :keyword decoder: A decoder to use for the stream. If omitted, the decoder is inferred from the response ``Content-Type`` header. :paramtype decoder: AsyncStreamDecoder :keyword deserialization_callback: A callback that takes the response and the decoded event and returns a deserialized object. - :paramtype deserialization_callback: Callable[[~azure.core.rest.AsyncHttpResponse, Any], ReturnType] + :paramtype deserialization_callback: Callable[[~{{ code_model.core_library }}.rest.AsyncHttpResponse, Any], ReturnType] :keyword terminal_event: Optional event ``data`` value that terminates the stream (e.g. ``"[DONE]"``). When an event's ``data`` equals this value, iteration stops and the event is not passed to ``deserialization_callback``. diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_jsonl_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_jsonl_async.py index 74e05cebd14..2893e7de8cc 100644 --- a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_jsonl_async.py +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_jsonl_async.py @@ -25,4 +25,5 @@ async def test_basic_send(client: JsonlClient): @pytest.mark.asyncio async def test_basic_recv(client: JsonlClient): - assert b"".join([d async for d in (await client.basic.receive())]) == JSONL + stream = await client.basic.receive() + assert [item.desc async for item in stream] == ["one", "two", "three"] diff --git a/packages/http-client-python/tests/mock_api/shared/test_streaming_jsonl.py b/packages/http-client-python/tests/mock_api/shared/test_streaming_jsonl.py index 494c17a3493..3515d83613f 100644 --- a/packages/http-client-python/tests/mock_api/shared/test_streaming_jsonl.py +++ b/packages/http-client-python/tests/mock_api/shared/test_streaming_jsonl.py @@ -22,4 +22,4 @@ def test_basic_send(client: JsonlClient): def test_basic_recv(client: JsonlClient): - assert b"".join(client.basic.receive()) == JSONL + assert [item.desc for item in client.basic.receive()] == ["one", "two", "three"] From 8bb99948dc5ccec109933aea52aea9a3f5ef1241 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 17 Aug 2026 13:16:31 -0700 Subject: [PATCH 07/29] docs(http-client-python): clarify streaming itemType fields and de-dupe 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 --- .chronus/changes/structured-streaming-2026-0-0.md | 8 -------- packages/http-client-python/emitter/src/http.ts | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.chronus/changes/structured-streaming-2026-0-0.md b/.chronus/changes/structured-streaming-2026-0-0.md index 877bc896b98..26098acbef7 100644 --- a/.chronus/changes/structured-streaming-2026-0-0.md +++ b/.chronus/changes/structured-streaming-2026-0-0.md @@ -15,11 +15,3 @@ stream: Stream[Thing] = client.receive() for thing in stream: ... ``` - -```python -from your_sdk import Stream - -stream: Stream[Thing] = client.receive() -for thing in stream: - ... -``` diff --git a/packages/http-client-python/emitter/src/http.ts b/packages/http-client-python/emitter/src/http.ts index 51743d17e58..8bc9ce50692 100644 --- a/packages/http-client-python/emitter/src/http.ts +++ b/packages/http-client-python/emitter/src/http.ts @@ -47,11 +47,26 @@ type EmittedType = ReturnType; interface StructuredStreamEvent { eventType: string | undefined; + /** + * Payload type for this one SSE event. Together with {@link eventType} these form the + * runtime dispatch table (wire event name -> model to deserialize) inside the generated + * `_callback`. This is a narrower type than {@link StructuredStreamingInfo.itemType}. + */ itemType: EmittedType; } interface StructuredStreamingInfo { kind: StructuredStreamKind; + /** + * The aggregate stream element type used for the `Stream[T]` / `AsyncStream[T]` return + * annotation (a single type expression). For homogeneous JSONL this is the one model; for + * heterogeneous SSE this is the union of every event payload. + * + * Note the deliberate overlap with the per-event {@link StructuredStreamEvent.itemType}: for + * heterogeneous SSE this union is exactly the sum of the `events[]` payload types. Both are + * carried because the union alone cannot recover the wire-name -> member mapping needed for + * dispatch, and the events list alone is not a single valid type expression for the annotation. + */ itemType: EmittedType; events?: StructuredStreamEvent[]; terminalEvent?: string; From 10d0a551da7d061e7718f97d2de07ef72c6a091e Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 17 Aug 2026 14:14:14 -0700 Subject: [PATCH 08/29] fix(http-client-python): make vendored streaming_base banner flavor-aware 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 --- .../pygen/codegen/templates/streaming_base.py.jinja2 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 index d94b376effa..daac525a0be 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -4,11 +4,11 @@ {% endif %} # pylint: disable=line-too-long,useless-suppression,unnecessary-ellipsis # -------------------------------------------------------------------------- -# This file is vendored from azure-core (azure.core.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. +# 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. # -------------------------------------------------------------------------- import codecs import json From 3ffe279b3bec66520195f8d22a87c86ed237220d Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 18 Aug 2026 08:57:44 -0700 Subject: [PATCH 09/29] fix(http-client-python): keep Stream/AsyncStream internal, not a public 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 --- .chronus/changes/structured-streaming-2026-0-0.md | 6 ++---- .../pygen/codegen/serializers/general_serializer.py | 13 ------------- .../pygen/codegen/templates/init.py.jinja2 | 9 --------- 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/.chronus/changes/structured-streaming-2026-0-0.md b/.chronus/changes/structured-streaming-2026-0-0.md index 26098acbef7..5fa79ea8015 100644 --- a/.chronus/changes/structured-streaming-2026-0-0.md +++ b/.chronus/changes/structured-streaming-2026-0-0.md @@ -6,12 +6,10 @@ packages: 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. -`Stream` and `AsyncStream` are available from the generated package's base namespace. Their 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. +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 -from your_sdk import Stream - -stream: Stream[Thing] = client.receive() +stream = client.receive() for thing in stream: ... ``` diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py index fd54fcc9260..4700b527667 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py @@ -200,24 +200,11 @@ def serialize_pkgutil_init_file(self) -> str: def serialize_init_file(self, clients: list[Client]) -> str: template = self.env.get_template("init.py.jinja2") - expose_streaming_types = ( - not self.async_mode - and self.code_model.need_streaming_base - and self.code_model.is_top_namespace(self.client_namespace) - ) return template.render( code_model=self.code_model, clients=clients, async_mode=self.async_mode, serialize_namespace=self.serialize_namespace, - streaming_import_path=( - self.code_model.get_relative_import_path( - self.serialize_namespace, - module_name="_utils.streaming_base", - ) - if expose_streaming_types - else None - ), ) def serialize_service_client_file(self, clients: list[Client]) -> str: diff --git a/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 index 69176f27529..007009e4bc9 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 @@ -9,9 +9,6 @@ from .{{ client.filename }} import {{ client.name }} # type: ignore {% endfor %} {% endif %} -{% if streaming_import_path %} -from {{ streaming_import_path }} import AsyncStream, Stream -{% endif %} {% if not async_mode and code_model.options.get("package-version") %} from {{ code_model.get_relative_import_path(serialize_namespace, module_name="_version") }} import VERSION @@ -20,15 +17,9 @@ __version__ = VERSION {{ keywords.patch_imports(try_except=True) }} __all__ = [ - {% if streaming_import_path %} - "AsyncStream", - {% endif %} {% for client in clients %} {{ keywords.escape_str(client.name) }}, {% endfor %} - {% if streaming_import_path %} - "Stream", - {% endif %} ] {{ keywords.extend_all }} From fd26ef27787ebe85e59ffa868e940c252ff88bce Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 18 Aug 2026 08:57:57 -0700 Subject: [PATCH 10/29] test(http-client-python): add SSE streaming Spector mock API tests (sync + 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 --- .../streaming-sse-tests-2026-8-18-8-40-0.md | 7 +++ .../asynctests/test_streaming_sse_async.py | 48 +++++++++++++++++++ .../mock_api/shared/test_streaming_sse.py | 41 ++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 .chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md create mode 100644 packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py create mode 100644 packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py diff --git a/.chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md b/.chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md new file mode 100644 index 00000000000..3e276589545 --- /dev/null +++ b/.chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/http-client-python" +--- + +Add Spector mock API tests (sync + async, shared across the Azure and unbranded flavors) for SSE structured streaming: unnamed homogeneous events, named heterogeneous events with a `[DONE]` terminal event, and a POST request whose response is an SSE stream. diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py new file mode 100644 index 00000000000..9f122dee185 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py @@ -0,0 +1,48 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import pytest +import pytest_asyncio + +from streaming.sse.aio import SseClient +from streaming.sse.named.models import ResponseCreated, ResponseDelta +from streaming.sse.retrieve.models import FinalResult, PartialResult, RetrievalRequest +from streaming.sse.unnamed.models import Info + + +@pytest_asyncio.fixture +async def client(): + async with SseClient(endpoint="http://localhost:3000") as client: + yield client + + +@pytest.mark.asyncio +async def test_unnamed_receive(client: SseClient): + stream = await client.unnamed.receive() + items = [item async for item in stream] + assert all(isinstance(item, Info) for item in items) + assert [item.desc for item in items] == ["one", "two", "three"] + + +@pytest.mark.asyncio +async def test_named_receive(client: SseClient): + stream = await client.named.receive() + items = [item async for item in stream] + # The terminal "[DONE]" event stops iteration and is not yielded. + assert len(items) == 3 + assert isinstance(items[0], ResponseCreated) and items[0].id == "resp_1" + assert isinstance(items[1], ResponseDelta) and items[1].delta == "Hello" + assert isinstance(items[2], ResponseDelta) and items[2].delta == " world" + + +@pytest.mark.asyncio +async def test_retrieve_stream(client: SseClient): + stream = await client.retrieve.stream(RetrievalRequest(query="what is typespec?")) + items = [item async for item in stream] + # The terminal "[DONE]" event stops iteration and is not yielded. + assert len(items) == 3 + assert isinstance(items[0], PartialResult) and items[0].text == "partial one" + assert isinstance(items[1], PartialResult) and items[1].text == "partial two" + assert isinstance(items[2], FinalResult) and items[2].references == ["doc1", "doc2"] diff --git a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py new file mode 100644 index 00000000000..235172ae24e --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py @@ -0,0 +1,41 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +import pytest + +from streaming.sse import SseClient +from streaming.sse.named.models import ResponseCreated, ResponseDelta +from streaming.sse.retrieve.models import FinalResult, PartialResult, RetrievalRequest +from streaming.sse.unnamed.models import Info + + +@pytest.fixture +def client(): + with SseClient(endpoint="http://localhost:3000") as client: + yield client + + +def test_unnamed_receive(client: SseClient): + items = list(client.unnamed.receive()) + assert all(isinstance(item, Info) for item in items) + assert [item.desc for item in items] == ["one", "two", "three"] + + +def test_named_receive(client: SseClient): + items = list(client.named.receive()) + # The terminal "[DONE]" event stops iteration and is not yielded. + assert len(items) == 3 + assert isinstance(items[0], ResponseCreated) and items[0].id == "resp_1" + assert isinstance(items[1], ResponseDelta) and items[1].delta == "Hello" + assert isinstance(items[2], ResponseDelta) and items[2].delta == " world" + + +def test_retrieve_stream(client: SseClient): + items = list(client.retrieve.stream(RetrievalRequest(query="what is typespec?"))) + # The terminal "[DONE]" event stops iteration and is not yielded. + assert len(items) == 3 + assert isinstance(items[0], PartialResult) and items[0].text == "partial one" + assert isinstance(items[1], PartialResult) and items[1].text == "partial two" + assert isinstance(items[2], FinalResult) and items[2].references == ["doc1", "doc2"] From 0233370e7fc4d85127e449c43db4a8fe2ddb917e Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 18 Aug 2026 12:35:28 -0700 Subject: [PATCH 11/29] test(http-client-python): assert streaming ops return Stream/AsyncStream 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 --- .../shared/asynctests/test_streaming_jsonl_async.py | 7 ++++++- .../shared/asynctests/test_streaming_sse_async.py | 4 ++++ .../tests/mock_api/shared/test_streaming_jsonl.py | 8 +++++++- .../tests/mock_api/shared/test_streaming_sse.py | 13 ++++++++++--- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_jsonl_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_jsonl_async.py index 2893e7de8cc..d46dd1c41b9 100644 --- a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_jsonl_async.py +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_jsonl_async.py @@ -7,6 +7,8 @@ import pytest_asyncio from streaming.jsonl.aio import JsonlClient +from streaming.jsonl._utils.streaming_base import AsyncStream +from streaming.jsonl.basic.models import Info @pytest_asyncio.fixture @@ -26,4 +28,7 @@ async def test_basic_send(client: JsonlClient): @pytest.mark.asyncio async def test_basic_recv(client: JsonlClient): stream = await client.basic.receive() - assert [item.desc async for item in stream] == ["one", "two", "three"] + assert isinstance(stream, AsyncStream) + items = [item async for item in stream] + assert all(isinstance(item, Info) for item in items) + assert [item.desc for item in items] == ["one", "two", "three"] diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py index 9f122dee185..8991eb984ab 100644 --- a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py @@ -7,6 +7,7 @@ import pytest_asyncio from streaming.sse.aio import SseClient +from streaming.sse._utils.streaming_base import AsyncStream from streaming.sse.named.models import ResponseCreated, ResponseDelta from streaming.sse.retrieve.models import FinalResult, PartialResult, RetrievalRequest from streaming.sse.unnamed.models import Info @@ -21,6 +22,7 @@ async def client(): @pytest.mark.asyncio async def test_unnamed_receive(client: SseClient): stream = await client.unnamed.receive() + assert isinstance(stream, AsyncStream) items = [item async for item in stream] assert all(isinstance(item, Info) for item in items) assert [item.desc for item in items] == ["one", "two", "three"] @@ -29,6 +31,7 @@ async def test_unnamed_receive(client: SseClient): @pytest.mark.asyncio async def test_named_receive(client: SseClient): stream = await client.named.receive() + assert isinstance(stream, AsyncStream) items = [item async for item in stream] # The terminal "[DONE]" event stops iteration and is not yielded. assert len(items) == 3 @@ -40,6 +43,7 @@ async def test_named_receive(client: SseClient): @pytest.mark.asyncio async def test_retrieve_stream(client: SseClient): stream = await client.retrieve.stream(RetrievalRequest(query="what is typespec?")) + assert isinstance(stream, AsyncStream) items = [item async for item in stream] # The terminal "[DONE]" event stops iteration and is not yielded. assert len(items) == 3 diff --git a/packages/http-client-python/tests/mock_api/shared/test_streaming_jsonl.py b/packages/http-client-python/tests/mock_api/shared/test_streaming_jsonl.py index 3515d83613f..bc2febf91d2 100644 --- a/packages/http-client-python/tests/mock_api/shared/test_streaming_jsonl.py +++ b/packages/http-client-python/tests/mock_api/shared/test_streaming_jsonl.py @@ -6,6 +6,8 @@ import pytest from streaming.jsonl import JsonlClient +from streaming.jsonl._utils.streaming_base import Stream +from streaming.jsonl.basic.models import Info @pytest.fixture @@ -22,4 +24,8 @@ def test_basic_send(client: JsonlClient): def test_basic_recv(client: JsonlClient): - assert [item.desc for item in client.basic.receive()] == ["one", "two", "three"] + stream = client.basic.receive() + assert isinstance(stream, Stream) + items = list(stream) + assert all(isinstance(item, Info) for item in items) + assert [item.desc for item in items] == ["one", "two", "three"] diff --git a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py index 235172ae24e..60c0de9e53a 100644 --- a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py +++ b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py @@ -6,6 +6,7 @@ import pytest from streaming.sse import SseClient +from streaming.sse._utils.streaming_base import Stream from streaming.sse.named.models import ResponseCreated, ResponseDelta from streaming.sse.retrieve.models import FinalResult, PartialResult, RetrievalRequest from streaming.sse.unnamed.models import Info @@ -18,13 +19,17 @@ def client(): def test_unnamed_receive(client: SseClient): - items = list(client.unnamed.receive()) + stream = client.unnamed.receive() + assert isinstance(stream, Stream) + items = list(stream) assert all(isinstance(item, Info) for item in items) assert [item.desc for item in items] == ["one", "two", "three"] def test_named_receive(client: SseClient): - items = list(client.named.receive()) + stream = client.named.receive() + assert isinstance(stream, Stream) + items = list(stream) # The terminal "[DONE]" event stops iteration and is not yielded. assert len(items) == 3 assert isinstance(items[0], ResponseCreated) and items[0].id == "resp_1" @@ -33,7 +38,9 @@ def test_named_receive(client: SseClient): def test_retrieve_stream(client: SseClient): - items = list(client.retrieve.stream(RetrievalRequest(query="what is typespec?"))) + stream = client.retrieve.stream(RetrievalRequest(query="what is typespec?")) + assert isinstance(stream, Stream) + items = list(stream) # The terminal "[DONE]" event stops iteration and is not yielded. assert len(items) == 3 assert isinstance(items[0], PartialResult) and items[0].text == "partial one" From ece5a8846265fb29ccd25c600e4e0cb4eddc9f85 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Wed, 19 Aug 2026 09:42:31 -0700 Subject: [PATCH 12/29] chore(http-client-python): address streaming PR review feedback - 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 --- .../streaming-sse-tests-2026-8-18-8-40-0.md | 7 ----- packages/http-client-python/package.json | 3 --- .../tests/unit/test_streaming_init.py | 27 ++++++++++--------- 3 files changed, 15 insertions(+), 22 deletions(-) delete mode 100644 .chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md diff --git a/.chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md b/.chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md deleted file mode 100644 index 3e276589545..00000000000 --- a/.chronus/changes/streaming-sse-tests-2026-8-18-8-40-0.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -changeKind: internal -packages: - - "@typespec/http-client-python" ---- - -Add Spector mock API tests (sync + async, shared across the Azure and unbranded flavors) for SSE structured streaming: unnamed homogeneous events, named heterogeneous events with a `[DONE]` terminal event, and a POST request whose response is an SSE stream. diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json index 7a33b7749ba..b530962f347 100644 --- a/packages/http-client-python/package.json +++ b/packages/http-client-python/package.json @@ -132,8 +132,5 @@ "typescript-eslint": "^8.49.0", "vitest": "^4.0.15", "prettier": "^3.9.5" - }, - "overrides": { - "@typespec/compiler": "$@typespec/compiler" } } diff --git a/packages/http-client-python/tests/unit/test_streaming_init.py b/packages/http-client-python/tests/unit/test_streaming_init.py index 1d83e492c5a..6454c991da4 100644 --- a/packages/http-client-python/tests/unit/test_streaming_init.py +++ b/packages/http-client-python/tests/unit/test_streaming_init.py @@ -34,24 +34,27 @@ def _render_init(*, has_structured_stream: bool, async_mode: bool = False) -> st return GeneralSerializer(code_model=code_model, env=_env(), async_mode=async_mode).serialize_init_file([client]) -def test_exports_stream_types_from_base_namespace(): - init_file = _render_init(has_structured_stream=True) - - assert "from ._utils.streaming_base import AsyncStream, Stream" in init_file - assert '"AsyncStream",' in init_file - assert '"Stream",' in init_file - - @pytest.mark.parametrize( "has_structured_stream,async_mode", [ (False, False), + (False, True), + (True, False), (True, True), ], ) -def test_does_not_export_stream_types_outside_structured_base_namespace(has_structured_stream, async_mode): +def test_does_not_export_stream_types(has_structured_stream, async_mode): + # Stream / AsyncStream are an internal implementation detail (vendored in + # _utils/streaming_base.py) and must never be part of the generated package's + # public API -- even when a structured stream is present in the sync base + # namespace. This guards against reintroducing a public export that would + # become a breaking change once the runtime moves to the core library. init_file = _render_init(has_structured_stream=has_structured_stream, async_mode=async_mode) - assert "streaming_base import" not in init_file - assert '"AsyncStream",' not in init_file - assert '"Stream",' not in init_file + # No public export of the streaming types -- neither an import line nor an + # __all__ entry, regardless of quote style. + assert "streaming_base" not in init_file + assert "AsyncStream" not in init_file + assert "Stream" not in init_file + # Sanity check: the client itself is still imported/exported as normal. + assert "from ._client import SampleClient" in init_file From 268f135ad0b6c76b83411abb799ab8f1fdc06d1c Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Wed, 19 Aug 2026 09:51:08 -0700 Subject: [PATCH 13/29] test(http-client-python): remove streaming init unit test 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 --- .../tests/unit/test_streaming_init.py | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 packages/http-client-python/tests/unit/test_streaming_init.py diff --git a/packages/http-client-python/tests/unit/test_streaming_init.py b/packages/http-client-python/tests/unit/test_streaming_init.py deleted file mode 100644 index 6454c991da4..00000000000 --- a/packages/http-client-python/tests/unit/test_streaming_init.py +++ /dev/null @@ -1,60 +0,0 @@ -# ------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- -from types import SimpleNamespace - -import pytest -from jinja2 import Environment, PackageLoader - -from pygen.codegen.serializers.general_serializer import GeneralSerializer - - -def _env() -> Environment: - return Environment( - loader=PackageLoader("pygen.codegen", "templates"), - keep_trailing_newline=True, - trim_blocks=True, - lstrip_blocks=True, - ) - - -def _render_init(*, has_structured_stream: bool, async_mode: bool = False) -> str: - code_model = SimpleNamespace( - namespace="sample", - license_header="", - options={"package-version": None}, - need_streaming_base=has_structured_stream, - get_serialize_namespace=lambda namespace, async_mode: f"{namespace}.aio" if async_mode else namespace, - get_relative_import_path=lambda _namespace, module_name: f".{module_name}", - is_top_namespace=lambda namespace: namespace == "sample", - ) - client = SimpleNamespace(filename="_client", name="SampleClient") - return GeneralSerializer(code_model=code_model, env=_env(), async_mode=async_mode).serialize_init_file([client]) - - -@pytest.mark.parametrize( - "has_structured_stream,async_mode", - [ - (False, False), - (False, True), - (True, False), - (True, True), - ], -) -def test_does_not_export_stream_types(has_structured_stream, async_mode): - # Stream / AsyncStream are an internal implementation detail (vendored in - # _utils/streaming_base.py) and must never be part of the generated package's - # public API -- even when a structured stream is present in the sync base - # namespace. This guards against reintroducing a public export that would - # become a breaking change once the runtime moves to the core library. - init_file = _render_init(has_structured_stream=has_structured_stream, async_mode=async_mode) - - # No public export of the streaming types -- neither an import line nor an - # __all__ entry, regardless of quote style. - assert "streaming_base" not in init_file - assert "AsyncStream" not in init_file - assert "Stream" not in init_file - # Sanity check: the client itself is still imported/exported as normal. - assert "from ._client import SampleClient" in init_file From 46cec7dd398bcb8043123f19f7cd872961e225f5 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 20 Aug 2026 08:39:49 -0700 Subject: [PATCH 14/29] build(http-client-python): revert incidental package-lock.json churn 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 --- packages/http-client-python/package-lock.json | 366 ++++++++---------- 1 file changed, 170 insertions(+), 196 deletions(-) diff --git a/packages/http-client-python/package-lock.json b/packages/http-client-python/package-lock.json index 1b84a448322..64e2b328755 100644 --- a/packages/http-client-python/package-lock.json +++ b/packages/http-client-python/package-lock.json @@ -999,8 +999,8 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha1-8p4iBXrVMWzyODbO6aNMgf/8t+Y=", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1014,9 +1014,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "peer": true, @@ -1027,8 +1027,8 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "peer": true, @@ -1041,8 +1041,8 @@ }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha1-G9AGzut+LlWyt3OrMY0wDhpmrto=", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1055,8 +1055,8 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha1-dyJYIEE9lhdQnak0IZCiAZ54dhw=", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1068,9 +1068,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha1-0iv9azp9jh8sCy8ubeERtT7G4T4=", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "peer": true, @@ -1081,7 +1081,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1093,9 +1093,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha1-B+mCx0YmFnqnoklcU4F4ktcTlJI=", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "peer": true, @@ -1111,9 +1111,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "peer": true, @@ -1124,16 +1124,16 @@ }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha1-afaofZUTq4u4/mO9sJecRI5oRmA=", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "peer": true, @@ -1145,9 +1145,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha1-by+8/3VQDSKdU14KlJrhNHLIR4c=", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "peer": true, @@ -1160,8 +1160,8 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha1-biEmoTR+hqTe34cG7Gf/jhB+u60=", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1171,8 +1171,8 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha1-l3nj/Zt+4zVxpXQ1z0M1oXlKbLI=", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1185,52 +1185,48 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha1-qCcsoDsqz0kmcCIrIyC2xCG/3mA=", + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "license": "Apache-2.0", "peer": true, - "dependencies": { - "@humanfs/types": "^0.15.0" - }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha1-j4AMzME/T4zTEW4tnAqUk52j4+0=", + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, - "license": "Apache-2.0", "peer": true, "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha1-8qCfYgEjkLK/8/xvskjd7IwJoJA=", + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, - "license": "Apache-2.0", "peer": true, "engines": { - "node": ">=18.18.0" + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha1-r1smkaIrRL6EewyoFkHF+2rQFyw=", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "peer": true, "engines": { "node": ">=12.22" @@ -1241,11 +1237,10 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha1-wrnS43TuYsWG062+qHGZsdenpro=", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, - "license": "Apache-2.0", "peer": true, "engines": { "node": ">=18.18" @@ -2066,8 +2061,8 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha1-WWoXRyM2lNUPatinhp/Lb1bPWEE=", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT", "peer": true @@ -2901,9 +2896,9 @@ } }, "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha1-T68BstbTJr/u2XrqH1IiC19MGUA=", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "peer": true, @@ -2916,8 +2911,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha1-ftW7VZCLOy8bxVxq8WU7rafweTc=", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peer": true, @@ -3240,8 +3235,8 @@ }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha1-s2MKvYlDQy9Us/BRkjjjPNffL3M=", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "peer": true, @@ -3259,24 +3254,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha1-qsTit3NKdAhnrrFr8CqtVWoeegE=", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -3384,8 +3361,8 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT", "peer": true @@ -3490,10 +3467,9 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha1-pvLc5hL63S7x9Rm3NVHxfoUZmDE=", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/default-browser": { @@ -3728,10 +3704,9 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha1-FLqDpdNz49MR5a/KKc9b+tllvzQ=", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "license": "MIT", "peer": true, "engines": { "node": ">=10" @@ -3741,9 +3716,9 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha1-Kk48iw91MZbvrpQ8j/qocw/Go/o=", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "peer": true, @@ -3753,8 +3728,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3803,8 +3778,8 @@ }, "node_modules/eslint-scope": { "version": "8.4.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha1-iOZGogf61hQ2/6OetQUUcgBlXII=", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3833,9 +3808,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha1-B+mCx0YmFnqnoklcU4F4ktcTlJI=", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "peer": true, @@ -3851,9 +3826,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha1-POdNiYhRNr4VNTQfjD1EJcKaXKs=", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "peer": true, @@ -3862,18 +3837,35 @@ "concat-map": "0.0.1" } }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha1-afaofZUTq4u4/mO9sJecRI5oRmA=", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT", "peer": true }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha1-WAyI+NVEXyvWqo88re+g3nn71p4=", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "peer": true, @@ -3886,8 +3878,8 @@ }, "node_modules/espree": { "version": "10.4.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/espree/-/espree-10.4.0.tgz", - "integrity": "sha1-1U9JSdRikAWh+haNk3w/8ffiqDc=", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3904,11 +3896,10 @@ } }, "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha1-CNBI8mHw3e21uulfRoCUY9nJSW0=", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, - "license": "BSD-3-Clause", "peer": true, "dependencies": { "estraverse": "^5.1.0" @@ -3919,8 +3910,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha1-eteWTWeauyi+5yzsY3WLHF0smSE=", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3933,10 +3924,9 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha1-LupSkHAvJquP5TcDcP+GyWXSESM=", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "peer": true, "engines": { "node": ">=4.0" @@ -3954,10 +3944,9 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha1-dNLrTeC42hKTcRkQ1Qd1ubcQ72Q=", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "peer": true, "engines": { "node": ">=0.10.0" @@ -4045,18 +4034,17 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha1-h0v2nG9ATCtdmcSBNBOZ/VWJJjM=", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT", "peer": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/fast-string-truncated-width": { @@ -4146,10 +4134,9 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha1-d4e93PETG/+5JjbGlFe7wO3W2B8=", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "flat-cache": "^4.0.0" @@ -4211,10 +4198,9 @@ }, "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha1-Ds45/LFO4BL0sEEL0z3ZwfAREnw=", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "flatted": "^3.2.9", @@ -4225,9 +4211,9 @@ } }, "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha1-ruyipQYwPwzuYcWebJ8qiNLyn8Y=", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC", "peer": true @@ -4383,10 +4369,9 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha1-bSN9mQg5UMeSkPJMdkKj3poo+eM=", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "ISC", "peer": true, "dependencies": { "is-glob": "^4.0.3" @@ -4436,8 +4421,8 @@ }, "node_modules/globals": { "version": "14.0.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/globals/-/globals-14.0.0.tgz", - "integrity": "sha1-iY10E8Kbq89rr+Vvyt3thYrack4=", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "peer": true, @@ -4570,8 +4555,8 @@ }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha1-PNQOcp82Q/2HywTlC/DrcivFlvU=", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "peer": true, @@ -4581,8 +4566,8 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha1-nOy1ZQPAraHydB271lRuSxO1fM8=", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "peer": true, @@ -4599,8 +4584,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "peer": true, @@ -4643,10 +4628,9 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MIT", "peer": true, "engines": { "node": ">=0.10.0" @@ -4663,10 +4647,9 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha1-ZPYeQsu7LuwgcanawLKLoeZdUIQ=", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "is-extglob": "^2.1.1" @@ -4815,9 +4798,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha1-0ZAFcqf3zwtfVAyDZz5gutNDZZI=", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "funding": [ { "type": "github", @@ -4838,10 +4821,9 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha1-kziAKjDTtmBfvgYT4JQAjKjAWhM=", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/json-schema-traverse": { @@ -4853,10 +4835,9 @@ }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/jsonwebtoken": { @@ -4907,10 +4888,9 @@ }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha1-qHmpnilFL5QkOfKkBeOvizHU3pM=", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "json-buffer": "3.0.1" @@ -4918,10 +4898,9 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/levn/-/levn-0.4.1.tgz", - "integrity": "sha1-rkViwAdHO5MqYgDUAyaN0v/8at4=", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "prelude-ls": "^1.2.1", @@ -5251,10 +5230,9 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha1-VYqlO0O2YeGSWgr9+japoQhf5Xo=", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "MIT", "peer": true }, "node_modules/lodash.once": { @@ -5670,10 +5648,9 @@ }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha1-fqHBpdkddk+yghOciP4R4YKjpzQ=", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "deep-is": "^0.1.3", @@ -5725,8 +5702,8 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha1-aR0nCeeMefrjoVZiJFLQB2LKqqI=", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "peer": true, @@ -5875,10 +5852,9 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha1-3rxkidem5rDnYRiIzsiAM30xY5Y=", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "license": "MIT", "peer": true, "engines": { "node": ">= 0.8.0" @@ -5916,8 +5892,8 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha1-AnQi4vrsCyXhVJw+G9gwm5EztuU=", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "peer": true, @@ -6019,8 +5995,8 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha1-SrzYUq0y3Xuqv+m0DgCjbbXzkuY=", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "peer": true, @@ -6479,8 +6455,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha1-MfEoGzgyYwQ0gxwxDAHMzajL4AY=", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "peer": true, @@ -6771,10 +6747,9 @@ }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha1-B7ggO/pwVsBlcFDjzNLDdzC6uPE=", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "prelude-ls": "^1.2.1" @@ -6880,8 +6855,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha1-mxpSWVIlhZ5V9mnZKPiMbFfyp34=", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -7186,10 +7161,9 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha1-0sRcbdT7zmIaZvE2y+Mor9BBCzQ=", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "peer": true, "engines": { "node": ">=0.10.0" From aa76c0d2f02202e3742f9092b5ab1124deb6603f Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 20 Aug 2026 13:13:38 -0700 Subject: [PATCH 15/29] feat(http-client-python): support named and model SSE terminal events 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 --- .../http-client-python/emitter/src/http.ts | 80 +++++++++++++--- .../emitter/test/streaming.test.ts | 92 +++++++++++++++++- .../pygen/codegen/models/response.py | 9 ++ .../codegen/serializers/builder_serializer.py | 14 ++- .../templates/streaming_base.py.jinja2 | 17 ++++ .../asynctests/test_streaming_sse_async.py | 80 ++++++++++++++++ .../mock_api/shared/test_streaming_sse.py | 95 +++++++++++++++++++ 7 files changed, 364 insertions(+), 23 deletions(-) diff --git a/packages/http-client-python/emitter/src/http.ts b/packages/http-client-python/emitter/src/http.ts index 8bc9ce50692..5526dfbb47a 100644 --- a/packages/http-client-python/emitter/src/http.ts +++ b/packages/http-client-python/emitter/src/http.ts @@ -53,6 +53,13 @@ interface StructuredStreamEvent { * `_callback`. This is a narrower type than {@link StructuredStreamingInfo.itemType}. */ itemType: EmittedType; + /** + * True when this event is a `@terminalEvent` that carries a payload (a named / model event, + * not a bare string-constant sentinel). Such events are deserialized and yielded like any + * other event, and iteration stops immediately after one is yielded. Contrast with + * {@link StructuredStreamingInfo.terminalEvent}, the sentinel that stops without yielding. + */ + isTerminal?: boolean; } interface StructuredStreamingInfo { @@ -69,6 +76,11 @@ interface StructuredStreamingInfo { */ itemType: EmittedType; events?: StructuredStreamEvent[]; + /** + * A bare string-constant `@terminalEvent` with no event name (e.g. `"[DONE]"`). Iteration + * stops when an event's `data` equals this value, and the sentinel is NOT yielded. Named / + * model terminal events are carried in {@link events} with `isTerminal: true` instead. + */ terminalEvent?: string; } @@ -104,6 +116,56 @@ function getStringConstantValue(type: SdkType): string | undefined { return type.kind === "constant" && typeof type.value === "string" ? type.value : undefined; } +/** The subset of an {@link SdkSseMetadata} event that terminal-event partitioning depends on. */ +interface SseEventLike { + eventType?: string | undefined; + isTerminalEvent: boolean; + type: SdkType; + payloadType: SdkType; +} + +/** + * Split the SSE events into the runtime dispatch table and a bare string-constant sentinel. + * + * A `@terminalEvent` comes in two shapes: + * * a nameless string constant (e.g. `"[DONE]"`) -> a pure sentinel: iteration stops when an + * event's `data` equals this value and the event is NOT yielded. Returned as `terminalEvent`. + * * a named / model event (e.g. `error`, `response.completed`) -> carries a payload the consumer + * needs, so it is deserialized and yielded like any other event, then iteration stops. Returned + * in `events` with `isTerminal: true`. + * + * `toItemType` maps an event payload to its emitted type; it is injected so this partitioning stays + * a pure function that can be unit-tested without a full emitter context. + */ +export function partitionSseEvents( + events: readonly SseEventLike[], + toItemType: (payloadType: SdkType) => EmittedType, +): { events: StructuredStreamEvent[]; terminalEvent?: string } { + const dispatch: StructuredStreamEvent[] = []; + let terminalEvent: string | undefined; + for (const event of events) { + if (event.isTerminalEvent) { + const sentinelValue = + event.eventType === undefined + ? (getStringConstantValue(event.payloadType) ?? getStringConstantValue(event.type)) + : undefined; + if (sentinelValue !== undefined) { + // Keep the first sentinel; no current spec defines more than one. + terminalEvent ??= sentinelValue; + continue; + } + dispatch.push({ + eventType: event.eventType, + itemType: toItemType(event.payloadType), + isTerminal: true, + }); + continue; + } + dispatch.push({ eventType: event.eventType, itemType: toItemType(event.payloadType) }); + } + return terminalEvent !== undefined ? { events: dispatch, terminalEvent } : { events: dispatch }; +} + function emitStructuredStreamingInfo( context: PythonSdkContext, response: SdkHttpResponse | SdkHttpErrorResponse, @@ -122,21 +184,11 @@ function emitStructuredStreamingInfo( const sseMetadata = response.sseMetadata; if (!sseMetadata) return streaming; - const events = sseMetadata.events - .filter((event) => !event.isTerminalEvent) - .map((event) => ({ - eventType: event.eventType, - itemType: getType(context, event.payloadType), - })); + const { events, terminalEvent } = partitionSseEvents(sseMetadata.events, (payloadType) => + getType(context, payloadType), + ); if (events.length > 0) streaming.events = events; - - const terminalEvent = sseMetadata.events.find((event) => event.isTerminalEvent); - if (terminalEvent) { - const terminalEventValue = - getStringConstantValue(terminalEvent.payloadType) ?? - getStringConstantValue(terminalEvent.type); - if (terminalEventValue !== undefined) streaming.terminalEvent = terminalEventValue; - } + if (terminalEvent !== undefined) streaming.terminalEvent = terminalEvent; return streaming; } diff --git a/packages/http-client-python/emitter/test/streaming.test.ts b/packages/http-client-python/emitter/test/streaming.test.ts index 491bc04b098..f0c03f4ac52 100644 --- a/packages/http-client-python/emitter/test/streaming.test.ts +++ b/packages/http-client-python/emitter/test/streaming.test.ts @@ -1,6 +1,10 @@ import { strictEqual } from "assert"; import { describe, it } from "vitest"; -import { getStructuredStreamKind, isStructuredStreamType } from "../src/http.js"; +import { + getStructuredStreamKind, + isStructuredStreamType, + partitionSseEvents, +} from "../src/http.js"; describe("typespec-python: structured streaming", () => { it("treats model and union payloads as structured", () => { @@ -42,4 +46,90 @@ describe("typespec-python: structured streaming", () => { undefined, ); }); + + describe("terminal-event partitioning", () => { + const identity = (payloadType: any) => payloadType; + const model = (name: string) => ({ kind: "model", name }); + const constant = (value: string) => ({ kind: "constant", value }); + + it("keeps a nameless string-constant `[DONE]` as a drop-and-stop sentinel", () => { + const created = model("ResponseCreated"); + const done = constant("[DONE]"); + const { events, terminalEvent } = partitionSseEvents( + [ + { + eventType: "response.created", + isTerminalEvent: false, + type: created, + payloadType: created, + }, + { eventType: undefined, isTerminalEvent: true, type: done, payloadType: done }, + ] as any, + identity, + ); + // The sentinel is NOT a dispatch event; it only sets `terminalEvent`. + strictEqual(terminalEvent, "[DONE]"); + strictEqual(events.length, 1); + strictEqual(events[0].eventType, "response.created"); + strictEqual(events[0].isTerminal, undefined); + }); + + it("keeps named / model `@terminalEvent`s in the dispatch table as yield-and-stop events", () => { + const created = model("ResponseCreated"); + const completed = model("ResponseCompleted"); + const errored = model("StreamError"); + const { events, terminalEvent } = partitionSseEvents( + [ + { + eventType: "response.created", + isTerminalEvent: false, + type: created, + payloadType: created, + }, + { + eventType: "response.completed", + isTerminalEvent: true, + type: completed, + payloadType: completed, + }, + { eventType: "error", isTerminalEvent: true, type: errored, payloadType: errored }, + ] as any, + identity, + ); + // No bare sentinel: the two terminals carry payloads, so they stay in `events`. + strictEqual(terminalEvent, undefined); + strictEqual(events.length, 3); + strictEqual(events[0].isTerminal, undefined); + strictEqual(events[1].eventType, "response.completed"); + strictEqual(events[1].isTerminal, true); + strictEqual(events[1].itemType, completed); + strictEqual(events[2].eventType, "error"); + strictEqual(events[2].isTerminal, true); + strictEqual(events[2].itemType, errored); + }); + + it("supports a sentinel and named terminals together", () => { + const delta = model("ResponseDelta"); + const completed = model("ResponseCompleted"); + const done = constant("[DONE]"); + const { events, terminalEvent } = partitionSseEvents( + [ + { eventType: "response.delta", isTerminalEvent: false, type: delta, payloadType: delta }, + { + eventType: "response.completed", + isTerminalEvent: true, + type: completed, + payloadType: completed, + }, + { eventType: undefined, isTerminalEvent: true, type: done, payloadType: done }, + ] as any, + identity, + ); + strictEqual(terminalEvent, "[DONE]"); + strictEqual(events.length, 2); + strictEqual(events[0].isTerminal, undefined); + strictEqual(events[1].eventType, "response.completed"); + strictEqual(events[1].isTerminal, true); + }); + }); }); diff --git a/packages/http-client-python/generator/pygen/codegen/models/response.py b/packages/http-client-python/generator/pygen/codegen/models/response.py index c102ffd8802..724db0db50a 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/response.py +++ b/packages/http-client-python/generator/pygen/codegen/models/response.py @@ -62,11 +62,20 @@ def __init__( self.streaming_kind: Optional[str] = streaming["kind"] if streaming else None self.streaming_events: list[tuple[Optional[str], BaseType]] = [] self._streaming_terminal_event: Optional[str] = streaming.get("terminalEvent") if streaming else None + # Named / model ``@terminalEvent`` events: deserialized and yielded like any other event, + # then iteration stops. The bare string-constant sentinel (``_streaming_terminal_event``) + # stops WITHOUT yielding and is matched on event ``data`` instead of the event name. + self.terminal_event_names: list[str] = [] if streaming: self.streaming_events = [ (event.get("eventType"), self.code_model.lookup_type(id(event["itemType"]))) for event in streaming.get("events", []) ] + self.terminal_event_names = [ + event["eventType"] + for event in streaming.get("events", []) + if event.get("isTerminal") and event.get("eventType") is not None + ] @property def result_property(self) -> str: diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index dd12919cf07..c449cab366f 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -1267,6 +1267,7 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] ) stream_class = response.stream_class_name(self.async_mode) # type: ignore[attr-defined] terminal_event = getattr(response, "terminal_event", None) + terminal_event_names = getattr(response, "terminal_event_names", []) streaming_events = getattr(response, "streaming_events", []) retval: list[str] = [] retval.append("def _callback(_http_response, _event):") @@ -1311,15 +1312,12 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] retval.append(" return cls(pipeline_response, deserialized, {}) # type: ignore") retval.append(" return deserialized") retval.append("") + stream_kwargs = ["response=response", "deserialization_callback=_callback"] if terminal_event is not None: - retval.append( - f"return {stream_class}(response=response, deserialization_callback=_callback, " - f"terminal_event={terminal_event!r}) # type: ignore" - ) - else: - retval.append( - f"return {stream_class}(response=response, deserialization_callback=_callback) # type: ignore" - ) + stream_kwargs.append(f"terminal_event={terminal_event!r}") + if terminal_event_names: + stream_kwargs.append(f"terminal_event_names={terminal_event_names!r}") + retval.append(f"return {stream_class}({', '.join(stream_kwargs)}) # type: ignore") return retval def _handle_response_body(self, builder: OperationType) -> list[str]: diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 index daac525a0be..5246167751e 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -24,6 +24,7 @@ from typing import ( List, Optional, Protocol, + Sequence, Type, TypeVar, cast, @@ -555,6 +556,10 @@ class Stream(Iterator[ReturnType_co]): ``"[DONE]"``). When an event's ``data`` equals this value, iteration stops and the event is not passed to ``deserialization_callback``. :paramtype terminal_event: str or None + :keyword terminal_event_names: Optional event names (the SSE ``event`` field) that terminate + the stream. Unlike ``terminal_event``, such an event carries a payload: it is passed to + ``deserialization_callback`` and yielded, and iteration stops immediately afterwards. + :paramtype terminal_event_names: ~typing.Sequence[str] or None """ def __init__( @@ -564,6 +569,7 @@ class Stream(Iterator[ReturnType_co]): deserialization_callback: Callable[[HttpResponse, DecodedType], ReturnType_co], decoder: Optional[StreamDecoder[DecodedType]] = None, terminal_event: Optional[str] = None, + terminal_event_names: Optional[Sequence[str]] = None, ) -> None: self._response = response content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() @@ -572,6 +578,7 @@ class Stream(Iterator[ReturnType_co]): ) self._deserialization_callback = deserialization_callback self._terminal_event = terminal_event + self._terminal_event_names = frozenset(terminal_event_names or ()) self._iterator = self._iter_results() def __next__(self) -> ReturnType_co: @@ -587,6 +594,8 @@ class Stream(Iterator[ReturnType_co]): break result = self._deserialization_callback(self._response, event) yield result + if self._terminal_event_names and getattr(event, "event", None) in self._terminal_event_names: + break finally: self._response.close() @@ -623,6 +632,10 @@ class AsyncStream(AsyncIterator[ReturnType_co]): ``"[DONE]"``). When an event's ``data`` equals this value, iteration stops and the event is not passed to ``deserialization_callback``. :paramtype terminal_event: str or None + :keyword terminal_event_names: Optional event names (the SSE ``event`` field) that terminate + the stream. Unlike ``terminal_event``, such an event carries a payload: it is passed to + ``deserialization_callback`` and yielded, and iteration stops immediately afterwards. + :paramtype terminal_event_names: ~typing.Sequence[str] or None """ def __init__( @@ -632,6 +645,7 @@ class AsyncStream(AsyncIterator[ReturnType_co]): deserialization_callback: Callable[[AsyncHttpResponse, DecodedType], ReturnType_co], decoder: Optional[AsyncStreamDecoder[DecodedType]] = None, terminal_event: Optional[str] = None, + terminal_event_names: Optional[Sequence[str]] = None, ) -> None: self._response = response content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() @@ -642,6 +656,7 @@ class AsyncStream(AsyncIterator[ReturnType_co]): ) self._deserialization_callback = deserialization_callback self._terminal_event = terminal_event + self._terminal_event_names = frozenset(terminal_event_names or ()) self._iterator = self._iter_results() async def __anext__(self) -> ReturnType_co: @@ -658,6 +673,8 @@ class AsyncStream(AsyncIterator[ReturnType_co]): break result = self._deserialization_callback(self._response, event) yield result + if self._terminal_event_names and getattr(event, "event", None) in self._terminal_event_names: + break finally: aclose = getattr(events, "aclose", None) if aclose is not None: diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py index 8991eb984ab..e8eff299908 100644 --- a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py @@ -3,6 +3,8 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- +import json + import pytest import pytest_asyncio @@ -50,3 +52,81 @@ async def test_retrieve_stream(client: SseClient): assert isinstance(items[0], PartialResult) and items[0].text == "partial one" assert isinstance(items[1], PartialResult) and items[1].text == "partial two" assert isinstance(items[2], FinalResult) and items[2].references == ["doc1", "doc2"] + + +# --------------------------------------------------------------------------- +# Named / model terminal events (yield-then-stop) -- see the sync test module +# for the rationale. Driven through the generated ``AsyncStream`` with a fake +# response because no published Spector spec produces named-model terminals. +# --------------------------------------------------------------------------- + + +class _FakeAsyncResponse: + """A minimal AsyncHttpResponse-shaped stand-in that replays SSE bytes.""" + + def __init__(self, body: bytes): + self.headers = {"Content-Type": "text/event-stream"} + self._body = body + self.closed = False + + def iter_bytes(self): + async def gen(): + for index in range(0, len(self._body), 8): + yield self._body[index : index + 8] + + return gen() + + async def close(self): + self.closed = True + + +def _event_kind(_response, event): + return (event.event, json.loads(event.data)) + + +_NAMED_TERMINAL_SSE = ( + b'event: response.partial\ndata: {"text": "one"}\n\n' + b'event: response.delta\ndata: {"delta": "hi"}\n\n' + b'event: response.completed\ndata: {"references": []}\n\n' + b'event: response.delta\ndata: {"delta": "AFTER-TERMINAL"}\n\n' +) + +_TERMINAL_EVENT_NAMES = ["response.completed", "error"] + + +@pytest.mark.asyncio +async def test_named_terminal_event_yields_then_stops(): + response = _FakeAsyncResponse(_NAMED_TERMINAL_SSE) + stream = AsyncStream( + response=response, + deserialization_callback=_event_kind, + terminal_event_names=_TERMINAL_EVENT_NAMES, + ) + items = [item async for item in stream] + # The named terminal `response.completed` IS yielded, then iteration stops. + assert items == [ + ("response.partial", {"text": "one"}), + ("response.delta", {"delta": "hi"}), + ("response.completed", {"references": []}), + ] + assert response.closed + + +@pytest.mark.asyncio +async def test_sentinel_and_named_terminal_coexist(): + body = ( + b'event: response.delta\ndata: {"delta": "a"}\n\n' + b"data: [DONE]\n\n" + b'event: response.delta\ndata: {"delta": "AFTER-DONE"}\n\n' + ) + response = _FakeAsyncResponse(body) + stream = AsyncStream( + response=response, + deserialization_callback=_event_kind, + terminal_event="[DONE]", + terminal_event_names=_TERMINAL_EVENT_NAMES, + ) + items = [item async for item in stream] + # The bare `[DONE]` sentinel stops iteration WITHOUT being yielded. + assert items == [("response.delta", {"delta": "a"})] + assert response.closed diff --git a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py index 60c0de9e53a..5be85663b1f 100644 --- a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py +++ b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py @@ -3,6 +3,8 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- +import json + import pytest from streaming.sse import SseClient @@ -46,3 +48,96 @@ def test_retrieve_stream(client: SseClient): assert isinstance(items[0], PartialResult) and items[0].text == "partial one" assert isinstance(items[1], PartialResult) and items[1].text == "partial two" assert isinstance(items[2], FinalResult) and items[2].references == ["doc1", "doc2"] + + +# --------------------------------------------------------------------------- +# Named / model terminal events (yield-then-stop). +# +# The published Spector SSE specs only cover the bare string-constant `[DONE]` +# sentinel (drop-and-stop, exercised above). A ``@terminalEvent`` can also be a +# *named* event carrying a model payload (e.g. `response.completed`, `error`): +# such an event IS deserialized and yielded, and iteration stops immediately +# afterwards. No published spec produces that shape, so we drive the generated +# ``Stream`` runtime directly with a fake response instead of the mock server. +# --------------------------------------------------------------------------- + + +class _FakeResponse: + """A minimal HttpResponse-shaped stand-in that replays SSE bytes.""" + + def __init__(self, body: bytes): + self.headers = {"Content-Type": "text/event-stream"} + self._body = body + self.closed = False + + def iter_bytes(self): + # Emit in small chunks so incremental SSE framing is exercised. + for index in range(0, len(self._body), 8): + yield self._body[index : index + 8] + + def close(self): + self.closed = True + + +def _event_kind(_response, event): + return (event.event, json.loads(event.data)) + + +_NAMED_TERMINAL_SSE = ( + b'event: response.partial\ndata: {"text": "one"}\n\n' + b'event: response.delta\ndata: {"delta": "hi"}\n\n' + b'event: response.completed\ndata: {"references": []}\n\n' + b'event: response.delta\ndata: {"delta": "AFTER-TERMINAL"}\n\n' +) + +_TERMINAL_EVENT_NAMES = ["response.completed", "error"] + + +def test_named_terminal_event_yields_then_stops(): + response = _FakeResponse(_NAMED_TERMINAL_SSE) + stream = Stream( + response=response, + deserialization_callback=_event_kind, + terminal_event_names=_TERMINAL_EVENT_NAMES, + ) + items = list(stream) + # The named terminal `response.completed` IS yielded (it carries a payload), + # then iteration stops -- the trailing `response.delta` must not appear. + assert items == [ + ("response.partial", {"text": "one"}), + ("response.delta", {"delta": "hi"}), + ("response.completed", {"references": []}), + ] + assert response.closed + + +def test_named_terminal_event_first_stops_immediately(): + body = b'event: error\ndata: {"code": "boom"}\n\n' b'event: response.delta\ndata: {"delta": "AFTER-ERROR"}\n\n' + response = _FakeResponse(body) + stream = Stream( + response=response, + deserialization_callback=_event_kind, + terminal_event_names=_TERMINAL_EVENT_NAMES, + ) + items = list(stream) + assert items == [("error", {"code": "boom"})] + assert response.closed + + +def test_sentinel_and_named_terminal_coexist(): + body = ( + b'event: response.delta\ndata: {"delta": "a"}\n\n' + b"data: [DONE]\n\n" + b'event: response.delta\ndata: {"delta": "AFTER-DONE"}\n\n' + ) + response = _FakeResponse(body) + stream = Stream( + response=response, + deserialization_callback=_event_kind, + terminal_event="[DONE]", + terminal_event_names=_TERMINAL_EVENT_NAMES, + ) + items = list(stream) + # The bare `[DONE]` sentinel stops iteration WITHOUT being yielded. + assert items == [("response.delta", {"delta": "a"})] + assert response.closed From e60bca32f83a33b812584e9d2082390a39aa730e Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 20 Aug 2026 15:00:00 -0700 Subject: [PATCH 16/29] fix(http-client-python): avoid duplicate stream kwarg for structured streams Treat structured streams as stream responses in has_stream_response so stream_value uses kwargs.pop("stream", True) when exposed. This preserves behavior and prevents duplicate stream forwarding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../generator/pygen/codegen/models/operation.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/operation.py b/packages/http-client-python/generator/pygen/codegen/models/operation.py index b2fb243fef9..c9bd18fa1d0 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -98,10 +98,6 @@ def exact_name_params(self) -> set[str]: @property 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 return ( f'kwargs.pop("stream", {self.has_stream_response})' if self.expose_stream_keyword and self.has_response_body and "stream" not in self.exact_name_params @@ -515,7 +511,7 @@ def filename(self) -> str: @property def has_stream_response(self) -> bool: - return any(r.is_stream_response for r in self.responses) + return any(r.is_stream_response or getattr(r, "is_structured_stream", False) for r in self.responses) @classmethod def get_request_builder(cls, yaml_data: dict[str, Any], client: "Client"): From c876d7fae806850599a151ac35deda7482a69da7 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 20 Aug 2026 15:34:52 -0700 Subject: [PATCH 17/29] refactor(http-client-python): use typed structured-stream response flag Replace getattr fallback in Operation.has_stream_response with direct Response.is_structured_stream access to keep stream response checks explicit and typed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../generator/pygen/codegen/models/operation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/operation.py b/packages/http-client-python/generator/pygen/codegen/models/operation.py index c9bd18fa1d0..d1cac714727 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -511,7 +511,7 @@ def filename(self) -> str: @property def has_stream_response(self) -> bool: - return any(r.is_stream_response or getattr(r, "is_structured_stream", False) for r in self.responses) + return any(r.is_stream_response or r.is_structured_stream for r in self.responses) @classmethod def get_request_builder(cls, yaml_data: dict[str, Any], client: "Client"): From c4e0aebc03f2f5768b902eed3c4140a89dee3088 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 20 Aug 2026 15:41:15 -0700 Subject: [PATCH 18/29] fix(http-client-python): support structured stream callbacks in msrest mode Branch structured stream per-event deserialization by models-mode, using self._deserialize(...) for msrest and _deserialize(...) for dpg, so generated msrest clients do not raise NameError. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../codegen/serializers/builder_serializer.py | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index c449cab366f..db3c8cad12f 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -1261,7 +1261,7 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] Produces a per-event deserialization callback and returns a ``Stream`` / ``AsyncStream`` wrapping the streamed HTTP response. """ - response = next(r for r in builder.responses if getattr(r, "is_structured_stream", False)) + response = next(r for r in builder.responses if r.is_structured_stream) item_annotation = response.type.type_annotation( # type: ignore[union-attr] is_operation_file=True, serialize_namespace=self.serialize_namespace ) @@ -1287,14 +1287,26 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] ) 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)") + if self.code_model.options["models-mode"] == "msrest": + retval.append(" deserialized = self._deserialize(") + retval.append(f" '{event_annotation}',") + retval.append(" _http_response") + retval.append(" )") + else: + 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)") + if self.code_model.options["models-mode"] == "msrest": + retval.append(" deserialized = self._deserialize(") + retval.append(f" '{event_annotation}',") + retval.append(" _http_response") + retval.append(" )") + else: + retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") else: retval.append(" deserialized = _event_json") elif len(unnamed_events) == 1: @@ -1302,12 +1314,30 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] is_operation_file=True, serialize_namespace=self.serialize_namespace, ) - retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") + if self.code_model.options["models-mode"] == "msrest": + retval.append(" deserialized = self._deserialize(") + retval.append(f" '{event_annotation}',") + retval.append(" _http_response") + retval.append(" )") + else: + retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") else: - retval.append(f" deserialized = _deserialize({item_annotation}, _event_json)") + if self.code_model.options["models-mode"] == "msrest": + retval.append(" deserialized = self._deserialize(") + retval.append(f" '{item_annotation}',") + retval.append(" _http_response") + retval.append(" )") + 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)") + if self.code_model.options["models-mode"] == "msrest": + retval.append(" deserialized = self._deserialize(") + retval.append(f" '{item_annotation}',") + retval.append(" _http_response") + retval.append(" )") + else: + 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") From 45b423f687d2621619a859bf241b853c3199f2ba Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Thu, 20 Aug 2026 16:01:28 -0700 Subject: [PATCH 19/29] refactor(http-client-python): use has_structured_stream directly Remove need_streaming_base alias and reference has_structured_stream directly when deciding utils folder contents. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../generator/pygen/codegen/models/code_model.py | 9 +-------- .../generator/pygen/codegen/serializers/__init__.py | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/code_model.py b/packages/http-client-python/generator/pygen/codegen/models/code_model.py index c1e0bb1ee6b..5db0e8aed85 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/code_model.py +++ b/packages/http-client-python/generator/pygen/codegen/models/code_model.py @@ -279,7 +279,7 @@ def need_utils_folder(self, async_mode: bool, client_namespace: str) -> bool: self.need_utils_utils(async_mode, client_namespace) or self.need_utils_serialization or self.options["models-mode"] == "dpg" - or self.need_streaming_base + or self.has_structured_stream ) @property @@ -295,13 +295,6 @@ def has_structured_stream(self) -> bool: for op in og.operations ) - @property - def need_streaming_base(self) -> bool: - # Whether to emit the vendored ``_utils/streaming_base.py`` (Stream / AsyncStream - # + JSONL / SSE decoders). Only needed when at least one operation returns a - # structured stream. - return self.has_structured_stream - def need_utils_utils(self, async_mode: bool, client_namespace: str) -> bool: return ( self.need_utils_form_data(async_mode, client_namespace) diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py index 0321dc17d91..e73e3866970 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -526,7 +526,7 @@ def _serialize_and_write_utils_folder(self, env: Environment, namespace: str): ) # write _utils/streaming_base.py (vendored Stream/AsyncStream + JSONL/SSE decoders) - if self.code_model.need_streaming_base: + if self.code_model.has_structured_stream: self.write_file( utils_folder_path / Path("streaming_base.py"), general_serializer.serialize_streaming_base_file(), From d7d58faf01054e0235331244dfd0c30917f226ed Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 21 Aug 2026 08:54:02 -0700 Subject: [PATCH 20/29] fix(http-client-python): deserialize msrest stream events correctly Use inline stream item annotations for union payloads and deserialize msrest structured-stream events from each event JSON payload using serialization types. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../codegen/serializers/builder_serializer.py | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index db3c8cad12f..a3930344d57 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -1262,7 +1262,7 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] ``AsyncStream`` wrapping the streamed HTTP response. """ response = next(r for r in builder.responses if r.is_structured_stream) - item_annotation = response.type.type_annotation( # type: ignore[union-attr] + item_annotation = response.stream_item_annotation( is_operation_file=True, serialize_namespace=self.serialize_namespace ) stream_class = response.stream_class_name(self.async_mode) # type: ignore[attr-defined] @@ -1288,9 +1288,12 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] keyword = "if" if index == 0 else "elif" retval.append(f" {keyword} _event.event == {event_type!r}:") if self.code_model.options["models-mode"] == "msrest": + serialization_type = event_item_type.serialization_type( + serialize_namespace=self.serialize_namespace + ) retval.append(" deserialized = self._deserialize(") - retval.append(f" '{event_annotation}',") - retval.append(" _http_response") + retval.append(f" '{serialization_type}',") + retval.append(" _event_json") retval.append(" )") else: retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") @@ -1301,9 +1304,12 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] serialize_namespace=self.serialize_namespace, ) if self.code_model.options["models-mode"] == "msrest": + serialization_type = unnamed_events[0].serialization_type( + serialize_namespace=self.serialize_namespace + ) retval.append(" deserialized = self._deserialize(") - retval.append(f" '{event_annotation}',") - retval.append(" _http_response") + retval.append(f" '{serialization_type}',") + retval.append(" _event_json") retval.append(" )") else: retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") @@ -1315,26 +1321,35 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] serialize_namespace=self.serialize_namespace, ) if self.code_model.options["models-mode"] == "msrest": + serialization_type = unnamed_events[0].serialization_type( + serialize_namespace=self.serialize_namespace + ) retval.append(" deserialized = self._deserialize(") - retval.append(f" '{event_annotation}',") - retval.append(" _http_response") + retval.append(f" '{serialization_type}',") + retval.append(" _event_json") retval.append(" )") else: retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") else: if self.code_model.options["models-mode"] == "msrest": + serialization_type = response.stream_item_type.serialization_type( + serialize_namespace=self.serialize_namespace + ) retval.append(" deserialized = self._deserialize(") - retval.append(f" '{item_annotation}',") - retval.append(" _http_response") + retval.append(f" '{serialization_type}',") + retval.append(" _event_json") retval.append(" )") else: retval.append(f" deserialized = _deserialize({item_annotation}, _event_json)") else: retval.append(" _event_json = _event.json()") if self.code_model.options["models-mode"] == "msrest": + serialization_type = response.stream_item_type.serialization_type( + serialize_namespace=self.serialize_namespace + ) retval.append(" deserialized = self._deserialize(") - retval.append(f" '{item_annotation}',") - retval.append(" _http_response") + retval.append(f" '{serialization_type}',") + retval.append(" _event_json") retval.append(" )") else: retval.append(f" deserialized = _deserialize({item_annotation}, _event_json)") From 72e27478d59af4dc4305a65a866953639709b88e Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 21 Aug 2026 09:34:49 -0700 Subject: [PATCH 21/29] fix(http-client-python): satisfy serializer pylint limit Suppress the statement-count warning for the structured stream response serializer, matching the existing generated-code style for complex response handlers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../generator/pygen/codegen/serializers/builder_serializer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index a3930344d57..a09bc2e96a9 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -1255,6 +1255,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran ) return retval + # pylint: disable=too-many-statements def handle_structured_stream_response(self, builder: OperationType) -> list[str]: """Emit the body for an operation returning a structured (JSONL / SSE) stream. From 0e1b8a48c91fca12f7ef3f84488554dcb574c009 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 24 Aug 2026 09:27:14 -0700 Subject: [PATCH 22/29] feat(http-client-python): support SSE id/retry, text payload envelopes, and automatic reconnect - Expose SSE 'id' and 'retry' field values on structured streams (last_event_id/retry) per the SSE spec (invalid values ignored: retry must be all-ASCII-digits, id must not contain NULL). - Support text/plain event envelope payloads (skip JSON parsing when payloadContentType indicates a raw text payload) via TCGC isEventEnvelope/payloadContentType metadata. - Add last_event_id kwarg to structured SSE stream operations, sent as the Last-Event-ID request header on the initial request. - Automatically reconnect the stream when the server has supplied a retry interval: sleeps for the latest retry value (ms), then re-issues the request with the last-seen event ID as the Last-Event-ID header. No reconnection is attempted when no retry value has been seen, or after a terminal event. - Add sync/async mock API tests validating id/retry parsing, invalid-value handling, and reconnect behavior against the vendored streaming runtime. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- ...se-protocol-support-2026-08-21-12-30-00.md | 7 ++ .../http-client-python/emitter/src/http.ts | 13 ++- .../emitter/test/streaming.test.ts | 20 ++++ .../pygen/codegen/models/response.py | 11 ++- .../codegen/serializers/builder_serializer.py | 59 ++++++++++-- .../templates/streaming_base.py.jinja2 | 92 +++++++++++++++---- .../asynctests/test_streaming_sse_async.py | 68 ++++++++++++++ .../mock_api/shared/test_streaming_sse.py | 61 ++++++++++++ 8 files changed, 301 insertions(+), 30 deletions(-) create mode 100644 .chronus/changes/structured-sse-protocol-support-2026-08-21-12-30-00.md diff --git a/.chronus/changes/structured-sse-protocol-support-2026-08-21-12-30-00.md b/.chronus/changes/structured-sse-protocol-support-2026-08-21-12-30-00.md new file mode 100644 index 00000000000..e4f5e3cb976 --- /dev/null +++ b/.chronus/changes/structured-sse-protocol-support-2026-08-21-12-30-00.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client-python" +--- + +Expose SSE event IDs and retry values on structured streams, support text payloads for event envelopes, and automatically reconnect streams when the server provides a retry interval. diff --git a/packages/http-client-python/emitter/src/http.ts b/packages/http-client-python/emitter/src/http.ts index 5526dfbb47a..4b30898ed9a 100644 --- a/packages/http-client-python/emitter/src/http.ts +++ b/packages/http-client-python/emitter/src/http.ts @@ -60,6 +60,8 @@ interface StructuredStreamEvent { * {@link StructuredStreamingInfo.terminalEvent}, the sentinel that stops without yielding. */ isTerminal?: boolean; + isEventEnvelope?: boolean; + payloadContentType?: string; } interface StructuredStreamingInfo { @@ -122,6 +124,8 @@ interface SseEventLike { isTerminalEvent: boolean; type: SdkType; payloadType: SdkType; + isEventEnvelope?: boolean; + payloadContentType?: string; } /** @@ -158,10 +162,17 @@ export function partitionSseEvents( eventType: event.eventType, itemType: toItemType(event.payloadType), isTerminal: true, + isEventEnvelope: event.isEventEnvelope, + payloadContentType: event.payloadContentType, }); continue; } - dispatch.push({ eventType: event.eventType, itemType: toItemType(event.payloadType) }); + dispatch.push({ + eventType: event.eventType, + itemType: toItemType(event.payloadType), + isEventEnvelope: event.isEventEnvelope, + payloadContentType: event.payloadContentType, + }); } return terminalEvent !== undefined ? { events: dispatch, terminalEvent } : { events: dispatch }; } diff --git a/packages/http-client-python/emitter/test/streaming.test.ts b/packages/http-client-python/emitter/test/streaming.test.ts index f0c03f4ac52..8bcb3e64810 100644 --- a/packages/http-client-python/emitter/test/streaming.test.ts +++ b/packages/http-client-python/emitter/test/streaming.test.ts @@ -131,5 +131,25 @@ describe("typespec-python: structured streaming", () => { strictEqual(events[1].eventType, "response.completed"); strictEqual(events[1].isTerminal, true); }); + + it("preserves event envelope and payload content metadata", () => { + const envelope = model("Envelope"); + const payload = { kind: "string" }; + const { events } = partitionSseEvents( + [ + { + eventType: "withEnvelope", + isTerminalEvent: false, + type: envelope, + payloadType: payload, + isEventEnvelope: true, + payloadContentType: "text/plain", + }, + ] as any, + identity, + ); + strictEqual(events[0].isEventEnvelope, true); + strictEqual(events[0].payloadContentType, "text/plain"); + }); }); }); diff --git a/packages/http-client-python/generator/pygen/codegen/models/response.py b/packages/http-client-python/generator/pygen/codegen/models/response.py index 724db0db50a..cec92d17fe3 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/response.py +++ b/packages/http-client-python/generator/pygen/codegen/models/response.py @@ -60,7 +60,7 @@ def __init__( self.default_content_type = yaml_data.get("defaultContentType") streaming = yaml_data.get("streaming") self.streaming_kind: Optional[str] = streaming["kind"] if streaming else None - self.streaming_events: list[tuple[Optional[str], BaseType]] = [] + self.streaming_events: list[tuple[Optional[str], BaseType, bool, Optional[str]]] = [] self._streaming_terminal_event: Optional[str] = streaming.get("terminalEvent") if streaming else None # Named / model ``@terminalEvent`` events: deserialized and yielded like any other event, # then iteration stops. The bare string-constant sentinel (``_streaming_terminal_event``) @@ -68,7 +68,12 @@ def __init__( self.terminal_event_names: list[str] = [] if streaming: self.streaming_events = [ - (event.get("eventType"), self.code_model.lookup_type(id(event["itemType"]))) + ( + event.get("eventType"), + self.code_model.lookup_type(id(event["itemType"])), + event.get("isEventEnvelope", False), + event.get("payloadContentType"), + ) for event in streaming.get("events", []) ] self.terminal_event_names = [ @@ -235,7 +240,7 @@ def imports(self, **kwargs: Any) -> FileImport: file_import.add_submodule_import(relative_path, stream_class, ImportType.LOCAL) if self.streaming_kind == "sse": file_import.add_import("json", ImportType.STDLIB) - for _event_type, event_item_type in self.streaming_events: + for _event_type, event_item_type, _is_envelope, _content_type in self.streaming_events: file_import.merge(event_item_type.imports(**kwargs)) return file_import diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index a09bc2e96a9..e6a3adcda28 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -1021,7 +1021,19 @@ def _call_request_builder_helper( return retval def call_request_builder(self, builder: OperationType, is_paging: bool = False) -> list[str]: - return self._call_request_builder_helper(builder, builder.request_builder, is_paging=is_paging) + retval = self._call_request_builder_helper(builder, builder.request_builder, is_paging=is_paging) + if builder.has_structured_stream_response and any( + response.streaming_kind == "sse" for response in builder.responses + ): + retval.insert(0, '_last_event_id = kwargs.pop("last_event_id", None)') + retval.extend( + [ + "", + "if _last_event_id is not None:", + ' _request.headers["Last-Event-ID"] = _last_event_id', + ] + ) + return retval def response_headers_and_deserialization( self, @@ -1273,21 +1285,26 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] retval: list[str] = [] 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) - for event_type, event_item_type in streaming_events + (event_type, event_item_type, is_envelope, payload_content_type) + for event_type, event_item_type, is_envelope, payload_content_type in streaming_events if event_type is not None ] - unnamed_events = [event_item_type for event_type, event_item_type in streaming_events if event_type is None] + unnamed_events = [ + (event_item_type, is_envelope, payload_content_type) + for event_type, event_item_type, is_envelope, payload_content_type in streaming_events + if event_type is None + ] if named_events: - for index, (event_type, event_item_type) in enumerate(named_events): + for index, (event_type, event_item_type, _is_envelope, payload_content_type) in enumerate(named_events): event_annotation = event_item_type.type_annotation( is_operation_file=True, serialize_namespace=self.serialize_namespace, ) keyword = "if" if index == 0 else "elif" retval.append(f" {keyword} _event.event == {event_type!r}:") + event_json = "_event.data" if payload_content_type == "text/plain" else "json.loads(_event.data)" + retval.append(f" _event_json = {event_json}") if self.code_model.options["models-mode"] == "msrest": serialization_type = event_item_type.serialization_type( serialize_namespace=self.serialize_namespace @@ -1300,12 +1317,15 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") retval.append(" else:") if len(unnamed_events) == 1: - event_annotation = unnamed_events[0].type_annotation( + event_item_type, _is_envelope, payload_content_type = unnamed_events[0] + event_annotation = event_item_type.type_annotation( is_operation_file=True, serialize_namespace=self.serialize_namespace, ) + event_json = "_event.data" if payload_content_type == "text/plain" else "json.loads(_event.data)" + retval.append(f" _event_json = {event_json}") if self.code_model.options["models-mode"] == "msrest": - serialization_type = unnamed_events[0].serialization_type( + serialization_type = event_item_type.serialization_type( serialize_namespace=self.serialize_namespace ) retval.append(" deserialized = self._deserialize(") @@ -1315,14 +1335,18 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] else: retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") else: + retval.append(" _event_json = json.loads(_event.data)") retval.append(" deserialized = _event_json") elif len(unnamed_events) == 1: - event_annotation = unnamed_events[0].type_annotation( + event_item_type, _is_envelope, payload_content_type = unnamed_events[0] + event_annotation = event_item_type.type_annotation( is_operation_file=True, serialize_namespace=self.serialize_namespace, ) + event_json = "_event.data" if payload_content_type == "text/plain" else "json.loads(_event.data)" + retval.append(f" _event_json = {event_json}") if self.code_model.options["models-mode"] == "msrest": - serialization_type = unnamed_events[0].serialization_type( + serialization_type = event_item_type.serialization_type( serialize_namespace=self.serialize_namespace ) retval.append(" deserialized = self._deserialize(") @@ -1332,6 +1356,7 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] else: retval.append(f" deserialized = _deserialize({event_annotation}, _event_json)") else: + retval.append(" _event_json = json.loads(_event.data)") if self.code_model.options["models-mode"] == "msrest": serialization_type = response.stream_item_type.serialization_type( serialize_namespace=self.serialize_namespace @@ -1363,6 +1388,20 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] stream_kwargs.append(f"terminal_event={terminal_event!r}") if terminal_event_names: stream_kwargs.append(f"terminal_event_names={terminal_event_names!r}") + if response.streaming_kind == "sse": # type: ignore[attr-defined] + retval.append("") + retval.append("async def _reconnect(_last_event_id):" if self.async_mode else "def _reconnect(_last_event_id):") + retval.append(" if _last_event_id is not None:") + retval.append(' _request.headers["Last-Event-ID"] = _last_event_id') + if self.async_mode: + retval.append( + f" return (await self._client.{self.pipeline_name}.run(_request, stream=True, **kwargs)).http_response" + ) + else: + retval.append( + f" return self._client.{self.pipeline_name}.run(_request, stream=True, **kwargs).http_response" + ) + stream_kwargs.append("reconnect_callback=_reconnect") retval.append(f"return {stream_class}({', '.join(stream_kwargs)}) # type: ignore") return retval diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 index 5246167751e..0d398af8f36 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -10,8 +10,10 @@ # 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. # -------------------------------------------------------------------------- +import asyncio import codecs import json +import time from contextlib import aclosing from types import TracebackType from typing import ( @@ -570,6 +572,7 @@ class Stream(Iterator[ReturnType_co]): decoder: Optional[StreamDecoder[DecodedType]] = None, terminal_event: Optional[str] = None, terminal_event_names: Optional[Sequence[str]] = None, + reconnect_callback: Optional[Callable[[Optional[str]], HttpResponse]] = None, ) -> None: self._response = response content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() @@ -579,8 +582,21 @@ class Stream(Iterator[ReturnType_co]): self._deserialization_callback = deserialization_callback self._terminal_event = terminal_event self._terminal_event_names = frozenset(terminal_event_names or ()) + self._reconnect_callback = reconnect_callback + self._last_event_id: Optional[str] = None + self._retry: Optional[int] = None self._iterator = self._iter_results() + @property + def last_event_id(self) -> Optional[str]: + """The most recently received SSE event ID, if one was provided.""" + return self._last_event_id + + @property + def retry(self) -> Optional[int]: + """The most recently received valid SSE retry value, if one was provided.""" + return self._retry + def __next__(self) -> ReturnType_co: return self._iterator.__next__() @@ -588,14 +604,28 @@ class Stream(Iterator[ReturnType_co]): return self def _iter_results(self) -> Generator[ReturnType_co, None, None]: + terminal = False try: - for event in self._decoder.iter_events(self._response.iter_bytes()): - if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: - break - result = self._deserialization_callback(self._response, event) - yield result - if self._terminal_event_names and getattr(event, "event", None) in self._terminal_event_names: + while True: + for event in self._decoder.iter_events(self._response.iter_bytes()): + if isinstance(event, ServerSentEvent): + self._last_event_id = event.id + if event.retry is not None: + self._retry = event.retry + if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: + terminal = True + break + result = self._deserialization_callback(self._response, event) + yield result + if self._terminal_event_names and getattr(event, "event", None) in self._terminal_event_names: + terminal = True + break + self._response.close() + if terminal or self._reconnect_callback is None or self._retry is None: break + time.sleep(self._retry / 1000) + self._response = self._reconnect_callback(self._last_event_id) + self._decoder = type(self._decoder)() finally: self._response.close() @@ -646,6 +676,7 @@ class AsyncStream(AsyncIterator[ReturnType_co]): decoder: Optional[AsyncStreamDecoder[DecodedType]] = None, terminal_event: Optional[str] = None, terminal_event_names: Optional[Sequence[str]] = None, + reconnect_callback: Optional[Callable[[Optional[str]], Any]] = None, ) -> None: self._response = response content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() @@ -657,8 +688,21 @@ class AsyncStream(AsyncIterator[ReturnType_co]): self._deserialization_callback = deserialization_callback self._terminal_event = terminal_event self._terminal_event_names = frozenset(terminal_event_names or ()) + self._reconnect_callback = reconnect_callback + self._last_event_id: Optional[str] = None + self._retry: Optional[int] = None self._iterator = self._iter_results() + @property + def last_event_id(self) -> Optional[str]: + """The most recently received SSE event ID, if one was provided.""" + return self._last_event_id + + @property + def retry(self) -> Optional[int]: + """The most recently received valid SSE retry value, if one was provided.""" + return self._retry + async def __anext__(self) -> ReturnType_co: return await self._iterator.__anext__() @@ -666,19 +710,35 @@ class AsyncStream(AsyncIterator[ReturnType_co]): return self async def _iter_results(self) -> AsyncGenerator[ReturnType_co, None]: - events = self._decoder.aiter_events(self._response.iter_bytes()) + terminal = False try: - async for event in events: - if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: - break - result = self._deserialization_callback(self._response, event) - yield result - if self._terminal_event_names and getattr(event, "event", None) in self._terminal_event_names: + while True: + events = self._decoder.aiter_events(self._response.iter_bytes()) + try: + async for event in events: + if isinstance(event, ServerSentEvent): + self._last_event_id = event.id + if event.retry is not None: + self._retry = event.retry + if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: + terminal = True + break + result = self._deserialization_callback(self._response, event) + yield result + if self._terminal_event_names and getattr(event, "event", None) in self._terminal_event_names: + terminal = True + break + finally: + aclose = getattr(events, "aclose", None) + if aclose is not None: + await aclose() + if terminal or self._reconnect_callback is None or self._retry is None: break + await self._response.close() + await asyncio.sleep(self._retry / 1000) + self._response = await self._reconnect_callback(self._last_event_id) + self._decoder = type(self._decoder)() finally: - aclose = getattr(events, "aclose", None) - if aclose is not None: - await aclose() await self._response.close() async def __aexit__( diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py index e8eff299908..d4c7866b761 100644 --- a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py @@ -130,3 +130,71 @@ async def test_sentinel_and_named_terminal_coexist(): # The bare `[DONE]` sentinel stops iteration WITHOUT being yielded. assert items == [("response.delta", {"delta": "a"})] assert response.closed + + +@pytest.mark.asyncio +async def test_sse_protocol_metadata_is_available_for_reconnect(): + body = b'id: event-1\nretry: 1000\nevent: message\ndata: {"message": "hello"}\n\n' + response = _FakeAsyncResponse(body) + stream = AsyncStream(response=response, deserialization_callback=lambda _response, event: event.data) + + assert [item async for item in stream] == ['{"message": "hello"}'] + assert stream.last_event_id == "event-1" + assert stream.retry == 1000 + + +@pytest.mark.asyncio +async def test_sse_protocol_invalid_metadata_is_ignored(): + body = b"id: invalid\x00id\nretry: not-a-number\nevent: message\ndata: hello\n\n" + response = _FakeAsyncResponse(body) + stream = AsyncStream(response=response, deserialization_callback=lambda _response, event: event.data) + + assert [item async for item in stream] == ["hello"] + assert stream.last_event_id == "" + assert stream.retry is None + + +@pytest.mark.asyncio +async def test_sse_reconnects_on_eof_using_latest_metadata(): + responses = [ + _FakeAsyncResponse(b"id: first\nretry: 0\ndata: one\n\n"), + _FakeAsyncResponse(b"id: second\ndata: two\n\ndata: [DONE]\n\n"), + ] + reconnect_ids = [] + + async def reconnect(last_event_id): + reconnect_ids.append(last_event_id) + return responses.pop(0) + + stream = AsyncStream( + response=responses.pop(0), + deserialization_callback=lambda _response, event: event.data, + terminal_event="[DONE]", + reconnect_callback=reconnect, + ) + + assert [item async for item in stream] == ["one", "two"] + assert reconnect_ids == ["first"] + assert stream.last_event_id == "second" + + +@pytest.mark.asyncio +async def test_sse_does_not_reconnect_without_retry_or_after_terminal(): + response = _FakeAsyncResponse(b"id: first\ndata: one\n\n") + + async def reconnect(_last_event_id): + pytest.fail("unexpected reconnect") + + stream = AsyncStream( + response=response, deserialization_callback=lambda _response, event: event.data, reconnect_callback=reconnect + ) + assert [item async for item in stream] == ["one"] + + response = _FakeAsyncResponse(b"retry: 0\ndata: one\n\ndata: [DONE]\n\n") + stream = AsyncStream( + response=response, + deserialization_callback=lambda _response, event: event.data, + terminal_event="[DONE]", + reconnect_callback=reconnect, + ) + assert [item async for item in stream] == ["one"] diff --git a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py index 5be85663b1f..5ab2652d077 100644 --- a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py +++ b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py @@ -141,3 +141,64 @@ def test_sentinel_and_named_terminal_coexist(): # The bare `[DONE]` sentinel stops iteration WITHOUT being yielded. assert items == [("response.delta", {"delta": "a"})] assert response.closed + + +def test_sse_protocol_metadata_is_available_for_reconnect(): + body = b'id: event-1\nretry: 1000\nevent: message\ndata: {"message": "hello"}\n\n' + response = _FakeResponse(body) + stream = Stream(response=response, deserialization_callback=lambda _response, event: event.data) + + assert list(stream) == ['{"message": "hello"}'] + assert stream.last_event_id == "event-1" + assert stream.retry == 1000 + + +def test_sse_protocol_invalid_metadata_is_ignored(): + body = b"id: invalid\x00id\nretry: not-a-number\nevent: message\ndata: hello\n\n" + response = _FakeResponse(body) + stream = Stream(response=response, deserialization_callback=lambda _response, event: event.data) + + assert list(stream) == ["hello"] + assert stream.last_event_id == "" + assert stream.retry is None + + +def test_sse_reconnects_on_eof_using_latest_metadata(): + responses = [ + _FakeResponse(b"id: first\nretry: 0\ndata: one\n\n"), + _FakeResponse(b"id: second\ndata: two\n\ndata: [DONE]\n\n"), + ] + reconnect_ids = [] + + def reconnect(last_event_id): + reconnect_ids.append(last_event_id) + return responses.pop(0) + + stream = Stream( + response=responses.pop(0), + deserialization_callback=lambda _response, event: event.data, + terminal_event="[DONE]", + reconnect_callback=reconnect, + ) + + assert list(stream) == ["one", "two"] + assert reconnect_ids == ["first"] + assert stream.last_event_id == "second" + + +def test_sse_does_not_reconnect_without_retry_or_after_terminal(): + response = _FakeResponse(b"id: first\ndata: one\n\n") + reconnect = lambda _last_event_id: pytest.fail("unexpected reconnect") + stream = Stream( + response=response, deserialization_callback=lambda _response, event: event.data, reconnect_callback=reconnect + ) + assert list(stream) == ["one"] + + response = _FakeResponse(b"retry: 0\ndata: one\n\ndata: [DONE]\n\n") + stream = Stream( + response=response, + deserialization_callback=lambda _response, event: event.data, + terminal_event="[DONE]", + reconnect_callback=reconnect, + ) + assert list(stream) == ["one"] From eb5282edba3ca67c1865ef2d7856a799167cdfbf Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 24 Aug 2026 10:44:15 -0700 Subject: [PATCH 23/29] fix(http-client-python): resolve lint/type-check issues in streaming reconnect logic - Add do-not-import-asyncio to file-level pylint disable (vendored transport-agnostic runtime; raw asyncio.sleep/time.sleep is correct) - Document reconnect_callback keyword in Stream/AsyncStream docstrings - Replace isinstance(event, ServerSentEvent) narrowing with getattr duck-typing to fix pyright losing the DecodedType generic binding Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../templates/streaming_base.py.jinja2 | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 index 0d398af8f36..93d9d9e476e 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -2,7 +2,7 @@ {% if code_model.license_header %} {{ code_model.license_header }} {% endif %} -# pylint: disable=line-too-long,useless-suppression,unnecessary-ellipsis +# pylint: disable=line-too-long,useless-suppression,unnecessary-ellipsis,do-not-import-asyncio # -------------------------------------------------------------------------- # This file is vendored from the core streaming runtime # ({{ code_model.core_library }}.streaming). It provides the Stream / AsyncStream @@ -562,6 +562,11 @@ class Stream(Iterator[ReturnType_co]): the stream. Unlike ``terminal_event``, such an event carries a payload: it is passed to ``deserialization_callback`` and yielded, and iteration stops immediately afterwards. :paramtype terminal_event_names: ~typing.Sequence[str] or None + :keyword reconnect_callback: Optional callback used to re-establish the underlying response + when the stream disconnects before a terminal event is seen and the server has provided + an SSE ``retry`` value. It is called with the last-seen SSE event ID (or ``None``) and + must return a new response to resume iterating from. + :paramtype reconnect_callback: Callable[[str or None], ~{{ code_model.core_library }}.rest.HttpResponse] or None """ def __init__( @@ -608,10 +613,12 @@ class Stream(Iterator[ReturnType_co]): try: while True: for event in self._decoder.iter_events(self._response.iter_bytes()): - if isinstance(event, ServerSentEvent): - self._last_event_id = event.id - if event.retry is not None: - self._retry = event.retry + event_id = getattr(event, "id", None) + if event_id is not None: + self._last_event_id = event_id + event_retry = getattr(event, "retry", None) + if event_retry is not None: + self._retry = event_retry if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: terminal = True break @@ -666,6 +673,11 @@ class AsyncStream(AsyncIterator[ReturnType_co]): the stream. Unlike ``terminal_event``, such an event carries a payload: it is passed to ``deserialization_callback`` and yielded, and iteration stops immediately afterwards. :paramtype terminal_event_names: ~typing.Sequence[str] or None + :keyword reconnect_callback: Optional callback used to re-establish the underlying response + when the stream disconnects before a terminal event is seen and the server has provided + an SSE ``retry`` value. It is called with the last-seen SSE event ID (or ``None``) and + must return (or resolve to) a new response to resume iterating from. + :paramtype reconnect_callback: Callable[[str or None], Any] or None """ def __init__( @@ -716,10 +728,12 @@ class AsyncStream(AsyncIterator[ReturnType_co]): events = self._decoder.aiter_events(self._response.iter_bytes()) try: async for event in events: - if isinstance(event, ServerSentEvent): - self._last_event_id = event.id - if event.retry is not None: - self._retry = event.retry + event_id = getattr(event, "id", None) + if event_id is not None: + self._last_event_id = event_id + event_retry = getattr(event, "retry", None) + if event_retry is not None: + self._retry = event_retry if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: terminal = True break From 484204344f1fce77d1f678095546277cf899f065 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 24 Aug 2026 10:52:22 -0700 Subject: [PATCH 24/29] fix(http-client-python): avoid double-closing response in Stream reconnect 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 --- .../generator/pygen/codegen/templates/streaming_base.py.jinja2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 index 93d9d9e476e..278b21dd99f 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -627,9 +627,9 @@ class Stream(Iterator[ReturnType_co]): if self._terminal_event_names and getattr(event, "event", None) in self._terminal_event_names: terminal = True break - self._response.close() if terminal or self._reconnect_callback is None or self._retry is None: break + self._response.close() time.sleep(self._retry / 1000) self._response = self._reconnect_callback(self._last_event_id) self._decoder = type(self._decoder)() From cb1a38b214adffbdadb1b482985cc3aef1e24742 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 24 Aug 2026 10:58:32 -0700 Subject: [PATCH 25/29] fix(http-client-python): apply cls once to the Stream object, not per-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 --- .../pygen/codegen/serializers/builder_serializer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index e6a3adcda28..368e3b12bff 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -1379,8 +1379,6 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] retval.append(" )") else: 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") retval.append("") stream_kwargs = ["response=response", "deserialization_callback=_callback"] @@ -1402,7 +1400,10 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] f" return self._client.{self.pipeline_name}.run(_request, stream=True, **kwargs).http_response" ) stream_kwargs.append("reconnect_callback=_reconnect") - retval.append(f"return {stream_class}({', '.join(stream_kwargs)}) # type: ignore") + retval.append(f"deserialized = {stream_class}({', '.join(stream_kwargs)}) # type: ignore") + retval.append("if cls:") + retval.append(" return cls(pipeline_response, deserialized, {}) # type: ignore") + retval.append("return deserialized") return retval def _handle_response_body(self, builder: OperationType) -> list[str]: From a169e3509c0ed81cb28239fcfcf26ff403fbdb05 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 24 Aug 2026 11:36:45 -0700 Subject: [PATCH 26/29] chore(http-client-python): consolidate structured streaming changelog 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 --- .../structured-sse-protocol-support-2026-08-21-12-30-00.md | 7 ------- .chronus/changes/structured-streaming-2026-0-0.md | 2 ++ 2 files changed, 2 insertions(+), 7 deletions(-) delete mode 100644 .chronus/changes/structured-sse-protocol-support-2026-08-21-12-30-00.md diff --git a/.chronus/changes/structured-sse-protocol-support-2026-08-21-12-30-00.md b/.chronus/changes/structured-sse-protocol-support-2026-08-21-12-30-00.md deleted file mode 100644 index e4f5e3cb976..00000000000 --- a/.chronus/changes/structured-sse-protocol-support-2026-08-21-12-30-00.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -changeKind: feature -packages: - - "@typespec/http-client-python" ---- - -Expose SSE event IDs and retry values on structured streams, support text payloads for event envelopes, and automatically reconnect streams when the server provides a retry interval. diff --git a/.chronus/changes/structured-streaming-2026-0-0.md b/.chronus/changes/structured-streaming-2026-0-0.md index 5fa79ea8015..42fa46c95e9 100644 --- a/.chronus/changes/structured-streaming-2026-0-0.md +++ b/.chronus/changes/structured-streaming-2026-0-0.md @@ -13,3 +13,5 @@ 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 payloads may be plain text as well as JSON, and the stream automatically reconnects (sending `Last-Event-ID`) if the connection drops after the server has provided a `retry` interval. From 393333b3a42432409447bc4ca4d409aeb88dee45 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 24 Aug 2026 11:41:17 -0700 Subject: [PATCH 27/29] docs(http-client-python): clarify Stream/AsyncStream are internal, not 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 --- packages/http-client-python/README.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/http-client-python/README.md b/packages/http-client-python/README.md index c896aa6a990..163db6ab99c 100644 --- a/packages/http-client-python/README.md +++ b/packages/http-client-python/README.md @@ -156,21 +156,18 @@ Emit YAML code model only, without running Python generator. For batch processin ## Structured streaming (JSONL / SSE) -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. +Operations whose HTTP response is a JSONL (`application/jsonl`) or SSE (`text/event-stream`) stream generate client methods that return an iterable stream object, 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`, the generated method returns `Stream[Thing]` (sync) / `AsyncStream[Thing]` (async), yielding deserialized `Thing` instances as each JSONL line arrives. Similarly, `SSEStream` produces a `Stream` / `AsyncStream` over the SSE event payloads. - -Generated packages re-export `Stream` and `AsyncStream` from their base namespace: +For an operation returning `JsonlStream`, the generated method returns a stream that iterates deserialized `Thing` instances as each JSONL line arrives. Similarly, `SSEStream` produces a stream over the SSE event payloads. ```python -from your_sdk import Stream from your_sdk.models import Thing -stream: Stream[Thing] = client.receive() +stream = 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. +The underlying `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`) and is an internal implementation detail — it is not part of the package's public API and is not re-exported from the base namespace. 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. From c8e66a238f674e617b667f72a2df021c86ca0325 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 24 Aug 2026 15:01:57 -0700 Subject: [PATCH 28/29] fix(http-client-python): correct SSE payload typing and reconnect lint Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../generator/pygen/codegen/models/response.py | 7 +++++-- .../pygen/codegen/serializers/builder_serializer.py | 10 +++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/response.py b/packages/http-client-python/generator/pygen/codegen/models/response.py index cec92d17fe3..abbd7c0c5a8 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/response.py +++ b/packages/http-client-python/generator/pygen/codegen/models/response.py @@ -145,8 +145,11 @@ def stream_class_name(self, async_mode: bool) -> str: @property def stream_item_type(self) -> Optional[BaseType]: - if len(self.streaming_events) == 1: - return self.streaming_events[0][1] + event_item_types = list(dict.fromkeys(event[1] for event in self.streaming_events)) + if len(event_item_types) == 1: + return event_item_types[0] + if event_item_types: + return CombinedType({"type": "combined"}, self.code_model, event_item_types) return self.type def serialization_type(self, **kwargs: Any) -> str: diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index 368e3b12bff..1d86a3cb689 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -1388,16 +1388,20 @@ def handle_structured_stream_response(self, builder: OperationType) -> list[str] stream_kwargs.append(f"terminal_event_names={terminal_event_names!r}") if response.streaming_kind == "sse": # type: ignore[attr-defined] retval.append("") - retval.append("async def _reconnect(_last_event_id):" if self.async_mode else "def _reconnect(_last_event_id):") + retval.append( + "async def _reconnect(_last_event_id):" if self.async_mode else "def _reconnect(_last_event_id):" + ) retval.append(" if _last_event_id is not None:") retval.append(' _request.headers["Last-Event-ID"] = _last_event_id') if self.async_mode: retval.append( - f" return (await self._client.{self.pipeline_name}.run(_request, stream=True, **kwargs)).http_response" + f" return (await self._client.{self.pipeline_name}.run(" + "_request, stream=True, **kwargs)).http_response # pylint: disable=protected-access" ) else: retval.append( - f" return self._client.{self.pipeline_name}.run(_request, stream=True, **kwargs).http_response" + f" return self._client.{self.pipeline_name}.run(" + "_request, stream=True, **kwargs).http_response # pylint: disable=protected-access" ) stream_kwargs.append("reconnect_callback=_reconnect") retval.append(f"deserialized = {stream_class}({', '.join(stream_kwargs)}) # type: ignore") From 0e8af1d227571c5a44a2887a144009e2ab185072 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 24 Aug 2026 20:27:39 -0700 Subject: [PATCH 29/29] fix(http-client-python): support updated SSE protocol scenarios Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51359cbf-bc53-429e-b947-8284a91d7d46 --- .../changes/structured-streaming-2026-0-0.md | 2 +- .../pygen/codegen/models/code_model.py | 20 +++-- .../templates/streaming_base.py.jinja2 | 25 +++--- packages/http-client-python/package-lock.json | 46 +++++----- packages/http-client-python/package.json | 4 +- .../asynctests/test_streaming_sse_async.py | 83 ++++++++++++++++--- .../mock_api/shared/test_streaming_sse.py | 77 ++++++++++++++--- 7 files changed, 187 insertions(+), 70 deletions(-) diff --git a/.chronus/changes/structured-streaming-2026-0-0.md b/.chronus/changes/structured-streaming-2026-0-0.md index 42fa46c95e9..d5133718f73 100644 --- a/.chronus/changes/structured-streaming-2026-0-0.md +++ b/.chronus/changes/structured-streaming-2026-0-0.md @@ -14,4 +14,4 @@ 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 payloads may be plain text as well as JSON, and the stream automatically reconnects (sending `Last-Event-ID`) if the connection drops after the server has provided a `retry` interval. +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 payloads may be plain text as well as JSON, and the stream automatically reconnects after an unexpected disconnect using a default three-second delay (or the server-provided `retry` interval) while sending `Last-Event-ID`. diff --git a/packages/http-client-python/generator/pygen/codegen/models/code_model.py b/packages/http-client-python/generator/pygen/codegen/models/code_model.py index 5db0e8aed85..5618f156055 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/code_model.py +++ b/packages/http-client-python/generator/pygen/codegen/models/code_model.py @@ -405,22 +405,26 @@ def core_library(self) -> Literal["azure.core", "corehttp"]: def _sort_model_types_helper( self, current: ModelType, - seen_schema_names: set[str], + seen_schema_keys: set[tuple[str, str]], seen_schema_yaml_ids: set[int], ): if current.id in seen_schema_yaml_ids: return [] - if current.name in seen_schema_names: - raise ValueError(f"We have already generated a schema with name {current.name}") + current_schema_key = (current.client_namespace, current.name) + if current_schema_key in seen_schema_keys: + raise ValueError( + f"We have already generated a schema with name {current.name} " + f"in namespace {current.client_namespace}" + ) ancestors = [current] if current.parents: for parent in current.parents: if parent.id in seen_schema_yaml_ids: continue - seen_schema_names.add(current.name) + seen_schema_keys.add(current_schema_key) seen_schema_yaml_ids.add(current.id) - ancestors = self._sort_model_types_helper(parent, seen_schema_names, seen_schema_yaml_ids) + ancestors - seen_schema_names.add(current.name) + ancestors = self._sort_model_types_helper(parent, seen_schema_keys, seen_schema_yaml_ids) + ancestors + seen_schema_keys.add(current_schema_key) seen_schema_yaml_ids.add(current.id) return ancestors @@ -430,11 +434,11 @@ def sort_model_types(self) -> None: :return: None :rtype: None """ - seen_schema_names: set[str] = set() + seen_schema_keys: set[tuple[str, str]] = set() seen_schema_yaml_ids: set[int] = set() sorted_object_schemas: list[ModelType] = [] for schema in sorted(self.model_types, key=lambda x: x.name.lower()): - sorted_object_schemas.extend(self._sort_model_types_helper(schema, seen_schema_names, seen_schema_yaml_ids)) + sorted_object_schemas.extend(self._sort_model_types_helper(schema, seen_schema_keys, seen_schema_yaml_ids)) self.model_types = sorted_object_schemas @property diff --git a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 index 278b21dd99f..abfb0d1e971 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/streaming_base.py.jinja2 @@ -563,9 +563,9 @@ class Stream(Iterator[ReturnType_co]): ``deserialization_callback`` and yielded, and iteration stops immediately afterwards. :paramtype terminal_event_names: ~typing.Sequence[str] or None :keyword reconnect_callback: Optional callback used to re-establish the underlying response - when the stream disconnects before a terminal event is seen and the server has provided - an SSE ``retry`` value. It is called with the last-seen SSE event ID (or ``None``) and - must return a new response to resume iterating from. + when the stream disconnects before a terminal event is seen. Reconnection uses a + three-second delay unless the server provides an SSE ``retry`` value. The callback is + called with the last-seen SSE event ID (or ``None``) and must return a new response. :paramtype reconnect_callback: Callable[[str or None], ~{{ code_model.core_library }}.rest.HttpResponse] or None """ @@ -590,6 +590,7 @@ class Stream(Iterator[ReturnType_co]): self._reconnect_callback = reconnect_callback self._last_event_id: Optional[str] = None self._retry: Optional[int] = None + self._reconnect_delay = 3000 self._iterator = self._iter_results() @property @@ -619,6 +620,7 @@ class Stream(Iterator[ReturnType_co]): event_retry = getattr(event, "retry", None) if event_retry is not None: self._retry = event_retry + self._reconnect_delay = event_retry if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: terminal = True break @@ -627,10 +629,10 @@ class Stream(Iterator[ReturnType_co]): if self._terminal_event_names and getattr(event, "event", None) in self._terminal_event_names: terminal = True break - if terminal or self._reconnect_callback is None or self._retry is None: + if terminal or self._reconnect_callback is None: break self._response.close() - time.sleep(self._retry / 1000) + time.sleep(self._reconnect_delay / 1000) self._response = self._reconnect_callback(self._last_event_id) self._decoder = type(self._decoder)() finally: @@ -674,9 +676,10 @@ class AsyncStream(AsyncIterator[ReturnType_co]): ``deserialization_callback`` and yielded, and iteration stops immediately afterwards. :paramtype terminal_event_names: ~typing.Sequence[str] or None :keyword reconnect_callback: Optional callback used to re-establish the underlying response - when the stream disconnects before a terminal event is seen and the server has provided - an SSE ``retry`` value. It is called with the last-seen SSE event ID (or ``None``) and - must return (or resolve to) a new response to resume iterating from. + when the stream disconnects before a terminal event is seen. Reconnection uses a + three-second delay unless the server provides an SSE ``retry`` value. The callback is + called with the last-seen SSE event ID (or ``None``) and must return (or resolve to) a + new response. :paramtype reconnect_callback: Callable[[str or None], Any] or None """ @@ -703,6 +706,7 @@ class AsyncStream(AsyncIterator[ReturnType_co]): self._reconnect_callback = reconnect_callback self._last_event_id: Optional[str] = None self._retry: Optional[int] = None + self._reconnect_delay = 3000 self._iterator = self._iter_results() @property @@ -734,6 +738,7 @@ class AsyncStream(AsyncIterator[ReturnType_co]): event_retry = getattr(event, "retry", None) if event_retry is not None: self._retry = event_retry + self._reconnect_delay = event_retry if self._terminal_event is not None and getattr(event, "data", None) == self._terminal_event: terminal = True break @@ -746,10 +751,10 @@ class AsyncStream(AsyncIterator[ReturnType_co]): aclose = getattr(events, "aclose", None) if aclose is not None: await aclose() - if terminal or self._reconnect_callback is None or self._retry is None: + if terminal or self._reconnect_callback is None: break await self._response.close() - await asyncio.sleep(self._retry / 1000) + await asyncio.sleep(self._reconnect_delay / 1000) self._response = await self._reconnect_callback(self._last_event_id) self._decoder = type(self._decoder)() finally: diff --git a/packages/http-client-python/package-lock.json b/packages/http-client-python/package-lock.json index 64e2b328755..2f0ef1b4c5e 100644 --- a/packages/http-client-python/package-lock.json +++ b/packages/http-client-python/package-lock.json @@ -29,11 +29,11 @@ "@typespec/compiler": "^1.15.0", "@typespec/events": "~0.85.0", "@typespec/http": "^1.15.0", - "@typespec/http-specs": "0.1.0-alpha.41", + "@typespec/http-specs": "0.1.0-alpha.42-dev.2", "@typespec/openapi": "^1.15.0", "@typespec/rest": "~0.85.0", "@typespec/spec-api": "0.1.0-alpha.16", - "@typespec/spector": "0.1.0-alpha.28", + "@typespec/spector": "0.1.0-alpha.29-dev.0", "@typespec/sse": "~0.85.0", "@typespec/streams": "~0.85.0", "@typespec/versioning": "~0.85.0", @@ -2472,26 +2472,26 @@ } }, "node_modules/@typespec/http-specs": { - "version": "0.1.0-alpha.41", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/http-specs/-/http-specs-0.1.0-alpha.41.tgz", - "integrity": "sha1-f6jsbsXAIlEFa1+1A/FQqcvxZkk=", + "version": "0.1.0-alpha.42-dev.2", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/http-specs/-/http-specs-0.1.0-alpha.42-dev.2.tgz", + "integrity": "sha1-EhDehpyxXzA+dUEwWHHzLWJBQbM=", "dev": true, "license": "MIT", "dependencies": { - "@typespec/spec-api": "^0.1.0-alpha.16", - "@typespec/spector": "^0.1.0-alpha.28" + "@typespec/spec-api": "^0.1.0-alpha.16 || >= 0.1.0-dev.0", + "@typespec/spector": "^0.1.0-alpha.28 || >= 0.1.0-alpha.29-dev.0" }, "engines": { "node": ">=22.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.15.0", - "@typespec/events": "^0.85.0", - "@typespec/http": "^1.15.0", - "@typespec/rest": "^0.85.0", - "@typespec/sse": "^0.85.0", - "@typespec/versioning": "^0.85.0", - "@typespec/xml": "^0.85.0" + "@typespec/compiler": "^1.15.0 || >= 1.16.0-dev.0", + "@typespec/events": "^0.85.0 || >= 0.86.0-dev.0", + "@typespec/http": "^1.15.0 || >= 1.16.0-dev.0", + "@typespec/rest": "^0.85.0 || >= 0.86.0-dev.0", + "@typespec/sse": "^0.85.0 || >= 0.86.0-dev.0", + "@typespec/versioning": "^0.85.0 || >= 0.86.0-dev.0", + "@typespec/xml": "^0.85.0 || >= 0.86.0-dev.0" } }, "node_modules/@typespec/openapi": { @@ -2584,19 +2584,19 @@ "license": "MIT" }, "node_modules/@typespec/spector": { - "version": "0.1.0-alpha.28", - "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/spector/-/spector-0.1.0-alpha.28.tgz", - "integrity": "sha1-/4kblwKNH+UZXSzz1SmZds8nt88=", + "version": "0.1.0-alpha.29-dev.0", + "resolved": "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/@typespec/spector/-/spector-0.1.0-alpha.29-dev.0.tgz", + "integrity": "sha1-Y76ZtxW9/gmRAbGeEFfohnKnUhk=", "dev": true, "license": "MIT", "dependencies": { "@azure/identity": "^4.13.1", - "@typespec/compiler": "^1.15.0", - "@typespec/http": "^1.15.0", - "@typespec/rest": "^0.85.0", - "@typespec/spec-api": "^0.1.0-alpha.16", - "@typespec/spec-coverage-sdk": "^0.1.0-alpha.16", - "@typespec/versioning": "^0.85.0", + "@typespec/compiler": "^1.15.0 || >= 1.16.0-dev.0", + "@typespec/http": "^1.15.0 || >= 1.16.0-dev.0", + "@typespec/rest": "^0.85.0 || >= 0.86.0-dev.0", + "@typespec/spec-api": "^0.1.0-alpha.16 || >= 0.1.0-dev.0", + "@typespec/spec-coverage-sdk": "^0.1.0-alpha.16 || >= 0.1.0-dev.0", + "@typespec/versioning": "^0.85.0 || >= 0.86.0-dev.0", "ajv": "^8.18.0", "express": "^5.2.1", "micromatch": "^4.0.8", diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json index b530962f347..17f49dd347b 100644 --- a/packages/http-client-python/package.json +++ b/packages/http-client-python/package.json @@ -116,12 +116,12 @@ "@typespec/rest": "~0.85.0", "@typespec/versioning": "~0.85.0", "@typespec/events": "~0.85.0", - "@typespec/spector": "0.1.0-alpha.28", + "@typespec/spector": "0.1.0-alpha.29-dev.0", "@typespec/spec-api": "0.1.0-alpha.16", "@typespec/sse": "~0.85.0", "@typespec/streams": "~0.85.0", "@typespec/xml": "~0.85.0", - "@typespec/http-specs": "0.1.0-alpha.41", + "@typespec/http-specs": "0.1.0-alpha.42-dev.2", "@types/js-yaml": "~4.0.5", "@types/node": "~25.0.2", "@types/semver": "7.5.8", diff --git a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py index d4c7866b761..1f73028c2df 100644 --- a/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py +++ b/packages/http-client-python/tests/mock_api/shared/asynctests/test_streaming_sse_async.py @@ -11,6 +11,8 @@ from streaming.sse.aio import SseClient from streaming.sse._utils.streaming_base import AsyncStream from streaming.sse.named.models import ResponseCreated, ResponseDelta +from streaming.sse.protocol.data.models import WithEnvelope1 +from streaming.sse.protocol.models import Info as ProtocolInfo from streaming.sse.retrieve.models import FinalResult, PartialResult, RetrievalRequest from streaming.sse.unnamed.models import Info @@ -23,11 +25,11 @@ async def client(): @pytest.mark.asyncio async def test_unnamed_receive(client: SseClient): - stream = await client.unnamed.receive() - assert isinstance(stream, AsyncStream) - items = [item async for item in stream] - assert all(isinstance(item, Info) for item in items) - assert [item.desc for item in items] == ["one", "two", "three"] + async with await client.unnamed.receive() as stream: + assert isinstance(stream, AsyncStream) + items = [await stream.__anext__() for _ in range(3)] + assert all(isinstance(item, Info) for item in items) + assert [item.desc for item in items] == ["one", "two", "three"] @pytest.mark.asyncio @@ -54,6 +56,68 @@ async def test_retrieve_stream(client: SseClient): assert isinstance(items[2], FinalResult) and items[2].references == ["doc1", "doc2"] +@pytest.mark.asyncio +async def test_protocol_data_with_envelope(client: SseClient): + async with await client.protocol.data.with_envelope() as stream: + assert await stream.__anext__() == "hello" + + +@pytest.mark.asyncio +async def test_protocol_data_without_envelope(client: SseClient): + async with await client.protocol.data.without_envelope() as stream: + item = await stream.__anext__() + assert isinstance(item, WithEnvelope1) + assert item.metadata == {"source": "test"} + assert item.contents == "world" + + +@pytest.mark.asyncio +async def test_protocol_event_id(client: SseClient): + async with await client.protocol.id() as stream: + item = await stream.__anext__() + assert isinstance(item, ProtocolInfo) + assert item.message == "hello" + assert stream.last_event_id == "event-1" + + +@pytest.mark.asyncio +async def test_protocol_invalid_event_id(client: SseClient): + async with await client.protocol.invalid_id() as stream: + item = await stream.__anext__() + assert isinstance(item, ProtocolInfo) + assert item.message == "hello" + assert stream.last_event_id == "" + + +@pytest.mark.asyncio +async def test_protocol_retry(client: SseClient): + async with await client.protocol.retry() as stream: + item = await stream.__anext__() + assert isinstance(item, ProtocolInfo) + assert item.message == "hello" + assert stream.retry == 1000 + + +@pytest.mark.asyncio +async def test_protocol_invalid_retry(client: SseClient): + async with await client.protocol.invalid_retry() as stream: + item = await stream.__anext__() + assert isinstance(item, ProtocolInfo) + assert item.message == "hello" + assert stream.retry is None + + +@pytest.mark.asyncio +async def test_protocol_reconnect(client: SseClient): + async with await client.protocol.reconnect() as stream: + first = await stream.__anext__() + second = await stream.__anext__() + assert isinstance(first, ProtocolInfo) + assert isinstance(second, ProtocolInfo) + assert [first.message, second.message] == ["hello", "world"] + assert stream.last_event_id == "event-2" + + # --------------------------------------------------------------------------- # Named / model terminal events (yield-then-stop) -- see the sync test module # for the rationale. Driven through the generated ``AsyncStream`` with a fake @@ -179,17 +243,10 @@ async def reconnect(last_event_id): @pytest.mark.asyncio -async def test_sse_does_not_reconnect_without_retry_or_after_terminal(): - response = _FakeAsyncResponse(b"id: first\ndata: one\n\n") - +async def test_sse_does_not_reconnect_after_terminal(): async def reconnect(_last_event_id): pytest.fail("unexpected reconnect") - stream = AsyncStream( - response=response, deserialization_callback=lambda _response, event: event.data, reconnect_callback=reconnect - ) - assert [item async for item in stream] == ["one"] - response = _FakeAsyncResponse(b"retry: 0\ndata: one\n\ndata: [DONE]\n\n") stream = AsyncStream( response=response, diff --git a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py index 5ab2652d077..22422523a46 100644 --- a/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py +++ b/packages/http-client-python/tests/mock_api/shared/test_streaming_sse.py @@ -10,6 +10,8 @@ from streaming.sse import SseClient from streaming.sse._utils.streaming_base import Stream from streaming.sse.named.models import ResponseCreated, ResponseDelta +from streaming.sse.protocol.data.models import WithEnvelope1 +from streaming.sse.protocol.models import Info as ProtocolInfo from streaming.sse.retrieve.models import FinalResult, PartialResult, RetrievalRequest from streaming.sse.unnamed.models import Info @@ -21,11 +23,11 @@ def client(): def test_unnamed_receive(client: SseClient): - stream = client.unnamed.receive() - assert isinstance(stream, Stream) - items = list(stream) - assert all(isinstance(item, Info) for item in items) - assert [item.desc for item in items] == ["one", "two", "three"] + with client.unnamed.receive() as stream: + assert isinstance(stream, Stream) + items = [next(stream) for _ in range(3)] + assert all(isinstance(item, Info) for item in items) + assert [item.desc for item in items] == ["one", "two", "three"] def test_named_receive(client: SseClient): @@ -50,6 +52,61 @@ def test_retrieve_stream(client: SseClient): assert isinstance(items[2], FinalResult) and items[2].references == ["doc1", "doc2"] +def test_protocol_data_with_envelope(client: SseClient): + with client.protocol.data.with_envelope() as stream: + assert next(stream) == "hello" + + +def test_protocol_data_without_envelope(client: SseClient): + with client.protocol.data.without_envelope() as stream: + item = next(stream) + assert isinstance(item, WithEnvelope1) + assert item.metadata == {"source": "test"} + assert item.contents == "world" + + +def test_protocol_event_id(client: SseClient): + with client.protocol.id() as stream: + item = next(stream) + assert isinstance(item, ProtocolInfo) + assert item.message == "hello" + assert stream.last_event_id == "event-1" + + +def test_protocol_invalid_event_id(client: SseClient): + with client.protocol.invalid_id() as stream: + item = next(stream) + assert isinstance(item, ProtocolInfo) + assert item.message == "hello" + assert stream.last_event_id == "" + + +def test_protocol_retry(client: SseClient): + with client.protocol.retry() as stream: + item = next(stream) + assert isinstance(item, ProtocolInfo) + assert item.message == "hello" + assert stream.retry == 1000 + + +def test_protocol_invalid_retry(client: SseClient): + with client.protocol.invalid_retry() as stream: + item = next(stream) + assert isinstance(item, ProtocolInfo) + assert item.message == "hello" + assert stream.retry is None + + +def test_protocol_reconnect(client: SseClient): + with client.protocol.reconnect() as stream: + first = next(stream) + second = next(stream) + assert isinstance(first, ProtocolInfo) + assert isinstance(second, ProtocolInfo) + assert [first.message, second.message] == ["hello", "world"] + assert stream.last_event_id == "event-2" + + # --------------------------------------------------------------------------- # Named / model terminal events (yield-then-stop). # @@ -186,15 +243,9 @@ def reconnect(last_event_id): assert stream.last_event_id == "second" -def test_sse_does_not_reconnect_without_retry_or_after_terminal(): - response = _FakeResponse(b"id: first\ndata: one\n\n") - reconnect = lambda _last_event_id: pytest.fail("unexpected reconnect") - stream = Stream( - response=response, deserialization_callback=lambda _response, event: event.data, reconnect_callback=reconnect - ) - assert list(stream) == ["one"] - +def test_sse_does_not_reconnect_after_terminal(): response = _FakeResponse(b"retry: 0\ndata: one\n\ndata: [DONE]\n\n") + reconnect = lambda _last_event_id: pytest.fail("unexpected reconnect") stream = Stream( response=response, deserialization_callback=lambda _response, event: event.data,