Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-client-python"
---

Generate TypedDict outputs in a patchable `types/` package with `_types.py` and `_patch.py` instead of a standalone `types.py` file.
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ def get_imported_namespace_for_client(imported_namespace: str, async_mode: bool
def get_imported_namespace_for_model(imported_namespace: str) -> str:
return imported_namespace + ".models"

@staticmethod
def get_imported_namespace_for_types(imported_namespace: str) -> str:
return imported_namespace + ".types"

def get_imported_namespace_for_operation(self, imported_namespace: str, async_mode: bool = False) -> str:
module_namespace = f".{self.operations_folder_name(imported_namespace)}"
return self.get_imported_namespace_for_client(imported_namespace, async_mode) + module_namespace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,15 @@ def imports(self, **kwargs: Any) -> FileImport:
if serialize_namespace_type == NamespaceType.TYPES_FILE:
# Same file — no import needed for same-namespace enums
serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace)
if self.client_namespace != serialize_namespace:
serialize_client_namespace = kwargs.get("serialize_client_namespace", self.code_model.namespace)
if self.client_namespace != serialize_client_namespace:
# Cross-namespace: import from sibling types module
relative_path = self.code_model.get_relative_import_path(
serialize_namespace, self.client_namespace
)
file_import.add_submodule_import(
f"{relative_path}types" if relative_path != "." else ".types",
self.code_model.get_relative_import_path(
serialize_namespace,
self.client_namespace,
module_name="types",
),
self.name,
ImportType.LOCAL,
typing_section=TypingSection.REGULAR,
Expand Down Expand Up @@ -307,7 +309,11 @@ def imports(self, **kwargs: Any) -> FileImport:
elif serialize_namespace_type == NamespaceType.TYPES_FILE:
# Import enum name directly to avoid dotted forward refs in TypedDict annotations
file_import.add_submodule_import(
f"{relative_path}models" if relative_path != "." else ".models",
self.code_model.get_relative_import_path(
serialize_namespace,
self.client_namespace,
module_name="models",
),
self.name,
ImportType.LOCAL,
typing_section=TypingSection.TYPING,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,15 +341,20 @@ def imports(self, **kwargs: Any) -> FileImport:
alias="_Model",
)
elif serialize_namespace_type == NamespaceType.TYPES_FILE:
serialize_client_namespace = kwargs.get("serialize_client_namespace", self.code_model.namespace)
# Don't import models that will be defined in this namespace's types.py —
# either as TypedDict classes (non-discriminated) or as Union aliases (discriminated bases).
# Only same-namespace non-json models are in the same types.py file.
same_namespace = relative_path == "."
same_namespace = self.client_namespace == serialize_client_namespace
will_be_in_types_file = self.base != "json" and same_namespace
if not will_be_in_types_file:
if same_namespace:
# json models from same namespace — import from .models (or .models._models for internal)
import_path = f".models.{self.code_model.models_filename}" if self.internal else ".models"
import_path = self.code_model.get_relative_import_path(
serialize_namespace,
self.client_namespace,
module_name=f"models.{self.code_model.models_filename}" if self.internal else "models",
)
file_import.add_submodule_import(
import_path,
self.name,
Expand Down Expand Up @@ -443,7 +448,8 @@ def imports(self, **kwargs: Any) -> FileImport:
serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace)
relative_path = self.code_model.get_relative_import_path(serialize_namespace, self.client_namespace)
alias = self.code_model.get_unique_types_alias(serialize_namespace, self.client_namespace)
same_namespace = relative_path == "."
serialize_client_namespace = kwargs.get("serialize_client_namespace", self.code_model.namespace)
same_namespace = self.client_namespace == serialize_client_namespace
if serialize_namespace_type in [NamespaceType.OPERATION, NamespaceType.CLIENT]:
file_import.add_submodule_import(
relative_path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .patch_serializer import PatchSerializer
from .sample_serializer import SampleSerializer
from .test_serializer import TestSerializer, TestGeneralSerializer
from .types_init_serializer import TypesInitSerializer
from .types_serializer import TypesSerializer
from .unions_serializer import UnionsSerializer
from ...utils import to_snake_case, VALID_PACKAGE_MODE
Expand Down Expand Up @@ -218,24 +219,19 @@ def serialize(self) -> None:
enums=[] if is_typeddict_mode else client_namespace_type.enums,
)

# write types.py per namespace (alongside models/)
# Only generate types.py if at least one model/enum would be imported via types
# write types/ package per namespace (alongside models/)
# Only generate types if at least one model/enum would be imported via types
# in operations (the model itself volunteers this via is_used_in_operations_via_types)
has_types_models = any(
m.is_used_in_operations_via_types for m in client_namespace_type.models if m.base != "json"
)
has_types_enums = any(e.is_typeddict_mode for e in client_namespace_type.enums)
if has_types_models or has_types_enums:
generation_dir = self.code_model.get_generation_dir(client_namespace)
self.write_file(
generation_dir / Path("types.py"),
TypesSerializer(
code_model=self.code_model,
env=env,
client_namespace=client_namespace,
models=client_namespace_type.models,
enums=client_namespace_type.enums if is_typeddict_mode else None,
).serialize(),
self._serialize_and_write_types_folder(
env=env,
namespace=client_namespace,
models=client_namespace_type.models,
enums=client_namespace_type.enums if is_typeddict_mode else [],
)

if not self.code_model.options["models-mode"]:
Expand Down Expand Up @@ -348,6 +344,32 @@ def _serialize_and_write_models_folder(

self._keep_patch_file(models_path / Path("_patch.py"), env)

def _serialize_and_write_types_folder(
self, env: Environment, namespace: str, models: list[ModelType], enums: list[EnumType]
) -> None:
generation_dir = self.code_model.get_generation_dir(namespace)
types_path = generation_dir / "types"
types_serializer = TypesSerializer(
code_model=self.code_model,
env=env,
client_namespace=namespace,
models=models,
enums=enums,
)
self.remove_file(generation_dir / Path("types.py"))
self.write_file(types_path / Path("_types.py"), types_serializer.serialize())
self.write_file(
types_path / Path("__init__.py"),
TypesInitSerializer(
code_model=self.code_model,
env=env,
models=types_serializer.typeddict_models,
discriminated_bases=types_serializer.discriminated_base_models,
enums=types_serializer.literal_enums,
).serialize(),
)
self._keep_patch_file(types_path / Path("_patch.py"), env)

def _serialize_and_write_rest_layer(self, env: Environment, namespace_path: Path) -> None:
rest_path = namespace_path / Path(self.code_model.rest_layer_name)
group_names = {rb.group_name for c in self.code_model.clients for rb in c.request_builders}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
from jinja2 import Environment

from ..models import CodeModel, ModelType
from ..models.enum_type import EnumType


class TypesInitSerializer:
def __init__(
self,
code_model: CodeModel,
env: Environment,
*,
models: list[ModelType],
discriminated_bases: list[ModelType],
enums: list[EnumType],
) -> None:
self.code_model = code_model
self.env = env
self.models = models
self.discriminated_bases = discriminated_bases
self.enums = enums

def serialize(self) -> str:
type_names = [m.name for m in self.models]
type_names.extend(m.name for m in self.discriminated_bases)
type_names.extend(e.name for e in self.enums if not e.internal)
type_names.sort()
template = self.env.get_template("types_init.py.jinja2")
return template.render(code_model=self.code_model, types=type_names)
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ def __init__(
self._models = models or []
self._enums = enums or []

@property
def serialize_namespace(self) -> str:
return self.code_model.get_imported_namespace_for_types(self.client_namespace)

@property
def literal_enums(self) -> list[EnumType]:
"""Enums to render as Literal type aliases in typeddict mode."""
Expand Down Expand Up @@ -189,9 +193,7 @@ def typeddict_models(self) -> list[ModelType]:
property — are kept, ensuring no forward reference is left undefined.
"""
needed = self._types_file_model_names
candidates = [
m for m in self._models if m.base != "json" and not m.discriminated_subtypes and m.name in needed
]
candidates = [m for m in self._models if m.base != "json" and not m.discriminated_subtypes and m.name in needed]
seen_names: dict[str, "ModelType"] = {}
result: list["ModelType"] = []
for m in candidates:
Expand Down Expand Up @@ -294,13 +296,15 @@ def imports(self) -> FileImport:
model.imports(
is_operation_file=False,
serialize_namespace=self.serialize_namespace,
serialize_client_namespace=self.client_namespace,
serialize_namespace_type=NamespaceType.TYPES_FILE,
)
)
for prop in model.properties:
file_import.merge(
prop.imports(
serialize_namespace=self.serialize_namespace,
serialize_client_namespace=self.client_namespace,
serialize_namespace_type=NamespaceType.TYPES_FILE,
called_by_property=True,
)
Expand Down Expand Up @@ -353,6 +357,7 @@ def declare_functional_model(self, model: ModelType) -> str:
for prop in model.properties:
type_annotation = prop.type_annotation(
serialize_namespace=self.serialize_namespace,
serialize_client_namespace=self.client_namespace,
serialize_namespace_type=NamespaceType.TYPES_FILE,
)
type_annotation = _qualify_shadowed_builtins(type_annotation, shadowed)
Expand Down Expand Up @@ -386,6 +391,7 @@ def get_properties_to_declare(model: ModelType) -> list[Property]:
def declare_property(self, prop: Property, shadowed_builtins: frozenset[str]) -> str:
type_annotation = prop.type_annotation(
serialize_namespace=self.serialize_namespace,
serialize_client_namespace=self.client_namespace,
serialize_namespace_type=NamespaceType.TYPES_FILE,
)
type_annotation = _qualify_shadowed_builtins(type_annotation, shadowed_builtins)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% import 'keywords.jinja2' as keywords %}
# coding=utf-8
{% if code_model.license_header %}
{{ code_model.license_header }}
{% endif %}
{{ keywords.path_type_checking_imports() }}
{% if types %}

from ._types import ( # type: ignore
{% for type_name in types %}
{{ type_name }},
{% endfor %}
)
{% endif %}
{{ keywords.patch_imports() }}
__all__ = [
{% for type_name in types %}
'{{ type_name }}',
{% endfor %}
]
{{ keywords.extend_all }}
_patch_sdk()
41 changes: 41 additions & 0 deletions packages/http-client-python/tests/unit/test_typeddict.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""Tests for TypedDict generation, unions generation, and models-mode interactions."""

from jinja2 import PackageLoader, Environment
from pygen.codegen.serializers import JinjaSerializer

from pygen.codegen.models import CodeModel, JSONModelType, DPGModelType, build_type
from pygen.codegen.models.imports import ImportType
Expand Down Expand Up @@ -39,6 +40,16 @@ def _make_code_model(models_mode="dpg"):
"models-mode": models_mode,
"flavor": "unbranded",
"client-side-validation": False,
"keep-setup-py": False,
"basic-setup-py": False,
"clear-output-folder": False,
"no-async": True,
"package-mode": False,
"generate-sample": False,
"generate-test": False,
"emit-cross-language-definition-file": False,
"no-namespace-folders": False,
"version-tolerant": False,
},
)

Expand Down Expand Up @@ -684,3 +695,33 @@ def test_type_changing_under_types_file_does_not_cause_spurious_builtins_import(

output = ts.serialize()
assert "import builtins" not in output


def test_jinja_serializer_writes_types_package(tmp_path):
"""TypedDict generation should emit a patchable types/ package instead of a single types.py file."""
code_model = _make_code_model(models_mode="typeddict")
model = _make_model(code_model, "Foo", model_cls=TypedDictModelType)
code_model.model_types = [model]

JinjaSerializer(code_model=code_model, output_folder=tmp_path).serialize()

namespace_path = tmp_path / "namespace"
assert (namespace_path / "types" / "__init__.py").is_file()
assert (namespace_path / "types" / "_types.py").is_file()
assert (namespace_path / "types" / "_patch.py").is_file()
assert not (namespace_path / "types.py").exists()


def test_jinja_serializer_removes_stale_types_py(tmp_path):
"""Regeneration should remove an old top-level types.py once the types/ package is emitted."""
code_model = _make_code_model(models_mode="typeddict")
model = _make_model(code_model, "Foo", model_cls=TypedDictModelType)
code_model.model_types = [model]

namespace_path = tmp_path / "namespace"
namespace_path.mkdir(parents=True, exist_ok=True)
(namespace_path / "types.py").write_text("# stale file", encoding="utf-8")

JinjaSerializer(code_model=code_model, output_folder=tmp_path).serialize()

assert not (namespace_path / "types.py").exists()
Loading