Skip to content
Open
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"
---

Fix invalid Python type annotations generated in `types.py` files. Internal enums used as TypedDict fields are now imported (as a bare symbol) from their private `_enums` submodule so the annotation resolves; duplicate runtime + `TYPE_CHECKING` imports of the same symbol are deduplicated to avoid `no-redef`; and TypedDicts that change an inherited field's requiredness are emitted as a flat (non-inheriting) TypedDict to satisfy PEP 589.
2 changes: 2 additions & 0 deletions cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ words:
- Declipse
- dedented
- dedup
- dedupe
- Dedupes
- deps
- deser
Expand Down Expand Up @@ -240,6 +241,7 @@ words:
- reactivex
- recase
- recorda
- redef
- regen
- rehype
- reinjected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ def type_annotation(self, **kwargs: Any) -> str:
model_alias = self.code_model.get_unique_models_alias(serialize_namespace, self.client_namespace)
module_name = f"{model_alias}."
file_name = f"{self.code_model.enums_filename}." if self.internal else ""
if serialize_namespace_type == NamespaceType.TYPES_FILE:
# In types.py the enum symbol is imported directly (bare name), so no ``_enums.``
# module prefix even for internal enums — the prefix would be an undefined name.
file_name = ""
model_name = module_name + file_name + self.name
# we don't need quoted annotation in operation files, and need it in model folder files.
if not kwargs.get("is_operation_file", False):
Expand Down Expand Up @@ -305,9 +309,17 @@ def imports(self, **kwargs: Any) -> FileImport:
typing_section=TypingSection.REGULAR,
)
elif serialize_namespace_type == NamespaceType.TYPES_FILE:
# Import enum name directly to avoid dotted forward refs in TypedDict annotations
# Import the enum symbol directly to avoid dotted forward refs in TypedDict
# annotations. Internal enums are not re-exported from the public ``models``
# package, so import them from the private ``_enums`` submodule instead — the
# bare-symbol import (rather than the ``_enums`` module) also avoids name
# collisions when internal enums come from several sibling namespaces.
module_name = f"models.{self.code_model.enums_filename}" if self.internal else "models"
enums_module = self.code_model.get_relative_import_path(
serialize_namespace, self.client_namespace, module_name=module_name
)
file_import.add_submodule_import(
f"{relative_path}models" if relative_path != "." else ".models",
enums_module,
self.name,
ImportType.LOCAL,
typing_section=TypingSection.TYPING,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,32 @@ def _add_type_checking_import(self):
if any(self.file_import.get_imports_from_section(TypingSection.TYPING)):
self.file_import.add_submodule_import("typing", "TYPE_CHECKING", ImportType.STDLIB)

def _dedupe_typing_imports(self):
"""Drop TYPE_CHECKING imports whose bound name is already imported at runtime.

A name imported in the regular (runtime) section is also available during type checking, so a
duplicate import under ``if TYPE_CHECKING:`` is redundant and triggers mypy's ``no-redef``
error. This can happen when the same symbol is imported from two different modules — e.g. a
cross-namespace enum imported at runtime from its ``_enums`` submodule and again for its
annotation from the public ``models`` package. The runtime import is sufficient.
"""
regular_bound_names = {
(i.alias or i.submodule_name)
for i in self.file_import.get_imports_from_section(TypingSection.REGULAR)
if i.submodule_name
}
if not regular_bound_names:
return
self.file_import.imports = [
Comment thread
l0lawrence marked this conversation as resolved.
i
for i in self.file_import.imports
if not (
i.typing_section == TypingSection.TYPING
and i.submodule_name
and (i.alias or i.submodule_name) in regular_bound_names
)
]

def _add_sys_import_if_needed(self):
all_imports = list(self.file_import.get_imports_from_section(TypingSection.REGULAR)) + list(
self.file_import.get_imports_from_section(TypingSection.TYPING)
Expand All @@ -106,6 +132,7 @@ def declare_definition(type_name: str, type_definition: TypeDefinition) -> list[
return "\n".join(declarations)

def __str__(self) -> str:
self._dedupe_typing_imports()
self._add_type_checking_import()
self._add_sys_import_if_needed()
regular_imports = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,39 @@ def has_keyword_wire_names(model: ModelType) -> bool:
"""Whether any property wire_name is a Python keyword or requires functional TypedDict form."""
return any(keyword.iskeyword(p.wire_name) or not p.wire_name.isidentifier() for p in model.properties)

@staticmethod
def needs_flat_typeddict(model: ModelType) -> bool:
"""Whether a TypedDict must be emitted in flat (non-inheriting) form.

PEP 589 forbids changing an inherited TypedDict field's requiredness in a subclass. When a
child redeclares an inherited field with a different requiredness (e.g. the parent renders it
as optional via ``total=False`` while the child needs ``Required[...]``), subclassing would
emit an illegal ``Overwriting TypedDict field ... while extending`` construct. Such models are
instead rendered as a flat TypedDict that lists every field (inherited + own) directly.

Models with keyword wire_names already flatten all fields via the functional form, so they
never hit this path.
"""
if TypesSerializer.has_keyword_wire_names(model):
return False
non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes]
if not non_discriminated_parents:
return False
for parent in non_discriminated_parents:
for parent_prop in parent.properties:
child_prop = next(
(p for p in model.properties if p.client_name == parent_prop.client_name),
None,
)
if child_prop is None or child_prop is parent_prop:
# Not overridden by the child (same object is reused when inherited unchanged).
continue
parent_required = not (parent_prop.optional or parent_prop.client_default_value is not None)
child_required = not (child_prop.optional or child_prop.client_default_value is not None)
if parent_required != child_required:
return True
return False

def get_shadowed_builtins(self, model: ModelType) -> frozenset[str]:
"""Return the set of builtin type names shadowed by property wire_names in this model.

Expand Down Expand Up @@ -303,7 +336,11 @@ def imports(self) -> FileImport:
if self.get_shadowed_builtins(model):
needs_builtins = True
for parent in model.parents:
if parent.client_namespace != model.client_namespace and not parent.discriminated_subtypes:
if (
parent.client_namespace != model.client_namespace
and not parent.discriminated_subtypes
and not self.needs_flat_typeddict(model)
):
# Import parent class from sibling namespace's types module
file_import.add_submodule_import(
self.code_model.get_relative_import_path(
Expand All @@ -329,7 +366,7 @@ def declare_model(self, model: ModelType) -> str:
if self.has_keyword_wire_names(model):
return "" # functional form is rendered separately
non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes]
if non_discriminated_parents:
if non_discriminated_parents and not self.needs_flat_typeddict(model):
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()}"
Expand Down Expand Up @@ -362,7 +399,7 @@ def get_properties_to_declare(model: ModelType) -> list[Property]:
if TypesSerializer.has_keyword_wire_names(model):
return [] # functional form handles all properties
non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes]
if non_discriminated_parents:
if non_discriminated_parents and not TypesSerializer.needs_flat_typeddict(model):
parent_properties = [p for bm in non_discriminated_parents for p in bm.properties]
return [
p
Expand Down
Loading
Loading