Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0834a2c
feat: add typeddict models-mode for Python HTTP client emitter
Apr 21, 2026
27c8bfb
Enhance Python HTTP client emitter with TypedDict support
iscai-msft Apr 21, 2026
c1487f8
add discriminator
Apr 21, 2026
02e6f35
Merge branch 'python/addTypedDict' of https://github.com/iscai-msft/t…
Apr 21, 2026
2647828
Merge branch 'main' of https://github.com/microsoft/typespec into pyt…
Apr 21, 2026
3a125b8
feat: return JSON for typeddict responses, drop NotRequired
Apr 23, 2026
e0569db
feat: add wire name mock API tests for typeddict naming spec
Apr 23, 2026
3e5c0ef
fix: remove redundant JSON overload for typeddict mode
Apr 23, 2026
f76c88d
fix: remove unused _deserialize import in typeddict mode
Apr 23, 2026
2401b66
fix: remove all unused imports in typeddict generated code
Apr 23, 2026
57bf76a
Merge branch 'main' into python/addTypedDict
iscai-msft Apr 24, 2026
47b5024
format and lint
Apr 27, 2026
87ea0a1
Merge branch 'python/addTypedDict' of https://github.com/iscai-msft/t…
Apr 27, 2026
95db199
fix: define JSON type alias in TypedDictModelType imports
Apr 28, 2026
37806df
switch to always generating typeddicts as typing hints
May 5, 2026
18786e3
switch to always generating typeddicts as typing hints
May 5, 2026
8d323ac
Merge branch 'python/addTypedDict' of https://github.com/iscai-msft/t…
May 5, 2026
db10eeb
move discriminated union to types.py
May 6, 2026
841b2ee
Merge branch 'main' of https://github.com/microsoft/typespec into pyt…
May 6, 2026
a206181
format
May 6, 2026
9572470
add for output as well
May 6, 2026
cdcedf5
add e2e tests
May 6, 2026
1d1d7c6
update unions serializer to get around pyright issue
May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/http-client-python"
---

[python] Always generate `TypedDict` typing hints for input models in the `_types.py` file
16 changes: 12 additions & 4 deletions packages/http-client-python/eng/scripts/ci/regenerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,18 @@ const EMITTER_OPTIONS: Record<string, Record<string, string> | Record<string, st
"clear-output-folder": "true",
},
],
"type/model/usage": {
"package-name": "typetest-model-usage",
namespace: "typetest.model.usage",
},
"type/model/usage": [
{
"package-name": "typetest-model-usage",
namespace: "typetest.model.usage",
},
{
"package-name": "typetest-model-usage-typeddictonly",
namespace: "typetest.model.usage.typeddictonly",
"models-mode": "typeddict",
"typed-dict-only-models": "InputRecord,OutputRecord,InputOutputRecord",
},
],
"type/model/visibility": [
{
"package-name": "typetest-model-visibility",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 5 additions & 3 deletions packages/http-client-python/generator/pygen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -181,6 +181,8 @@ def _validate_and_transform(self, key: str, value: Any) -> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +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", [])

@property
def is_usage_output(self) -> bool:
Expand Down Expand Up @@ -305,7 +308,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,
Expand All @@ -320,7 +323,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(
Expand Down Expand Up @@ -352,6 +355,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)
Expand All @@ -364,7 +383,48 @@ 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)
return file_import


class TypedDictModelType(DPGModelType):
base = "typeddict"

def type_annotation(self, **kwargs: Any) -> str:
kwargs.pop("is_response", None)
return super().type_annotation(**kwargs)

def docstring_type(self, **kwargs: Any) -> str:
kwargs.pop("is_response", None)
return super().docstring_type(**kwargs)

def docstring_text(self, **kwargs: Any) -> str:
kwargs.pop("is_response", None)
return super().docstring_text(**kwargs)

def imports(self, **kwargs: Any) -> FileImport:
kwargs.pop("is_response", None)
return super().imports(**kwargs)
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,21 @@ 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}]"
return type_annotation
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"
Expand All @@ -121,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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class NamespaceType(str, Enum):
OPERATION = "operation"
CLIENT = "client"
TYPES_FILE = "types_file"
UNIONS_FILE = "unions_file"


LOCALS_LENGTH_LIMIT = 25
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,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

Expand Down Expand Up @@ -200,7 +201,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)
Expand Down Expand Up @@ -298,11 +299,19 @@ 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
if self.code_model.has_non_json_models(models):
models_mode = self.code_model.options["models-mode"]
if models_mode in ("dpg", "typeddict"):
serializer = DpgModelSerializer
else:
serializer = MsrestModelSerializer
# 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(
Expand All @@ -313,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)
Expand Down Expand Up @@ -519,11 +528,29 @@ 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("_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).serialize(),
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"'
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading