diff --git a/src/datamodel_code_generator/__init__.py b/src/datamodel_code_generator/__init__.py index a75f5da13..876451ac0 100644 --- a/src/datamodel_code_generator/__init__.py +++ b/src/datamodel_code_generator/__init__.py @@ -896,6 +896,81 @@ def get_header_and_first_line(csv_file: IO[str]) -> dict[str, Any]: return None +def generate_dynamic_models( + input_: Path | str | ParseResult | Mapping[str, Any], + *, + input_file_type: InputFileType = InputFileType.Auto, +) -> dict[str, type]: + """Generate actual Python model classes from schema at runtime. + + This function creates real Python classes using Pydantic's create_model(), + allowing you to instantiate and validate data directly without generating code. + + Args: + input_: The input source (file path, string content, URL, or dict). + input_file_type: The type of input file. Defaults to Auto-detection. + + Returns: + Dictionary mapping class names to actual Python model classes. + + Raises: + DynamicModelError: If model generation fails. + TypeResolutionError: If a field type cannot be resolved. + + Example: + >>> schema = { + ... "type": "object", + ... "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, + ... "required": ["name"], + ... } + >>> models = generate_dynamic_models(schema, input_file_type=InputFileType.JsonSchema) + >>> User = models["Model"] + >>> user = User(name="John", age=30) + >>> user.model_dump() + {'name': 'John', 'age': 30} + + Note: + - Only Pydantic v2 models are supported + - Circular references are handled via model_rebuild() + - Custom validators/methods are not included + - For code generation, use generate() instead + """ + from datamodel_code_generator.parser.jsonschema import JsonSchemaParser # noqa: PLC0415 + from datamodel_code_generator.parser.openapi import OpenAPIParser # noqa: PLC0415 + + source: str | Path | dict[str, Any] | ParseResult + if isinstance(input_, Mapping): + source = dict(input_) + elif isinstance(input_, Path): + source = input_ + elif isinstance(input_, str): + path = Path(input_) + source = path if path.exists() and path.is_file() else input_ + else: + source = input_ + + if input_file_type == InputFileType.Auto: + if isinstance(source, Mapping): + input_file_type = InputFileType.JsonSchema + elif isinstance(source, Path): + input_file_type = infer_input_type(source.read_text()) + elif isinstance(source, str): + input_file_type = infer_input_type(source) + else: + input_file_type = InputFileType.JsonSchema + + if input_file_type == InputFileType.OpenAPI: + parser = OpenAPIParser(source=source) + elif input_file_type == InputFileType.GraphQL: + msg = "GraphQL is not yet supported for dynamic model generation" + raise Error(msg) + else: + parser = JsonSchemaParser(source=source) + + parser.parse_raw() + return parser.create_dynamic_models() + + def infer_input_type(text: str) -> InputFileType: """Automatically detect the input file type from text content.""" import yaml.parser # noqa: PLC0415 @@ -955,4 +1030,5 @@ def infer_input_type(text: str) -> InputFileType: "SchemaParseError", "TargetPydanticVersion", "generate", + "generate_dynamic_models", ] diff --git a/src/datamodel_code_generator/dynamic/__init__.py b/src/datamodel_code_generator/dynamic/__init__.py new file mode 100644 index 000000000..7565eca6a --- /dev/null +++ b/src/datamodel_code_generator/dynamic/__init__.py @@ -0,0 +1,19 @@ +"""Dynamic model generation module. + +This module provides functionality to generate actual Python model classes +at runtime using Pydantic's create_model(), instead of generating text code. +""" + +from __future__ import annotations + +from datamodel_code_generator.dynamic.creator import DynamicModelCreator +from datamodel_code_generator.dynamic.exceptions import ( + DynamicModelError, + TypeResolutionError, +) + +__all__ = [ + "DynamicModelCreator", + "DynamicModelError", + "TypeResolutionError", +] diff --git a/src/datamodel_code_generator/dynamic/constraints.py b/src/datamodel_code_generator/dynamic/constraints.py new file mode 100644 index 000000000..a908594e7 --- /dev/null +++ b/src/datamodel_code_generator/dynamic/constraints.py @@ -0,0 +1,61 @@ +"""Constraint conversion utilities for dynamic model generation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from datamodel_code_generator.util import model_dump + +if TYPE_CHECKING: + from datamodel_code_generator.model.base import ConstraintsBase + +CONSTRAINT_FIELD_MAP: dict[str, str] = { + "ge": "ge", + "gt": "gt", + "le": "le", + "lt": "lt", + "multiple_of": "multiple_of", + "min_length": "min_length", + "max_length": "max_length", + "regex": "pattern", + "pattern": "pattern", + "min_items": "min_length", + "max_items": "max_length", +} + +JSON_SCHEMA_EXTRA_FIELDS: frozenset[str] = frozenset({ + "unique_items", + "min_properties", + "max_properties", +}) + + +def constraints_to_field_kwargs( + constraints: ConstraintsBase | None, +) -> dict[str, Any]: + """Convert DataModel constraints to Pydantic Field kwargs.""" + if constraints is None: + return {} + + kwargs: dict[str, Any] = {} + json_schema_extra: dict[str, Any] = {} + + for field_name, value in model_dump(constraints).items(): + if value is None: + continue + + if field_name in CONSTRAINT_FIELD_MAP: + kwargs[CONSTRAINT_FIELD_MAP[field_name]] = value + elif field_name in JSON_SCHEMA_EXTRA_FIELDS: + json_schema_extra[_to_camel_case(field_name)] = value + + if json_schema_extra: + kwargs["json_schema_extra"] = json_schema_extra + + return kwargs + + +def _to_camel_case(snake_str: str) -> str: + """Convert snake_case to camelCase.""" + components = snake_str.split("_") + return components[0] + "".join(x.title() for x in components[1:]) diff --git a/src/datamodel_code_generator/dynamic/creator.py b/src/datamodel_code_generator/dynamic/creator.py new file mode 100644 index 000000000..3163573a6 --- /dev/null +++ b/src/datamodel_code_generator/dynamic/creator.py @@ -0,0 +1,185 @@ +"""Dynamic model creator for generating Python classes at runtime.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Any, cast + +from pydantic import BaseModel, Field, create_model + +from datamodel_code_generator.dynamic.constraints import constraints_to_field_kwargs +from datamodel_code_generator.dynamic.exceptions import TypeResolutionError +from datamodel_code_generator.dynamic.type_resolver import TypeResolver +from datamodel_code_generator.model.enum import Enum as EnumModel +from datamodel_code_generator.parser.base import sort_data_models + +if TYPE_CHECKING: + from pydantic.fields import FieldInfo + + from datamodel_code_generator.model.base import DataModel, DataModelFieldBase + from datamodel_code_generator.parser.base import Parser + + +class DynamicModelCreator: + """Creates actual Python classes from DataModel objects.""" + + def __init__(self, parser: Parser) -> None: + """Initialize with a parser instance.""" + self.parser = parser + self._models: dict[str, type[Any]] = {} + self._short_name_lookup: dict[str, type[Any]] = {} + self._type_resolver = TypeResolver(self._short_name_lookup) + + def create_models(self) -> dict[str, type]: + """Create all models from parser results. + + Returns: + Dictionary mapping class names to actual Python classes. + + Raises: + TypeResolutionError: If a type cannot be resolved. + DynamicModelError: If model generation fails. + """ + if not self.parser.results: + return {} + + _, sorted_models_dict, _ = sort_data_models(self.parser.results) + + for data_model in sorted_models_dict.values(): + if isinstance(data_model, EnumModel): + self._create_enum_model(data_model) + else: + self._create_pydantic_model(data_model) + + self._rebuild_models() + + return self._models + + def _create_pydantic_model(self, data_model: DataModel) -> type[Any]: + """Create a single Pydantic model from DataModel.""" + field_definitions: dict[str, tuple[Any, FieldInfo]] = {} + + for field in data_model.fields: + if field.name is None: + continue + + try: + field_type, type_constraints = self._type_resolver.resolve_with_constraints(field.data_type) + except Exception as e: + raise TypeResolutionError(field.data_type, data_model.class_name, field.name or "") from e + + field_info = self._create_field_info(field, type_constraints) + field_definitions[field.name] = (field_type, field_info) + + base_classes = self._resolve_base_classes(data_model) + module_name = self._get_module_name(data_model) + + if len(base_classes) > 1: + combined_base_name = f"_{data_model.class_name}Base" + combined_base = type(combined_base_name, base_classes, {}) + effective_base: type[Any] = combined_base + else: + effective_base = base_classes[0] if base_classes else BaseModel + + model = cast( + "type[Any]", + create_model( + data_model.class_name, + __base__=effective_base, + __module__=module_name, + **cast("dict[str, Any]", field_definitions), + ), + ) + + model_key = self._get_model_key(data_model) + self._models[model_key] = model + self._short_name_lookup[data_model.class_name] = model + + if model_key != data_model.class_name: + self._models[data_model.class_name] = model + + return model + + def _get_model_key(self, data_model: DataModel) -> str: + """Get module-qualified key for model storage.""" + module_name = self._get_module_name(data_model) + if module_name and module_name != "__dynamic__": + return f"{module_name}.{data_model.class_name}" + return data_model.class_name + + @staticmethod + def _create_field_info(field: DataModelFieldBase, type_constraints: dict[str, Any] | None = None) -> FieldInfo: + """Convert DataModelFieldBase to Pydantic FieldInfo.""" + kwargs = constraints_to_field_kwargs(field.constraints) + + if type_constraints: + kwargs.update(type_constraints) + + if (hasattr(field, "has_default") and field.has_default) or field.default is not None: + kwargs["default"] = field.default + elif (default_factory := getattr(field, "default_factory", None)) is not None: + kwargs["default_factory"] = default_factory + elif field.required: + kwargs["default"] = ... + else: + kwargs["default"] = None + + if field.alias: + kwargs["alias"] = field.alias + + description = field.extras.get("description") + if description: + kwargs["description"] = description + + return Field(**kwargs) + + def _resolve_base_classes(self, data_model: DataModel) -> tuple[type, ...]: + """Resolve base classes for the model.""" + if not data_model.base_classes: + return (BaseModel,) + + bases = [] + for base_class in data_model.base_classes: + if base_class.reference and base_class.reference.short_name in self._short_name_lookup: + bases.append(self._short_name_lookup[base_class.reference.short_name]) + else: + bases.append(BaseModel) + + return tuple(bases) if bases else (BaseModel,) + + @staticmethod + def _get_module_name(data_model: DataModel) -> str: + """Determine module name for the dynamic model.""" + if data_model.reference and data_model.reference.path: + parts = data_model.reference.path.split("/") + return ".".join(p for p in parts if p and p != "#") + return "__dynamic__" + + def _create_enum_model(self, data_model: DataModel) -> type[Any]: + """Create an Enum class from DataModel.""" + members = {} + for field in data_model.fields: + if field.name and field.default is not None: + value = field.default + if isinstance(value, str): + value = value.strip("'\"") + members[field.name] = value + + enum_class: type[Any] = Enum(data_model.class_name, members) + + model_key = self._get_model_key(data_model) + self._models[model_key] = enum_class + self._short_name_lookup[data_model.class_name] = enum_class + + if model_key != data_model.class_name: + self._models[data_model.class_name] = enum_class + + return enum_class + + def _rebuild_models(self) -> None: + """Resolve forward references by calling model_rebuild() on all models.""" + namespace = {**self._short_name_lookup} + + for model in self._models.values(): + if isinstance(model, type) and issubclass(model, BaseModel): + model.model_rebuild(_types_namespace=namespace) diff --git a/src/datamodel_code_generator/dynamic/exceptions.py b/src/datamodel_code_generator/dynamic/exceptions.py new file mode 100644 index 000000000..5777ee141 --- /dev/null +++ b/src/datamodel_code_generator/dynamic/exceptions.py @@ -0,0 +1,23 @@ +"""Custom exceptions for dynamic model generation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datamodel_code_generator.types import DataType + + +class DynamicModelError(Exception): + """Base exception for dynamic model generation.""" + + +class TypeResolutionError(DynamicModelError): + """Failed to resolve a type to a Python type object.""" + + def __init__(self, type_info: DataType, model_name: str, field_name: str) -> None: + """Initialize with type info and context.""" + self.type_info = type_info + self.model_name = model_name + self.field_name = field_name + super().__init__(f"Cannot resolve type for field '{field_name}' in model '{model_name}': {type_info}") diff --git a/src/datamodel_code_generator/dynamic/type_resolver.py b/src/datamodel_code_generator/dynamic/type_resolver.py new file mode 100644 index 000000000..58816139f --- /dev/null +++ b/src/datamodel_code_generator/dynamic/type_resolver.py @@ -0,0 +1,117 @@ +"""Type resolution utilities for dynamic model generation.""" + +from __future__ import annotations + +import builtins +import decimal +import operator +from functools import reduce +from typing import TYPE_CHECKING, Any, ForwardRef, Literal + +if TYPE_CHECKING: + from datamodel_code_generator.types import DataType + +TYPE_ALIASES: dict[str, str] = { + "string": "str", + "integer": "int", + "number": "float", + "boolean": "bool", +} + +CONSTRAINED_TYPE_BASE: dict[str, type[Any]] = { + "conint": int, + "confloat": float, + "constr": str, + "conbytes": bytes, + "condecimal": decimal.Decimal, + "conlist": list, + "conset": set, + "confrozenset": frozenset, +} + + +class TypeResolver: + """Resolves DataType objects to actual Python types.""" + + def __init__(self, models: dict[str, type[Any]]) -> None: + """Initialize with a models lookup dictionary.""" + self._models = models + + def resolve(self, data_type: DataType) -> Any: + """Resolve a DataType to a Python type.""" + resolved_type, _ = self.resolve_with_constraints(data_type) + return resolved_type + + def resolve_with_constraints(self, data_type: DataType) -> tuple[Any, dict[str, Any]]: + """Resolve a DataType to a Python type and extract constraints.""" + constraints: dict[str, Any] = {} + + if data_type.reference is not None: + return self._resolve_reference(data_type.reference.short_name), constraints + + if data_type.literals: + return Literal[tuple(data_type.literals)], constraints + + if len(data_type.data_types) > 1: + inner_types = [self.resolve(dt) for dt in data_type.data_types] + return reduce(operator.or_, inner_types), constraints + + if data_type.data_types: + return self._resolve_container(data_type), constraints + + return self._resolve_type_string(data_type, constraints) + + def _resolve_reference(self, model_name: str) -> Any: + """Resolve a model reference to a type or ForwardRef.""" + if model_name in self._models: + return self._models[model_name] + return ForwardRef(model_name) + + def _resolve_container(self, data_type: DataType) -> Any: + """Resolve container types (list, set, dict, optional).""" + inner = self.resolve(data_type.data_types[0]) + if data_type.is_optional: + return inner | None + if data_type.is_list: + return list[inner] + if data_type.is_set: + return set[inner] + if data_type.is_dict: + key_type = self.resolve(data_type.dict_key) if data_type.dict_key else str + return dict[key_type, inner] + return inner + + def _resolve_type_string(self, data_type: DataType, constraints: dict[str, Any]) -> tuple[Any, dict[str, Any]]: + """Resolve type from type string.""" + if not data_type.type: + return Any, constraints + + type_str = data_type.type + + if type_str in CONSTRAINED_TYPE_BASE: + base_type = CONSTRAINED_TYPE_BASE[type_str] + self._extract_constraints(data_type, constraints) + return base_type, constraints + + normalized = TYPE_ALIASES.get(type_str, type_str) + if builtin_type := getattr(builtins, normalized, None): + return builtin_type, constraints + + if type_str in self._models: + return self._models[type_str], constraints + + return Any, constraints + + @staticmethod + def _extract_constraints(data_type: DataType, constraints: dict[str, Any]) -> None: + """Extract constraints from DataType kwargs.""" + if not data_type.kwargs: + return + for key, kwarg_value in data_type.kwargs.items(): + if key == "regex": + pattern_value = kwarg_value + if isinstance(pattern_value, str) and pattern_value.startswith("r'") and pattern_value.endswith("'"): + pattern_value = pattern_value[2:-1] + constraints["pattern"] = pattern_value + else: + constraints[key] = kwarg_value diff --git a/src/datamodel_code_generator/parser/base.py b/src/datamodel_code_generator/parser/base.py index bfcaaa93a..c7ec1d5b2 100644 --- a/src/datamodel_code_generator/parser/base.py +++ b/src/datamodel_code_generator/parser/base.py @@ -3132,3 +3132,39 @@ def parse( # noqa: PLR0913, PLR0914, PLR0917 for k, v in results.items() } ) + + def create_dynamic_models(self) -> dict[str, type]: + """Create actual Python model classes from parsed schema. + + This method should be called after parse_raw() has been executed. + It uses Pydantic's create_model() to generate real Python classes + that can be instantiated and used for validation. + + Returns: + Dictionary mapping class names to Pydantic model classes. + + Raises: + DynamicModelError: Base exception for any generation failure. + TypeResolutionError: If a field type cannot be resolved. + + Example: + parser = JsonSchemaParser(source=schema) + parser.parse_raw() + models = parser.create_dynamic_models() + + User = models['User'] + user = User(name='John', age=30) + print(user.model_dump()) + + Note: + - Only Pydantic v2 models are supported + - Circular references are resolved via model_rebuild() + - Custom validators/methods are not included + """ + from datamodel_code_generator.dynamic.creator import DynamicModelCreator # noqa: PLC0415 + + if not self.results: + return {} + + creator = DynamicModelCreator(self) + return creator.create_models() diff --git a/tests/data/dynamic/alias.yaml b/tests/data/dynamic/alias.yaml new file mode 100644 index 000000000..8f8743a95 --- /dev/null +++ b/tests/data/dynamic/alias.yaml @@ -0,0 +1,12 @@ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + userName: + type: string diff --git a/tests/data/dynamic/allof.json b/tests/data/dynamic/allof.json new file mode 100644 index 000000000..7ae171713 --- /dev/null +++ b/tests/data/dynamic/allof.json @@ -0,0 +1,12 @@ +{ + "$defs": { + "Base": {"type": "object", "properties": {"id": {"type": "integer"}}}, + "Child": { + "allOf": [ + {"$ref": "#/$defs/Base"}, + {"properties": {"name": {"type": "string"}}} + ] + } + }, + "$ref": "#/$defs/Child" +} diff --git a/tests/data/dynamic/array_constraints.json b/tests/data/dynamic/array_constraints.json new file mode 100644 index 000000000..cd40f86e3 --- /dev/null +++ b/tests/data/dynamic/array_constraints.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 5 + } + } +} diff --git a/tests/data/dynamic/array_default.json b/tests/data/dynamic/array_default.json new file mode 100644 index 000000000..8c679d8cf --- /dev/null +++ b/tests/data/dynamic/array_default.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "string"}, + "default": [] + } + } +} diff --git a/tests/data/dynamic/circular.json b/tests/data/dynamic/circular.json new file mode 100644 index 000000000..8540a2194 --- /dev/null +++ b/tests/data/dynamic/circular.json @@ -0,0 +1,12 @@ +{ + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/$defs/Node"}} + } + } + }, + "$ref": "#/$defs/Node" +} diff --git a/tests/data/dynamic/default.json b/tests/data/dynamic/default.json new file mode 100644 index 000000000..b3ae41222 --- /dev/null +++ b/tests/data/dynamic/default.json @@ -0,0 +1,6 @@ +{ + "type": "object", + "properties": { + "count": {"type": "integer", "default": 10} + } +} diff --git a/tests/data/dynamic/description.json b/tests/data/dynamic/description.json new file mode 100644 index 000000000..5b7f14edd --- /dev/null +++ b/tests/data/dynamic/description.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The user's name" + } + } +} diff --git a/tests/data/dynamic/enum.json b/tests/data/dynamic/enum.json new file mode 100644 index 000000000..cfaba9d33 --- /dev/null +++ b/tests/data/dynamic/enum.json @@ -0,0 +1,6 @@ +{ + "type": "object", + "properties": { + "status": {"enum": ["pending", "active", "completed"]} + } +} diff --git a/tests/data/dynamic/exclusive_constraints.json b/tests/data/dynamic/exclusive_constraints.json new file mode 100644 index 000000000..2aa691081 --- /dev/null +++ b/tests/data/dynamic/exclusive_constraints.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "value": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100 + } + } +} diff --git a/tests/data/dynamic/formats.json b/tests/data/dynamic/formats.json new file mode 100644 index 000000000..a701d5d6c --- /dev/null +++ b/tests/data/dynamic/formats.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "date_field": {"type": "string", "format": "date"}, + "datetime_field": {"type": "string", "format": "date-time"}, + "uuid_field": {"type": "string", "format": "uuid"}, + "time_field": {"type": "string", "format": "time"}, + "decimal_field": {"type": "string", "format": "decimal"}, + "binary_field": {"type": "string", "format": "binary"} + } +} diff --git a/tests/data/dynamic/min_max_properties.json b/tests/data/dynamic/min_max_properties.json new file mode 100644 index 000000000..7e34c166c --- /dev/null +++ b/tests/data/dynamic/min_max_properties.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "data": { + "type": "object", + "minProperties": 1, + "maxProperties": 5 + } + } +} diff --git a/tests/data/dynamic/multiple_allof.json b/tests/data/dynamic/multiple_allof.json new file mode 100644 index 000000000..9e8e2a8b7 --- /dev/null +++ b/tests/data/dynamic/multiple_allof.json @@ -0,0 +1,19 @@ +{ + "$defs": { + "Named": { + "type": "object", + "properties": {"name": {"type": "string"}} + }, + "Aged": { + "type": "object", + "properties": {"age": {"type": "integer"}} + }, + "Person": { + "allOf": [ + {"$ref": "#/$defs/Named"}, + {"$ref": "#/$defs/Aged"} + ] + } + }, + "$ref": "#/$defs/Person" +} diff --git a/tests/data/dynamic/nested.json b/tests/data/dynamic/nested.json new file mode 100644 index 000000000..c59fe5ae0 --- /dev/null +++ b/tests/data/dynamic/nested.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "properties": { + "user": {"$ref": "#/$defs/User"} + }, + "$defs": { + "User": { + "type": "object", + "properties": {"name": {"type": "string"}} + } + } +} diff --git a/tests/data/dynamic/numeric_constraints.json b/tests/data/dynamic/numeric_constraints.json new file mode 100644 index 000000000..7af6f6cac --- /dev/null +++ b/tests/data/dynamic/numeric_constraints.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "multipleOf": 5 + } + } +} diff --git a/tests/data/dynamic/objects.json b/tests/data/dynamic/objects.json new file mode 100644 index 000000000..11698a0f2 --- /dev/null +++ b/tests/data/dynamic/objects.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "properties": { + "dict_str": { + "type": "object", + "additionalProperties": {"type": "string"} + }, + "dict_int": { + "type": "object", + "additionalProperties": {"type": "integer"} + }, + "plain_object": {"type": "object"}, + "plain_array": {"type": "array"} + } +} diff --git a/tests/data/dynamic/openapi.yaml b/tests/data/dynamic/openapi.yaml new file mode 100644 index 000000000..1003f2ec8 --- /dev/null +++ b/tests/data/dynamic/openapi.yaml @@ -0,0 +1,16 @@ +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +paths: {} +components: + schemas: + User: + type: object + properties: + name: + type: string + email: + type: string + required: + - name diff --git a/tests/data/dynamic/optional.json b/tests/data/dynamic/optional.json new file mode 100644 index 000000000..b2d86c099 --- /dev/null +++ b/tests/data/dynamic/optional.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "nickname": {"type": "string"} + }, + "required": ["name"] +} diff --git a/tests/data/dynamic/refs.json b/tests/data/dynamic/refs.json new file mode 100644 index 000000000..639858505 --- /dev/null +++ b/tests/data/dynamic/refs.json @@ -0,0 +1,25 @@ +{ + "$defs": { + "Address": { + "type": "object", + "properties": {"city": {"type": "string"}} + }, + "Inner": { + "type": "object", + "properties": {"value": {"type": "string"}} + }, + "Middle": { + "type": "object", + "properties": {"inner": {"$ref": "#/$defs/Inner"}} + }, + "Outer": { + "type": "object", + "properties": {"middle": {"$ref": "#/$defs/Middle"}} + } + }, + "type": "object", + "properties": { + "address": {"$ref": "#/$defs/Address"}, + "outer": {"$ref": "#/$defs/Outer"} + } +} diff --git a/tests/data/dynamic/required.json b/tests/data/dynamic/required.json new file mode 100644 index 000000000..def2d36a1 --- /dev/null +++ b/tests/data/dynamic/required.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + }, + "required": ["name", "email"] +} diff --git a/tests/data/dynamic/simple.json b/tests/data/dynamic/simple.json new file mode 100644 index 000000000..878e9ed51 --- /dev/null +++ b/tests/data/dynamic/simple.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"} + }, + "required": ["name"] +} diff --git a/tests/data/dynamic/standalone_enum.json b/tests/data/dynamic/standalone_enum.json new file mode 100644 index 000000000..d11a5c962 --- /dev/null +++ b/tests/data/dynamic/standalone_enum.json @@ -0,0 +1,12 @@ +{ + "$defs": { + "Status": { + "type": "string", + "enum": ["active", "inactive", "pending"] + } + }, + "type": "object", + "properties": { + "status": {"$ref": "#/$defs/Status"} + } +} diff --git a/tests/data/dynamic/string_constraints.json b/tests/data/dynamic/string_constraints.json new file mode 100644 index 000000000..2142656eb --- /dev/null +++ b/tests/data/dynamic/string_constraints.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 2, + "maxLength": 10, + "pattern": "^[A-Z]+$" + } + } +} diff --git a/tests/data/dynamic/tuple.json b/tests/data/dynamic/tuple.json new file mode 100644 index 000000000..28678c998 --- /dev/null +++ b/tests/data/dynamic/tuple.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "coords": { + "type": "array", + "prefixItems": [{"type": "number"}, {"type": "number"}], + "items": false + } + } +} diff --git a/tests/data/dynamic/types.json b/tests/data/dynamic/types.json new file mode 100644 index 000000000..e3ef17f35 --- /dev/null +++ b/tests/data/dynamic/types.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "bool_field": {"type": "boolean"}, + "float_field": {"type": "number"}, + "string_field": {"type": "string"}, + "nullable_field": {"type": ["string", "null"]}, + "const_field": {"const": "active"} + } +} diff --git a/tests/data/dynamic/unions.json b/tests/data/dynamic/unions.json new file mode 100644 index 000000000..d8063a1a8 --- /dev/null +++ b/tests/data/dynamic/unions.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "anyof_field": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + "oneof_field": { + "oneOf": [{"type": "boolean"}, {"type": "number"}] + }, + "multi_union": { + "anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "boolean"}] + } + } +} diff --git a/tests/data/dynamic/unique_items.json b/tests/data/dynamic/unique_items.json new file mode 100644 index 000000000..e0c2e4cde --- /dev/null +++ b/tests/data/dynamic/unique_items.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true + }, + "constrained_tags": { + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true, + "minItems": 1, + "maxItems": 10 + } + } +} diff --git a/tests/dynamic/__init__.py b/tests/dynamic/__init__.py new file mode 100644 index 000000000..0bc4701ff --- /dev/null +++ b/tests/dynamic/__init__.py @@ -0,0 +1 @@ +"""Tests for dynamic model generation.""" diff --git a/tests/dynamic/test_creator.py b/tests/dynamic/test_creator.py new file mode 100644 index 000000000..82fbb8fa0 --- /dev/null +++ b/tests/dynamic/test_creator.py @@ -0,0 +1,254 @@ +# ruff: noqa: D103 +"""Tests for dynamic model generation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from inline_snapshot import snapshot +from pydantic import VERSION, ValidationError + +from datamodel_code_generator import InputFileType, generate_dynamic_models + +PYDANTIC_V2 = VERSION.startswith("2.") +DATA_DIR = Path(__file__).parent.parent / "data" / "dynamic" + +pytestmark = pytest.mark.skipif(not PYDANTIC_V2, reason="Dynamic models require Pydantic v2") + + +def test_simple_model() -> None: + models = generate_dynamic_models(DATA_DIR / "simple.json") + assert "Model" in models + instance = models["Model"](name="John", age=30) + assert instance.name == snapshot("John") + assert instance.age == snapshot(30) + + +def test_nested_models() -> None: + models = generate_dynamic_models(DATA_DIR / "nested.json") + assert "Model" in models + assert "User" in models + user = models["User"](name="Alice") + model = models["Model"](user=user) + assert model.user.name == snapshot("Alice") + + +def test_numeric_constraints() -> None: + models = generate_dynamic_models(DATA_DIR / "numeric_constraints.json") + models["Model"](count=50) + with pytest.raises(ValidationError): + models["Model"](count=-1) + with pytest.raises(ValidationError): + models["Model"](count=7) + + +def test_array_constraints() -> None: + models = generate_dynamic_models(DATA_DIR / "array_constraints.json") + models["Model"](tags=["a", "b", "c"]) + with pytest.raises(ValidationError): + models["Model"](tags=[]) + with pytest.raises(ValidationError): + models["Model"](tags=["a", "b", "c", "d", "e", "f"]) + + +def test_circular_reference() -> None: + models = generate_dynamic_models(DATA_DIR / "circular.json") + assert "Node" in models + node_class = models["Node"] + node = node_class(value="root", children=[node_class(value="child", children=[])]) + assert node.children[0].value == snapshot("child") + + +def test_enum_values() -> None: + models = generate_dynamic_models(DATA_DIR / "enum.json") + models["Model"](status="active") + with pytest.raises(ValidationError): + models["Model"](status="invalid") + + +def test_optional_field() -> None: + models = generate_dynamic_models(DATA_DIR / "optional.json") + instance = models["Model"](name="John") + assert instance.name == snapshot("John") + assert instance.nickname is None + + +def test_default_value() -> None: + models = generate_dynamic_models(DATA_DIR / "default.json") + instance = models["Model"]() + assert instance.count == snapshot(10) + + +def test_allof_inheritance() -> None: + models = generate_dynamic_models(DATA_DIR / "allof.json") + child = models["Child"](id=1, name="Test") + assert child.id == snapshot(1) + assert child.name == snapshot("Test") + + +def test_openapi_schema() -> None: + models = generate_dynamic_models(DATA_DIR / "openapi.yaml", input_file_type=InputFileType.OpenAPI) + assert "User" in models + user = models["User"](name="John", email="john@example.com") + assert user.name == snapshot("John") + assert user.email == snapshot("john@example.com") + + +def test_string_constraints() -> None: + models = generate_dynamic_models(DATA_DIR / "string_constraints.json") + models["Model"](code="ABC") + with pytest.raises(ValidationError): + models["Model"](code="A") + with pytest.raises(ValidationError): + models["Model"](code="abc") + + +def test_exclusive_constraints() -> None: + models = generate_dynamic_models(DATA_DIR / "exclusive_constraints.json") + models["Model"](value=50) + with pytest.raises(ValidationError): + models["Model"](value=0) + with pytest.raises(ValidationError): + models["Model"](value=100) + + +def test_formats() -> None: + from datetime import date, datetime, time, timezone + from decimal import Decimal + from uuid import uuid4 + + models = generate_dynamic_models(DATA_DIR / "formats.json") + test_uuid = uuid4() + instance = models["Model"]( + date_field=date(2024, 1, 1), + datetime_field=datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc), + uuid_field=test_uuid, + time_field=time(12, 30), + decimal_field=Decimal("123.45"), + binary_field=b"hello", + ) + assert instance.date_field == snapshot(date(2024, 1, 1)) + assert instance.uuid_field == test_uuid + assert instance.time_field == snapshot(time(12, 30)) + assert instance.decimal_field == snapshot(Decimal("123.45")) + assert instance.binary_field == snapshot(b"hello") + + +def test_types() -> None: + models = generate_dynamic_models(DATA_DIR / "types.json") + instance = models["Model"]( + bool_field=True, float_field=19.99, string_field="test", nullable_field=None, const_field="active" + ) + assert instance.bool_field is True + assert instance.float_field == snapshot(19.99) + assert instance.nullable_field is None + assert instance.const_field == snapshot("active") + + +def test_objects() -> None: + models = generate_dynamic_models(DATA_DIR / "objects.json") + instance = models["Model"]( + dict_str={"key": "value"}, + dict_int={"count": 10}, + plain_object={"any": "value"}, + plain_array=[1, "two", 3.0], + ) + assert instance.dict_str == snapshot({"key": "value"}) + assert instance.dict_int == snapshot({"count": 10}) + + +def test_unions() -> None: + models = generate_dynamic_models(DATA_DIR / "unions.json") + assert models["Model"](anyof_field="test").anyof_field == snapshot("test") + assert models["Model"](anyof_field=42).anyof_field == snapshot(42) + assert models["Model"](oneof_field=True).oneof_field is True + assert models["Model"](multi_union="str").multi_union == snapshot("str") + + +def test_refs() -> None: + models = generate_dynamic_models(DATA_DIR / "refs.json") + for name in ["Address", "Inner", "Middle", "Model", "Outer"]: + assert name in models + address = models["Address"](city="Tokyo") + inner = models["Inner"](value="test") + middle = models["Middle"](inner=inner) + outer = models["Outer"](middle=middle) + instance = models["Model"](address=address, outer=outer) + assert instance.address.city == snapshot("Tokyo") + assert instance.outer.middle.inner.value == snapshot("test") + + +def test_standalone_enum() -> None: + models = generate_dynamic_models(DATA_DIR / "standalone_enum.json") + assert "Model" in models + assert "Status" in models + + +def test_multiple_allof() -> None: + models = generate_dynamic_models(DATA_DIR / "multiple_allof.json") + person = models["Person"](name="John", age=30) + assert person.name == snapshot("John") + assert person.age == snapshot(30) + + +def test_required_field() -> None: + models = generate_dynamic_models(DATA_DIR / "required.json") + with pytest.raises(ValidationError): + models["Model"](name="John") + instance = models["Model"](name="John", email="john@test.com") + assert instance.email == snapshot("john@test.com") + + +def test_tuple_type() -> None: + models = generate_dynamic_models(DATA_DIR / "tuple.json") + assert "Model" in models + + +def test_unique_items() -> None: + models = generate_dynamic_models(DATA_DIR / "unique_items.json") + instance = models["Model"](tags=["a", "b"], constrained_tags=["x"]) + assert "a" in instance.tags + + +def test_min_max_properties() -> None: + models = generate_dynamic_models(DATA_DIR / "min_max_properties.json") + assert "Model" in models + + +def test_description() -> None: + models = generate_dynamic_models(DATA_DIR / "description.json") + instance = models["Model"](name="Test") + assert instance.name == snapshot("Test") + field_info = models["Model"].model_fields["name"] + assert field_info.description == snapshot("The user's name") + + +def test_graphql_not_supported() -> None: + from datamodel_code_generator import Error + + with pytest.raises(Error, match="GraphQL is not yet supported"): + generate_dynamic_models("type Query { hello: String }", input_file_type=InputFileType.GraphQL) + + +def test_type_resolution_error() -> None: + from unittest.mock import MagicMock, patch + + from datamodel_code_generator.dynamic.creator import DynamicModelCreator + from datamodel_code_generator.dynamic.exceptions import TypeResolutionError + + mock_parser = MagicMock() + creator = DynamicModelCreator(mock_parser) + mock_field = MagicMock() + mock_field.name = "test_field" + mock_field.data_type = MagicMock() + mock_data_model = MagicMock() + mock_data_model.class_name = "TestModel" + mock_data_model.fields = [mock_field] + mock_data_model.base_classes = [] + mock_data_model.reference = None + + with patch.object(creator._type_resolver, "resolve_with_constraints", side_effect=ValueError("test error")): + with pytest.raises(TypeResolutionError) as exc_info: + creator._create_pydantic_model(mock_data_model) + assert "test_field" in str(exc_info.value)