Skip to content

Commit 5acb178

Browse files
authored
Add TypedDict closed and extra_items support (PEP 728) (#2922)
* Add TypedDict closed and extra_items support (PEP 728) * Add pragma no branch for unreachable coverage branches * Remove inline comments
1 parent 54c3ed9 commit 5acb178

15 files changed

Lines changed: 194 additions & 47 deletions

File tree

src/datamodel_code_generator/_types/generate_config_dict.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from typing import TYPE_CHECKING, Any, TypedDict
77

8-
from typing_extensions import NotRequired
8+
from typing_extensions import NotRequired, TypedDict
99

1010
if TYPE_CHECKING:
1111
from collections import defaultdict
@@ -38,7 +38,7 @@
3838
from datamodel_code_generator.validators import ModelValidators, ValidatorMode
3939

4040

41-
class GenerateConfigDict(TypedDict):
41+
class GenerateConfigDict(TypedDict, closed=True):
4242
input_filename: NotRequired[str | None]
4343
input_file_type: NotRequired[InputFileType]
4444
output: NotRequired[Path | None]

src/datamodel_code_generator/_types/parse_config_dict.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33

44
from __future__ import annotations
55

6-
from typing import TYPE_CHECKING, TypedDict
6+
from typing import TYPE_CHECKING
77

8-
from typing_extensions import NotRequired
8+
from typing_extensions import NotRequired, TypedDict
99

1010
if TYPE_CHECKING:
1111
from pathlib import Path
1212

1313
from datamodel_code_generator.enums import AllExportsCollisionStrategy, AllExportsScope, ModuleSplitMode
1414

1515

16-
class ParseConfigDict(TypedDict):
16+
class ParseConfigDict(TypedDict, closed=True):
1717
with_import: NotRequired[bool | None]
1818
format_: NotRequired[bool | None]
1919
settings_path: NotRequired[Path | None]

src/datamodel_code_generator/_types/parser_config_dicts.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict
77

8-
from typing_extensions import NotRequired
8+
from typing_extensions import NotRequired, TypedDict
99

1010
if TYPE_CHECKING:
1111
from collections import defaultdict
@@ -160,7 +160,7 @@ class ParserConfigDict(TypedDict):
160160
default_value_overrides: NotRequired[Mapping[str, Any] | None]
161161

162162

163-
class GraphQLParserConfigDict(ParserConfigDict):
163+
class GraphQLParserConfigDict(ParserConfigDict, closed=True):
164164
data_model_scalar_type: NotRequired[type[DataModel]]
165165
data_model_union_type: NotRequired[type[DataModel]]
166166
graphql_no_typename: NotRequired[bool]
@@ -170,7 +170,7 @@ class JSONSchemaParserConfigDict(ParserConfigDict):
170170
pass
171171

172172

173-
class OpenAPIParserConfigDict(JSONSchemaParserConfigDict):
173+
class OpenAPIParserConfigDict(JSONSchemaParserConfigDict, closed=True):
174174
openapi_scopes: NotRequired[list[OpenAPIScope] | None]
175175
include_path_parameters: NotRequired[bool]
176176
use_status_code_in_response_name: NotRequired[bool]

src/datamodel_code_generator/format.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ def has_typed_dict_read_only(self) -> bool:
128128
"""Check if Python version supports TypedDict ReadOnly (PEP 705)."""
129129
return self._is_py_313_or_later
130130

131+
@property
132+
def has_typed_dict_closed(self) -> bool:
133+
"""Check if Python version supports TypedDict closed/extra_items (PEP 728).
134+
135+
PEP 728 is targeted for Python 3.15. Until then, typing_extensions is required.
136+
"""
137+
return False
138+
131139
@property
132140
def has_kw_only_dataclass(self) -> bool:
133141
"""Check if Python version supports kw_only in dataclasses."""

src/datamodel_code_generator/input_model.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ def _transform_single_model_to_inheritance(
653653
defs.update(parent_defs)
654654

655655
parent_def = {k: v for k, v in parent_schema.items() if k != "$defs"}
656+
parent_def["x-is-base-class"] = True
656657
defs[parent_name] = parent_def
657658

658659
original_props = cast("dict[str, object]", schema.get("properties", {}))
@@ -801,7 +802,10 @@ def load_model_schema( # noqa: PLR0912, PLR0914, PLR0915
801802
if "$defs" in schema:
802803
schema_defs = cast("dict[str, object]", schema["$defs"])
803804
for k, v in schema_defs.items():
804-
if k not in merged_defs:
805+
new_is_base = isinstance(v, dict) and v.get("x-is-base-class")
806+
existing = merged_defs.get(k)
807+
existing_is_base = isinstance(existing, dict) and existing.get("x-is-base-class") if existing else False
808+
if k not in merged_defs or (new_is_base and not existing_is_base):
805809
merged_defs[k] = v
806810

807811
model_def = {k: v for k, v in schema.items() if k != "$defs"}

src/datamodel_code_generator/model/template/TypedDictFunction.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@
2020
{% endif %}
2121
{%- endif %}
2222
{%- endfor -%}
23-
})
23+
}{{ typed_dict_kwargs_suffix|default('') }})
2424

