From 0834a2c0b34c27d22b8e430c7eab135ee300bf79 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 21 Apr 2026 12:58:07 -0400 Subject: [PATCH 01/17] feat: add typeddict models-mode for Python HTTP client emitter Add a new 'typeddict' value for the models-mode option that generates Python TypedDict classes instead of DPG model classes. Key features: - TypedDict classes with Required[T]/NotRequired[T] annotations - TypedDict inheritance for non-discriminated models - Discriminated models: Union of leaf TypedDicts, no abstract base class - Input-only: operations accept TypedDict input, return dict output - Wire names used as TypedDict keys - _model_base.py still generated for serialization utilities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../http-client-python/emitter/src/types.ts | 2 +- .../eng/scripts/ci/regenerate-common.ts | 32 +++-- .../pygen/codegen/models/__init__.py | 4 +- .../pygen/codegen/models/code_model.py | 4 +- .../pygen/codegen/models/lro_operation.py | 2 +- .../pygen/codegen/models/model_type.py | 15 +++ .../pygen/codegen/models/operation.py | 8 +- .../pygen/codegen/models/paging_operation.py | 2 +- .../pygen/codegen/models/parameter.py | 2 +- .../models/request_builder_parameter.py | 4 +- .../pygen/codegen/serializers/__init__.py | 12 +- .../codegen/serializers/builder_serializer.py | 21 +-- .../codegen/serializers/model_serializer.py | 122 ++++++++++++++++++ .../templates/model_container.py.jinja2 | 2 + .../templates/model_typeddict.py.jinja2 | 27 ++++ .../generator/pygen/preprocess/__init__.py | 4 +- 16 files changed, 227 insertions(+), 36 deletions(-) create mode 100644 packages/http-client-python/generator/pygen/codegen/templates/model_typeddict.py.jinja2 diff --git a/packages/http-client-python/emitter/src/types.ts b/packages/http-client-python/emitter/src/types.ts index 3e0ee6f6b32..1841efea310 100644 --- a/packages/http-client-python/emitter/src/types.ts +++ b/packages/http-client-python/emitter/src/types.ts @@ -289,7 +289,7 @@ function emitModel(context: PythonSdkContext, type: SdkModelType): Record>, properties: new Array>(), snakeCaseName: camelToSnakeCase(type.name), - base: "dpg", + base: (context.emitContext.options as any)["models-mode"] === "typeddict" ? "typeddict" : "dpg", internal: type.access === "internal", crossLanguageDefinitionId: type.crossLanguageDefinitionId, usage: type.usage, diff --git a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts index 8dd426152b4..b686988e600 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts @@ -203,14 +203,30 @@ export const BASE_EMITTER_OPTIONS: Record< "package-name": "typetest-model-nesteddiscriminator", namespace: "typetest.model.nesteddiscriminator", }, - "type/model/inheritance/not-discriminated": { - "package-name": "typetest-model-notdiscriminated", - namespace: "typetest.model.notdiscriminated", - }, - "type/model/inheritance/single-discriminator": { - "package-name": "typetest-model-singlediscriminator", - namespace: "typetest.model.singlediscriminator", - }, + "type/model/inheritance/not-discriminated": [ + { + "package-name": "typetest-model-notdiscriminated", + namespace: "typetest.model.notdiscriminated", + }, + { + "package-name": "typetest-model-notdiscriminated-typeddict", + namespace: "typetest.model.notdiscriminated.typeddict", + "models-mode": "typeddict", + "generate-test": "false", + }, + ], + "type/model/inheritance/single-discriminator": [ + { + "package-name": "typetest-model-singlediscriminator", + namespace: "typetest.model.singlediscriminator", + }, + { + "package-name": "typetest-model-singlediscriminator-typeddict", + namespace: "typetest.model.singlediscriminator.typeddict", + "models-mode": "typeddict", + "generate-test": "false", + }, + ], "type/model/inheritance/recursive": [ { "package-name": "typetest-model-recursive", diff --git a/packages/http-client-python/generator/pygen/codegen/models/__init__.py b/packages/http-client-python/generator/pygen/codegen/models/__init__.py index a1d9f9a4dbc..5848854ed86 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/models/__init__.py @@ -9,7 +9,7 @@ from .base_builder import BaseBuilder, ParameterListType from .code_model import CodeModel from .client import Client -from .model_type import ModelType, JSONModelType, DPGModelType, MsrestModelType +from .model_type import ModelType, JSONModelType, DPGModelType, MsrestModelType, TypedDictModelType from .dictionary_type import DictionaryType from .list_type import ListType from .combined_type import CombinedType @@ -171,6 +171,8 @@ def build_type(yaml_data: dict[str, Any], code_model: CodeModel) -> BaseType: model_type = JSONModelType elif yaml_data["base"] == "dpg": model_type = DPGModelType # type: ignore + elif yaml_data["base"] == "typeddict": + model_type = TypedDictModelType # type: ignore else: model_type = MsrestModelType # type: ignore response = model_type(yaml_data, code_model) 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 81f5f20bf8b..6a28ddfcaad 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 @@ -251,7 +251,7 @@ def need_utils_folder(self, async_mode: bool, client_namespace: str) -> bool: return ( self.need_utils_utils(async_mode, client_namespace) or self.need_utils_serialization - or self.options["models-mode"] == "dpg" + or self.options["models-mode"] in ("dpg", "typeddict") ) @property @@ -271,7 +271,7 @@ def need_utils_form_data(self, async_mode: bool, client_namespace: str) -> bool: (not async_mode) and self.is_top_namespace(client_namespace) and self.has_form_data - and self.options["models-mode"] == "dpg" + and self.options["models-mode"] in ("dpg", "typeddict") ) def need_utils_etag(self, client_namespace: str) -> bool: diff --git a/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py b/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py index b7673d4d865..1a3d37bb85b 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py @@ -125,7 +125,7 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: ImportType.SDKCORE, ) if ( - self.code_model.options["models-mode"] == "dpg" + self.code_model.options["models-mode"] in ("dpg", "typeddict") and self.lro_response and self.lro_response.type and self.lro_response.type.type == "model" diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index d0784f81efb..069c572e5fa 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -374,3 +374,18 @@ def imports(self, **kwargs: Any) -> FileImport: if self.flattened_property: file_import.add_submodule_import("typing", "Any", ImportType.STDLIB) return file_import + + +class TypedDictModelType(GeneratedModelType): + base = "typeddict" + + def serialization_type(self, **kwargs: Any) -> str: + return self.type_annotation(skip_quote=True, **kwargs) + + @property + def instance_check_template(self) -> str: + return "isinstance({}, dict)" + + def imports(self, **kwargs: Any) -> FileImport: + file_import = super().imports(**kwargs) + return file_import 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 c5f15593893..4ffe1169e11 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -51,7 +51,7 @@ def is_internal(target: Optional[BaseType]) -> bool: - return isinstance(target, ModelType) and target.base == "dpg" and target.internal + return isinstance(target, ModelType) and target.base in ("dpg", "typeddict") and target.internal class OperationBase( # pylint: disable=too-many-public-methods,too-many-instance-attributes @@ -176,7 +176,7 @@ def response_docstring_text(self, **kwargs) -> str: retval = self._response_docstring_helper("docstring_text", **kwargs) if not self.code_model.options["version-tolerant"]: retval += " or the result of cls(response)" - if self.code_model.options["models-mode"] == "dpg" and any( + if self.code_model.options["models-mode"] in ("dpg", "typeddict") and any( isinstance(r.type, ModelType) for r in self.responses ): r = next(r for r in self.responses if isinstance(r.type, ModelType)) @@ -209,7 +209,7 @@ def default_error_deserialization(self, serialize_namespace: str) -> Optional[st f"{exception_schema.type_annotation(skip_quote=True, serialize_namespace=serialize_namespace)}," f"{pylint_disable}" ) - return None if self.code_model.options["models-mode"] == "dpg" else "'object'," + return None if self.code_model.options["models-mode"] in ("dpg", "typeddict") else "'object'," @property def non_default_errors(self) -> list[Response]: @@ -421,7 +421,7 @@ def imports( # pylint: disable=too-many-branches, disable=too-many-statements for overload in self.overloads: if overload.parameters.has_body: file_import.merge(overload.parameters.body_parameter.type.imports(**kwargs)) - if self.code_model.options["models-mode"] == "dpg": + if self.code_model.options["models-mode"] in ("dpg", "typeddict"): relative_path = self.code_model.get_relative_import_path( serialize_namespace, module_name="_utils.model_base" ) diff --git a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py index f64363ed5fb..3ce9d9abfea 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py @@ -181,7 +181,7 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: "case_insensitive_dict", ImportType.SDKCORE, ) - if self.code_model.options["models-mode"] == "dpg": + if self.code_model.options["models-mode"] in ("dpg", "typeddict"): relative_path = self.code_model.get_relative_import_path( serialize_namespace, module_name="_utils.model_base" ) diff --git a/packages/http-client-python/generator/pygen/codegen/models/parameter.py b/packages/http-client-python/generator/pygen/codegen/models/parameter.py index 26eb0ba5c9a..34a765b849a 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/parameter.py @@ -336,7 +336,7 @@ def method_location( # pylint: disable=too-many-return-statements ) -> ParameterMethodLocation: if not self.in_method_signature: raise ValueError(f"Parameter '{self.client_name}' is not in the method.") - if self.code_model.options["models-mode"] == "dpg" and self.in_flattened_body: + if self.code_model.options["models-mode"] in ("dpg", "typeddict") and self.in_flattened_body: return ParameterMethodLocation.KEYWORD_ONLY if self.grouper: return ParameterMethodLocation.POSITIONAL diff --git a/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py b/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py index c73df6db5af..393dc92edb4 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py @@ -26,7 +26,7 @@ def __init__(self, *args, **kwargs) -> None: if ( isinstance(self.type, (BinaryType, StringType)) or any("xml" in ct for ct in self.content_types) - or self.code_model.options["models-mode"] == "dpg" + or self.code_model.options["models-mode"] in ("dpg", "typeddict") ): self.client_name = "content" else: @@ -40,7 +40,7 @@ def type_annotation(self, **kwargs: Any) -> str: @property def in_method_signature(self) -> bool: return ( - super().in_method_signature and not self.is_partial_body and self.code_model.options["models-mode"] != "dpg" + super().in_method_signature and not self.is_partial_body and self.code_model.options["models-mode"] not in ("dpg", "typeddict") ) @property 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 018605575c9..897fd750769 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -25,7 +25,7 @@ from .enum_serializer import EnumSerializer from .general_serializer import GeneralSerializer from .model_init_serializer import ModelInitSerializer -from .model_serializer import DpgModelSerializer, MsrestModelSerializer +from .model_serializer import DpgModelSerializer, MsrestModelSerializer, TypedDictModelSerializer from .operations_init_serializer import OperationsInitSerializer from .operation_groups_serializer import OperationGroupsSerializer from .request_builders_serializer import RequestBuildersSerializer @@ -285,7 +285,13 @@ def _serialize_and_write_models_folder( ) -> None: # Write the models folder models_path = self.code_model.get_generation_dir(namespace) / "models" - serializer = DpgModelSerializer if self.code_model.options["models-mode"] == "dpg" else MsrestModelSerializer + models_mode = self.code_model.options["models-mode"] + if models_mode == "dpg": + serializer = DpgModelSerializer + elif models_mode == "typeddict": + serializer = TypedDictModelSerializer + else: + serializer = MsrestModelSerializer if self.code_model.has_non_json_models(models): self.write_file( models_path / Path(f"{self.code_model.models_filename}.py"), @@ -474,7 +480,7 @@ def _serialize_and_write_utils_folder(self, env: Environment, namespace: str): ) # write _model_base.py - if self.code_model.options["models-mode"] == "dpg": + if self.code_model.options["models-mode"] in ("dpg", "typeddict"): self.write_file( utils_folder_path / Path("model_base.py"), general_serializer.serialize_model_base_file(), 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 e43dcd916eb..3b45ff46dc3 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 @@ -29,6 +29,7 @@ CombinedType, JSONModelType, DPGModelType, + TypedDictModelType, ParameterListType, ByteArraySchema, ) @@ -711,7 +712,7 @@ def _serialize_body_parameter(self, builder: OperationType) -> list[str]: f"_{body_kwarg_name} = self._serialize.body({body_param.client_name}, " f"'{serialization_type}'{is_xml_cmd}{serialization_ctxt_cmd})" ) - elif self.code_model.options["models-mode"] == "dpg": + elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): if json_serializable(body_param.default_content_type): if hasattr(body_param.type, "encode") and body_param.type.encode: # type: ignore create_body_call = ( @@ -790,9 +791,9 @@ def _initialize_overloads(self, builder: OperationType, is_paging: bool = False) overload.request_builder.parameters.body_parameter.client_name for overload in builder.overloads ] all_dpg_model_overloads = False - if self.code_model.options["models-mode"] == "dpg" and builder.overloads: + if self.code_model.options["models-mode"] in ("dpg", "typeddict") and builder.overloads: all_dpg_model_overloads = all( - isinstance(o.parameters.body_parameter.type, DPGModelType) for o in builder.overloads + isinstance(o.parameters.body_parameter.type, (DPGModelType, TypedDictModelType)) for o in builder.overloads ) if not all_dpg_model_overloads: for v in sorted(set(client_names), key=client_names.index): @@ -997,7 +998,7 @@ def response_deserialization( # pylint: disable=too-many-statements deserialize_code.append(f" '{serialization_type}',{pylint_disable}") deserialize_code.append(" pipeline_response.http_response") deserialize_code.append(")") - elif self.code_model.options["models-mode"] == "dpg": + elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): if builder.has_stream_response: deserialize_code.append("deserialized = response.content") else: @@ -1071,7 +1072,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran type_annotation = e.type.type_annotation( # type: ignore is_operation_file=True, skip_quote=True, serialize_namespace=self.serialize_namespace ) - if self.code_model.options["models-mode"] == "dpg": + if self.code_model.options["models-mode"] in ("dpg", "typeddict"): if xml_serializable(str(e.default_content_type)): fn = "_failsafe_deserialize_xml" else: @@ -1113,7 +1114,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran type_annotation = e.type.type_annotation( # type: ignore is_operation_file=True, skip_quote=True, serialize_namespace=self.serialize_namespace ) - if self.code_model.options["models-mode"] == "dpg": + if self.code_model.options["models-mode"] in ("dpg", "typeddict"): if xml_serializable(str(e.default_content_type)): retval.append( " error = _failsafe_deserialize_xml(" @@ -1141,7 +1142,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran indent = " " if builder.non_default_errors else " " if builder.non_default_errors: retval.append(" else:") - if self.code_model.options["models-mode"] == "dpg": + if self.code_model.options["models-mode"] in ("dpg", "typeddict"): default_exception = next(e for e in builder.exceptions if "default" in e.status_codes and e.type) if xml_serializable(str(default_exception.default_content_type)): fn = "_failsafe_deserialize_xml" @@ -1410,7 +1411,7 @@ def _extract_data_callback( # pylint: disable=too-many-statements,too-many-bran f"self._deserialize(\n {deserialize_type},{pylint_disable}\n pipeline_response{suffix}\n)" ) retval.append(f" deserialized = {deserialized}") - elif self.code_model.options["models-mode"] == "dpg": + elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): # we don't want to generate paging models for DPG retval.append(f" deserialized = {deserialized}") else: @@ -1428,7 +1429,7 @@ def _extract_data_callback( # pylint: disable=too-many-statements,too-many-bran "".join([f'.get("{i}", {{}})' for i in item_name_array[:-1]]) + f'.get("{item_name_array[-1]}", [])' ) pylint_disable = "" - if self.code_model.options["models-mode"] == "dpg": + if self.code_model.options["models-mode"] in ("dpg", "typeddict"): item_type = builder.item_type.type_annotation( is_operation_file=True, serialize_namespace=self.serialize_namespace ) @@ -1605,7 +1606,7 @@ def get_long_running_output(self, builder: LROOperationType) -> list[str]: retval.append(" response_headers = {}") if ( not self.code_model.options["models-mode"] - or self.code_model.options["models-mode"] == "dpg" + or self.code_model.options["models-mode"] in ("dpg", "typeddict") or builder.lro_response.headers ): retval.append(" response = pipeline_response.http_response") diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py index d428a113e5e..d34cc2310e6 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py @@ -382,3 +382,125 @@ def global_pylint_disables(self) -> str: if final_result: return "# pylint: disable=" + ", ".join(final_result) return "" + + +class TypedDictModelSerializer(_ModelSerializer): + def _is_parent_discriminated_base(self, model: ModelType) -> bool: + """Check if any parent of this model is a discriminated base (has discriminated_subtypes).""" + return any(p.discriminated_subtypes for p in model.parents) + + def _reorder_models(self, models: list[ModelType]) -> list[ModelType]: + """Reorder so discriminated base Union aliases come after all their subtypes.""" + bases = [m for m in models if m.discriminated_subtypes] + non_bases = [m for m in models if not m.discriminated_subtypes] + return non_bases + bases + + def serialize(self) -> str: + template = self.env.get_template("model_container.py.jinja2") + return template.render( + code_model=self.code_model, + imports=FileImportSerializer(self.imports()), + str=str, + serializer=self, + models=self._reorder_models(self.models), + ) + + def imports(self) -> FileImport: + file_import = FileImport(self.code_model) + has_required = False + has_optional = False + has_discriminated_union = False + for model in self.models: + if model.base == "json": + continue + if model.discriminated_subtypes: + has_discriminated_union = True + file_import.merge( + model.imports( + is_operation_file=False, + serialize_namespace=self.serialize_namespace, + serialize_namespace_type=NamespaceType.MODEL, + ) + ) + for prop in model.properties: + file_import.merge( + prop.imports( + serialize_namespace=self.serialize_namespace, + serialize_namespace_type=NamespaceType.MODEL, + called_by_property=True, + ) + ) + if prop.optional or prop.client_default_value is not None: + has_optional = True + else: + has_required = True + for parent in model.parents: + if parent.client_namespace != model.client_namespace and not parent.discriminated_subtypes: + file_import.add_submodule_import( + self.code_model.get_relative_import_path( + self.serialize_namespace, + self.code_model.get_imported_namespace_for_model(parent.client_namespace), + ), + parent.name, + ImportType.LOCAL, + ) + file_import.add_submodule_import("typing", "TypedDict", ImportType.STDLIB) + if has_optional: + file_import.add_submodule_import("typing", "NotRequired", ImportType.STDLIB) + if has_required: + file_import.add_submodule_import("typing", "Required", ImportType.STDLIB) + if has_discriminated_union: + file_import.add_submodule_import("typing", "Union", ImportType.STDLIB) + return file_import + + def declare_model(self, model: ModelType) -> str: + # If the model's parent is a discriminated base, don't inherit from it + non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes] + if non_discriminated_parents: + basename = ", ".join([m.name for m in non_discriminated_parents]) + return f"class {model.name}({basename}):{model.pylint_disable()}" + return f"class {model.name}(TypedDict, total=False):{model.pylint_disable()}" + + @staticmethod + def get_properties_to_declare(model: ModelType) -> list[Property]: + # Only exclude inherited properties from non-discriminated parents + non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes] + if non_discriminated_parents: + parent_properties = [p for bm in non_discriminated_parents for p in bm.properties] + properties_to_declare = [ + p + for p in model.properties + if not any( + p.client_name == pp.client_name + and p.type_annotation() == pp.type_annotation() + and not p.is_base_discriminator + for pp in parent_properties + ) + ] + else: + properties_to_declare = model.properties + return properties_to_declare + + def declare_property(self, prop: Property) -> str: + type_annotation = prop.type_annotation(serialize_namespace=self.serialize_namespace) + is_optional = prop.optional or prop.client_default_value is not None + if is_optional: + return f"{prop.wire_name}: NotRequired[{type_annotation}]" + return f"{prop.wire_name}: Required[{type_annotation}]" + + def initialize_properties(self, model: ModelType) -> list[str]: + return [] + + def need_init(self, model: ModelType) -> bool: + return False + + def discriminated_subtypes_union(self, model: ModelType) -> str: + subtypes = list(model.discriminated_subtypes.values()) + subtype_names = [s.name for s in subtypes] + return f"{model.name} = Union[{', '.join(subtype_names)}]" + + def is_discriminated_base(self, model: ModelType) -> bool: + return bool(model.discriminated_subtypes) + + def global_pylint_disables(self) -> str: + return "" diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 index dc98f999c45..6e033ba77fb 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 @@ -13,5 +13,7 @@ {% include "model_dpg.py.jinja2" %} {% elif model.base == "msrest" %} {% include "model_msrest.py.jinja2" %} +{% elif model.base == "typeddict" %} +{% include "model_typeddict.py.jinja2" %} {% endif %} {% endfor %} diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_typeddict.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_typeddict.py.jinja2 new file mode 100644 index 00000000000..082ec87cf9f --- /dev/null +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_typeddict.py.jinja2 @@ -0,0 +1,27 @@ +{# actual template starts here #} +{% import "macros.jinja2" as macros %} + +{% if serializer.is_discriminated_base(model) %} +{{ serializer.discriminated_subtypes_union(model) }} +{% else %} + +{{ serializer.declare_model(model) }} + """{{ op_tools.wrap_string(model.description(is_operation_file=False), "\n ") }} + + {% if model.properties != None %} + {% for p in model.properties %} + {% for line in serializer.variable_documentation_string(p) %} + {{ macros.wrap_model_string(line, '\n ') -}} + {% endfor %} + {% endfor %} + {% endif %} + """ + + {% for p in serializer.get_properties_to_declare(model)%} + {{ serializer.declare_property(p) }} + {% set prop_description = p.description(is_operation_file=False).replace('"', '\\"') %} + {% if prop_description %} + """{{ macros.wrap_model_string(prop_description, '\n ', '\"\"\"') -}} + {% endif %} + {% endfor %} +{% endif %} diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 6d3344059a3..be029d64f3c 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -216,7 +216,7 @@ def add_body_param_type( model_type = ( body_parameter["type"] if origin_type == "model" else body_parameter["type"].get("elementType", {}) ) - is_dpg_model = model_type.get("base") == "dpg" + is_dpg_model = model_type.get("base") in ("dpg", "typeddict") body_parameter["type"] = { "type": "combined", "types": [body_parameter["type"]], @@ -225,7 +225,7 @@ def add_body_param_type( if not (self.is_tsp and has_multi_part_content_type(body_parameter)): body_parameter["type"]["types"].append(KNOWN_TYPES["binary"]) - if self.options["models-mode"] == "dpg" and is_dpg_model: + if self.options["models-mode"] in ("dpg", "typeddict") and is_dpg_model: if origin_type == "model": body_parameter["type"]["types"].insert(1, KNOWN_TYPES["any-object"]) else: From 27c8bfb31577bf7c03b13726a8f5803b38b63bc2 Mon Sep 17 00:00:00 2001 From: iscai-msft <43154838+iscai-msft@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:49:59 -0400 Subject: [PATCH 02/17] Enhance Python HTTP client emitter with TypedDict support --- .chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md diff --git a/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md b/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md new file mode 100644 index 00000000000..25dfde1e67f --- /dev/null +++ b/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/http-client-python" +--- + +[python] add `typeddict` `models-mode` for Python HTTP client emitter to generated `TypedDict`s for input models From c1487f899c6d5cee216a8616ed8ee290b83549cf Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 21 Apr 2026 14:37:31 -0400 Subject: [PATCH 03/17] add discriminator --- .../models/request_builder_parameter.py | 4 +- .../pygen/codegen/serializers/__init__.py | 48 +++++++++++++++ .../codegen/serializers/builder_serializer.py | 3 +- .../codegen/serializers/model_serializer.py | 6 +- .../templates/model_typeddict.py.jinja2 | 1 + ...inheritance_not_discriminated_typeddict.py | 31 ++++++++++ ...eritance_single_discriminator_typeddict.py | 58 +++++++++++++++++++ 7 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_not_discriminated_typeddict.py create mode 100644 packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_single_discriminator_typeddict.py diff --git a/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py b/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py index 393dc92edb4..810fbf3b7e7 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py @@ -40,7 +40,9 @@ def type_annotation(self, **kwargs: Any) -> str: @property def in_method_signature(self) -> bool: return ( - super().in_method_signature and not self.is_partial_body and self.code_model.options["models-mode"] not in ("dpg", "typeddict") + super().in_method_signature + and not self.is_partial_body + and self.code_model.options["models-mode"] not in ("dpg", "typeddict") ) @property 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 897fd750769..f07a9078c3f 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -22,6 +22,7 @@ ModelType, EnumType, ) +from ..models.primitive_types import DatetimeType, ByteArraySchema, BinaryType from .enum_serializer import EnumSerializer from .general_serializer import GeneralSerializer from .model_init_serializer import ModelInitSerializer @@ -118,8 +119,55 @@ def keep_version_file(self) -> bool: # If parsing the version fails, we assume the version file is not valid and overwrite. return False + @staticmethod + def _validate_typeddict_models(code_model: CodeModel) -> None: + """Validate that models are compatible with typeddict mode. + + Raises ValueError if any model uses unsupported features: + readonly properties, datetime types, bytes types, + or additional properties (extends Record). + """ + unsupported: list[str] = [] + for model in code_model.model_types: + if model.base != "typeddict": + continue + model_name = model.name + + for prop in model.properties: + # Readonly + if prop.readonly: + unsupported.append( + f"Model '{model_name}' has readonly property '{prop.client_name}', " + "which is not supported in typeddict mode." + ) + # Datetime + if isinstance(prop.type, DatetimeType): + unsupported.append( + f"Model '{model_name}' has datetime property '{prop.client_name}', " + "which is not supported in typeddict mode." + ) + # Bytes + if isinstance(prop.type, (ByteArraySchema, BinaryType)): + unsupported.append( + f"Model '{model_name}' has bytes property '{prop.client_name}', " + "which is not supported in typeddict mode." + ) + # Additional properties (extends Record) + if prop.client_name == "additional_properties": + unsupported.append( + f"Model '{model_name}' has additional properties (extends Record), " + "which is not supported in typeddict mode." + ) + + if unsupported: + raise ValueError("The following models are not compatible with typeddict mode:\n" + "\n".join(unsupported)) + # pylint: disable=too-many-branches def serialize(self) -> None: + # Validate typeddict mode constraints + if self.code_model.options.get("models-mode") == "typeddict": + self._validate_typeddict_models(self.code_model) + # remove existing folders when generate from tsp if self.code_model.is_tsp and self.code_model.options.get("clear-output-folder"): # remove generated_samples and generated_tests folder 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 3b45ff46dc3..784c049ea1a 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 @@ -793,7 +793,8 @@ def _initialize_overloads(self, builder: OperationType, is_paging: bool = False) all_dpg_model_overloads = False if self.code_model.options["models-mode"] in ("dpg", "typeddict") and builder.overloads: all_dpg_model_overloads = all( - isinstance(o.parameters.body_parameter.type, (DPGModelType, TypedDictModelType)) for o in builder.overloads + isinstance(o.parameters.body_parameter.type, (DPGModelType, TypedDictModelType)) + for o in builder.overloads ) if not all_dpg_model_overloads: for v in sorted(set(client_names), key=client_names.index): diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py index d34cc2310e6..c42ad88421b 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py @@ -444,11 +444,11 @@ def imports(self) -> FileImport: parent.name, ImportType.LOCAL, ) - file_import.add_submodule_import("typing", "TypedDict", ImportType.STDLIB) + file_import.add_submodule_import("typing_extensions", "TypedDict", ImportType.STDLIB) if has_optional: - file_import.add_submodule_import("typing", "NotRequired", ImportType.STDLIB) + file_import.add_submodule_import("typing_extensions", "NotRequired", ImportType.STDLIB) if has_required: - file_import.add_submodule_import("typing", "Required", ImportType.STDLIB) + file_import.add_submodule_import("typing_extensions", "Required", ImportType.STDLIB) if has_discriminated_union: file_import.add_submodule_import("typing", "Union", ImportType.STDLIB) return file_import diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_typeddict.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_typeddict.py.jinja2 index 082ec87cf9f..8176626fe3f 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_typeddict.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_typeddict.py.jinja2 @@ -25,3 +25,4 @@ {% endif %} {% endfor %} {% endif %} + diff --git a/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_not_discriminated_typeddict.py b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_not_discriminated_typeddict.py new file mode 100644 index 00000000000..782b3b3b340 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_not_discriminated_typeddict.py @@ -0,0 +1,31 @@ +# ------------------------------------------------------------------------- +# 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 typetest.model.notdiscriminated.typeddict import NotDiscriminatedClient +from typetest.model.notdiscriminated.typeddict.models import Siamese + + +@pytest.fixture +def client(): + with NotDiscriminatedClient() as client: + yield client + + +@pytest.fixture +def valid_body(): + return Siamese(name="abc", age=32, smart=True) + + +def test_get_valid(client, valid_body): + assert client.get_valid() == valid_body + + +def test_post_valid(client, valid_body): + client.post_valid(valid_body) + + +def test_put_valid(client, valid_body): + assert valid_body == client.put_valid(valid_body) diff --git a/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_single_discriminator_typeddict.py b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_single_discriminator_typeddict.py new file mode 100644 index 00000000000..ffa676409b4 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_single_discriminator_typeddict.py @@ -0,0 +1,58 @@ +# ------------------------------------------------------------------------- +# 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 typetest.model.singlediscriminator.typeddict import SingleDiscriminatorClient +from typetest.model.singlediscriminator.typeddict.models import Sparrow, Eagle, Dinosaur, TRex + + +@pytest.fixture +def client(): + with SingleDiscriminatorClient() as client: + yield client + + +@pytest.fixture +def valid_body(): + return Sparrow(wingspan=1, kind="sparrow") + + +def test_get_model(client, valid_body): + assert client.get_model() == valid_body + + +def test_put_model(client, valid_body): + client.put_model(valid_body) + + +@pytest.fixture +def recursive_body(): + return Eagle( + wingspan=5, + kind="eagle", + partner={"wingspan": 2, "kind": "goose"}, + friends=[{"wingspan": 2, "kind": "seagull"}], + hate={"key3": {"wingspan": 1, "kind": "sparrow"}}, + ) + + +def test_get_recursive_model(client, recursive_body): + assert client.get_recursive_model() == recursive_body + + +def test_put_recursive_model(client, recursive_body): + client.put_recursive_model(recursive_body) + + +def test_get_missing_discriminator(client): + assert client.get_missing_discriminator() == {"wingspan": 1} + + +def test_get_wrong_discriminator(client): + assert client.get_wrong_discriminator() == {"wingspan": 1, "kind": "wrongKind"} + + +def test_get_legacy_model(client): + assert client.get_legacy_model() == TRex(size=20, kind="t-rex") From 3a125b87fd0ec80a27c7a176a33b3da3b9e494c7 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Thu, 23 Apr 2026 11:03:34 -0400 Subject: [PATCH 04/17] feat: return JSON for typeddict responses, drop NotRequired - TypedDictModelType returns 'JSON' for response type annotations - Response.type_annotation/docstring passes is_response=True - Typeddict deserialization uses response.json() directly - Removed NotRequired from TypedDictModelSerializer (total=False handles it) - Updated mock API tests to verify JSON dict responses Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pygen/codegen/models/model_type.py | 22 +++++++++++---- .../pygen/codegen/models/response.py | 3 ++ .../codegen/serializers/builder_serializer.py | 6 ++++ .../codegen/serializers/model_serializer.py | 9 ++---- ...inheritance_not_discriminated_typeddict.py | 10 +++++-- ...eritance_single_discriminator_typeddict.py | 28 +++++++++++++------ 6 files changed, 55 insertions(+), 23 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index 069c572e5fa..572169771fa 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -376,16 +376,26 @@ def imports(self, **kwargs: Any) -> FileImport: return file_import -class TypedDictModelType(GeneratedModelType): +class TypedDictModelType(DPGModelType): base = "typeddict" - def serialization_type(self, **kwargs: Any) -> str: - return self.type_annotation(skip_quote=True, **kwargs) + def type_annotation(self, **kwargs: Any) -> str: + if kwargs.pop("is_response", False): + return "JSON" + return super().type_annotation(**kwargs) - @property - def instance_check_template(self) -> str: - return "isinstance({}, dict)" + def docstring_type(self, **kwargs: Any) -> str: + if kwargs.pop("is_response", False): + return "JSON" + return super().docstring_type(**kwargs) + + def docstring_text(self, **kwargs: Any) -> str: + if kwargs.pop("is_response", False): + return "JSON" + return super().docstring_text(**kwargs) def imports(self, **kwargs: Any) -> FileImport: file_import = super().imports(**kwargs) + file_import.add_submodule_import("collections.abc", "MutableMapping", ImportType.STDLIB) + file_import.add_submodule_import("typing", "Any", ImportType.STDLIB) return file_import 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 d93d46bd897..40599eb47ae 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/response.py +++ b/packages/http-client-python/generator/pygen/codegen/models/response.py @@ -95,6 +95,7 @@ def serialization_type(self, **kwargs: Any) -> str: def type_annotation(self, **kwargs: Any) -> str: if self.type: kwargs["is_operation_file"] = True + kwargs["is_response"] = True type_annotation = self.type.type_annotation(**kwargs) if self.nullable: return f"Optional[{type_annotation}]" @@ -102,11 +103,13 @@ def type_annotation(self, **kwargs: Any) -> str: return "None" def docstring_text(self, **kwargs: Any) -> str: + kwargs["is_response"] = True 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.nullable and self.type: return f"{self.type.docstring_type(**kwargs)} or None" return self.type.docstring_type(**kwargs) if self.type else "None" 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 784c049ea1a..9c5b14440af 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 @@ -999,6 +999,12 @@ def response_deserialization( # pylint: disable=too-many-statements deserialize_code.append(f" '{serialization_type}',{pylint_disable}") deserialize_code.append(" pipeline_response.http_response") deserialize_code.append(")") + elif self.code_model.options["models-mode"] == "typeddict": + if builder.has_stream_response: + deserialize_code.append("deserialized = response.content") + else: + response_attr = "json" if json_serializable(str(response.default_content_type)) else "text" + deserialize_code.append(f"deserialized = response.{response_attr}()") elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): if builder.has_stream_response: deserialize_code.append("deserialized = response.content") diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py index c42ad88421b..27d4451ae2f 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/model_serializer.py @@ -408,7 +408,6 @@ def serialize(self) -> str: def imports(self) -> FileImport: file_import = FileImport(self.code_model) has_required = False - has_optional = False has_discriminated_union = False for model in self.models: if model.base == "json": @@ -430,9 +429,7 @@ def imports(self) -> FileImport: called_by_property=True, ) ) - if prop.optional or prop.client_default_value is not None: - has_optional = True - else: + if not (prop.optional or prop.client_default_value is not None): has_required = True for parent in model.parents: if parent.client_namespace != model.client_namespace and not parent.discriminated_subtypes: @@ -445,8 +442,6 @@ def imports(self) -> FileImport: ImportType.LOCAL, ) file_import.add_submodule_import("typing_extensions", "TypedDict", ImportType.STDLIB) - if has_optional: - file_import.add_submodule_import("typing_extensions", "NotRequired", ImportType.STDLIB) if has_required: file_import.add_submodule_import("typing_extensions", "Required", ImportType.STDLIB) if has_discriminated_union: @@ -485,7 +480,7 @@ def declare_property(self, prop: Property) -> str: type_annotation = prop.type_annotation(serialize_namespace=self.serialize_namespace) is_optional = prop.optional or prop.client_default_value is not None if is_optional: - return f"{prop.wire_name}: NotRequired[{type_annotation}]" + return f"{prop.wire_name}: {type_annotation}" return f"{prop.wire_name}: Required[{type_annotation}]" def initialize_properties(self, model: ModelType) -> list[str]: diff --git a/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_not_discriminated_typeddict.py b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_not_discriminated_typeddict.py index 782b3b3b340..782791ab1e7 100644 --- a/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_not_discriminated_typeddict.py +++ b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_not_discriminated_typeddict.py @@ -20,7 +20,10 @@ def valid_body(): def test_get_valid(client, valid_body): - assert client.get_valid() == valid_body + result = client.get_valid() + assert result["name"] == "abc" + assert result["age"] == 32 + assert result["smart"] is True def test_post_valid(client, valid_body): @@ -28,4 +31,7 @@ def test_post_valid(client, valid_body): def test_put_valid(client, valid_body): - assert valid_body == client.put_valid(valid_body) + result = client.put_valid(valid_body) + assert result["name"] == "abc" + assert result["age"] == 32 + assert result["smart"] is True diff --git a/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_single_discriminator_typeddict.py b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_single_discriminator_typeddict.py index ffa676409b4..19335d1bc69 100644 --- a/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_single_discriminator_typeddict.py +++ b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_inheritance_single_discriminator_typeddict.py @@ -5,7 +5,7 @@ # -------------------------------------------------------------------------- import pytest from typetest.model.singlediscriminator.typeddict import SingleDiscriminatorClient -from typetest.model.singlediscriminator.typeddict.models import Sparrow, Eagle, Dinosaur, TRex +from typetest.model.singlediscriminator.typeddict.models import Sparrow, Eagle @pytest.fixture @@ -19,8 +19,10 @@ def valid_body(): return Sparrow(wingspan=1, kind="sparrow") -def test_get_model(client, valid_body): - assert client.get_model() == valid_body +def test_get_model(client): + result = client.get_model() + assert result["wingspan"] == 1 + assert result["kind"] == "sparrow" def test_put_model(client, valid_body): @@ -38,8 +40,13 @@ def recursive_body(): ) -def test_get_recursive_model(client, recursive_body): - assert client.get_recursive_model() == recursive_body +def test_get_recursive_model(client): + result = client.get_recursive_model() + assert result["wingspan"] == 5 + assert result["kind"] == "eagle" + assert result["partner"]["kind"] == "goose" + assert result["friends"][0]["kind"] == "seagull" + assert result["hate"]["key3"]["kind"] == "sparrow" def test_put_recursive_model(client, recursive_body): @@ -47,12 +54,17 @@ def test_put_recursive_model(client, recursive_body): def test_get_missing_discriminator(client): - assert client.get_missing_discriminator() == {"wingspan": 1} + result = client.get_missing_discriminator() + assert result["wingspan"] == 1 def test_get_wrong_discriminator(client): - assert client.get_wrong_discriminator() == {"wingspan": 1, "kind": "wrongKind"} + result = client.get_wrong_discriminator() + assert result["wingspan"] == 1 + assert result["kind"] == "wrongKind" def test_get_legacy_model(client): - assert client.get_legacy_model() == TRex(size=20, kind="t-rex") + result = client.get_legacy_model() + assert result["size"] == 20 + assert result["kind"] == "t-rex" From e0569dba5b04e4240504ad262865934d3eca3aa0 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Thu, 23 Apr 2026 12:48:31 -0400 Subject: [PATCH 05/17] feat: add wire name mock API tests for typeddict naming spec - Add client/naming typeddict variant to regenerate-common.ts - Create test_client_naming_typeddict.py with 11 tests verifying TypedDict uses wire names (defaultName, wireName) not client names - Tests cover: ClientNameModel, LanguageClientNameModel, ClientNameAndJsonEncodedNameModel, ClientModel, PythonModel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../eng/scripts/ci/regenerate-common.ts | 14 +++- .../azure/test_client_naming_typeddict.py | 64 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 packages/http-client-python/tests/mock_api/azure/test_client_naming_typeddict.py diff --git a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts index b686988e600..9abd4a57a8f 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts @@ -105,9 +105,17 @@ export const BASE_AZURE_EMITTER_OPTIONS: Record< "package-name": "client-structure-twooperationgroup", namespace: "client.structure.twooperationgroup", }, - "client/naming": { - namespace: "client.naming.main", - }, + "client/naming": [ + { + namespace: "client.naming.main", + }, + { + "package-name": "client-naming-typeddict", + namespace: "client.naming.typeddict", + "models-mode": "typeddict", + "generate-test": "false", + }, + ], "client/overload": { namespace: "client.overload", }, diff --git a/packages/http-client-python/tests/mock_api/azure/test_client_naming_typeddict.py b/packages/http-client-python/tests/mock_api/azure/test_client_naming_typeddict.py new file mode 100644 index 00000000000..40786f16e39 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/azure/test_client_naming_typeddict.py @@ -0,0 +1,64 @@ +# ------------------------------------------------------------------------- +# 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 client.naming.typeddict import NamingClient, models + + +@pytest.fixture +def client(): + with NamingClient() as client: + yield client + + +def test_client(client: NamingClient): + """TypedDict uses wire name 'defaultName', not client name 'client_name'.""" + client.property.client(models.ClientNameModel(defaultName=True)) + + +def test_language(client: NamingClient): + """TypedDict uses wire name 'defaultName', not language-specific name 'python_name'.""" + client.property.language(models.LanguageClientNameModel(defaultName=True)) + + +def test_compatible_with_encoded_name(client: NamingClient): + """TypedDict uses encoded wire name 'wireName', not client name 'client_name'.""" + client.property.compatible_with_encoded_name( + models.ClientNameAndJsonEncodedNameModel(wireName=True) + ) + + +def test_operation(client: NamingClient): + client.client_name() + + +def test_parameter(client: NamingClient): + client.parameter(client_name="true") + + +def test_header_request(client: NamingClient): + client.header.request(client_name="true") + + +def test_header_response(client: NamingClient): + assert client.header.response(cls=lambda x, y, z: z)["default-name"] == "true" + + +def test_model_client(client: NamingClient): + """TypedDict uses wire name 'defaultName', not client name 'default_name'.""" + client.model_client.client(models.ClientModel(defaultName=True)) + + +def test_model_language(client: NamingClient): + """TypedDict uses wire name 'defaultName', not client name 'default_name'.""" + client.model_client.language(models.PythonModel(defaultName=True)) + + +def test_union_enum_member_name(client: NamingClient): + client.union_enum.union_enum_member_name(models.ExtensibleEnum.CLIENT_ENUM_VALUE1) + + +def test_union_enum_name(client: NamingClient): + client.union_enum.union_enum_name(models.ClientExtensibleEnum.ENUM_VALUE1) From 3e5c0ef1595ee055ae92069dc0c2decd15435176 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Thu, 23 Apr 2026 14:06:49 -0400 Subject: [PATCH 06/17] fix: remove redundant JSON overload for typeddict mode TypedDict is already JSON, so the MutableMapping[str, Any] overload is unnecessary. Only keep TypedDict model + IO[bytes] overloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../http-client-python/generator/pygen/preprocess/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index be029d64f3c..5117a9227e5 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -225,7 +225,7 @@ def add_body_param_type( if not (self.is_tsp and has_multi_part_content_type(body_parameter)): body_parameter["type"]["types"].append(KNOWN_TYPES["binary"]) - if self.options["models-mode"] in ("dpg", "typeddict") and is_dpg_model: + if self.options["models-mode"] == "dpg" and is_dpg_model: if origin_type == "model": body_parameter["type"]["types"].insert(1, KNOWN_TYPES["any-object"]) else: From f76c88d3dc398a81d896eac4c4961cae878bcb27 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Thu, 23 Apr 2026 14:34:33 -0400 Subject: [PATCH 07/17] fix: remove unused _deserialize import in typeddict mode Typeddict mode uses response.json() directly, so _deserialize is never called. Skip importing it to avoid W0611 unused-import lint warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/pygen/codegen/models/operation.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 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 4ffe1169e11..ac079658283 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -449,11 +449,14 @@ def imports( # pylint: disable=too-many-branches, disable=too-many-statements file_import.add_import("json", ImportType.STDLIB) if self.enable_import_deserialize_xml: file_import.add_submodule_import(relative_path, "_deserialize_xml", ImportType.LOCAL) - if any( - r.type - and not isinstance(r.type, BinaryIteratorType) - and not xml_serializable(str(r.default_content_type)) - for r in self.responses + if ( + self.code_model.options["models-mode"] != "typeddict" + and any( + r.type + and not isinstance(r.type, BinaryIteratorType) + and not xml_serializable(str(r.default_content_type)) + for r in self.responses + ) ): file_import.add_submodule_import(relative_path, "_deserialize", ImportType.LOCAL) if self.default_error_deserialization(serialize_namespace) or self.non_default_errors: From 2401b663f9eb29a73d5ee938d1f6215e40e9d920 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Thu, 23 Apr 2026 15:29:49 -0400 Subject: [PATCH 08/17] fix: remove all unused imports in typeddict generated code - Remove unused MutableMapping/Any imports from TypedDictModelType.imports() - Skip _deserialize import in paging_operation.py for typeddict mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/pygen/codegen/models/model_type.py | 5 +---- .../generator/pygen/codegen/models/paging_operation.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index 572169771fa..54a836da6c2 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -395,7 +395,4 @@ def docstring_text(self, **kwargs: Any) -> str: return super().docstring_text(**kwargs) def imports(self, **kwargs: Any) -> FileImport: - file_import = super().imports(**kwargs) - file_import.add_submodule_import("collections.abc", "MutableMapping", ImportType.STDLIB) - file_import.add_submodule_import("typing", "Any", ImportType.STDLIB) - return file_import + return super().imports(**kwargs) diff --git a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py index 3ce9d9abfea..832f31a554c 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py @@ -186,7 +186,7 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: serialize_namespace, module_name="_utils.model_base" ) file_import.merge(self.item_type.imports(**kwargs)) - if self.default_error_deserialization(serialize_namespace) or self.need_deserialize: + if (self.default_error_deserialization(serialize_namespace) or self.need_deserialize) and self.code_model.options["models-mode"] != "typeddict": file_import.add_submodule_import(relative_path, "_deserialize", ImportType.LOCAL) if self.is_xml_paging: file_import.add_submodule_import("xml.etree", "ElementTree", ImportType.STDLIB, alias="ET") From 47b5024a3ea951f2f4f4c2e51a71728dad33e816 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Mon, 27 Apr 2026 12:03:35 -0400 Subject: [PATCH 09/17] format and lint --- .../generator/pygen/codegen/models/model_type.py | 3 --- .../generator/pygen/codegen/models/operation.py | 13 +++++-------- .../pygen/codegen/models/paging_operation.py | 4 +++- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index 54a836da6c2..192493dede9 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -393,6 +393,3 @@ def docstring_text(self, **kwargs: Any) -> str: if kwargs.pop("is_response", False): return "JSON" return super().docstring_text(**kwargs) - - def imports(self, **kwargs: Any) -> FileImport: - return super().imports(**kwargs) 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 ac079658283..60737410140 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -449,14 +449,11 @@ def imports( # pylint: disable=too-many-branches, disable=too-many-statements file_import.add_import("json", ImportType.STDLIB) if self.enable_import_deserialize_xml: file_import.add_submodule_import(relative_path, "_deserialize_xml", ImportType.LOCAL) - if ( - self.code_model.options["models-mode"] != "typeddict" - and any( - r.type - and not isinstance(r.type, BinaryIteratorType) - and not xml_serializable(str(r.default_content_type)) - for r in self.responses - ) + if self.code_model.options["models-mode"] != "typeddict" and any( + r.type + and not isinstance(r.type, BinaryIteratorType) + and not xml_serializable(str(r.default_content_type)) + for r in self.responses ): file_import.add_submodule_import(relative_path, "_deserialize", ImportType.LOCAL) if self.default_error_deserialization(serialize_namespace) or self.non_default_errors: diff --git a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py index 832f31a554c..e6fb19fe3db 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py @@ -186,7 +186,9 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: serialize_namespace, module_name="_utils.model_base" ) file_import.merge(self.item_type.imports(**kwargs)) - if (self.default_error_deserialization(serialize_namespace) or self.need_deserialize) and self.code_model.options["models-mode"] != "typeddict": + if ( + self.default_error_deserialization(serialize_namespace) or self.need_deserialize + ) and self.code_model.options["models-mode"] != "typeddict": file_import.add_submodule_import(relative_path, "_deserialize", ImportType.LOCAL) if self.is_xml_paging: file_import.add_submodule_import("xml.etree", "ElementTree", ImportType.STDLIB, alias="ET") From 95db199249e6bd99d19b7616d3a2d76ec252cd8e Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 28 Apr 2026 15:03:56 -0400 Subject: [PATCH 10/17] fix: define JSON type alias in TypedDictModelType imports TypedDictModelType returns 'JSON' for response type annotations but never defined the JSON = MutableMapping[str, Any] type alias, causing NameError at runtime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/pygen/codegen/models/model_type.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index 192493dede9..fda02ef13a7 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -393,3 +393,8 @@ def docstring_text(self, **kwargs: Any) -> str: if kwargs.pop("is_response", False): return "JSON" return super().docstring_text(**kwargs) + + def imports(self, **kwargs: Any) -> FileImport: + file_import = super().imports(**kwargs) + file_import.define_mutable_mapping_type() + return file_import From 37806df0a62f5a3ef19f1e38eb462576b81d8f5f Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 5 May 2026 15:58:08 -0400 Subject: [PATCH 11/17] switch to always generating typeddicts as typing hints --- .../http-client-python/emitter/src/types.ts | 2 +- .../eng/scripts/ci/regenerate-common.ts | 46 ++---- .../generator/pygen/__init__.py | 6 +- .../pygen/codegen/models/__init__.py | 4 +- .../pygen/codegen/models/code_model.py | 4 +- .../pygen/codegen/models/lro_operation.py | 2 +- .../pygen/codegen/models/operation.py | 10 +- .../pygen/codegen/models/paging_operation.py | 6 +- .../pygen/codegen/models/parameter.py | 2 +- .../models/request_builder_parameter.py | 6 +- .../pygen/codegen/serializers/__init__.py | 62 +------- .../codegen/serializers/builder_serializer.py | 28 ++-- .../codegen/serializers/types_serializer.py | 115 +++++++++++++- .../templates/model_container.py.jinja2 | 2 - .../pygen/codegen/templates/types.py.jinja2 | 29 ++++ .../generator/pygen/preprocess/__init__.py | 2 +- .../tests/unit/test_typeddict.py | 144 ++++++++++++++++++ 17 files changed, 335 insertions(+), 135 deletions(-) create mode 100644 packages/http-client-python/tests/unit/test_typeddict.py diff --git a/packages/http-client-python/emitter/src/types.ts b/packages/http-client-python/emitter/src/types.ts index 89577ff0bdf..c8dbcf9b584 100644 --- a/packages/http-client-python/emitter/src/types.ts +++ b/packages/http-client-python/emitter/src/types.ts @@ -290,7 +290,7 @@ function emitModel(context: PythonSdkContext, type: SdkModelType): Record>, properties: new Array>(), snakeCaseName: camelToSnakeCase(type.name), - base: (context.emitContext.options as any)["models-mode"] === "typeddict" ? "typeddict" : "dpg", + base: "dpg", internal: type.access === "internal", crossLanguageDefinitionId: type.crossLanguageDefinitionId, usage: type.usage, diff --git a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts index 9abd4a57a8f..8dd426152b4 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts @@ -105,17 +105,9 @@ export const BASE_AZURE_EMITTER_OPTIONS: Record< "package-name": "client-structure-twooperationgroup", namespace: "client.structure.twooperationgroup", }, - "client/naming": [ - { - namespace: "client.naming.main", - }, - { - "package-name": "client-naming-typeddict", - namespace: "client.naming.typeddict", - "models-mode": "typeddict", - "generate-test": "false", - }, - ], + "client/naming": { + namespace: "client.naming.main", + }, "client/overload": { namespace: "client.overload", }, @@ -211,30 +203,14 @@ export const BASE_EMITTER_OPTIONS: Record< "package-name": "typetest-model-nesteddiscriminator", namespace: "typetest.model.nesteddiscriminator", }, - "type/model/inheritance/not-discriminated": [ - { - "package-name": "typetest-model-notdiscriminated", - namespace: "typetest.model.notdiscriminated", - }, - { - "package-name": "typetest-model-notdiscriminated-typeddict", - namespace: "typetest.model.notdiscriminated.typeddict", - "models-mode": "typeddict", - "generate-test": "false", - }, - ], - "type/model/inheritance/single-discriminator": [ - { - "package-name": "typetest-model-singlediscriminator", - namespace: "typetest.model.singlediscriminator", - }, - { - "package-name": "typetest-model-singlediscriminator-typeddict", - namespace: "typetest.model.singlediscriminator.typeddict", - "models-mode": "typeddict", - "generate-test": "false", - }, - ], + "type/model/inheritance/not-discriminated": { + "package-name": "typetest-model-notdiscriminated", + namespace: "typetest.model.notdiscriminated", + }, + "type/model/inheritance/single-discriminator": { + "package-name": "typetest-model-singlediscriminator", + namespace: "typetest.model.singlediscriminator", + }, "type/model/inheritance/recursive": [ { "package-name": "typetest-model-recursive", diff --git a/packages/http-client-python/generator/pygen/__init__.py b/packages/http-client-python/generator/pygen/__init__.py index 91cfab71f69..23368828a24 100644 --- a/packages/http-client-python/generator/pygen/__init__.py +++ b/packages/http-client-python/generator/pygen/__init__.py @@ -167,10 +167,10 @@ def _validate_and_transform(self, key: str, value: Any) -> Any: if key == "models-mode" and value == "none": value = False # switch to falsy value for easier code writing - if key == "models-mode" and value not in ["msrest", "dpg", False]: + if key == "models-mode" and value not in ["msrest", "dpg", "typeddict", False]: raise ValueError( - "--models-mode can only be 'msrest', 'dpg' or 'none'. " - "Pass in 'msrest' if you want msrest models, or " + "--models-mode can only be 'msrest', 'dpg', 'typeddict', or 'none'. " + "Pass in 'msrest' if you want msrest models, 'typeddict' for TypedDict models, or " "'none' if you don't want any." ) if key == "package-mode": diff --git a/packages/http-client-python/generator/pygen/codegen/models/__init__.py b/packages/http-client-python/generator/pygen/codegen/models/__init__.py index 5848854ed86..a1d9f9a4dbc 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/models/__init__.py @@ -9,7 +9,7 @@ from .base_builder import BaseBuilder, ParameterListType from .code_model import CodeModel from .client import Client -from .model_type import ModelType, JSONModelType, DPGModelType, MsrestModelType, TypedDictModelType +from .model_type import ModelType, JSONModelType, DPGModelType, MsrestModelType from .dictionary_type import DictionaryType from .list_type import ListType from .combined_type import CombinedType @@ -171,8 +171,6 @@ def build_type(yaml_data: dict[str, Any], code_model: CodeModel) -> BaseType: model_type = JSONModelType elif yaml_data["base"] == "dpg": model_type = DPGModelType # type: ignore - elif yaml_data["base"] == "typeddict": - model_type = TypedDictModelType # type: ignore else: model_type = MsrestModelType # type: ignore response = model_type(yaml_data, code_model) 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 6a28ddfcaad..81f5f20bf8b 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 @@ -251,7 +251,7 @@ def need_utils_folder(self, async_mode: bool, client_namespace: str) -> bool: return ( self.need_utils_utils(async_mode, client_namespace) or self.need_utils_serialization - or self.options["models-mode"] in ("dpg", "typeddict") + or self.options["models-mode"] == "dpg" ) @property @@ -271,7 +271,7 @@ def need_utils_form_data(self, async_mode: bool, client_namespace: str) -> bool: (not async_mode) and self.is_top_namespace(client_namespace) and self.has_form_data - and self.options["models-mode"] in ("dpg", "typeddict") + and self.options["models-mode"] == "dpg" ) def need_utils_etag(self, client_namespace: str) -> bool: diff --git a/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py b/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py index 1a3d37bb85b..b7673d4d865 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py @@ -125,7 +125,7 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: ImportType.SDKCORE, ) if ( - self.code_model.options["models-mode"] in ("dpg", "typeddict") + self.code_model.options["models-mode"] == "dpg" and self.lro_response and self.lro_response.type and self.lro_response.type.type == "model" 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 60737410140..c5f15593893 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -51,7 +51,7 @@ def is_internal(target: Optional[BaseType]) -> bool: - return isinstance(target, ModelType) and target.base in ("dpg", "typeddict") and target.internal + return isinstance(target, ModelType) and target.base == "dpg" and target.internal class OperationBase( # pylint: disable=too-many-public-methods,too-many-instance-attributes @@ -176,7 +176,7 @@ def response_docstring_text(self, **kwargs) -> str: retval = self._response_docstring_helper("docstring_text", **kwargs) if not self.code_model.options["version-tolerant"]: retval += " or the result of cls(response)" - if self.code_model.options["models-mode"] in ("dpg", "typeddict") and any( + if self.code_model.options["models-mode"] == "dpg" and any( isinstance(r.type, ModelType) for r in self.responses ): r = next(r for r in self.responses if isinstance(r.type, ModelType)) @@ -209,7 +209,7 @@ def default_error_deserialization(self, serialize_namespace: str) -> Optional[st f"{exception_schema.type_annotation(skip_quote=True, serialize_namespace=serialize_namespace)}," f"{pylint_disable}" ) - return None if self.code_model.options["models-mode"] in ("dpg", "typeddict") else "'object'," + return None if self.code_model.options["models-mode"] == "dpg" else "'object'," @property def non_default_errors(self) -> list[Response]: @@ -421,7 +421,7 @@ def imports( # pylint: disable=too-many-branches, disable=too-many-statements for overload in self.overloads: if overload.parameters.has_body: file_import.merge(overload.parameters.body_parameter.type.imports(**kwargs)) - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": relative_path = self.code_model.get_relative_import_path( serialize_namespace, module_name="_utils.model_base" ) @@ -449,7 +449,7 @@ def imports( # pylint: disable=too-many-branches, disable=too-many-statements file_import.add_import("json", ImportType.STDLIB) if self.enable_import_deserialize_xml: file_import.add_submodule_import(relative_path, "_deserialize_xml", ImportType.LOCAL) - if self.code_model.options["models-mode"] != "typeddict" and any( + if any( r.type and not isinstance(r.type, BinaryIteratorType) and not xml_serializable(str(r.default_content_type)) diff --git a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py index e6fb19fe3db..f64363ed5fb 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py @@ -181,14 +181,12 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: "case_insensitive_dict", ImportType.SDKCORE, ) - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": relative_path = self.code_model.get_relative_import_path( serialize_namespace, module_name="_utils.model_base" ) file_import.merge(self.item_type.imports(**kwargs)) - if ( - self.default_error_deserialization(serialize_namespace) or self.need_deserialize - ) and self.code_model.options["models-mode"] != "typeddict": + if self.default_error_deserialization(serialize_namespace) or self.need_deserialize: file_import.add_submodule_import(relative_path, "_deserialize", ImportType.LOCAL) if self.is_xml_paging: file_import.add_submodule_import("xml.etree", "ElementTree", ImportType.STDLIB, alias="ET") diff --git a/packages/http-client-python/generator/pygen/codegen/models/parameter.py b/packages/http-client-python/generator/pygen/codegen/models/parameter.py index 34a765b849a..26eb0ba5c9a 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/parameter.py @@ -336,7 +336,7 @@ def method_location( # pylint: disable=too-many-return-statements ) -> ParameterMethodLocation: if not self.in_method_signature: raise ValueError(f"Parameter '{self.client_name}' is not in the method.") - if self.code_model.options["models-mode"] in ("dpg", "typeddict") and self.in_flattened_body: + if self.code_model.options["models-mode"] == "dpg" and self.in_flattened_body: return ParameterMethodLocation.KEYWORD_ONLY if self.grouper: return ParameterMethodLocation.POSITIONAL diff --git a/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py b/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py index 810fbf3b7e7..c73df6db5af 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py @@ -26,7 +26,7 @@ def __init__(self, *args, **kwargs) -> None: if ( isinstance(self.type, (BinaryType, StringType)) or any("xml" in ct for ct in self.content_types) - or self.code_model.options["models-mode"] in ("dpg", "typeddict") + or self.code_model.options["models-mode"] == "dpg" ): self.client_name = "content" else: @@ -40,9 +40,7 @@ def type_annotation(self, **kwargs: Any) -> str: @property def in_method_signature(self) -> bool: return ( - super().in_method_signature - and not self.is_partial_body - and self.code_model.options["models-mode"] not in ("dpg", "typeddict") + super().in_method_signature and not self.is_partial_body and self.code_model.options["models-mode"] != "dpg" ) @property 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 c769535dda4..2230d98e78a 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -22,11 +22,10 @@ ModelType, EnumType, ) -from ..models.primitive_types import DatetimeType, ByteArraySchema, BinaryType from .enum_serializer import EnumSerializer from .general_serializer import GeneralSerializer from .model_init_serializer import ModelInitSerializer -from .model_serializer import DpgModelSerializer, MsrestModelSerializer, TypedDictModelSerializer +from .model_serializer import DpgModelSerializer, MsrestModelSerializer from .operations_init_serializer import OperationsInitSerializer from .operation_groups_serializer import OperationGroupsSerializer from .request_builders_serializer import RequestBuildersSerializer @@ -119,55 +118,8 @@ def keep_version_file(self) -> bool: # If parsing the version fails, we assume the version file is not valid and overwrite. return False - @staticmethod - def _validate_typeddict_models(code_model: CodeModel) -> None: - """Validate that models are compatible with typeddict mode. - - Raises ValueError if any model uses unsupported features: - readonly properties, datetime types, bytes types, - or additional properties (extends Record). - """ - unsupported: list[str] = [] - for model in code_model.model_types: - if model.base != "typeddict": - continue - model_name = model.name - - for prop in model.properties: - # Readonly - if prop.readonly: - unsupported.append( - f"Model '{model_name}' has readonly property '{prop.client_name}', " - "which is not supported in typeddict mode." - ) - # Datetime - if isinstance(prop.type, DatetimeType): - unsupported.append( - f"Model '{model_name}' has datetime property '{prop.client_name}', " - "which is not supported in typeddict mode." - ) - # Bytes - if isinstance(prop.type, (ByteArraySchema, BinaryType)): - unsupported.append( - f"Model '{model_name}' has bytes property '{prop.client_name}', " - "which is not supported in typeddict mode." - ) - # Additional properties (extends Record) - if prop.client_name == "additional_properties": - unsupported.append( - f"Model '{model_name}' has additional properties (extends Record), " - "which is not supported in typeddict mode." - ) - - if unsupported: - raise ValueError("The following models are not compatible with typeddict mode:\n" + "\n".join(unsupported)) - # pylint: disable=too-many-branches def serialize(self) -> None: - # Validate typeddict mode constraints - if self.code_model.options.get("models-mode") == "typeddict": - self._validate_typeddict_models(self.code_model) - # remove existing folders when generate from tsp if self.code_model.is_tsp and self.code_model.options.get("clear-output-folder"): # remove generated_samples and generated_tests folder @@ -345,8 +297,6 @@ def _serialize_and_write_models_folder( models_mode = self.code_model.options["models-mode"] if models_mode == "dpg": serializer = DpgModelSerializer - elif models_mode == "typeddict": - serializer = TypedDictModelSerializer else: serializer = MsrestModelSerializer if self.code_model.has_non_json_models(models): @@ -537,7 +487,7 @@ def _serialize_and_write_utils_folder(self, env: Environment, namespace: str): ) # write _model_base.py - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": self.write_file( utils_folder_path / Path("model_base.py"), general_serializer.serialize_model_base_file(), @@ -571,10 +521,14 @@ def _serialize_and_write_top_level_folder(self, env: Environment, namespace: str ) # write _types.py - if self.code_model.named_unions: + if self.code_model.named_unions or self.code_model.model_types: self.write_file( generation_dir / Path("_types.py"), - TypesSerializer(code_model=self.code_model, env=env).serialize(), + TypesSerializer( + code_model=self.code_model, + env=env, + models=self.code_model.model_types, + ).serialize(), ) # pylint: disable=line-too-long 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 9c5b14440af..e43dcd916eb 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 @@ -29,7 +29,6 @@ CombinedType, JSONModelType, DPGModelType, - TypedDictModelType, ParameterListType, ByteArraySchema, ) @@ -712,7 +711,7 @@ def _serialize_body_parameter(self, builder: OperationType) -> list[str]: f"_{body_kwarg_name} = self._serialize.body({body_param.client_name}, " f"'{serialization_type}'{is_xml_cmd}{serialization_ctxt_cmd})" ) - elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): + elif self.code_model.options["models-mode"] == "dpg": if json_serializable(body_param.default_content_type): if hasattr(body_param.type, "encode") and body_param.type.encode: # type: ignore create_body_call = ( @@ -791,10 +790,9 @@ def _initialize_overloads(self, builder: OperationType, is_paging: bool = False) overload.request_builder.parameters.body_parameter.client_name for overload in builder.overloads ] all_dpg_model_overloads = False - if self.code_model.options["models-mode"] in ("dpg", "typeddict") and builder.overloads: + if self.code_model.options["models-mode"] == "dpg" and builder.overloads: all_dpg_model_overloads = all( - isinstance(o.parameters.body_parameter.type, (DPGModelType, TypedDictModelType)) - for o in builder.overloads + isinstance(o.parameters.body_parameter.type, DPGModelType) for o in builder.overloads ) if not all_dpg_model_overloads: for v in sorted(set(client_names), key=client_names.index): @@ -999,13 +997,7 @@ def response_deserialization( # pylint: disable=too-many-statements deserialize_code.append(f" '{serialization_type}',{pylint_disable}") deserialize_code.append(" pipeline_response.http_response") deserialize_code.append(")") - elif self.code_model.options["models-mode"] == "typeddict": - if builder.has_stream_response: - deserialize_code.append("deserialized = response.content") - else: - response_attr = "json" if json_serializable(str(response.default_content_type)) else "text" - deserialize_code.append(f"deserialized = response.{response_attr}()") - elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): + elif self.code_model.options["models-mode"] == "dpg": if builder.has_stream_response: deserialize_code.append("deserialized = response.content") else: @@ -1079,7 +1071,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran type_annotation = e.type.type_annotation( # type: ignore is_operation_file=True, skip_quote=True, serialize_namespace=self.serialize_namespace ) - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": if xml_serializable(str(e.default_content_type)): fn = "_failsafe_deserialize_xml" else: @@ -1121,7 +1113,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran type_annotation = e.type.type_annotation( # type: ignore is_operation_file=True, skip_quote=True, serialize_namespace=self.serialize_namespace ) - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": if xml_serializable(str(e.default_content_type)): retval.append( " error = _failsafe_deserialize_xml(" @@ -1149,7 +1141,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran indent = " " if builder.non_default_errors else " " if builder.non_default_errors: retval.append(" else:") - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": default_exception = next(e for e in builder.exceptions if "default" in e.status_codes and e.type) if xml_serializable(str(default_exception.default_content_type)): fn = "_failsafe_deserialize_xml" @@ -1418,7 +1410,7 @@ def _extract_data_callback( # pylint: disable=too-many-statements,too-many-bran f"self._deserialize(\n {deserialize_type},{pylint_disable}\n pipeline_response{suffix}\n)" ) retval.append(f" deserialized = {deserialized}") - elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): + elif self.code_model.options["models-mode"] == "dpg": # we don't want to generate paging models for DPG retval.append(f" deserialized = {deserialized}") else: @@ -1436,7 +1428,7 @@ def _extract_data_callback( # pylint: disable=too-many-statements,too-many-bran "".join([f'.get("{i}", {{}})' for i in item_name_array[:-1]]) + f'.get("{item_name_array[-1]}", [])' ) pylint_disable = "" - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": item_type = builder.item_type.type_annotation( is_operation_file=True, serialize_namespace=self.serialize_namespace ) @@ -1613,7 +1605,7 @@ def get_long_running_output(self, builder: LROOperationType) -> list[str]: retval.append(" response_headers = {}") if ( not self.code_model.options["models-mode"] - or self.code_model.options["models-mode"] in ("dpg", "typeddict") + or self.code_model.options["models-mode"] == "dpg" or builder.lro_response.headers ): retval.append(" response = pipeline_response.http_response") diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py index 749d8ca240c..7a764409562 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py @@ -3,15 +3,42 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- +from typing import Optional +from ..models import ModelType, CodeModel from ..models.imports import FileImport, ImportType from ..models.utils import NamespaceType +from ..models.property import Property +from .model_serializer import _documentation_string from .import_serializer import FileImportSerializer from .base_serializer import BaseSerializer class TypesSerializer(BaseSerializer): + def __init__( + self, + code_model: CodeModel, + env, + client_namespace: Optional[str] = None, + models: Optional[list[ModelType]] = None, + ): + super().__init__(code_model=code_model, env=env) + self._client_namespace = client_namespace + self._models = models or [] + + @property + def typeddict_models(self) -> list[ModelType]: + """Models that should be rendered as TypedDicts.""" + return [m for m in self._models if m.base != "json"] + + def _reorder_models(self, models: list[ModelType]) -> list[ModelType]: + """Reorder so discriminated base Union aliases come after all their subtypes.""" + bases = [m for m in models if m.discriminated_subtypes] + non_bases = [m for m in models if not m.discriminated_subtypes] + return non_bases + bases + def imports(self) -> FileImport: file_import = FileImport(self.code_model) + # Named union imports if self.code_model.named_unions: file_import.add_submodule_import( "typing", @@ -24,13 +51,99 @@ def imports(self) -> FileImport: serialize_namespace=self.serialize_namespace, serialize_namespace_type=NamespaceType.TYPES_FILE ) ) + + # TypedDict imports + td_models = self.typeddict_models + if td_models: + file_import.add_submodule_import("typing_extensions", "TypedDict", ImportType.STDLIB) + has_required = False + has_discriminated_union = False + for model in td_models: + if model.discriminated_subtypes: + has_discriminated_union = True + file_import.merge( + model.imports( + is_operation_file=False, + serialize_namespace=self.serialize_namespace, + serialize_namespace_type=NamespaceType.TYPES_FILE, + ) + ) + for prop in model.properties: + file_import.merge( + prop.imports( + serialize_namespace=self.serialize_namespace, + serialize_namespace_type=NamespaceType.TYPES_FILE, + called_by_property=True, + ) + ) + if not (prop.optional or prop.client_default_value is not None): + has_required = True + for parent in model.parents: + if parent.client_namespace != model.client_namespace and not parent.discriminated_subtypes: + file_import.add_submodule_import( + self.code_model.get_relative_import_path( + self.serialize_namespace, + self.code_model.get_imported_namespace_for_model(parent.client_namespace), + ), + parent.name, + ImportType.LOCAL, + ) + if has_required: + file_import.add_submodule_import("typing_extensions", "Required", ImportType.STDLIB) + if has_discriminated_union and not self.code_model.named_unions: + file_import.add_submodule_import("typing", "Union", ImportType.STDLIB) return file_import + def declare_model(self, model: ModelType) -> str: + non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes] + if non_discriminated_parents: + basename = ", ".join([m.name for m in non_discriminated_parents]) + return f"class {model.name}({basename}):{model.pylint_disable()}" + return f"class {model.name}(TypedDict, total=False):{model.pylint_disable()}" + + @staticmethod + def get_properties_to_declare(model: ModelType) -> list[Property]: + non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes] + if non_discriminated_parents: + parent_properties = [p for bm in non_discriminated_parents for p in bm.properties] + properties_to_declare = [ + p + for p in model.properties + if not any( + p.client_name == pp.client_name + and p.type_annotation() == pp.type_annotation() + and not p.is_base_discriminator + for pp in parent_properties + ) + ] + else: + properties_to_declare = model.properties + return properties_to_declare + + def declare_property(self, prop: Property) -> str: + type_annotation = prop.type_annotation(serialize_namespace=self.serialize_namespace) + is_optional = prop.optional or prop.client_default_value is not None + if is_optional: + return f"{prop.wire_name}: {type_annotation}" + return f"{prop.wire_name}: Required[{type_annotation}]" + + def discriminated_subtypes_union(self, model: ModelType) -> str: + subtypes = list(model.discriminated_subtypes.values()) + subtype_names = [s.name for s in subtypes] + return f"{model.name} = Union[{', '.join(subtype_names)}]" + + def is_discriminated_base(self, model: ModelType) -> bool: + return bool(model.discriminated_subtypes) + + @staticmethod + def variable_documentation_string(prop: Property) -> list[str]: + return _documentation_string(prop, "ivar", "vartype") + def serialize(self) -> str: - # Generate the models template = self.env.get_template("types.py.jinja2") return template.render( code_model=self.code_model, imports=FileImportSerializer(self.imports()), serializer=self, + models=self._reorder_models(self.typeddict_models), ) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 index 6e033ba77fb..dc98f999c45 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 @@ -13,7 +13,5 @@ {% include "model_dpg.py.jinja2" %} {% elif model.base == "msrest" %} {% include "model_msrest.py.jinja2" %} -{% elif model.base == "typeddict" %} -{% include "model_typeddict.py.jinja2" %} {% endif %} {% endfor %} diff --git a/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 index 19435f14e88..d60aebeccd3 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 @@ -7,3 +7,32 @@ {% for nu in code_model.named_unions %} {{nu.name}} = {{nu.type_definition()}} {% endfor %} +{% import 'operation_tools.jinja2' as op_tools %} +{% import "macros.jinja2" as macros %} +{% for model in models %} +{% if serializer.is_discriminated_base(model) %} +{{ serializer.discriminated_subtypes_union(model) }} +{% else %} + + +{{ serializer.declare_model(model) }} + """{{ op_tools.wrap_string(model.description(is_operation_file=False), "\n ") }} + + {% if model.properties != None %} + {% for p in model.properties %} + {% for line in serializer.variable_documentation_string(p) %} + {{ macros.wrap_model_string(line, '\n ') -}} + {% endfor %} + {% endfor %} + {% endif %} + """ + + {% for p in serializer.get_properties_to_declare(model)%} + {{ serializer.declare_property(p) }} + {% set prop_description = p.description(is_operation_file=False).replace('"', '\\"') %} + {% if prop_description %} + """{{ macros.wrap_model_string(prop_description, '\n ', '\"\"\"') -}} + {% endif %} + {% endfor %} +{% endif %} +{% endfor %} diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 5117a9227e5..6d3344059a3 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -216,7 +216,7 @@ def add_body_param_type( model_type = ( body_parameter["type"] if origin_type == "model" else body_parameter["type"].get("elementType", {}) ) - is_dpg_model = model_type.get("base") in ("dpg", "typeddict") + is_dpg_model = model_type.get("base") == "dpg" body_parameter["type"] = { "type": "combined", "types": [body_parameter["type"]], diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py new file mode 100644 index 00000000000..c5261dd3004 --- /dev/null +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -0,0 +1,144 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +"""Tests for TypedDict generation and models-mode interactions.""" + +from jinja2 import PackageLoader, Environment + +from pygen.codegen.models import CodeModel, JSONModelType, DPGModelType +from pygen.codegen.models.model_type import TypedDictModelType +from pygen.codegen.serializers.types_serializer import TypesSerializer + + +def _make_code_model(models_mode="dpg"): + return CodeModel( + { + "clients": [ + { + "name": "client", + "namespace": "blah", + "moduleName": "blah", + "parameters": [], + "url": "", + "operationGroups": [], + } + ], + "namespace": "namespace", + }, + options={ + "show-send-request": True, + "builders-visibility": "public", + "show-operations": True, + "models-mode": models_mode, + "flavor": "unbranded", + "client-side-validation": False, + }, + ) + + +def _make_model(code_model, name, model_cls=None, properties=None): + """Create a model of the given class attached to code_model.""" + if model_cls is None: + if code_model.options["models-mode"] == "typeddict": + model_cls = TypedDictModelType + elif code_model.options["models-mode"] == "dpg": + model_cls = DPGModelType + else: + model_cls = JSONModelType + return model_cls( + yaml_data={ + "name": name, + "type": "model", + "snakeCaseName": name.lower(), + "usage": 2, + }, + code_model=code_model, + properties=properties or [], + ) + + +def _make_env(): + return Environment( + loader=PackageLoader("pygen.codegen", "templates"), + trim_blocks=True, + lstrip_blocks=True, + ) + + +# ---------- models-mode=none ---------- + + +def test_models_mode_none_produces_json_model_type(): + """When models-mode is none (False), all models should be JSONModelType.""" + code_model = _make_code_model(models_mode=False) + model = _make_model(code_model, "Foo", model_cls=JSONModelType) + assert model.base == "json" + + +def test_models_mode_none_no_typeddict_models(): + """TypesSerializer.typeddict_models should be empty when models-mode=none.""" + code_model = _make_code_model(models_mode=False) + m1 = _make_model(code_model, "Foo", model_cls=JSONModelType) + m2 = _make_model(code_model, "Bar", model_cls=JSONModelType) + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1, m2]) + assert ts.typeddict_models == [] + + +def test_models_mode_none_types_file_has_no_typeddict_imports(): + """When models-mode=none, the _types.py should not import TypedDict.""" + code_model = _make_code_model(models_mode=False) + m1 = _make_model(code_model, "Foo", model_cls=JSONModelType) + code_model.model_types = [m1] + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1]) + output = ts.serialize() + assert "TypedDict" not in output + assert "Required" not in output + + +# ---------- models-mode=dpg ---------- + + +def test_models_mode_dpg_no_typeddict_models(): + """DPG models have base='dpg', not 'typeddict', so should not appear as typeddict_models.""" + code_model = _make_code_model(models_mode="dpg") + m1 = _make_model(code_model, "Foo", model_cls=DPGModelType) + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1]) + # DPG models have base != "json" so they DO appear in typeddict_models + # This is a filtering based on base != "json" + assert len(ts.typeddict_models) == 1 + + +# ---------- models-mode=typeddict ---------- + + +def test_models_mode_typeddict_models_included(): + """TypedDictModelType models should appear in typeddict_models.""" + code_model = _make_code_model(models_mode="typeddict") + m1 = _make_model(code_model, "Foo", model_cls=TypedDictModelType) + m2 = _make_model(code_model, "Bar", model_cls=TypedDictModelType) + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1, m2]) + assert len(ts.typeddict_models) == 2 + + +def test_models_mode_typeddict_serialize_contains_class(): + """Serialized output should contain TypedDict class definitions.""" + code_model = _make_code_model(models_mode="typeddict") + m1 = _make_model(code_model, "Foo", model_cls=TypedDictModelType) + code_model.model_types = [m1] + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1]) + output = ts.serialize() + assert "class Foo(TypedDict, total=False):" in output + assert "TypedDict" in output From 18786e307f1e263c34f93ef4278350537848a4fd Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 5 May 2026 15:58:08 -0400 Subject: [PATCH 12/17] switch to always generating typeddicts as typing hints --- .../python-addTypedDict-2026-3-21-17-47-3.md | 4 +- .../http-client-python/emitter/src/types.ts | 2 +- .../eng/scripts/ci/regenerate-common.ts | 48 ++--- .../generator/pygen/__init__.py | 6 +- .../pygen/codegen/models/__init__.py | 4 +- .../pygen/codegen/models/code_model.py | 4 +- .../pygen/codegen/models/combined_type.py | 6 +- .../pygen/codegen/models/enum_type.py | 2 +- .../pygen/codegen/models/lro_operation.py | 2 +- .../pygen/codegen/models/model_type.py | 4 +- .../pygen/codegen/models/operation.py | 10 +- .../pygen/codegen/models/paging_operation.py | 6 +- .../pygen/codegen/models/parameter.py | 4 +- .../models/request_builder_parameter.py | 6 +- .../pygen/codegen/models/response.py | 2 +- .../generator/pygen/codegen/models/utils.py | 1 + .../pygen/codegen/serializers/__init__.py | 83 +++------ .../codegen/serializers/builder_serializer.py | 28 +-- .../codegen/serializers/types_serializer.py | 104 +++++++++-- .../codegen/serializers/unions_serializer.py | 60 ++++++ .../templates/model_container.py.jinja2 | 4 +- .../pygen/codegen/templates/types.py.jinja2 | 26 ++- .../pygen/codegen/templates/unions.py.jinja2 | 12 ++ .../generator/pygen/preprocess/__init__.py | 2 +- .../tests/unit/test_typeddict.py | 171 ++++++++++++++++++ 25 files changed, 437 insertions(+), 164 deletions(-) create mode 100644 packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py create mode 100644 packages/http-client-python/generator/pygen/codegen/templates/unions.py.jinja2 create mode 100644 packages/http-client-python/tests/unit/test_typeddict.py diff --git a/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md b/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md index 25dfde1e67f..c629da91ce2 100644 --- a/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md +++ b/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md @@ -1,8 +1,8 @@ --- # Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking -changeKind: fix +changeKind: feature packages: - "@typespec/http-client-python" --- -[python] add `typeddict` `models-mode` for Python HTTP client emitter to generated `TypedDict`s for input models +[python] Always generate `TypedDict` typing hints for input models in the `_types.py` file diff --git a/packages/http-client-python/emitter/src/types.ts b/packages/http-client-python/emitter/src/types.ts index 89577ff0bdf..c8dbcf9b584 100644 --- a/packages/http-client-python/emitter/src/types.ts +++ b/packages/http-client-python/emitter/src/types.ts @@ -290,7 +290,7 @@ function emitModel(context: PythonSdkContext, type: SdkModelType): Record>, properties: new Array>(), snakeCaseName: camelToSnakeCase(type.name), - base: (context.emitContext.options as any)["models-mode"] === "typeddict" ? "typeddict" : "dpg", + base: "dpg", internal: type.access === "internal", crossLanguageDefinitionId: type.crossLanguageDefinitionId, usage: type.usage, diff --git a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts index 9abd4a57a8f..6e91285624c 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts @@ -105,17 +105,9 @@ export const BASE_AZURE_EMITTER_OPTIONS: Record< "package-name": "client-structure-twooperationgroup", namespace: "client.structure.twooperationgroup", }, - "client/naming": [ - { - namespace: "client.naming.main", - }, - { - "package-name": "client-naming-typeddict", - namespace: "client.naming.typeddict", - "models-mode": "typeddict", - "generate-test": "false", - }, - ], + "client/naming": { + namespace: "client.naming.main", + }, "client/overload": { namespace: "client.overload", }, @@ -211,30 +203,14 @@ export const BASE_EMITTER_OPTIONS: Record< "package-name": "typetest-model-nesteddiscriminator", namespace: "typetest.model.nesteddiscriminator", }, - "type/model/inheritance/not-discriminated": [ - { - "package-name": "typetest-model-notdiscriminated", - namespace: "typetest.model.notdiscriminated", - }, - { - "package-name": "typetest-model-notdiscriminated-typeddict", - namespace: "typetest.model.notdiscriminated.typeddict", - "models-mode": "typeddict", - "generate-test": "false", - }, - ], - "type/model/inheritance/single-discriminator": [ - { - "package-name": "typetest-model-singlediscriminator", - namespace: "typetest.model.singlediscriminator", - }, - { - "package-name": "typetest-model-singlediscriminator-typeddict", - namespace: "typetest.model.singlediscriminator.typeddict", - "models-mode": "typeddict", - "generate-test": "false", - }, - ], + "type/model/inheritance/not-discriminated": { + "package-name": "typetest-model-notdiscriminated", + namespace: "typetest.model.notdiscriminated", + }, + "type/model/inheritance/single-discriminator": { + "package-name": "typetest-model-singlediscriminator", + namespace: "typetest.model.singlediscriminator", + }, "type/model/inheritance/recursive": [ { "package-name": "typetest-model-recursive", @@ -310,7 +286,7 @@ export const BASE_EMITTER_OPTIONS: Record< "package-name": "versioning-added", namespace: "versioning.added", }, - // check whether import of _validation.py/_types.py works when "generation-subdir" is configured + // check whether import of _validation.py/_unions.py/types.py works when "generation-subdir" is configured { "package-name": "generation-subdir2", namespace: "generation.subdir2", diff --git a/packages/http-client-python/generator/pygen/__init__.py b/packages/http-client-python/generator/pygen/__init__.py index 91cfab71f69..23368828a24 100644 --- a/packages/http-client-python/generator/pygen/__init__.py +++ b/packages/http-client-python/generator/pygen/__init__.py @@ -167,10 +167,10 @@ def _validate_and_transform(self, key: str, value: Any) -> Any: if key == "models-mode" and value == "none": value = False # switch to falsy value for easier code writing - if key == "models-mode" and value not in ["msrest", "dpg", False]: + if key == "models-mode" and value not in ["msrest", "dpg", "typeddict", False]: raise ValueError( - "--models-mode can only be 'msrest', 'dpg' or 'none'. " - "Pass in 'msrest' if you want msrest models, or " + "--models-mode can only be 'msrest', 'dpg', 'typeddict', or 'none'. " + "Pass in 'msrest' if you want msrest models, 'typeddict' for TypedDict models, or " "'none' if you don't want any." ) if key == "package-mode": diff --git a/packages/http-client-python/generator/pygen/codegen/models/__init__.py b/packages/http-client-python/generator/pygen/codegen/models/__init__.py index 5848854ed86..a1d9f9a4dbc 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/models/__init__.py @@ -9,7 +9,7 @@ from .base_builder import BaseBuilder, ParameterListType from .code_model import CodeModel from .client import Client -from .model_type import ModelType, JSONModelType, DPGModelType, MsrestModelType, TypedDictModelType +from .model_type import ModelType, JSONModelType, DPGModelType, MsrestModelType from .dictionary_type import DictionaryType from .list_type import ListType from .combined_type import CombinedType @@ -171,8 +171,6 @@ def build_type(yaml_data: dict[str, Any], code_model: CodeModel) -> BaseType: model_type = JSONModelType elif yaml_data["base"] == "dpg": model_type = DPGModelType # type: ignore - elif yaml_data["base"] == "typeddict": - model_type = TypedDictModelType # type: ignore else: model_type = MsrestModelType # type: ignore response = model_type(yaml_data, code_model) 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 6a28ddfcaad..81f5f20bf8b 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 @@ -251,7 +251,7 @@ def need_utils_folder(self, async_mode: bool, client_namespace: str) -> bool: return ( self.need_utils_utils(async_mode, client_namespace) or self.need_utils_serialization - or self.options["models-mode"] in ("dpg", "typeddict") + or self.options["models-mode"] == "dpg" ) @property @@ -271,7 +271,7 @@ def need_utils_form_data(self, async_mode: bool, client_namespace: str) -> bool: (not async_mode) and self.is_top_namespace(client_namespace) and self.has_form_data - and self.options["models-mode"] in ("dpg", "typeddict") + and self.options["models-mode"] == "dpg" ) def need_utils_etag(self, client_namespace: str) -> bool: diff --git a/packages/http-client-python/generator/pygen/codegen/models/combined_type.py b/packages/http-client-python/generator/pygen/codegen/models/combined_type.py index 249b7474dcd..1014482f5e0 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/combined_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/combined_type.py @@ -66,7 +66,7 @@ def docstring_type(self, **kwargs: Any) -> str: def type_annotation(self, **kwargs: Any) -> str: if self.name: - return f'"_types.{self.name}"' + return f'"_unions.{self.name}"' return self.type_definition(**kwargs) def type_definition(self, **kwargs: Any) -> str: @@ -116,10 +116,10 @@ def imports(self, **kwargs: Any) -> FileImport: file_import = FileImport(self.code_model) serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace) serialize_namespace_type = kwargs.get("serialize_namespace_type") - if self.name and serialize_namespace_type != NamespaceType.TYPES_FILE: + if self.name and serialize_namespace_type != NamespaceType.UNIONS_FILE: file_import.add_submodule_import( self.code_model.get_relative_import_path(serialize_namespace), - "_types", + "_unions", ImportType.LOCAL, TypingSection.TYPING, ) diff --git a/packages/http-client-python/generator/pygen/codegen/models/enum_type.py b/packages/http-client-python/generator/pygen/codegen/models/enum_type.py index 9cbec3d1b30..671a8c64d5b 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/enum_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/enum_type.py @@ -255,7 +255,7 @@ def imports(self, **kwargs: Any) -> FileImport: alias=alias, typing_section=TypingSection.REGULAR, ) - elif serialize_namespace_type == NamespaceType.TYPES_FILE or ( + elif serialize_namespace_type in [NamespaceType.TYPES_FILE, NamespaceType.UNIONS_FILE] or ( serialize_namespace_type == NamespaceType.MODEL and called_by_property ): file_import.add_submodule_import( diff --git a/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py b/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py index 1a3d37bb85b..b7673d4d865 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/lro_operation.py @@ -125,7 +125,7 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: ImportType.SDKCORE, ) if ( - self.code_model.options["models-mode"] in ("dpg", "typeddict") + self.code_model.options["models-mode"] == "dpg" and self.lro_response and self.lro_response.type and self.lro_response.type.type == "model" diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index fda02ef13a7..036ff04c749 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -311,7 +311,7 @@ def imports(self, **kwargs: Any) -> FileImport: alias = self.code_model.get_unique_models_alias(serialize_namespace, self.client_namespace) serialize_namespace_type = kwargs.get("serialize_namespace_type") called_by_property = kwargs.get("called_by_property", False) - # add import for models in operations or _types file + # add import for models in operations, types, or unions file if serialize_namespace_type in [NamespaceType.OPERATION, NamespaceType.CLIENT]: file_import.add_submodule_import( relative_path, @@ -326,7 +326,7 @@ def imports(self, **kwargs: Any) -> FileImport: ImportType.LOCAL, alias="_Model", ) - elif serialize_namespace_type == NamespaceType.TYPES_FILE or ( + elif serialize_namespace_type in [NamespaceType.TYPES_FILE, NamespaceType.UNIONS_FILE] or ( serialize_namespace_type == NamespaceType.MODEL and called_by_property ): file_import.add_submodule_import( 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 60737410140..c5f15593893 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -51,7 +51,7 @@ def is_internal(target: Optional[BaseType]) -> bool: - return isinstance(target, ModelType) and target.base in ("dpg", "typeddict") and target.internal + return isinstance(target, ModelType) and target.base == "dpg" and target.internal class OperationBase( # pylint: disable=too-many-public-methods,too-many-instance-attributes @@ -176,7 +176,7 @@ def response_docstring_text(self, **kwargs) -> str: retval = self._response_docstring_helper("docstring_text", **kwargs) if not self.code_model.options["version-tolerant"]: retval += " or the result of cls(response)" - if self.code_model.options["models-mode"] in ("dpg", "typeddict") and any( + if self.code_model.options["models-mode"] == "dpg" and any( isinstance(r.type, ModelType) for r in self.responses ): r = next(r for r in self.responses if isinstance(r.type, ModelType)) @@ -209,7 +209,7 @@ def default_error_deserialization(self, serialize_namespace: str) -> Optional[st f"{exception_schema.type_annotation(skip_quote=True, serialize_namespace=serialize_namespace)}," f"{pylint_disable}" ) - return None if self.code_model.options["models-mode"] in ("dpg", "typeddict") else "'object'," + return None if self.code_model.options["models-mode"] == "dpg" else "'object'," @property def non_default_errors(self) -> list[Response]: @@ -421,7 +421,7 @@ def imports( # pylint: disable=too-many-branches, disable=too-many-statements for overload in self.overloads: if overload.parameters.has_body: file_import.merge(overload.parameters.body_parameter.type.imports(**kwargs)) - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": relative_path = self.code_model.get_relative_import_path( serialize_namespace, module_name="_utils.model_base" ) @@ -449,7 +449,7 @@ def imports( # pylint: disable=too-many-branches, disable=too-many-statements file_import.add_import("json", ImportType.STDLIB) if self.enable_import_deserialize_xml: file_import.add_submodule_import(relative_path, "_deserialize_xml", ImportType.LOCAL) - if self.code_model.options["models-mode"] != "typeddict" and any( + if any( r.type and not isinstance(r.type, BinaryIteratorType) and not xml_serializable(str(r.default_content_type)) diff --git a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py index e6fb19fe3db..f64363ed5fb 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/paging_operation.py @@ -181,14 +181,12 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: "case_insensitive_dict", ImportType.SDKCORE, ) - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": relative_path = self.code_model.get_relative_import_path( serialize_namespace, module_name="_utils.model_base" ) file_import.merge(self.item_type.imports(**kwargs)) - if ( - self.default_error_deserialization(serialize_namespace) or self.need_deserialize - ) and self.code_model.options["models-mode"] != "typeddict": + if self.default_error_deserialization(serialize_namespace) or self.need_deserialize: file_import.add_submodule_import(relative_path, "_deserialize", ImportType.LOCAL) if self.is_xml_paging: file_import.add_submodule_import("xml.etree", "ElementTree", ImportType.STDLIB, alias="ET") diff --git a/packages/http-client-python/generator/pygen/codegen/models/parameter.py b/packages/http-client-python/generator/pygen/codegen/models/parameter.py index 34a765b849a..f934f34bf25 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/parameter.py @@ -178,7 +178,7 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: if isinstance(self.type, CombinedType) and self.type.name: file_import.add_submodule_import( self.code_model.get_relative_import_path(serialize_namespace), - "_types", + "_unions", ImportType.LOCAL, TypingSection.TYPING, ) @@ -336,7 +336,7 @@ def method_location( # pylint: disable=too-many-return-statements ) -> ParameterMethodLocation: if not self.in_method_signature: raise ValueError(f"Parameter '{self.client_name}' is not in the method.") - if self.code_model.options["models-mode"] in ("dpg", "typeddict") and self.in_flattened_body: + if self.code_model.options["models-mode"] == "dpg" and self.in_flattened_body: return ParameterMethodLocation.KEYWORD_ONLY if self.grouper: return ParameterMethodLocation.POSITIONAL diff --git a/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py b/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py index 810fbf3b7e7..c73df6db5af 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/request_builder_parameter.py @@ -26,7 +26,7 @@ def __init__(self, *args, **kwargs) -> None: if ( isinstance(self.type, (BinaryType, StringType)) or any("xml" in ct for ct in self.content_types) - or self.code_model.options["models-mode"] in ("dpg", "typeddict") + or self.code_model.options["models-mode"] == "dpg" ): self.client_name = "content" else: @@ -40,9 +40,7 @@ def type_annotation(self, **kwargs: Any) -> str: @property def in_method_signature(self) -> bool: return ( - super().in_method_signature - and not self.is_partial_body - and self.code_model.options["models-mode"] not in ("dpg", "typeddict") + super().in_method_signature and not self.is_partial_body and self.code_model.options["models-mode"] != "dpg" ) @property 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 40599eb47ae..a2bb431c44d 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/response.py +++ b/packages/http-client-python/generator/pygen/codegen/models/response.py @@ -124,7 +124,7 @@ def imports(self, **kwargs: Any) -> FileImport: serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace) file_import.add_submodule_import( self.code_model.get_relative_import_path(serialize_namespace), - "_types", + "_unions", ImportType.LOCAL, TypingSection.TYPING, ) diff --git a/packages/http-client-python/generator/pygen/codegen/models/utils.py b/packages/http-client-python/generator/pygen/codegen/models/utils.py index 82e80b85577..b6722fcd3f0 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/utils.py +++ b/packages/http-client-python/generator/pygen/codegen/models/utils.py @@ -30,6 +30,7 @@ class NamespaceType(str, Enum): OPERATION = "operation" CLIENT = "client" TYPES_FILE = "types_file" + UNIONS_FILE = "unions_file" LOCALS_LENGTH_LIMIT = 25 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 c769535dda4..a21a012b596 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -22,11 +22,10 @@ ModelType, EnumType, ) -from ..models.primitive_types import DatetimeType, ByteArraySchema, BinaryType from .enum_serializer import EnumSerializer from .general_serializer import GeneralSerializer from .model_init_serializer import ModelInitSerializer -from .model_serializer import DpgModelSerializer, MsrestModelSerializer, TypedDictModelSerializer +from .model_serializer import DpgModelSerializer, MsrestModelSerializer from .operations_init_serializer import OperationsInitSerializer from .operation_groups_serializer import OperationGroupsSerializer from .request_builders_serializer import RequestBuildersSerializer @@ -34,6 +33,7 @@ from .sample_serializer import SampleSerializer from .test_serializer import TestSerializer, TestGeneralSerializer from .types_serializer import TypesSerializer +from .unions_serializer import UnionsSerializer from ...utils import to_snake_case, VALID_PACKAGE_MODE from .utils import extract_sample_name, get_namespace_from_package_name, get_namespace_config, hash_file_import @@ -119,55 +119,8 @@ def keep_version_file(self) -> bool: # If parsing the version fails, we assume the version file is not valid and overwrite. return False - @staticmethod - def _validate_typeddict_models(code_model: CodeModel) -> None: - """Validate that models are compatible with typeddict mode. - - Raises ValueError if any model uses unsupported features: - readonly properties, datetime types, bytes types, - or additional properties (extends Record). - """ - unsupported: list[str] = [] - for model in code_model.model_types: - if model.base != "typeddict": - continue - model_name = model.name - - for prop in model.properties: - # Readonly - if prop.readonly: - unsupported.append( - f"Model '{model_name}' has readonly property '{prop.client_name}', " - "which is not supported in typeddict mode." - ) - # Datetime - if isinstance(prop.type, DatetimeType): - unsupported.append( - f"Model '{model_name}' has datetime property '{prop.client_name}', " - "which is not supported in typeddict mode." - ) - # Bytes - if isinstance(prop.type, (ByteArraySchema, BinaryType)): - unsupported.append( - f"Model '{model_name}' has bytes property '{prop.client_name}', " - "which is not supported in typeddict mode." - ) - # Additional properties (extends Record) - if prop.client_name == "additional_properties": - unsupported.append( - f"Model '{model_name}' has additional properties (extends Record), " - "which is not supported in typeddict mode." - ) - - if unsupported: - raise ValueError("The following models are not compatible with typeddict mode:\n" + "\n".join(unsupported)) - # pylint: disable=too-many-branches def serialize(self) -> None: - # Validate typeddict mode constraints - if self.code_model.options.get("models-mode") == "typeddict": - self._validate_typeddict_models(self.code_model) - # remove existing folders when generate from tsp if self.code_model.is_tsp and self.code_model.options.get("clear-output-folder"): # remove generated_samples and generated_tests folder @@ -244,7 +197,7 @@ def serialize(self) -> None: general_serializer.serialize_pkgutil_init_file(), ) - # _utils/py.typed/_types.py/_validation.py + # _utils/py.typed/_unions.py/types.py/_validation.py # is always put in top level namespace if self.code_model.is_top_namespace(client_namespace): self._serialize_and_write_top_level_folder(env=env, namespace=client_namespace) @@ -343,10 +296,8 @@ def _serialize_and_write_models_folder( # Write the models folder models_path = self.code_model.get_generation_dir(namespace) / "models" models_mode = self.code_model.options["models-mode"] - if models_mode == "dpg": + if models_mode in ("dpg", "typeddict"): serializer = DpgModelSerializer - elif models_mode == "typeddict": - serializer = TypedDictModelSerializer else: serializer = MsrestModelSerializer if self.code_model.has_non_json_models(models): @@ -537,7 +488,7 @@ def _serialize_and_write_utils_folder(self, env: Environment, namespace: str): ) # write _model_base.py - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": self.write_file( utils_folder_path / Path("model_base.py"), general_serializer.serialize_model_base_file(), @@ -570,11 +521,27 @@ def _serialize_and_write_top_level_folder(self, env: Environment, namespace: str general_serializer.serialize_validation_file(), ) - # write _types.py - if self.code_model.named_unions: + # write _unions.py + has_discriminated_bases = any(m for m in self.code_model.model_types if m.base != "json" and m.discriminated_subtypes) + if self.code_model.named_unions or has_discriminated_bases: self.write_file( - generation_dir / Path("_types.py"), - TypesSerializer(code_model=self.code_model, env=env).serialize(), + generation_dir / Path("_unions.py"), + UnionsSerializer( + code_model=self.code_model, + env=env, + models=self.code_model.model_types, + ).serialize(), + ) + + # write types.py + if self.code_model.model_types: + self.write_file( + generation_dir / Path("types.py"), + TypesSerializer( + code_model=self.code_model, + env=env, + models=self.code_model.model_types, + ).serialize(), ) # pylint: disable=line-too-long 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 9c5b14440af..e43dcd916eb 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 @@ -29,7 +29,6 @@ CombinedType, JSONModelType, DPGModelType, - TypedDictModelType, ParameterListType, ByteArraySchema, ) @@ -712,7 +711,7 @@ def _serialize_body_parameter(self, builder: OperationType) -> list[str]: f"_{body_kwarg_name} = self._serialize.body({body_param.client_name}, " f"'{serialization_type}'{is_xml_cmd}{serialization_ctxt_cmd})" ) - elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): + elif self.code_model.options["models-mode"] == "dpg": if json_serializable(body_param.default_content_type): if hasattr(body_param.type, "encode") and body_param.type.encode: # type: ignore create_body_call = ( @@ -791,10 +790,9 @@ def _initialize_overloads(self, builder: OperationType, is_paging: bool = False) overload.request_builder.parameters.body_parameter.client_name for overload in builder.overloads ] all_dpg_model_overloads = False - if self.code_model.options["models-mode"] in ("dpg", "typeddict") and builder.overloads: + if self.code_model.options["models-mode"] == "dpg" and builder.overloads: all_dpg_model_overloads = all( - isinstance(o.parameters.body_parameter.type, (DPGModelType, TypedDictModelType)) - for o in builder.overloads + isinstance(o.parameters.body_parameter.type, DPGModelType) for o in builder.overloads ) if not all_dpg_model_overloads: for v in sorted(set(client_names), key=client_names.index): @@ -999,13 +997,7 @@ def response_deserialization( # pylint: disable=too-many-statements deserialize_code.append(f" '{serialization_type}',{pylint_disable}") deserialize_code.append(" pipeline_response.http_response") deserialize_code.append(")") - elif self.code_model.options["models-mode"] == "typeddict": - if builder.has_stream_response: - deserialize_code.append("deserialized = response.content") - else: - response_attr = "json" if json_serializable(str(response.default_content_type)) else "text" - deserialize_code.append(f"deserialized = response.{response_attr}()") - elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): + elif self.code_model.options["models-mode"] == "dpg": if builder.has_stream_response: deserialize_code.append("deserialized = response.content") else: @@ -1079,7 +1071,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran type_annotation = e.type.type_annotation( # type: ignore is_operation_file=True, skip_quote=True, serialize_namespace=self.serialize_namespace ) - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": if xml_serializable(str(e.default_content_type)): fn = "_failsafe_deserialize_xml" else: @@ -1121,7 +1113,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran type_annotation = e.type.type_annotation( # type: ignore is_operation_file=True, skip_quote=True, serialize_namespace=self.serialize_namespace ) - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": if xml_serializable(str(e.default_content_type)): retval.append( " error = _failsafe_deserialize_xml(" @@ -1149,7 +1141,7 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran indent = " " if builder.non_default_errors else " " if builder.non_default_errors: retval.append(" else:") - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": default_exception = next(e for e in builder.exceptions if "default" in e.status_codes and e.type) if xml_serializable(str(default_exception.default_content_type)): fn = "_failsafe_deserialize_xml" @@ -1418,7 +1410,7 @@ def _extract_data_callback( # pylint: disable=too-many-statements,too-many-bran f"self._deserialize(\n {deserialize_type},{pylint_disable}\n pipeline_response{suffix}\n)" ) retval.append(f" deserialized = {deserialized}") - elif self.code_model.options["models-mode"] in ("dpg", "typeddict"): + elif self.code_model.options["models-mode"] == "dpg": # we don't want to generate paging models for DPG retval.append(f" deserialized = {deserialized}") else: @@ -1436,7 +1428,7 @@ def _extract_data_callback( # pylint: disable=too-many-statements,too-many-bran "".join([f'.get("{i}", {{}})' for i in item_name_array[:-1]]) + f'.get("{item_name_array[-1]}", [])' ) pylint_disable = "" - if self.code_model.options["models-mode"] in ("dpg", "typeddict"): + if self.code_model.options["models-mode"] == "dpg": item_type = builder.item_type.type_annotation( is_operation_file=True, serialize_namespace=self.serialize_namespace ) @@ -1613,7 +1605,7 @@ def get_long_running_output(self, builder: LROOperationType) -> list[str]: retval.append(" response_headers = {}") if ( not self.code_model.options["models-mode"] - or self.code_model.options["models-mode"] in ("dpg", "typeddict") + or self.code_model.options["models-mode"] == "dpg" or builder.lro_response.headers ): retval.append(" response = pipeline_response.http_response") diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py index 749d8ca240c..7fa07f72029 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py @@ -3,34 +3,114 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- +from typing import Optional +from ..models import ModelType, CodeModel from ..models.imports import FileImport, ImportType from ..models.utils import NamespaceType +from ..models.property import Property +from .model_serializer import _documentation_string from .import_serializer import FileImportSerializer from .base_serializer import BaseSerializer class TypesSerializer(BaseSerializer): + def __init__( + self, + code_model: CodeModel, + env, + client_namespace: Optional[str] = None, + models: Optional[list[ModelType]] = None, + ): + super().__init__(code_model=code_model, env=env) + self._client_namespace = client_namespace + self._models = models or [] + + @property + def typeddict_models(self) -> list[ModelType]: + """Models that should be rendered as TypedDicts (excluding discriminated bases which become unions).""" + return [m for m in self._models if m.base != "json" and not m.discriminated_subtypes] + def imports(self) -> FileImport: file_import = FileImport(self.code_model) - if self.code_model.named_unions: - file_import.add_submodule_import( - "typing", - "Union", - ImportType.STDLIB, - ) - for nu in self.code_model.named_unions: - file_import.merge( - nu.imports( - serialize_namespace=self.serialize_namespace, serialize_namespace_type=NamespaceType.TYPES_FILE + + td_models = self.typeddict_models + if td_models: + file_import.add_submodule_import("typing_extensions", "TypedDict", ImportType.STDLIB) + has_required = False + for model in td_models: + file_import.merge( + model.imports( + is_operation_file=False, + serialize_namespace=self.serialize_namespace, + serialize_namespace_type=NamespaceType.TYPES_FILE, + ) ) - ) + for prop in model.properties: + file_import.merge( + prop.imports( + serialize_namespace=self.serialize_namespace, + serialize_namespace_type=NamespaceType.TYPES_FILE, + called_by_property=True, + ) + ) + if not (prop.optional or prop.client_default_value is not None): + has_required = True + for parent in model.parents: + if parent.client_namespace != model.client_namespace and not parent.discriminated_subtypes: + file_import.add_submodule_import( + self.code_model.get_relative_import_path( + self.serialize_namespace, + self.code_model.get_imported_namespace_for_model(parent.client_namespace), + ), + parent.name, + ImportType.LOCAL, + ) + if has_required: + file_import.add_submodule_import("typing_extensions", "Required", ImportType.STDLIB) return file_import + def declare_model(self, model: ModelType) -> str: + non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes] + if non_discriminated_parents: + basename = ", ".join([m.name for m in non_discriminated_parents]) + return f"class {model.name}({basename}):{model.pylint_disable()}" + return f"class {model.name}(TypedDict, total=False):{model.pylint_disable()}" + + @staticmethod + def get_properties_to_declare(model: ModelType) -> list[Property]: + non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes] + if non_discriminated_parents: + parent_properties = [p for bm in non_discriminated_parents for p in bm.properties] + properties_to_declare = [ + p + for p in model.properties + if not any( + p.client_name == pp.client_name + and p.type_annotation() == pp.type_annotation() + and not p.is_base_discriminator + for pp in parent_properties + ) + ] + else: + properties_to_declare = model.properties + return properties_to_declare + + def declare_property(self, prop: Property) -> str: + type_annotation = prop.type_annotation(serialize_namespace=self.serialize_namespace) + is_optional = prop.optional or prop.client_default_value is not None + if is_optional: + return f"{prop.wire_name}: {type_annotation}" + return f"{prop.wire_name}: Required[{type_annotation}]" + + @staticmethod + def variable_documentation_string(prop: Property) -> list[str]: + return _documentation_string(prop, "ivar", "vartype") + def serialize(self) -> str: - # Generate the models template = self.env.get_template("types.py.jinja2") return template.render( code_model=self.code_model, imports=FileImportSerializer(self.imports()), serializer=self, + models=self.typeddict_models, ) diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py new file mode 100644 index 00000000000..e15fee99c82 --- /dev/null +++ b/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py @@ -0,0 +1,60 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- +from typing import Optional + +from ..models import CodeModel, ModelType +from ..models.imports import FileImport, ImportType +from ..models.utils import NamespaceType +from .import_serializer import FileImportSerializer +from .base_serializer import BaseSerializer + + +class UnionsSerializer(BaseSerializer): + def __init__( + self, + code_model: CodeModel, + env, + models: Optional[list[ModelType]] = None, + ): + super().__init__(code_model=code_model, env=env) + self._models = models or [] + + @property + def discriminated_base_models(self) -> list[ModelType]: + """Models that are discriminated bases, rendered as Union aliases.""" + return [m for m in self._models if m.base != "json" and m.discriminated_subtypes] + + def imports(self) -> FileImport: + file_import = FileImport(self.code_model) + has_unions = bool(self.code_model.named_unions) or bool(self.discriminated_base_models) + if has_unions: + file_import.add_submodule_import( + "typing", + "Union", + ImportType.STDLIB, + ) + for nu in self.code_model.named_unions: + file_import.merge( + nu.imports( + serialize_namespace=self.serialize_namespace, + serialize_namespace_type=NamespaceType.UNIONS_FILE, + ) + ) + return file_import + + def discriminated_subtypes_union(self, model: ModelType) -> str: + subtypes = list(model.discriminated_subtypes.values()) + subtype_names = [s.name for s in subtypes] + return f"{model.name} = Union[{', '.join(subtype_names)}]" + + def serialize(self) -> str: + template = self.env.get_template("unions.py.jinja2") + return template.render( + code_model=self.code_model, + imports=FileImportSerializer(self.imports()), + serializer=self, + discriminated_bases=self.discriminated_base_models, + ) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 index 6e033ba77fb..d91228cbdd8 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/model_container.py.jinja2 @@ -9,11 +9,9 @@ {{ imports }} {% for model in models %} -{% if model.base == "dpg" %} +{% if model.base == "dpg" or model.base == "typeddict" %} {% include "model_dpg.py.jinja2" %} {% elif model.base == "msrest" %} {% include "model_msrest.py.jinja2" %} -{% elif model.base == "typeddict" %} -{% include "model_typeddict.py.jinja2" %} {% endif %} {% endfor %} diff --git a/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 index 19435f14e88..8ff3e618aca 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 @@ -4,6 +4,28 @@ {% endif %} {{ imports }} -{% for nu in code_model.named_unions %} -{{nu.name}} = {{nu.type_definition()}} +{% import 'operation_tools.jinja2' as op_tools %} +{% import "macros.jinja2" as macros %} +{% for model in models %} + + +{{ serializer.declare_model(model) }} + """{{ op_tools.wrap_string(model.description(is_operation_file=False), "\n ") }} + + {% if model.properties != None %} + {% for p in model.properties %} + {% for line in serializer.variable_documentation_string(p) %} + {{ macros.wrap_model_string(line, '\n ') -}} + {% endfor %} + {% endfor %} + {% endif %} + """ + + {% for p in serializer.get_properties_to_declare(model)%} + {{ serializer.declare_property(p) }} + {% set prop_description = p.description(is_operation_file=False).replace('"', '\\"') %} + {% if prop_description %} + """{{ macros.wrap_model_string(prop_description, '\n ', '\"\"\"') -}} + {% endif %} + {% endfor %} {% endfor %} diff --git a/packages/http-client-python/generator/pygen/codegen/templates/unions.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/unions.py.jinja2 new file mode 100644 index 00000000000..e57874df349 --- /dev/null +++ b/packages/http-client-python/generator/pygen/codegen/templates/unions.py.jinja2 @@ -0,0 +1,12 @@ +# coding=utf-8 +{% if code_model.license_header %} +{{ code_model.license_header }} +{% endif %} + +{{ imports }} +{% for nu in code_model.named_unions %} +{{nu.name}} = {{nu.type_definition()}} +{% endfor %} +{% for model in discriminated_bases %} +{{ serializer.discriminated_subtypes_union(model) }} +{% endfor %} diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 5117a9227e5..6d3344059a3 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -216,7 +216,7 @@ def add_body_param_type( model_type = ( body_parameter["type"] if origin_type == "model" else body_parameter["type"].get("elementType", {}) ) - is_dpg_model = model_type.get("base") in ("dpg", "typeddict") + is_dpg_model = model_type.get("base") == "dpg" body_parameter["type"] = { "type": "combined", "types": [body_parameter["type"]], diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py new file mode 100644 index 00000000000..6a49978a899 --- /dev/null +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -0,0 +1,171 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +"""Tests for TypedDict generation, unions generation, and models-mode interactions.""" + +from jinja2 import PackageLoader, Environment + +from pygen.codegen.models import CodeModel, JSONModelType, DPGModelType +from pygen.codegen.models.model_type import TypedDictModelType +from pygen.codegen.serializers.types_serializer import TypesSerializer +from pygen.codegen.serializers.unions_serializer import UnionsSerializer + + +def _make_code_model(models_mode="dpg"): + return CodeModel( + { + "clients": [ + { + "name": "client", + "namespace": "blah", + "moduleName": "blah", + "parameters": [], + "url": "", + "operationGroups": [], + } + ], + "namespace": "namespace", + }, + options={ + "show-send-request": True, + "builders-visibility": "public", + "show-operations": True, + "models-mode": models_mode, + "flavor": "unbranded", + "client-side-validation": False, + }, + ) + + +def _make_model(code_model, name, model_cls=None, properties=None): + """Create a model of the given class attached to code_model.""" + if model_cls is None: + if code_model.options["models-mode"] == "typeddict": + model_cls = TypedDictModelType + elif code_model.options["models-mode"] == "dpg": + model_cls = DPGModelType + else: + model_cls = JSONModelType + return model_cls( + yaml_data={ + "name": name, + "type": "model", + "snakeCaseName": name.lower(), + "usage": 2, + }, + code_model=code_model, + properties=properties or [], + ) + + +def _make_env(): + return Environment( + loader=PackageLoader("pygen.codegen", "templates"), + trim_blocks=True, + lstrip_blocks=True, + ) + + +# ---------- models-mode=none ---------- + + +def test_models_mode_none_produces_json_model_type(): + """When models-mode is none (False), all models should be JSONModelType.""" + code_model = _make_code_model(models_mode=False) + model = _make_model(code_model, "Foo", model_cls=JSONModelType) + assert model.base == "json" + + +def test_models_mode_none_no_typeddict_models(): + """TypesSerializer.typeddict_models should be empty when models-mode=none.""" + code_model = _make_code_model(models_mode=False) + m1 = _make_model(code_model, "Foo", model_cls=JSONModelType) + m2 = _make_model(code_model, "Bar", model_cls=JSONModelType) + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1, m2]) + assert ts.typeddict_models == [] + + +def test_models_mode_none_types_file_has_no_typeddict_imports(): + """When models-mode=none, the types.py should not import TypedDict.""" + code_model = _make_code_model(models_mode=False) + m1 = _make_model(code_model, "Foo", model_cls=JSONModelType) + code_model.model_types = [m1] + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1]) + output = ts.serialize() + assert "TypedDict" not in output + assert "Required" not in output + + +# ---------- models-mode=dpg ---------- + + +def test_models_mode_dpg_no_typeddict_models(): + """DPG models have base='dpg', not 'typeddict', so should not appear as typeddict_models.""" + code_model = _make_code_model(models_mode="dpg") + m1 = _make_model(code_model, "Foo", model_cls=DPGModelType) + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1]) + # DPG models have base != "json" so they DO appear in typeddict_models + assert len(ts.typeddict_models) == 1 + + +# ---------- models-mode=typeddict ---------- + + +def test_models_mode_typeddict_models_included(): + """TypedDictModelType models should appear in typeddict_models.""" + code_model = _make_code_model(models_mode="typeddict") + m1 = _make_model(code_model, "Foo", model_cls=TypedDictModelType) + m2 = _make_model(code_model, "Bar", model_cls=TypedDictModelType) + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1, m2]) + assert len(ts.typeddict_models) == 2 + + +def test_models_mode_typeddict_serialize_contains_class(): + """Serialized types.py output should contain TypedDict class definitions.""" + code_model = _make_code_model(models_mode="typeddict") + m1 = _make_model(code_model, "Foo", model_cls=TypedDictModelType) + code_model.model_types = [m1] + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1]) + output = ts.serialize() + assert "class Foo(TypedDict, total=False):" in output + assert "TypedDict" in output + + +def test_types_file_has_no_named_unions(): + """Serialized types.py should not contain named union definitions.""" + code_model = _make_code_model(models_mode="dpg") + m1 = _make_model(code_model, "Foo", model_cls=DPGModelType) + code_model.model_types = [m1] + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[m1]) + output = ts.serialize() + # Named unions should be in _unions.py, not types.py + assert "named_unions" not in output + + +# ---------- unions serializer ---------- + + +def test_unions_serializer_no_unions(): + """UnionsSerializer with no named unions should produce minimal output.""" + code_model = _make_code_model(models_mode="dpg") + + env = _make_env() + us = UnionsSerializer(code_model=code_model, env=env) + output = us.serialize() + assert "TypedDict" not in output + assert "Union" not in output From db10eeb78142f9707dc56219237a918e7001022b Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Wed, 6 May 2026 10:40:47 -0400 Subject: [PATCH 13/17] move discriminated union to types.py --- .../pygen/codegen/templates/types.py.jinja2 | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 index 7681c78e3f4..8ff3e618aca 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2 @@ -29,32 +29,3 @@ {% endif %} {% endfor %} {% endfor %} -{% import 'operation_tools.jinja2' as op_tools %} -{% import "macros.jinja2" as macros %} -{% for model in models %} -{% if serializer.is_discriminated_base(model) %} -{{ serializer.discriminated_subtypes_union(model) }} -{% else %} - - -{{ serializer.declare_model(model) }} - """{{ op_tools.wrap_string(model.description(is_operation_file=False), "\n ") }} - - {% if model.properties != None %} - {% for p in model.properties %} - {% for line in serializer.variable_documentation_string(p) %} - {{ macros.wrap_model_string(line, '\n ') -}} - {% endfor %} - {% endfor %} - {% endif %} - """ - - {% for p in serializer.get_properties_to_declare(model)%} - {{ serializer.declare_property(p) }} - {% set prop_description = p.description(is_operation_file=False).replace('"', '\\"') %} - {% if prop_description %} - """{{ macros.wrap_model_string(prop_description, '\n ', '\"\"\"') -}} - {% endif %} - {% endfor %} -{% endif %} -{% endfor %} From a206181fdf868ff05f9f3be7d86d13ca01104344 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Wed, 6 May 2026 11:25:02 -0400 Subject: [PATCH 14/17] format --- packages/http-client-python/eng/scripts/setup/run_batch.py | 4 +--- .../generator/pygen/codegen/serializers/__init__.py | 4 +++- .../generator/pygen/codegen/serializers/types_serializer.py | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/http-client-python/eng/scripts/setup/run_batch.py b/packages/http-client-python/eng/scripts/setup/run_batch.py index 6d6bef5b0d2..9b978ec0319 100644 --- a/packages/http-client-python/eng/scripts/setup/run_batch.py +++ b/packages/http-client-python/eng/scripts/setup/run_batch.py @@ -51,9 +51,7 @@ def _coerce(value): return False return value - pygen_args = { - k: _coerce(v) for k, v in command_args.items() if k not in ["emit-yaml-only"] - } + pygen_args = {k: _coerce(v) for k, v in command_args.items() if k not in ["emit-yaml-only"]} # Run preprocess and codegen (black is batched at the end for performance) preprocess.PreProcessPlugin(output_folder=output_dir, tsp_file=yaml_path, **pygen_args).process() 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 1ea2ea0b7a7..ac9a6781344 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -525,7 +525,9 @@ def _serialize_and_write_top_level_folder(self, env: Environment, namespace: str ) # write _unions.py - has_discriminated_bases = any(m for m in self.code_model.model_types if m.base != "json" and m.discriminated_subtypes) + has_discriminated_bases = any( + m for m in self.code_model.model_types if m.base != "json" and m.discriminated_subtypes + ) if self.code_model.named_unions or has_discriminated_bases: self.write_file( generation_dir / Path("_unions.py"), diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py index b9a57a10b6c..7fa07f72029 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py @@ -102,7 +102,6 @@ def declare_property(self, prop: Property) -> str: return f"{prop.wire_name}: {type_annotation}" return f"{prop.wire_name}: Required[{type_annotation}]" - @staticmethod def variable_documentation_string(prop: Property) -> list[str]: return _documentation_string(prop, "ivar", "vartype") From 95724704f4df74d40a51b81c548771e4241ee6d8 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Wed, 6 May 2026 11:33:31 -0400 Subject: [PATCH 15/17] add for output as well --- .../generator/pygen/codegen/models/model_type.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index b01d8810513..738667c1816 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -374,21 +374,17 @@ class TypedDictModelType(DPGModelType): base = "typeddict" def type_annotation(self, **kwargs: Any) -> str: - if kwargs.pop("is_response", False): - return "JSON" + kwargs.pop("is_response", None) return super().type_annotation(**kwargs) def docstring_type(self, **kwargs: Any) -> str: - if kwargs.pop("is_response", False): - return "JSON" + kwargs.pop("is_response", None) return super().docstring_type(**kwargs) def docstring_text(self, **kwargs: Any) -> str: - if kwargs.pop("is_response", False): - return "JSON" + kwargs.pop("is_response", None) return super().docstring_text(**kwargs) def imports(self, **kwargs: Any) -> FileImport: - file_import = super().imports(**kwargs) - file_import.define_mutable_mapping_type() - return file_import + kwargs.pop("is_response", None) + return super().imports(**kwargs) From cdcedf53cb8f6945fbc06f0ab3d4b2dfcd3eddfa Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Wed, 6 May 2026 13:58:56 -0400 Subject: [PATCH 16/17] add e2e tests --- .../eng/scripts/ci/regenerate.ts | 16 +++- .../generator/pygen/__init__.py | 2 + .../pygen/codegen/models/code_model.py | 2 +- .../pygen/codegen/models/model_type.py | 42 +++++++++++ .../pygen/codegen/serializers/__init__.py | 10 ++- .../codegen/serializers/builder_serializer.py | 35 ++++++--- ...test_typetest_model_usage_typeddictonly.py | 44 +++++++++++ .../tests/unit/test_typeddict.py | 75 +++++++++++++++++++ 8 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 packages/http-client-python/tests/mock_api/shared/test_typetest_model_usage_typeddictonly.py diff --git a/packages/http-client-python/eng/scripts/ci/regenerate.ts b/packages/http-client-python/eng/scripts/ci/regenerate.ts index a65230db578..bf6a8457b74 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate.ts @@ -307,10 +307,18 @@ const EMITTER_OPTIONS: Record | Record Any: raise ValueError( f"--package-mode can only be {' or '.join(TYPESPEC_PACKAGE_MODE)} or directory which contains template files" # pylint: disable=line-too-long ) + if key == "typed-dict-only-models" and isinstance(value, str): + value = [v.strip() for v in value.split(",") if v.strip()] return value def setdefault(self, key: str, default: Any, /) -> Any: # type: ignore # pylint: disable=arguments-differ 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 b6c3b2dda0d..884072a831f 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 @@ -349,7 +349,7 @@ def model_types(self, val: list[ModelType]) -> None: @staticmethod def get_public_model_types(models: list[ModelType]) -> list[ModelType]: - return [m for m in models if not m.internal and not m.base == "json"] + return [m for m in models if not m.internal and not m.base == "json" and not m.is_typed_dict_only] @property def public_model_types(self) -> list[ModelType]: diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index 738667c1816..0eea2063b32 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -77,6 +77,10 @@ def __init__( self.cross_language_definition_id: Optional[str] = self.yaml_data.get("crossLanguageDefinitionId") self.usage: int = self.yaml_data.get("usage", UsageFlags.Input.value | UsageFlags.Output.value) self.client_namespace: str = self.yaml_data.get("clientNamespace", code_model.namespace) + self.is_typed_dict_only: bool = ( + self.yaml_data.get("typedDictOnly", False) + or self.name in code_model.options.get("typed-dict-only-models", []) + ) @property def is_usage_output(self) -> bool: @@ -352,6 +356,22 @@ def imports(self, **kwargs: Any) -> FileImport: class DPGModelType(GeneratedModelType): base = "dpg" + def type_annotation(self, **kwargs: Any) -> str: + if self.is_typed_dict_only: + is_operation_file = kwargs.pop("is_operation_file", False) + skip_quote = kwargs.get("skip_quote", False) + retval = f"types.{self.name}" + return retval if is_operation_file or skip_quote else f'"{retval}"' + return super().type_annotation(**kwargs) + + def docstring_type(self, **kwargs: Any) -> str: + if self.is_typed_dict_only: + client_namespace = self.client_namespace + if self.code_model.options.get("generation-subdir"): + client_namespace += f".{self.code_model.options['generation-subdir']}" + return f"~{client_namespace}.types.{self.name}" + return super().docstring_type(**kwargs) + def serialization_type(self, **kwargs: Any) -> str: return ( self.type_annotation(skip_quote=True, **kwargs) @@ -364,6 +384,28 @@ def instance_check_template(self) -> str: return "isinstance({}, " + f"_models.{self.name})" def imports(self, **kwargs: Any) -> FileImport: + if self.is_typed_dict_only: + file_import = FileImport(self.code_model) + serialize_namespace_type = kwargs.get("serialize_namespace_type") + serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace) + relative_path = self.code_model.get_relative_import_path(serialize_namespace, self.client_namespace) + if serialize_namespace_type in [NamespaceType.OPERATION, NamespaceType.CLIENT]: + file_import.add_submodule_import( + relative_path, + "types", + ImportType.LOCAL, + ) + elif serialize_namespace_type in [NamespaceType.TYPES_FILE, NamespaceType.UNIONS_FILE] or ( + serialize_namespace_type == NamespaceType.MODEL + and kwargs.get("called_by_property", False) + ): + file_import.add_submodule_import( + relative_path, + "types", + ImportType.LOCAL, + typing_section=TypingSection.TYPING, + ) + return file_import file_import = super().imports(**kwargs) if self.flattened_property: file_import.add_submodule_import("typing", "Any", ImportType.STDLIB) 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 ac9a6781344..b131b8d1622 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -304,10 +304,14 @@ def _serialize_and_write_models_folder( serializer = DpgModelSerializer else: serializer = MsrestModelSerializer - if self.code_model.has_non_json_models(models): + # Filter out typed-dict-only models — they only appear in types.py, not as model classes + class_models = [m for m in models if not m.is_typed_dict_only] + if self.code_model.has_non_json_models(class_models): self.write_file( models_path / Path(f"{self.code_model.models_filename}.py"), - serializer(code_model=self.code_model, env=env, client_namespace=namespace, models=models).serialize(), + serializer( + code_model=self.code_model, env=env, client_namespace=namespace, models=class_models + ).serialize(), ) if enums: self.write_file( @@ -318,7 +322,7 @@ def _serialize_and_write_models_folder( ) self.write_file( models_path / Path("__init__.py"), - ModelInitSerializer(code_model=self.code_model, env=env, models=models, enums=enums).serialize(), + ModelInitSerializer(code_model=self.code_model, env=env, models=class_models, enums=enums).serialize(), ) self._keep_patch_file(models_path / Path("_patch.py"), env) 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 e43dcd916eb..3f60558523f 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 @@ -1000,6 +1000,12 @@ def response_deserialization( # pylint: disable=too-many-statements elif self.code_model.options["models-mode"] == "dpg": if builder.has_stream_response: deserialize_code.append("deserialized = response.content") + elif isinstance(response.type, ModelType) and response.type.is_typed_dict_only: + # Typed-dict-only models skip deserialization — return raw JSON + deserialize_code.append("if response.content:") + deserialize_code.append(" deserialized = response.json()") + deserialize_code.append("else:") + deserialize_code.append(" deserialized = None") else: format_filed = ( f', format="{response.type.encode}"' @@ -1429,18 +1435,23 @@ def _extract_data_callback( # pylint: disable=too-many-statements,too-many-bran ) pylint_disable = "" if self.code_model.options["models-mode"] == "dpg": - item_type = builder.item_type.type_annotation( - is_operation_file=True, serialize_namespace=self.serialize_namespace - ) - pylint_disable = ( - " # pylint: disable=protected-access" if getattr(builder.item_type, "internal", False) else "" - ) - list_of_elem_deserialized = [ - "_deserialize(", - f"{item_type},{pylint_disable}", - f"deserialized{access},", - ")", - ] + is_item_typed_dict_only = isinstance(builder.item_type, ModelType) and builder.item_type.is_typed_dict_only + if is_item_typed_dict_only: + # Typed-dict-only models skip deserialization — return raw JSON items + list_of_elem_deserialized = [f"deserialized{access}"] + else: + item_type = builder.item_type.type_annotation( + is_operation_file=True, serialize_namespace=self.serialize_namespace + ) + pylint_disable = ( + " # pylint: disable=protected-access" if getattr(builder.item_type, "internal", False) else "" + ) + list_of_elem_deserialized = [ + "_deserialize(", + f"{item_type},{pylint_disable}", + f"deserialized{access},", + ")", + ] else: list_of_elem_deserialized = [f"deserialized{access}"] list_of_elem_deserialized_str = "\n ".join(list_of_elem_deserialized) diff --git a/packages/http-client-python/tests/mock_api/shared/test_typetest_model_usage_typeddictonly.py b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_usage_typeddictonly.py new file mode 100644 index 00000000000..586eb7be791 --- /dev/null +++ b/packages/http-client-python/tests/mock_api/shared/test_typetest_model_usage_typeddictonly.py @@ -0,0 +1,44 @@ +# ------------------------------------------------------------------------- +# 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 typetest.model.usage.typeddictonly import UsageClient +from typetest.model.usage.typeddictonly.types import InputRecord, OutputRecord, InputOutputRecord + + +@pytest.fixture +def client(): + with UsageClient() as client: + yield client + + +def test_input(client: UsageClient): + # TypedDict-only: pass a plain dict matching the TypedDict schema + result = client.input({"requiredProp": "example-value"}) + assert result is None + + +def test_output(client: UsageClient): + # TypedDict-only: output should be a plain dict (no model deserialization) + output = client.output() + assert isinstance(output, dict) + assert output["requiredProp"] == "example-value" + + +def test_input_and_output(client: UsageClient): + # TypedDict-only: input a dict, get a dict back + result = client.input_and_output({"requiredProp": "example-value"}) + assert isinstance(result, dict) + assert result["requiredProp"] == "example-value" + + +def test_no_model_classes(): + """Verify that typed-dict-only models don't generate model classes.""" + from typetest.model.usage.typeddictonly import models + + # models.__all__ should be empty — no model classes exported + assert models.__all__ == [] + # The TypedDicts should only exist in the types module + assert hasattr(InputRecord, "__required_keys__") or hasattr(InputRecord, "__annotations__") diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index 6a49978a899..c35db047b0f 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -169,3 +169,78 @@ def test_unions_serializer_no_unions(): output = us.serialize() assert "TypedDict" not in output assert "Union" not in output + + +# ---------- typed-dict-only ---------- + + +def _make_typed_dict_only_model(code_model, name, **extra_yaml): + """Create a TypedDictModelType with typedDictOnly=True.""" + yaml_data = { + "name": name, + "type": "model", + "snakeCaseName": name.lower(), + "usage": 2, + "typedDictOnly": True, + **extra_yaml, + } + return TypedDictModelType( + yaml_data=yaml_data, + code_model=code_model, + properties=[], + ) + + +def test_typed_dict_only_property(): + """is_typed_dict_only should be True when yaml_data has typedDictOnly=True.""" + code_model = _make_code_model(models_mode="typeddict") + model = _make_typed_dict_only_model(code_model, "Foo") + assert model.is_typed_dict_only is True + + normal_model = _make_model(code_model, "Bar", model_cls=TypedDictModelType) + assert normal_model.is_typed_dict_only is False + + +def test_typed_dict_only_excluded_from_public_model_types(): + """Typed-dict-only models should not appear in public_model_types.""" + code_model = _make_code_model(models_mode="typeddict") + normal = _make_model(code_model, "Normal", model_cls=TypedDictModelType) + td_only = _make_typed_dict_only_model(code_model, "TdOnly") + code_model.model_types = [normal, td_only] + + public = code_model.public_model_types + assert normal in public + assert td_only not in public + + +def test_typed_dict_only_still_in_types_file(): + """Typed-dict-only models should still appear in types.py as TypedDicts.""" + code_model = _make_code_model(models_mode="typeddict") + td_only = _make_typed_dict_only_model(code_model, "MyModel") + code_model.model_types = [td_only] + + env = _make_env() + ts = TypesSerializer(code_model=code_model, env=env, models=[td_only]) + output = ts.serialize() + assert "class MyModel(TypedDict, total=False):" in output + + +def test_typed_dict_only_type_annotation(): + """Typed-dict-only models should use types.Name, not _models.Name.""" + code_model = _make_code_model(models_mode="typeddict") + model = _make_typed_dict_only_model(code_model, "Foo") + + # In operation files, should be types.Name + annotation = model.type_annotation(is_operation_file=True) + assert annotation == "types.Foo" + assert "_models" not in annotation + + +def test_typed_dict_only_docstring_type(): + """Typed-dict-only models should reference types module, not models.""" + code_model = _make_code_model(models_mode="typeddict") + model = _make_typed_dict_only_model(code_model, "Foo") + + docstring = model.docstring_type() + assert "types.Foo" in docstring + assert "models.Foo" not in docstring From 1d1d7c673cb92fbfd4a44edc92960740cdeeb925 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Wed, 6 May 2026 15:50:48 -0400 Subject: [PATCH 17/17] update unions serializer to get around pyright issue --- .../generator/pygen/codegen/models/model_type.py | 10 ++++------ .../pygen/codegen/serializers/unions_serializer.py | 10 +++++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index 0eea2063b32..803bcade644 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -77,10 +77,9 @@ def __init__( self.cross_language_definition_id: Optional[str] = self.yaml_data.get("crossLanguageDefinitionId") self.usage: int = self.yaml_data.get("usage", UsageFlags.Input.value | UsageFlags.Output.value) self.client_namespace: str = self.yaml_data.get("clientNamespace", code_model.namespace) - self.is_typed_dict_only: bool = ( - self.yaml_data.get("typedDictOnly", False) - or self.name in code_model.options.get("typed-dict-only-models", []) - ) + self.is_typed_dict_only: bool = self.yaml_data.get( + "typedDictOnly", False + ) or self.name in code_model.options.get("typed-dict-only-models", []) @property def is_usage_output(self) -> bool: @@ -396,8 +395,7 @@ def imports(self, **kwargs: Any) -> FileImport: ImportType.LOCAL, ) elif serialize_namespace_type in [NamespaceType.TYPES_FILE, NamespaceType.UNIONS_FILE] or ( - serialize_namespace_type == NamespaceType.MODEL - and kwargs.get("called_by_property", False) + serialize_namespace_type == NamespaceType.MODEL and kwargs.get("called_by_property", False) ): file_import.add_submodule_import( relative_path, diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py index e15fee99c82..66a61503aa4 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/unions_serializer.py @@ -43,11 +43,19 @@ def imports(self) -> FileImport: serialize_namespace_type=NamespaceType.UNIONS_FILE, ) ) + for model in self.discriminated_base_models: + for subtype in model.discriminated_subtypes.values(): + file_import.merge( + subtype.imports( + serialize_namespace=self.serialize_namespace, + serialize_namespace_type=NamespaceType.UNIONS_FILE, + ) + ) return file_import def discriminated_subtypes_union(self, model: ModelType) -> str: subtypes = list(model.discriminated_subtypes.values()) - subtype_names = [s.name for s in subtypes] + subtype_names = [s.type_annotation(skip_quote=True) for s in subtypes] return f"{model.name} = Union[{', '.join(subtype_names)}]" def serialize(self) -> str: