Skip to content

Commit 7c8bf77

Browse files
iscai-msftCopilot
andcommitted
fix: handle reserved words in TypedDict field names comprehensively
- Use builtins.X qualification when a TypedDict field wire_name shadows a Python builtin type name (e.g. int, str, list) that appears in type annotations within the same class - Add functional TypedDict form for models with Python keyword wire_names (e.g. and, class, for) since keywords can't be identifiers in class bodies - Only import builtins when actually needed (when shadowed builtins appear in annotations) - Remove the previous sorting workaround Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4497199 commit 7c8bf77

2 files changed

Lines changed: 101 additions & 17 deletions

File tree

packages/http-client-python/generator/pygen/codegen/serializers/types_serializer.py

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# Licensed under the MIT License. See License.txt in the project root for
44
# license information.
55
# --------------------------------------------------------------------------
6+
import keyword
7+
import re
68
from typing import Optional
79
from ..models import ModelType, CodeModel
810
from ..models.imports import FileImport, ImportType
@@ -12,6 +14,23 @@
1214
from .import_serializer import FileImportSerializer
1315
from .base_serializer import BaseSerializer
1416

17+
# Python builtin type names that can be shadowed by TypedDict field wire_names.
18+
# When a field name matches one of these, all references to that builtin in type
19+
# annotations within the same class are qualified as builtins.X.
20+
_BUILTIN_TYPE_NAMES = frozenset({
21+
"int", "str", "float", "bool", "list", "dict", "tuple", "set",
22+
"bytes", "type", "object", "complex", "frozenset", "bytearray", "memoryview",
23+
})
24+
25+
26+
def _qualify_shadowed_builtins(annotation: str, shadowed: frozenset[str]) -> str:
27+
"""Replace bare builtin type references with builtins.X when shadowed by a field name."""
28+
if not shadowed:
29+
return annotation
30+
for name in shadowed:
31+
annotation = re.sub(rf"\b{name}\b", f"builtins.{name}", annotation)
32+
return annotation
33+
1534