src/datamodel_code_generator/model/typed_dict.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
IMPORT_READ_ONLY,
1717
IMPORT_READ_ONLY_BACKPORT,
1818
IMPORT_TYPED_DICT,
19+
IMPORT_TYPED_DICT_BACKPORT,
1920
)
2021
from datamodel_code_generator.types import NOT_REQUIRED_PREFIX, READ_ONLY_PREFIX
2122

@@ -88,6 +89,68 @@ def __init__( # noqa: PLR0913
8889
keyword_only=keyword_only,
8990
treat_dot_as_module=treat_dot_as_module,
9091
)
92+
self._setup_closed_extra_items()
93+
94+
def _setup_closed_extra_items(self) -> None:
95+
"""Set up closed and extra_items kwargs based on additionalProperties.
96+
97+
For PEP 728 TypedDict support:
98+
- additionalProperties: false -> closed=True
99+
- additionalProperties: { type: X } -> extra_items=X
100+
101+
Note: closed=True is not applied to TypedDicts used as base classes,
102+
as PEP 728 doesn't allow child TypedDicts to add new fields when
103+
parent has closed=True.
104+
"""
105+
additional_props = self.extra_template_data.get("additionalProperties")
106+
additional_props_type = self.extra_template_data.get("additionalPropertiesType")
107+
is_base_class = self.extra_template_data.get("is_base_class", False)
108+
109+
typed_dict_kwargs: dict[str, str] = {}
110+
111+
if additional_props is False and not is_base_class:
112+
typed_dict_kwargs["closed"] = "True"
113+
elif additional_props_type and not is_base_class:
114+
typed_dict_kwargs["extra_items"] = additional_props_type
115+
116+
if typed_dict_kwargs:
117+
self.extra_template_data["typed_dict_kwargs"] = typed_dict_kwargs
118+
kwargs_str = ", ".join(f"{k}={v}" for k, v in typed_dict_kwargs.items())
119+
self.extra_template_data["typed_dict_kwargs_suffix"] = f", {kwargs_str}"
120+
121+
@property
122+
def _has_typed_dict_kwargs(self) -> bool:
123+
"""Check if this TypedDict has closed or extra_items kwargs."""
124+
return bool(self.extra_template_data.get("typed_dict_kwargs"))
125+
126+
@property
127+
def _use_typeddict_backport(self) -> bool:
128+
"""Check if this TypedDict needs typing_extensions.TypedDict for closed/extra_items."""
129+
return bool(self.extra_template_data.get("use_typeddict_backport"))
130+
131+
@property
132+
def base_class(self) -> str:
133+
"""Get base class string with kwargs if needed.
134+
135+
For PEP 728 support, includes closed=True or extra_items=X in the base class.
136+
"""
137+
base = super().base_class
138+
typed_dict_kwargs = self.extra_template_data.get("typed_dict_kwargs")
139+
if typed_dict_kwargs:
140+
kwargs_str = ", ".join(f"{k}={v}" for k, v in typed_dict_kwargs.items())
141+
return f"{base}, {kwargs_str}"
142+
return base
143+
144+
@property
145+
def imports(self) -> tuple[Import, ...]:
146+
"""Get imports, using backport TypedDict when closed/extra_items are used on Python < 3.13."""
147+
base_imports = list(super().imports)
148+
149+
if self._use_typeddict_backport and self._has_typed_dict_kwargs:
150+
base_imports = [i for i in base_imports if i != IMPORT_TYPED_DICT]
151+
base_imports.append(IMPORT_TYPED_DICT_BACKPORT)
152+
153+
return tuple(base_imports)
91154

