Skip to content

Commit b8b4039

Browse files
committed
Fix pydantic typed extra runtime compatibility
1 parent 1828eff commit b8b4039

9 files changed

Lines changed: 270 additions & 38 deletions

.github/workflows/test.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ jobs:
9797
- tox_env: pydantic25
9898
name: pydantic25
9999
coverage_name: pydantic25
100+
- tox_env: pydantic213
101+
name: pydantic213
102+
coverage_name: pydantic213
100103
runs-on: ubuntu-24.04
101104
env:
102105
OS: ubuntu-24.04

src/datamodel_code_generator/parser/base.py

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,10 +1426,7 @@ def __init__( # noqa: PLR0912, PLR0915
14261426
self.extra_template_data: defaultdict[str, Any] = config.extra_template_data or defaultdict(dict)
14271427
self.validators = config.validators
14281428
self.generate_schema_validators: bool = config.generate_schema_validators
1429-
if typed_extra_plain_annotation_key := self.data_model_type.TYPED_EXTRA_PLAIN_ANNOTATION_TEMPLATE_DATA_KEY:
1430-
self.extra_template_data.setdefault(ALL_MODEL, {})[typed_extra_plain_annotation_key] = (
1431-
config.target_python_version.has_native_deferred_annotations
1432-
)
1429+
self._set_typed_extra_annotation_mode(use_deferred_annotations=True)
14331430

14341431
if self.validators:
14351432
for model_name, model_config in self.validators.items():
@@ -2988,19 +2985,20 @@ def __update_type_aliases(
29882985
)
29892986
# When annotations are deferred (from __future__ or native PEP-649) only
29902987
# TypeAliasBase / RootModel need quoting; regular DataModels are fine as-is.
2991-
# Class-body __annotations__ dicts are an exception: their right hand side is
2992-
# evaluated immediately, so typed extra references still need forward refs.
2988+
# Typed extra annotations are an exception: class-body __annotations__ dicts
2989+
# are evaluated immediately, and Pydantic can force native deferred annotations
2990+
# while constructing the class, so their forward references must stay quoted.
29932991
process_all_fields = is_type_alias_or_root or not use_deferred_annotations
29942992
if not process_all_fields and not any(
2995-
getattr(field, "use_pydantic_extra_annotations_dict", False) for field in model.fields
2993+
getattr(field, "is_pydantic_extra_field", False) for field in model.fields
29962994
):
29972995
continue
29982996
if isinstance(model, TypeStatement):
29992997
continue
30002998

3001-
has_forward_ref = False
2999+
has_aliased_forward_ref = False
30023000
for field in model.fields:
3003-
if not process_all_fields and not getattr(field, "use_pydantic_extra_annotations_dict", False):
3001+
if not process_all_fields and not getattr(field, "is_pydantic_extra_field", False):
30043002
continue
30053003
for data_type in field.data_type.all_data_types:
30063004
if not data_type.reference:
@@ -3017,10 +3015,10 @@ def __update_type_aliases(
30173015
if source_index is not None and source_index >= i:
30183016
data_type.alias = f'"{name}"'
30193017
cls.__disable_union_operator_for_forward_ref_parents(data_type)
3020-
has_forward_ref = True
3018+
has_aliased_forward_ref = True
30213019

3022-
if has_forward_ref:
3023-
model.has_forward_reference = True
3020+
if has_aliased_forward_ref:
3021+
model.has_forward_reference = model.has_forward_reference or process_all_fields
30243022
_clear_model_imports_cache_if_retained(model, can_retain_cache=can_retain_cache)
30253023

30263024
@classmethod
@@ -3648,6 +3646,32 @@ def __get_resolve_reference_action_parts(
36483646
),
36493647
]
36503648

3649+
def _uses_deferred_annotations(
3650+
self,
3651+
with_import: bool | None, # noqa: FBT001
3652+
disable_future_imports: bool, # noqa: FBT001
3653+
) -> bool:
3654+
"""Return whether generated annotations use deferred evaluation."""
3655+
return bool(
3656+
self.target_python_version.has_native_deferred_annotations or (with_import and not disable_future_imports)
3657+
)
3658+
3659+
def _set_typed_extra_annotation_mode(self, *, use_deferred_annotations: bool) -> None:
3660+
"""Select the safe typed-extra annotation form for the generated runtime."""
3661+
if not (key := self.data_model_type.TYPED_EXTRA_PLAIN_ANNOTATION_TEMPLATE_DATA_KEY):
3662+
return
3663+
3664+
native_deferred_annotations = self.target_python_version.has_native_deferred_annotations
3665+
match native_deferred_annotations:
3666+
case True:
3667+
use_plain_annotation = True
3668+
case False if use_deferred_annotations:
3669+
use_plain_annotation = False
3670+
case _:
3671+
use_plain_annotation = True
3672+
3673+
self.extra_template_data.setdefault(ALL_MODEL, {})[key] = use_plain_annotation
3674+
36513675
def _prepare_parse_config(
36523676
self,
36533677
with_import: bool | None, # noqa: FBT001
@@ -3657,9 +3681,7 @@ def _prepare_parse_config(
36573681
module_split_mode: ModuleSplitMode | None,
36583682
) -> ParseConfig:
36593683
"""Prepare configuration for the parse operation."""
3660-
use_deferred_annotations = bool(
3661-
self.target_python_version.has_native_deferred_annotations or (with_import and not disable_future_imports)
3662-
)
3684+
use_deferred_annotations = self._uses_deferred_annotations(with_import, disable_future_imports)
36633685

36643686
if (
36653687
with_import
@@ -4058,6 +4080,9 @@ def parse( # noqa: PLR0912, PLR0913, PLR0914, PLR0917
40584080
collect_model_metadata: bool = False, # noqa: FBT001, FBT002
40594081
) -> str | dict[tuple[str, ...], Result]:
40604082
"""Parse schema and generate code, returning single file or module dict."""
4083+
self._set_typed_extra_annotation_mode(
4084+
use_deferred_annotations=self._uses_deferred_annotations(with_import, disable_future_imports)
4085+
)
40614086
self.parse_raw()
40624087

40634088
config = self._prepare_parse_config(
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# generated by datamodel-codegen:
2+
# filename: additional_properties_nullable_self_ref.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Dict, Optional
8+
9+
from pydantic import BaseModel, ConfigDict
10+
11+
12+
class Node(BaseModel):
13+
model_config = ConfigDict(
14+
extra='allow',
15+
)
16+
__annotations__ = {
17+
'__pydantic_extra__': Dict[str, Optional["Node"]],
18+
}
19+
name: str
20+
21+
22+
Node.model_rebuild()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# generated by datamodel-codegen:
2+
# filename: additional_properties_schema_with_properties.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from pydantic import BaseModel, ConfigDict
6+
7+
8+
class KnownAndExtra(BaseModel):
9+
model_config = ConfigDict(
10+
extra='allow',
11+
)
12+
__pydantic_extra__: dict[str, int]
13+
name: str
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# generated by datamodel-codegen:
2+
# filename: additional_properties_self_ref.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from pydantic import BaseModel, ConfigDict
6+
7+
8+
class Node(BaseModel):
9+
model_config = ConfigDict(
10+
extra='allow',
11+
)
12+
__pydantic_extra__: dict[str, "Node"]
13+
name: str
14+
15+
16+
Node.model_rebuild()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# generated by datamodel-codegen:
2+
# filename: additional_properties_self_ref.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Dict
8+
9+
from pydantic import BaseModel, ConfigDict
10+
11+
12+
class Node(BaseModel):
13+
model_config = ConfigDict(
14+
extra='allow',
15+
)
16+
__annotations__ = {
17+
'__pydantic_extra__': Dict[str, "Node"],
18+
}
19+
name: str | None = None
20+
21+
22+
Node.model_rebuild()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Node",
4+
"type": "object",
5+
"properties": {
6+
"name": {
7+
"type": "string"
8+
}
9+
},
10+
"required": [
11+
"name"
12+
],
13+
"additionalProperties": {
14+
"anyOf": [
15+
{
16+
"$ref": "#"
17+
},
18+
{
19+
"type": "null"
20+
}
21+
]
22+
}
23+
}

tests/main/jsonschema/test_main_jsonschema.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6397,6 +6397,122 @@ def test_main_jsonschema_additional_properties_self_ref(output_file: Path) -> No
63976397
)
63986398

63996399

6400+
@BLACK_PY314_SKIP
6401+
def test_main_jsonschema_additional_properties_self_ref_target_python_314(output_file: Path) -> None:
6402+
"""Test Python 3.14 typed extras keep self-references safe at runtime."""
6403+
run_main_and_assert(
6404+
input_path=JSON_SCHEMA_DATA_PATH / "additional_properties_self_ref.json",
6405+
output_path=output_file,
6406+
input_file_type="jsonschema",
6407+
assert_func=assert_file_content,
6408+
expected_file="additional_properties_self_ref_py314.py",
6409+
extra_args=[
6410+
"--output-model-type",
6411+
"pydantic_v2.BaseModel",
6412+
"--target-python-version",
6413+
"3.14",
6414+
],
6415+
)
6416+
assert_generated_model_json_validation(
6417+
output_file,
6418+
module_name="additional_properties_self_ref_py314",
6419+
model_name="Node",
6420+
valid_json='{"name":"root","child":{"name":"leaf"}}',
6421+
invalid_json='{"name":"root","child":{"name":"leaf","bad":1}}',
6422+
expected_error_type="model_type",
6423+
expected_attribute_path=("__pydantic_extra__", "child", "name"),
6424+
expected_attribute_value="leaf",
6425+
)
6426+
6427+
6428+
def test_main_jsonschema_additional_properties_scalar_no_future_imports_target_python_313(
6429+
output_file: Path,
6430+
) -> None:
6431+
"""Test typed extras do not hide ordinary fields without deferred annotations."""
6432+
run_main_and_assert(
6433+
input_path=JSON_SCHEMA_DATA_PATH / "additional_properties_schema_with_properties.json",
6434+
output_path=output_file,
6435+
input_file_type="jsonschema",
6436+
assert_func=assert_file_content,
6437+
expected_file="additional_properties_schema_with_properties_py313_no_future_imports.py",
6438+
extra_args=[
6439+
"--output-model-type",
6440+
"pydantic_v2.BaseModel",
6441+
"--target-python-version",
6442+
"3.13",
6443+
"--disable-future-imports",
6444+
],
6445+
force_exec_validation=True,
6446+
)
6447+
assert_generated_model_json_validation(
6448+
output_file,
6449+
module_name="additional_properties_schema_with_properties_py313_no_future_imports",
6450+
model_name="KnownAndExtra",
6451+
valid_json='{"name":"known","size":1}',
6452+
invalid_json='{"name":"known","size":[]}',
6453+
expected_error_type="int_type",
6454+
expected_attribute_path=("__pydantic_extra__",),
6455+
expected_attribute_value={"size": 1},
6456+
)
6457+
6458+
6459+
def test_main_jsonschema_additional_properties_self_ref_use_union_operator_force_optional(
6460+
output_file: Path,
6461+
) -> None:
6462+
"""Test typed self-references do not rewrite unrelated PEP 604 annotations."""
6463+
run_main_and_assert(
6464+
input_path=JSON_SCHEMA_DATA_PATH / "additional_properties_self_ref.json",
6465+
output_path=output_file,
6466+
input_file_type="jsonschema",
6467+
assert_func=assert_file_content,
6468+
expected_file="additional_properties_self_ref_union_operator_force_optional.py",
6469+
extra_args=[
6470+
"--output-model-type",
6471+
"pydantic_v2.BaseModel",
6472+
"--use-union-operator",
6473+
"--force-optional",
6474+
],
6475+
force_exec_validation=True,
6476+
)
6477+
assert_generated_model_json_validation(
6478+
output_file,
6479+
module_name="additional_properties_self_ref_union_operator_force_optional",
6480+
model_name="Node",
6481+
valid_json='{"name":"root","child":{"name":"leaf"}}',
6482+
invalid_json='{"name":"root","child":{"name":"leaf","bad":1}}',
6483+
expected_error_type="model_type",
6484+
expected_attribute_path=("__pydantic_extra__", "child", "name"),
6485+
expected_attribute_value="leaf",
6486+
)
6487+
6488+
6489+
def test_main_jsonschema_additional_properties_nullable_self_ref_use_union_operator(output_file: Path) -> None:
6490+
"""Test typed-extra forward refs invalidate retained import caches."""
6491+
run_main_and_assert(
6492+
input_path=JSON_SCHEMA_DATA_PATH / "additional_properties_nullable_self_ref.json",
6493+
output_path=output_file,
6494+
input_file_type="jsonschema",
6495+
assert_func=assert_file_content,
6496+
expected_file="additional_properties_nullable_self_ref_union_operator.py",
6497+
extra_args=[
6498+
"--output-model-type",
6499+
"pydantic_v2.BaseModel",
6500+
"--use-union-operator",
6501+
],
6502+
force_exec_validation=True,
6503+
)
6504+
assert_generated_model_json_validation(
6505+
output_file,
6506+
module_name="additional_properties_nullable_self_ref_union_operator",
6507+
model_name="Node",
6508+
valid_json='{"name":"root","child":{"name":"leaf"},"empty":null}',
6509+
invalid_json='{"name":"root","child":{"name":"leaf","bad":1}}',
6510+
expected_error_type="model_type",
6511+
expected_attribute_path=("__pydantic_extra__", "child", "name"),
6512+
expected_attribute_value="leaf",
6513+
)
6514+
6515+
64006516
def test_main_jsonschema_property_names_type_non_string(output_file: Path) -> None:
64016517
"""Test non-string propertyNames type rejects every JSON object key."""
64026518
run_main_and_assert(

0 commit comments

Comments
 (0)