diff --git a/src/datamodel_code_generator/model/_types.py b/src/datamodel_code_generator/model/_types.py index e2b96fecf..0ca60c613 100644 --- a/src/datamodel_code_generator/model/_types.py +++ b/src/datamodel_code_generator/model/_types.py @@ -16,3 +16,15 @@ class WrappedDefault: def __repr__(self) -> str: """Return type constructor representation, e.g., 'CountType(10)'.""" return f"{self.type_name}({self.value!r})" + + +@dataclass(repr=False) +class ValidatedDefault: + """Represents a structured default validated through the field type.""" + + value: Any + type_name: str + + def __repr__(self) -> str: + """Return TypeAdapter validation representation for structured defaults.""" + return f"TypeAdapter({self.type_name}).validate_python({self.value!r})" diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index 905842de2..5c5a90e21 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -25,7 +25,7 @@ IMPORT_UNION, Import, ) -from datamodel_code_generator.model._types import WrappedDefault +from datamodel_code_generator.model._types import ValidatedDefault, WrappedDefault from datamodel_code_generator.reference import Reference, _BaseModel from datamodel_code_generator.types import ( ANY, @@ -38,7 +38,7 @@ get_optional_type, ) -__all__ = ["WrappedDefault"] +__all__ = ["ValidatedDefault", "WrappedDefault"] if TYPE_CHECKING: from collections.abc import Iterator @@ -599,6 +599,7 @@ class DataModel(TemplateBase, Nullable, ABC): # noqa: PLR0904 SUPPORTS_DISCRIMINATOR: ClassVar[bool] = False SUPPORTS_FIELD_RENAMING: ClassVar[bool] = False SUPPORTS_WRAPPED_DEFAULT: ClassVar[bool] = False + SUPPORTS_VALIDATED_DEFAULT: ClassVar[bool] = False SUPPORTS_KW_ONLY: ClassVar[bool] = False has_forward_reference: bool = False diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index a34c95f7d..fd7e855ca 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -19,7 +19,7 @@ DataModel, DataModelFieldBase, ) -from datamodel_code_generator.model._types import WrappedDefault +from datamodel_code_generator.model._types import ValidatedDefault, WrappedDefault from datamodel_code_generator.model.base import UNDEFINED, repr_set_sorted from datamodel_code_generator.types import STANDARD_LIST, UnionIntFloat, chain_as_tuple @@ -27,6 +27,7 @@ # (pydantic_base -> pydantic_v2.imports -> pydantic_v2/__init__ -> pydantic_v2.base_model -> pydantic_base) IMPORT_ANYURL = Import.from_full_path("pydantic.AnyUrl") IMPORT_FIELD = Import.from_full_path("pydantic.Field") +IMPORT_TYPE_ADAPTER = Import.from_full_path("pydantic.TypeAdapter") if TYPE_CHECKING: from collections import defaultdict @@ -126,7 +127,7 @@ def _get_strict_field_constraint_value(self, constraint: str, value: Any) -> Any return int(value) def _get_default_as_pydantic_model(self) -> str | None: # noqa: PLR0911, PLR0912 - if isinstance(self.default, WrappedDefault): + if isinstance(self.default, (ValidatedDefault, WrappedDefault)): return f"lambda :{self.default!r}" if self.data_type.is_list and len(self.data_type.data_types) == 1: data_type_child = self.data_type.data_types[0] @@ -296,8 +297,12 @@ def annotated(self) -> str | None: @property def imports(self) -> tuple[Import, ...]: """Get all required imports including Field if needed.""" - if self.field: - return chain_as_tuple(super().imports, (IMPORT_FIELD,)) + field = self.field + if field: + extra_imports = [IMPORT_FIELD] + if isinstance(self.default, ValidatedDefault): + extra_imports.append(IMPORT_TYPE_ADAPTER) + return chain_as_tuple(super().imports, extra_imports) return super().imports diff --git a/src/datamodel_code_generator/model/pydantic_v2/base_model.py b/src/datamodel_code_generator/model/pydantic_v2/base_model.py index 3fb3078d3..45ae53a2d 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/base_model.py +++ b/src/datamodel_code_generator/model/pydantic_v2/base_model.py @@ -223,6 +223,7 @@ class BaseModel(BaseModelBase): SUPPORTS_DISCRIMINATOR: ClassVar[bool] = True SUPPORTS_FIELD_RENAMING: ClassVar[bool] = True SUPPORTS_WRAPPED_DEFAULT: ClassVar[bool] = True + SUPPORTS_VALIDATED_DEFAULT: ClassVar[bool] = True # In Pydantic 2.11+, populate_by_name is deprecated in favor of validate_by_name + validate_by_alias # Default to V2 compatible (populate_by_name) unless target_pydantic_version is specified _CONFIG_ATTRIBUTES_V2: ClassVar[list[ConfigAttribute]] = [ diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index 9fca23c22..5e7cb4fc0 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -74,6 +74,7 @@ ConstraintsBase, DataModel, DataModelFieldBase, + ValidatedDefault, WrappedDefault, ) from datamodel_code_generator.model.enum import Enum, Member @@ -82,7 +83,7 @@ from datamodel_code_generator.parser._graph import stable_toposort from datamodel_code_generator.parser._scc import find_circular_sccs, strongly_connected_components from datamodel_code_generator.reference import ModelResolver, ModelType, Reference -from datamodel_code_generator.types import ANY, DataType, DataTypeManager +from datamodel_code_generator.types import ANY, NONE, DataType, DataTypeManager, _remove_none_from_union from datamodel_code_generator.util import camel_to_snake if TYPE_CHECKING: @@ -399,6 +400,90 @@ def iter_models_field_data_types( yield model, field, data_type +def _unwrap_type_alias(data_type: DataType) -> DataType: + current = data_type + seen: set[int] = set() + while current.reference and isinstance(current.reference.source, TypeAliasBase): + source = current.reference.source + if id(source) in seen or not source.fields: + break + seen.add(id(source)) + current = source.fields[0].data_type + return current + + +def _supports_validated_default_model(source: object) -> bool: + return isinstance(source, DataModel) and source.SUPPORTS_VALIDATED_DEFAULT and not source.is_alias + + +def _contains_model_reference(data_type: DataType) -> bool: + stack = [data_type] + seen: set[int] = set() + + while stack: + resolved = _unwrap_type_alias(stack.pop()) + resolved_id = id(resolved) + if resolved_id in seen: + continue + seen.add(resolved_id) + + if resolved.reference and _supports_validated_default_model(resolved.reference.source): + return True + + if resolved.dict_key: + stack.append(resolved.dict_key) + stack.extend(resolved.data_types) + + return False + + +def _uses_existing_model_factory_path(data_type: DataType) -> bool: + for candidate in data_type.data_types or (data_type,): + if candidate.reference and _supports_validated_default_model(candidate.reference.source): + return True + if ( + candidate.is_list + and len(candidate.data_types) == 1 + and candidate.data_types[0].reference + and _supports_validated_default_model(candidate.data_types[0].reference.source) + ): + return True + return False + + +def _default_matches_plain_container_branch(default: Any, data_type: DataType) -> bool: + resolved = _unwrap_type_alias(data_type) + return (isinstance(default, dict) and resolved.is_dict and not _contains_model_reference(resolved)) or ( + isinstance(default, list) and resolved.is_list and not _contains_model_reference(resolved) + ) + + +def _get_validated_default_type_name(data_type: DataType) -> str: + type_name = data_type.type_hint + if data_type.is_optional: + return _remove_none_from_union(type_name, use_union_operator=data_type.use_union_operator) + return type_name + + +def _needs_validated_default(default: Any, data_type: DataType) -> bool: + if not isinstance(default, (dict, list)): + return False + + resolved = _unwrap_type_alias(data_type) + if not _contains_model_reference(resolved): + return False + + if resolved.is_union: + non_none_branches = tuple(branch for branch in resolved.data_types if branch.type_hint != NONE) + # This must use the declared field type, not the unwrapped branch: alias-backed + # unions like `type Alias = A | None` only get the correct factory via `Alias`. + if len(non_none_branches) == 1 and _uses_existing_model_factory_path(data_type): + return False + return not any(_default_matches_plain_container_branch(default, branch) for branch in resolved.data_types) + + return not _uses_existing_model_factory_path(data_type) + + def _alias_base_class_imports( model: DataModel, aliased_imports: dict[tuple[str | None, str], Import], @@ -2152,6 +2237,31 @@ def __wrap_root_model_default_values( type_name=type_name, ) + def __wrap_validated_default_values( + self, + models: list[DataModel], + ) -> None: + """Wrap structured defaults that must be validated through the declared field type.""" + if not self.data_model_type.SUPPORTS_VALIDATED_DEFAULT: + return + for model in models: + if isinstance(model, (Enum, self.data_model_root_type)): + continue + for model_field in model.fields: + if model_field.default is None: + continue + if isinstance(model_field.default, (Member, WrappedDefault)): + continue + if isinstance(model_field.default, ValidatedDefault): + model_field.default.type_name = _get_validated_default_type_name(model_field.data_type) + continue + if not _needs_validated_default(model_field.default, model_field.data_type): + continue + model_field.default = ValidatedDefault( + value=model_field.default, + type_name=_get_validated_default_type_name(model_field.data_type), + ) + def __override_required_field( self, models: list[DataModel], @@ -3187,6 +3297,7 @@ def _process_single_module( # noqa: PLR0913, PLR0917 models = self.__remove_overridden_models(models) self.__apply_type_overrides(models) self.__update_type_aliases(models) + self.__wrap_validated_default_values(models) return ModuleContext(module, module_, models, is_init, imports, scoped_model_resolver) @@ -3219,6 +3330,7 @@ def _finalize_modules( for ctx in contexts: self.__change_imported_model_name(ctx.models, ctx.imports, ctx.scoped_model_resolver) + self.__wrap_validated_default_values(ctx.models) def _generate_module_output( # noqa: PLR0913, PLR0917 self, diff --git a/tests/data/expected/main/jsonschema/type_alias_chain_model_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_chain_model_default_object_ref.py new file mode 100644 index 000000000..513a02386 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_chain_model_default_object_ref.py @@ -0,0 +1,25 @@ +# generated by datamodel-codegen: +# filename: type_alias_chain_model_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +type Alias1 = A + + +type Alias2 = Alias1 + + +class Model(BaseModel): + x: Alias2 | None = Field( + default_factory=lambda: TypeAdapter(Alias2).validate_python({'type': 'a'}) + ) diff --git a/tests/data/expected/main/jsonschema/type_alias_chain_union_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_chain_union_default_object_ref.py new file mode 100644 index 000000000..6454ab364 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_chain_union_default_object_ref.py @@ -0,0 +1,32 @@ +# generated by datamodel-codegen: +# filename: type_alias_chain_union_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] = 'b' + + +type Alias1 = A | B + + +type Alias2 = Alias1 + + +class Model(BaseModel): + x_value: Annotated[ + Alias2 | None, + Field( + default_factory=lambda: TypeAdapter(Alias2).validate_python({'type': 'b'}) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_dict_union_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_dict_union_default_object_ref.py new file mode 100644 index 000000000..d0c1b2935 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_dict_union_default_object_ref.py @@ -0,0 +1,32 @@ +# generated by datamodel-codegen: +# filename: type_alias_dict_union_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] + value: int | None = None + + +type Alias = dict[str, A | B] + + +class Model(BaseModel): + x_value: Annotated[ + Alias | None, + Field( + default_factory=lambda: TypeAdapter(Alias).validate_python( + {'k': {'type': 'b', 'value': 1}} + ) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_inline_dict_union_default_object.py b/tests/data/expected/main/jsonschema/type_alias_inline_dict_union_default_object.py new file mode 100644 index 000000000..6a440b719 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_inline_dict_union_default_object.py @@ -0,0 +1,29 @@ +# generated by datamodel-codegen: +# filename: type_alias_inline_dict_union_default_object.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] + value: int | None = None + + +class Model(BaseModel): + x_value: Annotated[ + dict[str, A | B] | None, + Field( + default_factory=lambda: TypeAdapter(dict[str, A | B]).validate_python( + {'k': {'type': 'b', 'value': 1}} + ) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_inline_list_union_default_object.py b/tests/data/expected/main/jsonschema/type_alias_inline_list_union_default_object.py new file mode 100644 index 000000000..56bd5e7e8 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_inline_list_union_default_object.py @@ -0,0 +1,29 @@ +# generated by datamodel-codegen: +# filename: type_alias_inline_list_union_default_object.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] + value: int | None = None + + +class Model(BaseModel): + x_value: Annotated[ + list[A | B] | None, + Field( + default_factory=lambda: TypeAdapter(list[A | B]).validate_python( + [{'type': 'b', 'value': 1}] + ) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object.py b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object.py new file mode 100644 index 000000000..3b58689e4 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: type_alias_inline_union_default_object.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] = 'b' + + +class Model(BaseModel): + x: A | B | None = Field( + default_factory=lambda: TypeAdapter(A | B).validate_python({'type': 'b'}) + ) diff --git a/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_import_collision.py b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_import_collision.py new file mode 100644 index 000000000..80eca5e3e --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_import_collision.py @@ -0,0 +1,32 @@ +# generated by datamodel-codegen: +# filename: type_alias_inline_union_default_object_import_collision.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from my_app import B +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B1(BaseModel): + type: Literal['b'] = 'b' + + +class Other(BaseModel): + a: B | None = None + + +class Model(BaseModel): + x: Annotated[ + A | B1 | None, + Field( + default_factory=lambda: TypeAdapter(A | B1).validate_python({'type': 'b'}) + ), + ] + other: Other | None = None diff --git a/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_one_of.py b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_one_of.py new file mode 100644 index 000000000..e083b4d74 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_one_of.py @@ -0,0 +1,26 @@ +# generated by datamodel-codegen: +# filename: type_alias_inline_union_default_object_one_of.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] = 'b' + + +class Model(BaseModel): + x_value: Annotated[ + A | B | None, + Field( + default_factory=lambda: TypeAdapter(A | B).validate_python({'type': 'b'}) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_silent_wrong_branch.py b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_silent_wrong_branch.py new file mode 100644 index 000000000..7916410d0 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_silent_wrong_branch.py @@ -0,0 +1,29 @@ +# generated by datamodel-codegen: +# filename: type_alias_inline_union_default_object_silent_wrong_branch.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + note: str | None = None + + +class B(BaseModel): + type: Literal['b'] + value: int | None = None + + +class Model(BaseModel): + x_value: Annotated[ + A | B | None, + Field( + default_factory=lambda: TypeAdapter(A | B).validate_python( + {'type': 'b', 'value': 1} + ) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_type_override.py b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_type_override.py new file mode 100644 index 000000000..6f5addbd2 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_inline_union_default_object_type_override.py @@ -0,0 +1,25 @@ +# generated by datamodel-codegen: +# filename: type_alias_inline_union_default_object_type_override.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from my_app import AliasA +from pydantic import BaseModel, Field, TypeAdapter + + +class B(BaseModel): + type: Literal['b'] = 'b' + + +class Model(BaseModel): + x: Annotated[ + AliasA | B | None, + Field( + default_factory=lambda: TypeAdapter(AliasA | B).validate_python( + {'type': 'b'} + ) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_list_model_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_list_model_default_object_ref.py new file mode 100644 index 000000000..3f2807a7a --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_list_model_default_object_ref.py @@ -0,0 +1,22 @@ +# generated by datamodel-codegen: +# filename: type_alias_list_model_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +type Alias = list[A] + + +class Model(BaseModel): + x: Alias | None = Field( + default_factory=lambda: TypeAdapter(Alias).validate_python([{'type': 'a'}]) + ) diff --git a/tests/data/expected/main/jsonschema/type_alias_list_union_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_list_union_default_object_ref.py new file mode 100644 index 000000000..c0075a848 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_list_union_default_object_ref.py @@ -0,0 +1,32 @@ +# generated by datamodel-codegen: +# filename: type_alias_list_union_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] + value: int | None = None + + +type Alias = list[A | B] + + +class Model(BaseModel): + x_value: Annotated[ + Alias | None, + Field( + default_factory=lambda: TypeAdapter(Alias).validate_python( + [{'type': 'b', 'value': 1}] + ) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_model_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_model_default_object_ref.py new file mode 100644 index 000000000..455bb9a0c --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_model_default_object_ref.py @@ -0,0 +1,22 @@ +# generated by datamodel-codegen: +# filename: type_alias_model_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +type Alias = A + + +class Model(BaseModel): + x: Alias | None = Field( + default_factory=lambda: TypeAdapter(Alias).validate_python({'type': 'a'}) + ) diff --git a/tests/data/expected/main/jsonschema/type_alias_nullable_list_model_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_nullable_list_model_default_object_ref.py new file mode 100644 index 000000000..5ac27b764 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_nullable_list_model_default_object_ref.py @@ -0,0 +1,22 @@ +# generated by datamodel-codegen: +# filename: type_alias_nullable_list_model_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +type Alias = list[A] | None + + +class Model(BaseModel): + x: Alias | None = Field( + default_factory=lambda: TypeAdapter(Alias).validate_python([{'type': 'a'}]) + ) diff --git a/tests/data/expected/main/jsonschema/type_alias_nullable_model_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_nullable_model_default_object_ref.py new file mode 100644 index 000000000..b2d51ed47 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_nullable_model_default_object_ref.py @@ -0,0 +1,22 @@ +# generated by datamodel-codegen: +# filename: type_alias_nullable_model_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +type Alias = A | None + + +class Model(BaseModel): + x: Alias | None = Field( + default_factory=lambda: TypeAdapter(Alias).validate_python({'type': 'a'}) + ) diff --git a/tests/data/expected/main/jsonschema/type_alias_recursive_default_list.py b/tests/data/expected/main/jsonschema/type_alias_recursive_default_list.py new file mode 100644 index 000000000..e55bd9c66 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_recursive_default_list.py @@ -0,0 +1,13 @@ +# generated by datamodel-codegen: +# filename: type_alias_recursive_default_list.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel + +type Alias = list[Alias] | int + + +class Model(BaseModel): + x: Alias | None = [] diff --git a/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref.py b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref.py new file mode 100644 index 000000000..a4d6de172 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref.py @@ -0,0 +1,26 @@ +# generated by datamodel-codegen: +# filename: type_alias_union_default_object_ref.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] = 'b' + + +type U = A | B + + +class Model(BaseModel): + x: U | None = Field( + default_factory=lambda: TypeAdapter(U).validate_python({'type': 'b'}) + ) diff --git a/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_any_of.py b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_any_of.py new file mode 100644 index 000000000..9e66ffc07 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_any_of.py @@ -0,0 +1,27 @@ +# generated by datamodel-codegen: +# filename: type_alias_union_default_object_ref_any_of.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] = 'b' + + +type U = A | B + + +class Model(BaseModel): + x_value: Annotated[ + U | None, + Field(default_factory=lambda: TypeAdapter(U).validate_python({'type': 'b'})), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_dict_alias_branch.py b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_dict_alias_branch.py new file mode 100644 index 000000000..38faef29d --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_dict_alias_branch.py @@ -0,0 +1,23 @@ +# generated by datamodel-codegen: +# filename: type_alias_union_default_object_ref_dict_alias_branch.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +type AliasDict = dict[str, str] + + +type U = AliasDict | A + + +class Model(BaseModel): + x: U | None = {'type': 'a'} diff --git a/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_mixed_scalar.py b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_mixed_scalar.py new file mode 100644 index 000000000..cda85e451 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_mixed_scalar.py @@ -0,0 +1,28 @@ +# generated by datamodel-codegen: +# filename: type_alias_union_default_object_ref_mixed_scalar.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class B(BaseModel): + type: Literal['b'] + value: int | None = None + + +type Alias = str | B + + +class Model(BaseModel): + x_value: Annotated[ + Alias | None, + Field( + default_factory=lambda: TypeAdapter(Alias).validate_python( + {'type': 'b', 'value': 1} + ) + ), + ] diff --git a/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_one_of.py b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_one_of.py new file mode 100644 index 000000000..4e8800298 --- /dev/null +++ b/tests/data/expected/main/jsonschema/type_alias_union_default_object_ref_one_of.py @@ -0,0 +1,27 @@ +# generated by datamodel-codegen: +# filename: type_alias_union_default_object_ref_one_of.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, TypeAdapter + + +class A(BaseModel): + type: Literal['a'] = 'a' + + +class B(BaseModel): + type: Literal['b'] = 'b' + + +type U = A | B + + +class Model(BaseModel): + x_value: Annotated[ + U | None, + Field(default_factory=lambda: TypeAdapter(U).validate_python({'type': 'b'})), + ] diff --git a/tests/data/jsonschema/type_alias_chain_model_default_object_ref.json b/tests/data/jsonschema/type_alias_chain_model_default_object_ref.json new file mode 100644 index 000000000..5821a1718 --- /dev/null +++ b/tests/data/jsonschema/type_alias_chain_model_default_object_ref.json @@ -0,0 +1,25 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "Alias1": { + "$ref": "#/$defs/A" + }, + "Alias2": { + "$ref": "#/$defs/Alias1" + } + }, + "properties": { + "x": { + "default": { + "type": "a" + }, + "$ref": "#/$defs/Alias2" + } + } +} diff --git a/tests/data/jsonschema/type_alias_chain_union_default_object_ref.json b/tests/data/jsonschema/type_alias_chain_union_default_object_ref.json new file mode 100644 index 000000000..02fcea3ab --- /dev/null +++ b/tests/data/jsonschema/type_alias_chain_union_default_object_ref.json @@ -0,0 +1,41 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + } + } + }, + "Alias1": { + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + }, + "Alias2": { + "$ref": "#/$defs/Alias1" + } + }, + "properties": { + "x_value": { + "default": { + "type": "b" + }, + "$ref": "#/$defs/Alias2" + } + } +} diff --git a/tests/data/jsonschema/type_alias_dict_union_default_object_ref.json b/tests/data/jsonschema/type_alias_dict_union_default_object_ref.json new file mode 100644 index 000000000..a8966576c --- /dev/null +++ b/tests/data/jsonschema/type_alias_dict_union_default_object_ref.json @@ -0,0 +1,50 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "type" + ] + }, + "Alias": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + } + }, + "properties": { + "x_value": { + "$ref": "#/$defs/Alias", + "default": { + "k": { + "type": "b", + "value": 1 + } + } + } + } +} diff --git a/tests/data/jsonschema/type_alias_inline_dict_union_default_object.json b/tests/data/jsonschema/type_alias_inline_dict_union_default_object.json new file mode 100644 index 000000000..902abbca1 --- /dev/null +++ b/tests/data/jsonschema/type_alias_inline_dict_union_default_object.json @@ -0,0 +1,47 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "type" + ] + } + }, + "properties": { + "x_value": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + }, + "default": { + "k": { + "type": "b", + "value": 1 + } + } + } + } +} diff --git a/tests/data/jsonschema/type_alias_inline_list_union_default_object.json b/tests/data/jsonschema/type_alias_inline_list_union_default_object.json new file mode 100644 index 000000000..10463a113 --- /dev/null +++ b/tests/data/jsonschema/type_alias_inline_list_union_default_object.json @@ -0,0 +1,47 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "type" + ] + } + }, + "properties": { + "x_value": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + }, + "default": [ + { + "type": "b", + "value": 1 + } + ] + } + } +} diff --git a/tests/data/jsonschema/type_alias_inline_union_default_object.json b/tests/data/jsonschema/type_alias_inline_union_default_object.json new file mode 100644 index 000000000..31af914b7 --- /dev/null +++ b/tests/data/jsonschema/type_alias_inline_union_default_object.json @@ -0,0 +1,33 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "properties": { + "type": { + "const": "b" + } + } + } + }, + "properties": { + "x": { + "default": { + "type": "b" + }, + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + } +} diff --git a/tests/data/jsonschema/type_alias_inline_union_default_object_import_collision.json b/tests/data/jsonschema/type_alias_inline_union_default_object_import_collision.json new file mode 100644 index 000000000..9aaabf1a9 --- /dev/null +++ b/tests/data/jsonschema/type_alias_inline_union_default_object_import_collision.json @@ -0,0 +1,43 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "properties": { + "type": { + "const": "b" + } + } + }, + "Other": { + "properties": { + "a": { + "$ref": "#/$defs/A" + } + } + } + }, + "properties": { + "x": { + "default": { + "type": "b" + }, + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + }, + "other": { + "$ref": "#/$defs/Other" + } + } +} diff --git a/tests/data/jsonschema/type_alias_inline_union_default_object_one_of.json b/tests/data/jsonschema/type_alias_inline_union_default_object_one_of.json new file mode 100644 index 000000000..be83c28d2 --- /dev/null +++ b/tests/data/jsonschema/type_alias_inline_union_default_object_one_of.json @@ -0,0 +1,35 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + } + } + } + }, + "properties": { + "x_value": { + "default": { + "type": "b" + }, + "oneOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + } +} diff --git a/tests/data/jsonschema/type_alias_inline_union_default_object_silent_wrong_branch.json b/tests/data/jsonschema/type_alias_inline_union_default_object_silent_wrong_branch.json new file mode 100644 index 000000000..0b4891d49 --- /dev/null +++ b/tests/data/jsonschema/type_alias_inline_union_default_object_silent_wrong_branch.json @@ -0,0 +1,42 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "note": { + "type": "string" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "type" + ] + } + }, + "properties": { + "x_value": { + "default": { + "type": "b", + "value": 1 + }, + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + } +} diff --git a/tests/data/jsonschema/type_alias_inline_union_default_object_type_override.json b/tests/data/jsonschema/type_alias_inline_union_default_object_type_override.json new file mode 100644 index 000000000..31af914b7 --- /dev/null +++ b/tests/data/jsonschema/type_alias_inline_union_default_object_type_override.json @@ -0,0 +1,33 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "properties": { + "type": { + "const": "b" + } + } + } + }, + "properties": { + "x": { + "default": { + "type": "b" + }, + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + } +} diff --git a/tests/data/jsonschema/type_alias_list_model_default_object_ref.json b/tests/data/jsonschema/type_alias_list_model_default_object_ref.json new file mode 100644 index 000000000..461badb89 --- /dev/null +++ b/tests/data/jsonschema/type_alias_list_model_default_object_ref.json @@ -0,0 +1,27 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "Alias": { + "type": "array", + "items": { + "$ref": "#/$defs/A" + } + } + }, + "properties": { + "x": { + "default": [ + { + "type": "a" + } + ], + "$ref": "#/$defs/Alias" + } + } +} diff --git a/tests/data/jsonschema/type_alias_list_union_default_object_ref.json b/tests/data/jsonschema/type_alias_list_union_default_object_ref.json new file mode 100644 index 000000000..446bf92b3 --- /dev/null +++ b/tests/data/jsonschema/type_alias_list_union_default_object_ref.json @@ -0,0 +1,50 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "type" + ] + }, + "Alias": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + } + }, + "properties": { + "x_value": { + "$ref": "#/$defs/Alias", + "default": [ + { + "type": "b", + "value": 1 + } + ] + } + } +} diff --git a/tests/data/jsonschema/type_alias_model_default_object_ref.json b/tests/data/jsonschema/type_alias_model_default_object_ref.json new file mode 100644 index 000000000..c18e25e54 --- /dev/null +++ b/tests/data/jsonschema/type_alias_model_default_object_ref.json @@ -0,0 +1,22 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "Alias": { + "$ref": "#/$defs/A" + } + }, + "properties": { + "x": { + "default": { + "type": "a" + }, + "$ref": "#/$defs/Alias" + } + } +} diff --git a/tests/data/jsonschema/type_alias_nullable_list_model_default_object_ref.json b/tests/data/jsonschema/type_alias_nullable_list_model_default_object_ref.json new file mode 100644 index 000000000..35baa4002 --- /dev/null +++ b/tests/data/jsonschema/type_alias_nullable_list_model_default_object_ref.json @@ -0,0 +1,34 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "Alias": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/A" + } + }, + { + "type": "null" + } + ] + } + }, + "properties": { + "x": { + "default": [ + { + "type": "a" + } + ], + "$ref": "#/$defs/Alias" + } + } +} diff --git a/tests/data/jsonschema/type_alias_nullable_model_default_object_ref.json b/tests/data/jsonschema/type_alias_nullable_model_default_object_ref.json new file mode 100644 index 000000000..f4d30ba5d --- /dev/null +++ b/tests/data/jsonschema/type_alias_nullable_model_default_object_ref.json @@ -0,0 +1,29 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "Alias": { + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "type": "null" + } + ] + } + }, + "properties": { + "x": { + "default": { + "type": "a" + }, + "$ref": "#/$defs/Alias" + } + } +} diff --git a/tests/data/jsonschema/type_alias_recursive_default_list.json b/tests/data/jsonschema/type_alias_recursive_default_list.json new file mode 100644 index 000000000..aa97d65e8 --- /dev/null +++ b/tests/data/jsonschema/type_alias_recursive_default_list.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Alias": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/Alias" + } + }, + { + "type": "integer" + } + ] + } + }, + "title": "Model", + "type": "object", + "properties": { + "x": { + "$ref": "#/$defs/Alias", + "default": [] + } + } +} diff --git a/tests/data/jsonschema/type_alias_union_default_object_ref.json b/tests/data/jsonschema/type_alias_union_default_object_ref.json new file mode 100644 index 000000000..869cfe099 --- /dev/null +++ b/tests/data/jsonschema/type_alias_union_default_object_ref.json @@ -0,0 +1,36 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "properties": { + "type": { + "const": "b" + } + } + }, + "U": { + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + }, + "properties": { + "x": { + "default": { + "type": "b" + }, + "$ref": "#/$defs/U" + } + } +} diff --git a/tests/data/jsonschema/type_alias_union_default_object_ref_any_of.json b/tests/data/jsonschema/type_alias_union_default_object_ref_any_of.json new file mode 100644 index 000000000..445fc50a9 --- /dev/null +++ b/tests/data/jsonschema/type_alias_union_default_object_ref_any_of.json @@ -0,0 +1,38 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + } + } + }, + "U": { + "anyOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + }, + "properties": { + "x_value": { + "default": { + "type": "b" + }, + "$ref": "#/$defs/U" + } + } +} diff --git a/tests/data/jsonschema/type_alias_union_default_object_ref_dict_alias_branch.json b/tests/data/jsonschema/type_alias_union_default_object_ref_dict_alias_branch.json new file mode 100644 index 000000000..20fd47163 --- /dev/null +++ b/tests/data/jsonschema/type_alias_union_default_object_ref_dict_alias_branch.json @@ -0,0 +1,35 @@ +{ + "$defs": { + "A": { + "properties": { + "type": { + "const": "a" + } + } + }, + "AliasDict": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "U": { + "anyOf": [ + { + "$ref": "#/$defs/AliasDict" + }, + { + "$ref": "#/$defs/A" + } + ] + } + }, + "properties": { + "x": { + "default": { + "type": "a" + }, + "$ref": "#/$defs/U" + } + } +} diff --git a/tests/data/jsonschema/type_alias_union_default_object_ref_mixed_scalar.json b/tests/data/jsonschema/type_alias_union_default_object_ref_mixed_scalar.json new file mode 100644 index 000000000..e499b1537 --- /dev/null +++ b/tests/data/jsonschema/type_alias_union_default_object_ref_mixed_scalar.json @@ -0,0 +1,37 @@ +{ + "$defs": { + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "type" + ] + }, + "Alias": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/B" + } + ] + } + }, + "properties": { + "x_value": { + "$ref": "#/$defs/Alias", + "default": { + "type": "b", + "value": 1 + } + } + } +} diff --git a/tests/data/jsonschema/type_alias_union_default_object_ref_one_of.json b/tests/data/jsonschema/type_alias_union_default_object_ref_one_of.json new file mode 100644 index 000000000..9f48604c3 --- /dev/null +++ b/tests/data/jsonschema/type_alias_union_default_object_ref_one_of.json @@ -0,0 +1,38 @@ +{ + "$defs": { + "A": { + "title": "A", + "properties": { + "type": { + "const": "a" + } + } + }, + "B": { + "title": "B", + "properties": { + "type": { + "const": "b" + } + } + }, + "U": { + "oneOf": [ + { + "$ref": "#/$defs/A" + }, + { + "$ref": "#/$defs/B" + } + ] + } + }, + "properties": { + "x_value": { + "default": { + "type": "b" + }, + "$ref": "#/$defs/U" + } + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index 125756681..053eaa060 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -25,7 +25,7 @@ from datamodel_code_generator.__main__ import Exit, main from datamodel_code_generator.format import is_supported_in_black from datamodel_code_generator.model import base as model_base -from tests.conftest import assert_directory_content, freeze_time +from tests.conftest import assert_directory_content, freeze_time, validate_generated_code from tests.main.conftest import ( ALIASES_DATA_PATH, BLACK_PY313_SKIP, @@ -48,6 +48,27 @@ FixtureRequest = pytest.FixtureRequest +def _install_test_my_app(base_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + package_dir = base_dir / "my_app" + package_dir.mkdir() + (package_dir / "__init__.py").write_text( + """from typing import Literal + +from pydantic import BaseModel + + +class AliasA(BaseModel): + type: Literal["a"] = "a" + + +class B(BaseModel): + type: Literal["b"] = "b" +""", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(str(base_dir)) + + @pytest.mark.benchmark def test_main_inheritance_forward_ref(output_file: Path, tmp_path: Path) -> None: """Test inheritance with forward references.""" @@ -5591,6 +5612,545 @@ def test_main_jsonschema_enum_literal_type_alias_default(output_file: Path) -> N ) +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_union_default_object_ref(output_file: Path) -> None: + """Validate $ref object-union defaults through the type alias.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_union_default_object_ref.json", + output_path=output_file, + input_file_type=None, + assert_func=assert_file_content, + expected_file="type_alias_union_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_model_default_object_ref(output_file: Path) -> None: + """Validate model-backed type alias defaults through the alias.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_model_default_object_ref.json", + output_path=output_file, + input_file_type=None, + assert_func=assert_file_content, + expected_file="type_alias_model_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_chain_model_default_object_ref(output_file: Path) -> None: + """Validate recursively chained model-backed aliases for structured defaults.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_chain_model_default_object_ref.json", + output_path=output_file, + input_file_type=None, + assert_func=assert_file_content, + expected_file="type_alias_chain_model_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_list_model_default_object_ref(output_file: Path) -> None: + """Validate list-of-model type alias defaults through the alias.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_list_model_default_object_ref.json", + output_path=output_file, + input_file_type=None, + assert_func=assert_file_content, + expected_file="type_alias_list_model_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_nullable_model_default_object_ref(output_file: Path) -> None: + """Validate nullable model-backed type alias defaults through the alias.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_nullable_model_default_object_ref.json", + output_path=output_file, + input_file_type=None, + assert_func=assert_file_content, + expected_file="type_alias_nullable_model_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_nullable_list_model_default_object_ref(output_file: Path) -> None: + """Validate nullable list-of-model type alias defaults through the alias.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_nullable_list_model_default_object_ref.json", + output_path=output_file, + input_file_type=None, + assert_func=assert_file_content, + expected_file="type_alias_nullable_list_model_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_recursive_default_list(output_file: Path) -> None: + """Avoid recursive alias traversal loops when checking structured defaults.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_recursive_default_list.json", + output_path=output_file, + input_file_type=None, + assert_func=assert_file_content, + expected_file="type_alias_recursive_default_list.py", + extra_args=[ + "--use-type-alias", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_inline_union_default_object(output_file: Path) -> None: + """Validate inline object-union defaults through the union type.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_inline_union_default_object.json", + output_path=output_file, + input_file_type=None, + assert_func=assert_file_content, + expected_file="type_alias_inline_union_default_object.py", + extra_args=[ + "--use-type-alias", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_union_default_object_ref_any_of_relevant_flags( + output_file: Path, +) -> None: + """Validate anyOf alias union defaults with the relevant type-alias flags.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_union_default_object_ref_any_of.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_union_default_object_ref_any_of.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_chain_union_default_object_ref_relevant_flags( + output_file: Path, +) -> None: + """Validate chained alias union defaults with the relevant type-alias flags.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_chain_union_default_object_ref.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_chain_union_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_union_default_object_ref_one_of_relevant_flags( + output_file: Path, +) -> None: + """Validate oneOf alias union defaults with the relevant type-alias flags.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_union_default_object_ref_one_of.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_union_default_object_ref_one_of.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_inline_union_default_object_one_of_relevant_flags( + output_file: Path, +) -> None: + """Validate oneOf inline union defaults with the relevant type-alias flags.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_inline_union_default_object_one_of.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_inline_union_default_object_one_of.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_inline_union_default_object_import_collision_relevant_flags( + output_file: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep TypeAdapter targets aligned after imported-name collision renames local models.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_inline_union_default_object_import_collision.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_inline_union_default_object_import_collision.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + "--type-overrides", + '{"Other.a": "my_app.B"}', + ], + skip_code_validation=True, + ) + _install_test_my_app(output_file.parent, monkeypatch) + validate_generated_code(output_file.read_text(encoding="utf-8"), str(output_file), do_exec=True) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_inline_union_default_object_type_override_relevant_flags( + output_file: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Validate TypeAdapter targets after late type overrides change field types.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_inline_union_default_object_type_override.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_inline_union_default_object_type_override.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + "--type-overrides", + '{"A": "my_app.AliasA"}', + ], + skip_code_validation=True, + ) + _install_test_my_app(output_file.parent, monkeypatch) + validate_generated_code(output_file.read_text(encoding="utf-8"), str(output_file), do_exec=True) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_inline_union_default_object_silent_wrong_branch_relevant_flags( + output_file: Path, +) -> None: + """Validate inline unions that would silently pick the wrong branch.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_inline_union_default_object_silent_wrong_branch.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_inline_union_default_object_silent_wrong_branch.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_inline_list_union_default_object_relevant_flags( + output_file: Path, +) -> None: + """Validate list-of-union defaults with the relevant type-alias flags.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_inline_list_union_default_object.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_inline_list_union_default_object.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_list_union_default_object_ref_relevant_flags( + output_file: Path, +) -> None: + """Validate aliased list-of-union defaults with the relevant type-alias flags.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_list_union_default_object_ref.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_list_union_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_inline_dict_union_default_object_relevant_flags( + output_file: Path, +) -> None: + """Validate dict-of-union defaults with the relevant type-alias flags.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_inline_dict_union_default_object.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_inline_dict_union_default_object.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_dict_union_default_object_ref_relevant_flags( + output_file: Path, +) -> None: + """Validate aliased dict-of-union defaults with the relevant type-alias flags.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_dict_union_default_object_ref.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_dict_union_default_object_ref.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_union_default_object_ref_dict_alias_branch_relevant_flags( + output_file: Path, +) -> None: + """Keep raw defaults when a plain dict union branch is hidden behind a type alias.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_union_default_object_ref_dict_alias_branch.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_union_default_object_ref_dict_alias_branch.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + +@pytest.mark.skipif( + int(black.__version__.split(".")[0]) < 23, + reason="Installed black doesn't support the new 'type' statement", +) +def test_main_jsonschema_type_alias_union_default_object_ref_mixed_scalar_relevant_flags( + output_file: Path, +) -> None: + """Validate aliased mixed scalar/model union defaults.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "type_alias_union_default_object_ref_mixed_scalar.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="type_alias_union_default_object_ref_mixed_scalar.py", + extra_args=[ + "--use-type-alias", + "--use-annotated", + "--target-python-version", + "3.12", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-pydantic-version", + "2.11", + ], + ) + + @pytest.mark.cli_doc( options=["--type-mappings"], option_description="""Override default type mappings for schema formats. diff --git a/tests/parser/test_base.py b/tests/parser/test_base.py index 5c8e98b73..1284c8875 100644 --- a/tests/parser/test_base.py +++ b/tests/parser/test_base.py @@ -17,6 +17,10 @@ from datamodel_code_generator.model.type_alias import TypeAlias, TypeAliasTypeBackport, TypeStatement from datamodel_code_generator.parser.base import ( Parser, + _contains_model_reference, + _get_validated_default_type_name, + _needs_validated_default, + _unwrap_type_alias, add_model_path_to_list, escape_characters, exact_import, @@ -646,3 +650,78 @@ def test_build_module_dependency_graph_with_missing_ref(parser_fixture: C) -> No graph = parser_fixture._Parser__build_module_dependency_graph(module_models_list) assert graph == {("pkg",): set()} + + +def test_unwrap_type_alias_stops_on_recursive_alias() -> None: + """Test _unwrap_type_alias stops when a type alias resolves back to itself.""" + alias_reference = Reference(path="Alias", original_name="Alias", name="Alias") + alias_data_type = DataType(reference=alias_reference) + alias_model = TypeStatement(fields=[DataModelField(data_type=alias_data_type)], reference=alias_reference) + + assert alias_reference.source is alias_model + assert _unwrap_type_alias(alias_data_type) is alias_data_type + + +def test_contains_model_reference_traverses_nested_data_types() -> None: + """Test _contains_model_reference walks nested container data types.""" + model_reference = Reference(path="Item", original_name="Item", name="Item") + BaseModel(fields=[], reference=model_reference) + + list_of_models = DataType( + is_list=True, + data_types=[DataType(reference=model_reference)], + use_union_operator=True, + ) + + assert _contains_model_reference(list_of_models) is True + + +def test_contains_model_reference_traverses_dict_key() -> None: + """Test _contains_model_reference walks dict_key data types.""" + model_reference = Reference(path="KeyModel", original_name="KeyModel", name="KeyModel") + BaseModel(fields=[], reference=model_reference) + + dict_with_model_key = DataType( + is_dict=True, + dict_key=DataType(reference=model_reference), + use_union_operator=True, + ) + + assert _contains_model_reference(dict_with_model_key) is True + + +def test_get_validated_default_type_name_strips_optional_none() -> None: + """Test _get_validated_default_type_name removes None from optional types.""" + optional_string = DataType(type="str", is_optional=True, use_union_operator=True) + + assert _get_validated_default_type_name(optional_string) == "str" + + +def test_needs_validated_default_for_union_type_alias() -> None: + """Test _needs_validated_default resolves type aliases before checking union branches.""" + a_reference = Reference(path="A", original_name="A", name="A") + b_reference = Reference(path="B", original_name="B", name="B") + BaseModel(fields=[], reference=a_reference) + BaseModel(fields=[], reference=b_reference) + + union_data_type = DataType( + data_types=[DataType(reference=a_reference), DataType(reference=b_reference)], + use_union_operator=True, + ) + alias_reference = Reference(path="Alias", original_name="Alias", name="Alias") + TypeStatement(fields=[DataModelField(data_type=union_data_type)], reference=alias_reference) + + assert _needs_validated_default({"type": "b"}, DataType(reference=alias_reference)) is True + + +def test_needs_validated_default_skips_optional_single_model_union() -> None: + """Test _needs_validated_default keeps the existing factory path for A | None.""" + model_reference = Reference(path="A", original_name="A", name="A") + BaseModel(fields=[], reference=model_reference) + + optional_model_union = DataType( + data_types=[DataType(reference=model_reference), DataType(type="None")], + use_union_operator=True, + ) + + assert _needs_validated_default({"type": "a"}, optional_model_union) is False