92155
@property
93156
def is_functional_syntax(self) -> bool:

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,9 +1136,22 @@ def get_ref_data_type(self, ref: str) -> DataType:
11361136
return self.data_type(reference=reference, is_optional=is_optional)
11371137

11381138
def set_additional_properties(self, path: str, obj: JsonSchemaObject) -> None:
1139-
"""Set additional properties flag in extra template data."""
1139+
"""Set additional properties flag in extra template data.
1140+
1141+
For TypedDict with PEP 728 support:
1142+
- additionalProperties: false -> closed=True
1143+
- additionalProperties: { type: X } -> extra_items=X
1144+
"""
11401145
if isinstance(obj.additionalProperties, bool):
11411146
self.extra_template_data[path]["additionalProperties"] = obj.additionalProperties
1147+
if obj.additionalProperties is False and not self.target_python_version.has_typed_dict_closed:
1148+
self.extra_template_data[path]["use_typeddict_backport"] = True
1149+
elif isinstance(obj.additionalProperties, JsonSchemaObject):
1150+
additional_props_type = self._build_lightweight_type(obj.additionalProperties)
1151+
if additional_props_type: # pragma: no branch
1152+
self.extra_template_data[path]["additionalPropertiesType"] = additional_props_type.type_hint
1153+
if not self.target_python_version.has_typed_dict_closed: # pragma: no branch
1154+
self.extra_template_data[path]["use_typeddict_backport"] = True
11421155

11431156
def set_unevaluated_properties(self, path: str, obj: JsonSchemaObject) -> None:
11441157
"""Set unevaluated properties flag in extra template data."""
@@ -1168,6 +1181,9 @@ def set_schema_extensions(self, path: str, obj: JsonSchemaObject) -> None:
11681181
if extensions:
11691182
self.extra_template_data[path]["extensions"] = extensions
11701183

1184+
if obj.extras.get("x-is-base-class"):
1185+
self.extra_template_data[path]["is_base_class"] = True
1186+
11711187
# Process model_extra_keys for json_schema_extra in ConfigDict
11721188
if self.model_extra_keys or self.model_extra_keys_without_x_prefix:
11731189
model_extras: dict[str, Any] = {}
@@ -2167,6 +2183,7 @@ def _parse_all_of_item( # noqa: PLR0912, PLR0913, PLR0915, PLR0917
21672183
ref = self.model_resolver.add_ref(all_of_item.ref)
21682184
if ref.path not in {b.path for b in base_classes}:
21692185
base_classes.append(ref)
2186+
self.extra_template_data[ref.path]["is_base_class"] = True
21702187
else:
21712188
# Merge child properties with parent constraints before processing
21722189
merged_item = self._merge_properties_with_parent_constraints(all_of_item, parent_refs)

tests/data/expected/main/input_model/config_class.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from datamodel_code_generator.enums import StrictTypes
1212
from datamodel_code_generator.validators import ModelValidators
13-
from typing_extensions import NotRequired
13+
from typing_extensions import NotRequired, TypedDict
1414

1515
AllExportsCollisionStrategy: TypeAlias = Literal[
1616
'error', 'minimal-prefix', 'full-prefix'
@@ -42,7 +42,7 @@
4242
]
4343

4444

45-
class DataclassArguments(TypedDict):
45+
class DataclassArguments(TypedDict, closed=True):
4646
init: NotRequired[bool]
4747
repr: NotRequired[bool]
4848
eq: NotRequired[bool]
@@ -114,7 +114,7 @@ class DataclassArguments(TypedDict):
114114
ValidatorMode: TypeAlias = Literal['before', 'after', 'wrap', 'plain']
115115

116116

117-
class GenerateConfig(TypedDict):
117+
class GenerateConfig(TypedDict, closed=True):
118118
input_filename: NotRequired[str | None]
119119
input_file_type: NotRequired[InputFileType]
120120
output: NotRequired[str | None]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# generated by datamodel-codegen:
2+
# filename: typed_dict_closed.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing_extensions import NotRequired, TypedDict
8+
9+
10+
class ClosedModel(TypedDict, closed=True):
11+
name: str
12+
age: NotRequired[int]

0 commit comments

Comments
 (0)