1635
class TypesSerializer(BaseSerializer):
1736
def __init__(
@@ -39,7 +58,6 @@ def discriminated_base_models(self) -> list[ModelType]:
3958
"""
4059
bases = [m for m in self._models if m.base != "json" and m.discriminated_subtypes]
4160
base_names = {m.name for m in bases}
42-
# Sort: models whose subtypes include other discriminated bases must come after them
4361
sorted_bases: list[ModelType] = []
4462
visited: set[str] = set()
4563

@@ -62,6 +80,30 @@ def discriminated_subtypes_union(self, model: ModelType) -> str:
6280
subtype_names = [s.name for s in subtypes]
6381
return f"{model.name} = Union[{', '.join(subtype_names)}]"
6482

83+
@staticmethod
84+
def has_keyword_wire_names(model: ModelType) -> bool:
85+
"""Whether any property wire_name is a Python keyword (requires functional TypedDict form)."""
86+
return any(keyword.iskeyword(p.wire_name) for p in model.properties)
87+
88+
@staticmethod
89+
def get_shadowed_builtins(model: ModelType) -> frozenset[str]:
90+
"""Return the set of builtin type names shadowed by property wire_names in this model.
91+
92+
Only includes a builtin if it is both used as a wire_name AND referenced
93+
in a type annotation within the same model (otherwise no shadowing occurs).
94+
"""
95+
wire_builtins = {p.wire_name for p in model.properties if p.wire_name in _BUILTIN_TYPE_NAMES}
96+
if not wire_builtins:
97+
return frozenset()
98+
# Check which of these builtins actually appear in type annotations
99+
used = set()
100+
for prop in model.properties:
101+
annotation = prop.type_annotation()
102+
for name in wire_builtins:
103+
if re.search(rf"\b{name}\b", annotation):
104+
used.add(name)
105+
return frozenset(used)
106+
65107
def imports(self) -> FileImport:
66108
file_import = FileImport(self.code_model)
67109

@@ -72,6 +114,7 @@ def imports(self) -> FileImport:
72114
if self.discriminated_base_models:
73115
file_import.add_submodule_import("typing", "Union", ImportType.STDLIB)
74116
has_required = False
117+
needs_builtins = False
75118
for model in td_models:
76119
file_import.merge(
77120
model.imports(
@@ -90,6 +133,8 @@ def imports(self) -> FileImport:
90133
)
91134
if not (prop.optional or prop.client_default_value is not None):
92135
has_required = True
136+
if self.get_shadowed_builtins(model):
137+
needs_builtins = True
93138
for parent in model.parents:
94139
if parent.client_namespace != model.client_namespace and not parent.discriminated_subtypes:
95140
file_import.add_submodule_import(
@@ -102,27 +147,55 @@ def imports(self) -> FileImport:
102147
)
103148
if has_required:
104149
file_import.add_submodule_import("typing_extensions", "Required", ImportType.STDLIB)
150+
if needs_builtins:
151+
file_import.add_import("builtins", ImportType.STDLIB)
105152
return file_import
106153

107154
def declare_model(self, model: ModelType) -> str:
155+
"""Generate the class declaration or functional form for a TypedDict model.
156+
157+
Uses functional form when any property wire_name is a Python keyword
158+
(e.g. 'and', 'class') since keywords can't be identifiers in class bodies.
159+
"""
160+
if self.has_keyword_wire_names(model):
161+
return "" # functional form is rendered separately
108162
non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes]
109163
if non_discriminated_parents:
110164
basename = ", ".join([m.name for m in non_discriminated_parents])
111165
return f"class {model.name}({basename}):{model.pylint_disable()}"
112166
return f"class {model.name}(TypedDict, total=False):{model.pylint_disable()}"
113167

114-
# Python builtin type names that can be shadowed by TypedDict field names
115-
_BUILTIN_TYPE_NAMES = frozenset({
116-
"int", "str", "float", "bool", "list", "dict", "tuple", "set",
117-
"bytes", "type", "object", "complex", "frozenset", "bytearray", "memoryview",
118-
})
168+
def declare_functional_model(self, model: ModelType) -> str:
169+
"""Generate a functional-form TypedDict for models with keyword wire_names.
170+
171+
Functional form is required when any field name is a Python keyword.
172+
All fields (including inherited) are included since functional form
173+
can't specify a base class.
174+
"""
175+
shadowed = self.get_shadowed_builtins(model)
176+
entries: list[str] = []
177+
for prop in model.properties:
178+
type_annotation = prop.type_annotation(
179+
serialize_namespace=self.serialize_namespace,
180+
serialize_namespace_type=NamespaceType.TYPES_FILE,
181+
)
182+
type_annotation = _qualify_shadowed_builtins(type_annotation, shadowed)
183+
is_optional = prop.optional or prop.client_default_value is not None
184+
if is_optional:
185+
entries.append(f' "{prop.wire_name}": {type_annotation},')
186+
else:
187+
entries.append(f' "{prop.wire_name}": Required[{type_annotation}],')
188+
fields = "\n".join(entries)
189+
return f'{model.name} = TypedDict("{model.name}", {{\n{fields}\n}}, total=False)'
119190

120191
@staticmethod
121192
def get_properties_to_declare(model: ModelType) -> list[Property]:
193+
if TypesSerializer.has_keyword_wire_names(model):
194+
return [] # functional form handles all properties
122195
non_discriminated_parents = [p for p in model.parents if not p.discriminated_subtypes]
123196
if non_discriminated_parents:
124197
parent_properties = [p for bm in non_discriminated_parents for p in bm.properties]
125-
properties_to_declare = [
198+
return [
126199
p
127200
for p in model.properties
128201
if not any(
@@ -132,20 +205,14 @@ def get_properties_to_declare(model: ModelType) -> list[Property]:
132205
for pp in parent_properties
133206
)
134207
]
135-
else:
136-
properties_to_declare = list(model.properties)
137-
# Move properties whose wire_name shadows a Python builtin type to the end,
138-
# so they don't shadow the builtin in subsequent type annotations.
139-
properties_to_declare.sort(
140-
key=lambda p: p.wire_name in TypesSerializer._BUILTIN_TYPE_NAMES
141-
)
142-
return properties_to_declare
208+
return list(model.properties)
143209

144-
def declare_property(self, prop: Property) -> str:
210+
def declare_property(self, prop: Property, shadowed_builtins: frozenset[str]) -> str:
145211
type_annotation = prop.type_annotation(
146212
serialize_namespace=self.serialize_namespace,
147213
serialize_namespace_type=NamespaceType.TYPES_FILE,
148214
)
215+
type_annotation = _qualify_shadowed_builtins(type_annotation, shadowed_builtins)
149216
is_optional = prop.optional or prop.client_default_value is not None
150217
if is_optional:
151218
return f"{prop.wire_name}: {type_annotation}"

packages/http-client-python/generator/pygen/codegen/templates/types.py.jinja2

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@
77
{% import 'operation_tools.jinja2' as op_tools %}
88
{% import "macros.jinja2" as macros %}
99
{% for model in models %}
10+
{% if serializer.has_keyword_wire_names(model) %}
11+
12+
13+
{{ serializer.declare_functional_model(model) }}
14+
"""{{ op_tools.wrap_string(model.description(is_operation_file=False), "\n") }}
15+
16+
{% if model.properties != None %}
17+
{% for p in model.properties %}
18+
{% for line in serializer.variable_documentation_string(p) %}
19+
{{ macros.wrap_model_string(line, '\n') -}}
20+
{% endfor %}
21+
{% endfor %}
22+
{% endif %}
23+
"""
24+
{% else %}
1025

1126

1227
{{ serializer.declare_model(model) }}
@@ -21,13 +36,15 @@
2136
{% endif %}
2237
"""
2338

39+
{% set shadowed = serializer.get_shadowed_builtins(model) %}
2440
{% for p in serializer.get_properties_to_declare(model)%}
25-
{{ serializer.declare_property(p) }}
41+
{{ serializer.declare_property(p, shadowed) }}
2642
{% set prop_description = p.description(is_operation_file=False).replace('"', '\\"') %}
2743
{% if prop_description %}
2844
"""{{ macros.wrap_model_string(prop_description, '\n ', '\"\"\"') -}}
2945
{% endif %}
3046
{% endfor %}
47+
{% endif %}
3148
{% endfor %}
3249
{% for model in discriminated_bases %}
3350
{{ serializer.discriminated_subtypes_union(model) }}

0 commit comments

Comments
 (0)