Skip to content

Commit 4b2eecb

Browse files
committed
Fix legacy pydantic extra templates
1 parent a425913 commit 4b2eecb

10 files changed

Lines changed: 430 additions & 2 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Compatibility handling for custom Jinja templates."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from jinja2 import FileSystemLoader, nodes
8+
9+
from datamodel_code_generator import Error
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Callable
13+
from typing import Any
14+
15+
from jinja2 import Environment
16+
17+
_LEGACY_PYDANTIC_EXTRA_ATTRIBUTE_NAME = "use_pydantic_extra_annotation_assignment"
18+
_LEGACY_PYDANTIC_EXTRA_ATTRIBUTE = f"field.{_LEGACY_PYDANTIC_EXTRA_ATTRIBUTE_NAME}"
19+
_LEGACY_PYDANTIC_EXTRA_ATTRIBUTE_COUNT = 3
20+
_PYDANTIC_EXTRA_ANNOTATIONS_ATTRIBUTE_NAME = "use_pydantic_extra_annotations_dict"
21+
_PYDANTIC_EXTRA_ANNOTATIONS_ATTRIBUTE = f"field.{_PYDANTIC_EXTRA_ANNOTATIONS_ATTRIBUTE_NAME}"
22+
_PYDANTIC_V2_BASE_MODEL_TEMPLATE = "BaseModel.jinja2"
23+
_CLASS_BODY_LINES_BLOCK = """{%- for line in class_body_lines %}
24+
{{ line }}
25+
{%- endfor %}"""
26+
_PYDANTIC_EXTRA_ANNOTATIONS_BLOCK = """{%- for field in fields %}
27+
{%- if field.use_pydantic_extra_annotations_dict %}
28+
if (__annotations__ := locals().get('__annotations__')) is None:
29+
__annotations__ = {}
30+
__annotations__['__pydantic_extra__'] = {{ field.pydantic_extra_type_hint }}
31+
{%- endif %}
32+
{%- endfor %}
33+
"""
34+
_LEGACY_PYDANTIC_EXTRA_FIELD_IF = (
35+
"{%- if not field.use_pydantic_extra_annotation_assignment and not field.annotated and field.field %}"
36+
)
37+
_PYDANTIC_EXTRA_FIELD_IF = (
38+
"{%- if not field.use_pydantic_extra_annotations_dict and not field.annotated and field.field %}"
39+
)
40+
_LEGACY_PYDANTIC_EXTRA_FIELD_ELIF = "{%- elif not field.use_pydantic_extra_annotation_assignment %}"
41+
_PYDANTIC_EXTRA_FIELD_ELIF = "{%- elif not field.use_pydantic_extra_annotations_dict %}"
42+
_LEGACY_PYDANTIC_EXTRA_ASSIGNMENT_BLOCK = """
43+
{%- for field in fields %}
44+
{%- if field.use_pydantic_extra_annotation_assignment %}
45+
46+
{{ class_name }}.__annotations__['__pydantic_extra__'] = {{ field.pydantic_extra_type_hint }}
47+
{{ class_name }}.model_rebuild(force=True)
48+
{%- endif %}
49+
{%- endfor %}"""
50+
51+
52+
def _legacy_template_error(filename: str) -> Error:
53+
return Error(
54+
"The custom Pydantic v2 BaseModel template uses the removed "
55+
"'use_pydantic_extra_annotation_assignment' property, but its typed-extra blocks do not match "
56+
f"the supported legacy template: {filename}. Copy the current pydantic_v2/BaseModel.jinja2 "
57+
"template and reapply the custom changes."
58+
)
59+
60+
61+
def _upgrade_legacy_pydantic_extra_template(environment: Environment, source: str, filename: str) -> str:
62+
"""Upgrade the pre-0.68.1 typed-extra blocks without changing other custom content."""
63+
if not (legacy_attribute_count := source.count(_LEGACY_PYDANTIC_EXTRA_ATTRIBUTE)):
64+
return source
65+
66+
replacements = (
67+
(
68+
_CLASS_BODY_LINES_BLOCK,
69+
f"{_PYDANTIC_EXTRA_ANNOTATIONS_BLOCK}{_CLASS_BODY_LINES_BLOCK}",
70+
),
71+
(_LEGACY_PYDANTIC_EXTRA_FIELD_IF, _PYDANTIC_EXTRA_FIELD_IF),
72+
(_LEGACY_PYDANTIC_EXTRA_FIELD_ELIF, _PYDANTIC_EXTRA_FIELD_ELIF),
73+
(_LEGACY_PYDANTIC_EXTRA_ASSIGNMENT_BLOCK, ""),
74+
)
75+
match tuple(source.count(old) for old, _ in replacements):
76+
case (1, 1, 1, 1):
77+
positions = tuple(source.index(old) for old, _ in replacements)
78+
case _:
79+
positions = ()
80+
81+
positions_are_ordered = bool(positions and positions == tuple(sorted(positions)))
82+
if (
83+
legacy_attribute_count != _LEGACY_PYDANTIC_EXTRA_ATTRIBUTE_COUNT
84+
or source.count(_PYDANTIC_EXTRA_ANNOTATIONS_ATTRIBUTE)
85+
or not positions_are_ordered
86+
):
87+
parsed = environment.parse(source)
88+
active_legacy_attributes = active_new_attributes = 0
89+
for node in parsed.find_all(nodes.Getattr):
90+
if not isinstance(node.node, nodes.Name) or node.node.name != "field":
91+
continue
92+
match node.attr:
93+
case attribute if attribute == _LEGACY_PYDANTIC_EXTRA_ATTRIBUTE_NAME:
94+
active_legacy_attributes += 1
95+
case attribute if attribute == _PYDANTIC_EXTRA_ANNOTATIONS_ATTRIBUTE_NAME:
96+
active_new_attributes += 1
97+
if not active_legacy_attributes:
98+
return source
99+
if (
100+
active_legacy_attributes != _LEGACY_PYDANTIC_EXTRA_ATTRIBUTE_COUNT
101+
or active_new_attributes
102+
or not positions_are_ordered
103+
):
104+
raise _legacy_template_error(filename)
105+
106+
for old, new in replacements:
107+
source = source.replace(old, new, 1)
108+
return source
109+
110+
111+
class CustomTemplateFileSystemLoader(FileSystemLoader):
112+
"""Load custom templates with narrowly scoped legacy compatibility."""
113+
114+
def get_source(
115+
self,
116+
environment: Environment,
117+
template: str,
118+
) -> tuple[str, str, Callable[..., Any]]:
119+
source, filename, is_up_to_date = super().get_source(environment, template)
120+
if template != _PYDANTIC_V2_BASE_MODEL_TEMPLATE:
121+
return source, filename, is_up_to_date
122+
if (upgraded_source := _upgrade_legacy_pydantic_extra_template(environment, source, filename)) is source:
123+
return source, filename, is_up_to_date
124+
return upgraded_source, filename, is_up_to_date

