|
| 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 |
0 commit comments