src/datamodel_code_generator/model/base.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
})
5757
_MODULE_NAME_INVALID_CHAR_PATTERN = re.compile(r"[^0-9a-zA-Z_]")
5858
_MODULE_NAME_INVALID_CHAR_WITH_DOTS_PATTERN = re.compile(r"[^0-9a-zA-Z_.]")
59+
_PYDANTIC_V2_TEMPLATE_SUBDIR = "pydantic_v2"
5960

6061

6162
@dataclass(frozen=True, slots=True)
@@ -991,7 +992,14 @@ def _get_environment(template_subdir: Path, custom_template_dir: Path | None) ->
991992
if custom_template_dir is not None:
992993
custom_dir = custom_template_dir / template_subdir
993994
if cached_path_exists(custom_dir):
994-
loaders.append(FileSystemLoader(str(custom_dir)))
995+
if template_subdir.as_posix() == _PYDANTIC_V2_TEMPLATE_SUBDIR:
996+
from datamodel_code_generator.model._custom_template import ( # noqa: PLC0415
997+
CustomTemplateFileSystemLoader,
998+
)
999+
1000+
loaders.append(CustomTemplateFileSystemLoader(str(custom_dir)))
1001+
else:
1002+
loaders.append(FileSystemLoader(str(custom_dir)))
9951003
has_custom_loader = True
9961004

9971005
loaders.append(FileSystemLoader(str(TEMPLATE_DIR / template_subdir)))
@@ -1021,8 +1029,18 @@ def _get_environment_with_absolute_path(absolute_template_dir: Path, builtin_sub
10211029
"""Get or create a cached Jinja2 Environment for absolute path templates."""
10221030
from jinja2 import ChoiceLoader, FileSystemLoader # noqa: PLC0415
10231031

1032+
custom_loader: FileSystemLoader
1033+
if builtin_subdir.as_posix() == _PYDANTIC_V2_TEMPLATE_SUBDIR:
1034+
from datamodel_code_generator.model._custom_template import ( # noqa: PLC0415
1035+
CustomTemplateFileSystemLoader,
1036+
)
1037+
1038+
custom_loader = CustomTemplateFileSystemLoader(str(absolute_template_dir))
1039+
else:
1040+
custom_loader = FileSystemLoader(str(absolute_template_dir))
1041+
10241042
loaders: list[FileSystemLoader] = [
1025-
FileSystemLoader(str(absolute_template_dir)),
1043+
custom_loader,
10261044
FileSystemLoader(str(TEMPLATE_DIR / builtin_subdir)),
10271045
]
10281046
return _build_environment(ChoiceLoader(loaders))
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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 __future__ import annotations
6+
7+
from typing import Dict
8+
9+
from pydantic import BaseModel, ConfigDict
10+
11+
12+
class KnownAndExtra(BaseModel):
13+
model_config = ConfigDict(
14+
extra='allow',
15+
)
16+
if (__annotations__ := locals().get('__annotations__')) is None:
17+
__annotations__ = {}
18+
__annotations__['__pydantic_extra__'] = Dict[str, int]
19+
name: str
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+
if (__annotations__ := locals().get('__annotations__')) is None:
17+
__annotations__ = {}
18+
__annotations__['__pydantic_extra__'] = Dict[str, "Node"]
19+
name: str
20+
21+
22+
Node.model_rebuild()

tests/data/templates_extensions/pydantic_v2/BaseModel.jinja2

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ an alias for Bar: every pydantic model class consumes considerable memory. #}
66

77
{% else -%}
88

9+
{# field.use_pydantic_extra_annotation_assignment is intentionally only a comment. #}
910
{% for decorator in decorators -%}
1011
{{ decorator }}
1112
{% endfor -%}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{% for decorator in decorators -%}
2+
{{ decorator }}
3+
{% endfor -%}
4+
class {{ class_name }}({%- if schema_runtime_validation_use_base -%}{{ schema_runtime_validation_base_class_name }}{% if base_class != 'BaseModel' %}, {{ base_class }}{% endif %}{%- else -%}{{ base_class }}{%- endif -%}):{% if comment is defined %} # {{ comment }}{% endif %}
5+
{%- if description %}
6+
{{ description }}
7+
{%- endif %}
8+
{%- if not fields and not description and not config and not class_body_lines and not schema_runtime_validation %}
9+
pass
10+
{%- endif %}
11+
{%- if config %}
12+
{%- filter indent(4) %}
13+
{% include 'ConfigDict.jinja2' %}
14+
{%- endfilter %}
15+
{%- endif %}
16+
{%- for line in class_body_lines %}
17+
{{ line }}
18+
{%- endfor %}
19+
{%- include 'schema_runtime_validation.jinja2' %}
20+
{%- for field in fields %}
21+
{%- if not field.use_pydantic_extra_annotation_assignment and not field.annotated and field.field %}
22+
{{ field.name }}: {{ field.type_hint }} = {{ field.field }}
23+
{%- elif not field.use_pydantic_extra_annotation_assignment %}
24+
{%- if field.annotated %}
25+
{{ field.name }}: {{ field.annotated }}
26+
{%- else %}
27+
{{ field.name }}: {{ field.type_hint }}
28+
{%- endif %}
29+
{%- if not field.has_default_factory_in_field and (not field.required or field.use_default_with_required) and (field.represented_default != 'None' or not field.strip_default_none or field.data_type.is_optional)
30+
%} = {{ field.represented_default }}
31+
{%- endif -%}
32+
{%- endif %}
33+
{%- if field.docstring %}
34+
{{ field.docstring }}
35+
{%- if field.use_inline_field_description and not loop.last %}
36+
37+
{% endif %}
38+
{%- elif field.inline_field_docstring %}
39+
{{ field.inline_field_docstring }}
40+
{%- if not loop.last %}
41+
42+
{% endif %}
43+
{%- endif %}
44+
{%- for method in methods -%}
45+
{{ method }}
46+
{%- endfor -%}
47+
{%- endfor -%}
48+
{%- if prepared_validators %}
49+
{% for v in prepared_validators %}
50+
51+
@field_validator({{ v.fields_str }}, {{ v.mode_str }})
52+
@classmethod
53+
{%- if v.mode == 'wrap' %}
54+
def {{ v.method_name }}(cls, v: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo) -> Any:
55+
return {{ v.function_name }}(v, handler, info)
56+
{%- elif v.mode == 'plain' %}
57+
def {{ v.method_name }}(cls, v: Any) -> Any:
58+
return {{ v.function_name }}(v)
59+
{%- else %}
60+
def {{ v.method_name }}(cls, v: Any, info: ValidationInfo) -> Any:
61+
return {{ v.function_name }}(v, info)
62+
{%- endif %}
63+
{%- endfor %}
64+
{%- endif %}
65+
{%- for field in fields %}
66+
{%- if field.use_pydantic_extra_annotation_assignment %}
67+
68+
{{ class_name }}.__annotations__['__pydantic_extra__'] = {{ field.pydantic_extra_type_hint }}
69+
{{ class_name }}.model_rebuild(force=True)
70+
{%- endif %}
71+
{%- endfor %}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{ field.use_pydantic_extra_annotation_assignment }}

0 commit comments

Comments
 (0)