diff --git a/src/datamodel_code_generator/format.py b/src/datamodel_code_generator/format.py index 196ce92f7..f88c830a1 100644 --- a/src/datamodel_code_generator/format.py +++ b/src/datamodel_code_generator/format.py @@ -716,10 +716,17 @@ def _is_union(node: ast.AST) -> TypeGuard[ast.Subscript]: return isinstance(node, ast.Subscript) and _is_name_or_attr(node.value, "Union") +_CONSTRAINED_CALL_NAMES = frozenset({"conbytes", "condecimal", "confloat", "conint", "conlist", "conset", "constr"}) + + def _is_constrained_string_call(node: ast.AST | None) -> TypeGuard[ast.Call]: return _is_call(node, "constr") +def _is_constrained_call(node: ast.AST | None) -> TypeGuard[ast.Call]: + return any(_is_call(node, name) for name in _CONSTRAINED_CALL_NAMES) + + def _contains_constrained_string_call(node: ast.AST) -> bool: return any(_is_constrained_string_call(child) for child in ast.walk(node)) @@ -738,7 +745,7 @@ def _is_simple_union_annotation(node: ast.AST) -> bool: return isinstance(node, ast.Name | ast.Attribute) or (isinstance(node, ast.Constant) and node.value is None) -def _should_format_constrained_string_union( +def _should_format_constrained_call_union( annotation: ast.AST, value: ast.AST | None, annotation_prefix: str, @@ -747,9 +754,9 @@ def _should_format_constrained_string_union( ) -> TypeGuard[ast.BinOp]: if not isinstance(annotation, ast.BinOp) or not isinstance(annotation.op, ast.BitOr) or value is None: return False - if not (_is_constrained_string_call(annotation.left) or _is_constrained_string_call(annotation.right)): + if not (_is_constrained_call(annotation.left) or _is_constrained_call(annotation.right)): return False - constrained_call = annotation.left if _is_constrained_string_call(annotation.left) else annotation.right + constrained_call = annotation.left if _is_constrained_call(annotation.left) else annotation.right return ( not _is_call(value, "Field") or len(annotation_prefix) > line_length @@ -1010,7 +1017,7 @@ def _format_generated_annotation_assignment( # noqa: PLR0911, PLR0912 value_prefix = f"{annotation_prefix} = " if statement.value is None and _is_list_of_annotated(statement.annotation): return f"{target_prefix}{_format_list_of_annotated(statement.annotation, indent, line_length, source)}" - if _should_format_constrained_string_union( + if _should_format_constrained_call_union( statement.annotation, statement.value, annotation_prefix, @@ -1020,7 +1027,7 @@ def _format_generated_annotation_assignment( # noqa: PLR0911, PLR0912 assert statement.value is not None value = _source_segment(source, statement.value) return ( - f"{target_prefix}{_format_constrained_string_union(statement.annotation, indent, line_length, source)}" + f"{target_prefix}{_format_constrained_call_union(statement.annotation, indent, line_length, source)}" f" = {value}" ) if _should_format_field_bit_or_annotation_assignment( @@ -1224,7 +1231,7 @@ def _format_generated_class_statement( return None -def _format_constrained_string_union( +def _format_constrained_call_union( annotation: ast.BinOp, indent: str, line_length: int, @@ -1232,13 +1239,13 @@ def _format_constrained_string_union( ) -> str: left = annotation.left right = annotation.right - if _is_constrained_string_call(left): + if _is_constrained_call(left): formatted_left = _format_constrained_call(left, f"{indent} ", line_length, source) inline_union = f"{formatted_left} | {_source_segment(source, right)}" if "\n" not in formatted_left and len(f"{indent} {inline_union}") <= line_length: return f"(\n{indent} {inline_union}\n{indent})" return f"(\n{indent} {formatted_left}\n{indent} | {_source_segment(source, right)}\n{indent})" - if _is_constrained_string_call(right): + if _is_constrained_call(right): return f"{_source_segment(source, left)} | {_format_constrained_call(right, indent, line_length, source)}" return _source_segment(source, annotation) # pragma: no cover diff --git a/src/datamodel_code_generator/model/__init__.py b/src/datamodel_code_generator/model/__init__.py index b254df041..46a506f81 100644 --- a/src/datamodel_code_generator/model/__init__.py +++ b/src/datamodel_code_generator/model/__init__.py @@ -11,6 +11,7 @@ from datamodel_code_generator import PythonVersion +from .base import UNDEFINED as UNDEFINED from .base import ConstraintsBase, DataModel, DataModelFieldBase, _rebuild_model_with_datamodel_namespace if TYPE_CHECKING: diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index 45f33e44f..c11ee18fd 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -26,6 +26,7 @@ IMPORT_UNION, Import, ) +from datamodel_code_generator.python_literal import represent_python_value from datamodel_code_generator.reference import Reference, _BaseModel from datamodel_code_generator.types import ( ANY, @@ -468,9 +469,7 @@ def method(self) -> str | None: @property def represented_default(self) -> str: """Get the repr() string of the default value.""" - if isinstance(self.default, set): - return repr_set_sorted(self.default) - return repr(self.default) + return represent_python_value(self.default) @property def annotated(self) -> str | None: diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index c7bd0a9d4..0264fa1b0 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -15,13 +15,19 @@ from datamodel_code_generator import cached_path_exists from datamodel_code_generator.imports import Import from datamodel_code_generator.model import ( + UNDEFINED, ConstraintsBase, DataModel, DataModelFieldBase, _rebuild_model_with_datamodel_namespace, ) -from datamodel_code_generator.model.base import UNDEFINED, repr_set_sorted -from datamodel_code_generator.types import UnionIntFloat, chain_as_tuple +from datamodel_code_generator.python_literal import represent_python_value +from datamodel_code_generator.types import ( + UnionIntFloat, + chain_as_tuple, + merge_normalized_constraint, + normalize_integer_constraint, +) # Defined here instead of importing from pydantic_v2.imports to avoid circular import # (pydantic_base -> pydantic_v2.imports -> pydantic_v2/__init__ -> pydantic_v2.base_model -> pydantic_base) @@ -68,6 +74,7 @@ class DataModelField(DataModelFieldBase): "regex", } _COMPARE_EXPRESSIONS: ClassVar[set[str]] = {"gt", "ge", "lt", "le"} + _INTEGER_CONSTRAINTS: ClassVar[set[str]] = _COMPARE_EXPRESSIONS | {"multiple_of"} constraints: Optional[Constraints] = None # noqa: UP045 _PARSE_METHOD: ClassVar[str] = "model_validate" @@ -138,26 +145,43 @@ def _has_field_statement(self) -> bool: return True return bool(self.nullable and self.required and not self.use_default_with_required) - def _has_float_data_type(self) -> bool: - """Check whether any nested data type is float-like.""" + def _has_numeric_data_type(self, type_name: str, strict_import_part: str) -> bool: + """Return whether any field data type is the given builtin or strict numeric type.""" return any( - data_type.type == "float" - or (data_type.strict and data_type.import_ and "Float" in data_type.import_.import_) + data_type.type == type_name + or (data_type.strict and data_type.import_ and strict_import_part in data_type.import_.import_) for data_type in self.data_type.all_data_types ) - def _get_strict_field_constraint_value(self, constraint: str, value: Any, *, is_float_type: bool) -> Any: - if value is None or constraint not in self._COMPARE_EXPRESSIONS: - return value + def _get_strict_field_constraint( + self, constraint: str, value: Any, *, is_float_type: bool, is_int_type: bool + ) -> tuple[str, Any] | None: + if value is None or constraint not in self._INTEGER_CONSTRAINTS: + return constraint, value if is_float_type: - return float(value) - str_value = str(value) - if "e" in str_value.lower(): # pragma: no cover - # Scientific notation like 1e-08 - keep as float - return float(value) - if isinstance(value, int) and not isinstance(value, bool): - return value - return int(value) + return constraint, float(value) + if is_int_type: + return normalize_integer_constraint(constraint, value) + if isinstance(value, float) and value.is_integer(): + return constraint, int(value) + return constraint, value + + def _get_normalized_constraint_data(self) -> dict[str, Any]: + """Build constraint data with integer-safe values, merging colliding bounds.""" + assert self.constraints is not None + dumped = self.constraints._exclude_unset_dump # noqa: SLF001 + has_integer_constraints = bool(self._INTEGER_CONSTRAINTS & dumped.keys()) + is_float_type = has_integer_constraints and self._has_numeric_data_type("float", "Float") + is_int_type = has_integer_constraints and not is_float_type and self._has_numeric_data_type("int", "Int") + constraint_data: dict[str, Any] = {} + for k, v in dumped.items(): + if ( + normalized := self._get_strict_field_constraint( + k, v, is_float_type=is_float_type, is_int_type=is_int_type + ) + ) is not None: + merge_normalized_constraint(constraint_data, normalized[0], normalized[1]) + return constraint_data def _get_default_factory_for_optional_nested_model(self) -> str | None: """Get default_factory for optional nested Pydantic model fields. @@ -196,12 +220,7 @@ def _get_field_data_and_default_factory(self) -> tuple[dict[str, Any], Any]: if any(d.import_ == IMPORT_ANYURL for d in self.data_type.all_data_types): constraint_data: dict[str, Any] = {} else: - dumped = self.constraints._exclude_unset_dump # noqa: SLF001 - is_float_type = bool(self._COMPARE_EXPRESSIONS & dumped.keys()) and self._has_float_data_type() - constraint_data = { - k: self._get_strict_field_constraint_value(k, v, is_float_type=is_float_type) - for k, v in dumped.items() - } + constraint_data = self._get_normalized_constraint_data() data = {**data, **constraint_data} if self.use_field_description: @@ -239,7 +258,7 @@ def __str__(self) -> str: """Return Field() call with all constraints and metadata.""" data, default_factory = self._get_field_data_and_default_factory() - field_arguments = sorted(f"{k}={v!r}" for k, v in data.items() if v is not None) + field_arguments = sorted(f"{k}={represent_python_value(v)}" for k, v in data.items() if v is not None) if not field_arguments and not default_factory: if self.nullable and self.required and not self.use_default_with_required: @@ -259,13 +278,13 @@ def __str__(self) -> str: ): field_arguments = ["...", *field_arguments] elif not default_factory: - default_repr = repr_set_sorted(self.default) if isinstance(self.default, set) else repr(self.default) + default_repr = represent_python_value(self.default) field_arguments = [default_repr, *field_arguments] if self.is_class_var: if self.default is UNDEFINED: # pragma: no cover return "" - return repr_set_sorted(self.default) if isinstance(self.default, set) else repr(self.default) + return represent_python_value(self.default) return f"Field({', '.join(field_arguments)})" diff --git a/src/datamodel_code_generator/model/pydantic_v2/types.py b/src/datamodel_code_generator/model/pydantic_v2/types.py index 58589d903..9b9f28095 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/types.py +++ b/src/datamodel_code_generator/model/pydantic_v2/types.py @@ -5,6 +5,7 @@ from __future__ import annotations +import ast from decimal import Decimal from typing import TYPE_CHECKING, Any, ClassVar @@ -65,11 +66,13 @@ IMPORT_UUID4, IMPORT_UUID5, ) +from datamodel_code_generator.python_literal import PythonCode from datamodel_code_generator.types import ( DataType, StrictTypes, Types, UnionIntFloat, + normalize_integer_constraints, ) from datamodel_code_generator.types import DataTypeManager as _DataTypeManagerBase @@ -126,8 +129,7 @@ def type_map_factory( IMPORT_CONSTR, strict=StrictTypes.str in strict_types, kwargs={ - pattern_key: r"r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*" - r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])\Z'", + pattern_key: _get_regex_literal(HOSTNAME_REGEX), **({"strict": True} if StrictTypes.str in strict_types else {}), }, ), @@ -188,6 +190,16 @@ def strict_type_map_factory(data_type: type[DataType]) -> dict[StrictTypes, Data ) +def _get_regex_literal(pattern: str) -> str: + escaped_regex = pattern.translate(escape_characters) + raw_literal = f"r'{escaped_regex}'" + try: + ast.literal_eval(raw_literal) + except SyntaxError: + return pattern + return PythonCode(raw_literal, pattern) + + class _PydanticDataTypeManager(_DataTypeManagerBase): """Base data type manager for Pydantic models with constrained types.""" @@ -299,7 +311,12 @@ def get_data_int_type( # noqa: PLR0911 return self.data_type.from_import(IMPORT_NON_NEGATIVE_INT) if data_type_kwargs == {"le": 0} and self.use_non_positive_negative_number_constrained_types: return self.data_type.from_import(IMPORT_NON_POSITIVE_INT) - kwargs = {k: int(v) for k, v in data_type_kwargs.items()} + if not (kwargs := normalize_integer_constraints(data_type_kwargs)): + return ( + self.copy_data_type(self.strict_type_map[StrictTypes.int]) + if strict + else self.copy_data_type(self.type_map[types]) + ) if strict: kwargs["strict"] = True return self.data_type.from_import(IMPORT_CONINT, kwargs=kwargs) @@ -362,9 +379,7 @@ def get_data_str_type(self, types: Types, **kwargs: Any) -> DataType: if strict: data_type_kwargs["strict"] = True # ty: ignore if self.PATTERN_KEY in data_type_kwargs: - escaped_regex = data_type_kwargs[self.PATTERN_KEY].translate(escape_characters) - # TODO: remove unneeded escaped characters - data_type_kwargs[self.PATTERN_KEY] = f"r'{escaped_regex}'" + data_type_kwargs[self.PATTERN_KEY] = _get_regex_literal(data_type_kwargs[self.PATTERN_KEY]) return self.data_type.from_import(IMPORT_CONSTR, kwargs=data_type_kwargs) if strict: return self.copy_data_type(self.strict_type_map[StrictTypes.str]) @@ -523,8 +538,7 @@ def type_map_factory( # noqa: PLR0913, PLR0917 IMPORT_CONSTR, strict=StrictTypes.str in strict_types, kwargs={ - pattern_key: r"r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*" - r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$'", + pattern_key: _get_regex_literal(HOSTNAME_REGEX), **({"strict": True} if StrictTypes.str in strict_types else {}), }, ), diff --git a/src/datamodel_code_generator/python_literal.py b/src/datamodel_code_generator/python_literal.py new file mode 100644 index 000000000..d6c536638 --- /dev/null +++ b/src/datamodel_code_generator/python_literal.py @@ -0,0 +1,53 @@ +"""Helpers for rendering Python source literals.""" + +from __future__ import annotations + +from math import isfinite, isnan +from typing import Any + +from typing_extensions import Self + + +class PythonCode(str): # noqa: FURB189 - must behave as str for regex consumers. + """Python expression rendered without extra quoting.""" + + code: str + __slots__ = ("code",) + + def __new__(cls, code: str, value: str | None = None) -> Self: + """Initialize with a raw Python expression and optional string value.""" + obj = super().__new__(cls, code if value is None else value) + obj.code = code + return obj + + def __repr__(self) -> str: + """Render the wrapped expression.""" + return self.code + + +def represent_python_value(value: Any) -> str: # noqa: PLR0911 + """Render a value as a Python expression safe for generated source.""" + match value: + case PythonCode(): + return value.code + case float() if isnan(value): + return "float('nan')" + case float() if not isfinite(value): + return "float('inf')" if value > 0 else "float('-inf')" + case dict(): + rendered_items = ", ".join( + f"{represent_python_value(key)}: {represent_python_value(item)}" for key, item in value.items() + ) + return f"{{{rendered_items}}}" + case list(): + return "[" + ", ".join(represent_python_value(item) for item in value) + "]" + case tuple(): + rendered_items = ", ".join(represent_python_value(item) for item in value) + trailing_comma = "," if len(value) == 1 else "" + return f"({rendered_items}{trailing_comma})" + case set() if not value: + return "set()" + case set(): + sorted_items = sorted(value, key=lambda item: (type(item).__name__, repr(item))) + return "{" + ", ".join(represent_python_value(item) for item in sorted_items) + "}" + return repr(value) diff --git a/src/datamodel_code_generator/types.py b/src/datamodel_code_generator/types.py index 47980ad1d..9424ff15f 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -11,7 +11,9 @@ import re from abc import ABC, abstractmethod from copy import deepcopy +from decimal import Decimal from enum import Enum, auto +from fractions import Fraction from functools import cache, lru_cache from itertools import chain from re import Pattern @@ -41,6 +43,7 @@ IMPORT_ABC_SEQUENCE, IMPORT_ANNOTATED, IMPORT_ANY, + IMPORT_DECIMAL, IMPORT_DICT, IMPORT_FROZEN_SET, IMPORT_LIST, @@ -53,6 +56,7 @@ IMPORT_UNION, Import, ) +from datamodel_code_generator.python_literal import represent_python_value from datamodel_code_generator.reference import Reference, _BaseModel T = TypeVar("T") @@ -165,6 +169,76 @@ def validate(cls, v: Any) -> UnionIntFloat: return cls(v) +def _contains_decimal(value: Any) -> bool: + match value: + case Decimal(): + return True + case dict(): + return any(_contains_decimal(k) or _contains_decimal(v) for k, v in value.items()) + case list() | tuple() | set() | frozenset(): + return any(_contains_decimal(item) for item in value) + case _: + return False + + +def _get_fraction_floor(value: Fraction) -> int: + return value.numerator // value.denominator + + +def _get_fraction_ceil(value: Fraction) -> int: + return -(-value.numerator // value.denominator) + + +def _get_constraint_number(value: Any) -> Any: + return value.value if isinstance(value, UnionIntFloat) else value + + +def normalize_integer_constraint(constraint: str, value: Any) -> tuple[str, Any] | None: # noqa: PLR0911 + """Return an integer-safe pydantic constraint for a numeric schema constraint.""" + number = _get_constraint_number(value) + try: + fraction = Fraction(str(number)) + except (TypeError, ValueError): + return constraint, value + + match constraint: + case "multiple_of" if fraction.numerator == 1: + return None + case "multiple_of": + return constraint, abs(fraction.numerator) + case _ if fraction.denominator == 1: + return constraint, int(fraction) + case "ge": + return "ge", _get_fraction_ceil(fraction) + case "gt": + return "ge", _get_fraction_floor(fraction) + 1 + case "le": + return "le", _get_fraction_floor(fraction) + case "lt": + return "le", _get_fraction_ceil(fraction) - 1 + return constraint, value + + +def merge_normalized_constraint(constraints: dict[str, Any], key: str, value: Any) -> None: + """Merge a normalized constraint, keeping the stronger bound when ge or le collides.""" + match constraints.get(key): + case None: + constraints[key] = value + case current if key == "ge": + constraints[key] = max(current, value) + case current: + constraints[key] = min(current, value) + + +def normalize_integer_constraints(constraints: dict[str, Any]) -> dict[str, Any]: + """Return integer-safe pydantic constraints.""" + normalized_constraints: dict[str, Any] = {} + for key, value in constraints.items(): + if (normalized := normalize_integer_constraint(key, value)) is not None: + merge_normalized_constraint(normalized_constraints, normalized[0], normalized[1]) + return normalized_constraints + + def chain_as_tuple(*iterables: Iterable[T]) -> tuple[T, ...]: """Chain multiple iterables and return as a tuple. @@ -653,6 +727,8 @@ def imports(self) -> Iterator[Import]: # Add base import if exists if self.import_: yield self.import_ + if self.kwargs and self.import_ != IMPORT_DECIMAL and _contains_decimal(self.kwargs): + yield IMPORT_DECIMAL # Yield imports based on conditions for field, import_ in self._conditional_imports(): @@ -807,7 +883,7 @@ def type_hint(self) -> str: # noqa: PLR0912, PLR0915 if self.is_optional and type_ != ANY: return get_optional_type(type_, self.use_union_operator) if self.is_func and self.kwargs: - kwargs: str = ", ".join(f"{k}={v}" for k, v in self.kwargs.items()) + kwargs: str = ", ".join(f"{k}={represent_python_value(v)}" for k, v in self.kwargs.items()) return f"{type_}({kwargs})" return type_ diff --git a/tests/data/expected/main/jsonschema/decimal_fractional_constraints.py b/tests/data/expected/main/jsonschema/decimal_fractional_constraints.py new file mode 100644 index 000000000..d295dc151 --- /dev/null +++ b/tests/data/expected/main/jsonschema/decimal_fractional_constraints.py @@ -0,0 +1,13 @@ +# generated by datamodel-codegen: +# filename: decimal_fractional_constraints.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from decimal import Decimal + +from pydantic import BaseModel, Field + + +class Model(BaseModel): + price: Decimal | None = Field(None, ge=0.5, multiple_of=0.01) diff --git a/tests/data/expected/main/jsonschema/integer_fractional_constraints.py b/tests/data/expected/main/jsonschema/integer_fractional_constraints.py new file mode 100644 index 000000000..efa5de8f3 --- /dev/null +++ b/tests/data/expected/main/jsonschema/integer_fractional_constraints.py @@ -0,0 +1,18 @@ +# generated by datamodel-codegen: +# filename: integer_fractional_constraints.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, conint + + +class Model(BaseModel): + ge_field: conint(ge=1) | None = None + any_multiple: int | None = None + gt_field: conint(ge=-1) | None = None + fraction_multiple: conint(multiple_of=3) | None = None + le_field: conint(le=2) | None = None + lt_field: conint(le=2) | None = None + combined_min: conint(ge=3) | None = None + combined_max: conint(le=2) | None = None diff --git a/tests/data/expected/main/jsonschema/integer_fractional_constraints_field_constraints.py b/tests/data/expected/main/jsonschema/integer_fractional_constraints_field_constraints.py new file mode 100644 index 000000000..53e2c5ee9 --- /dev/null +++ b/tests/data/expected/main/jsonschema/integer_fractional_constraints_field_constraints.py @@ -0,0 +1,18 @@ +# generated by datamodel-codegen: +# filename: integer_fractional_constraints.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class Model(BaseModel): + ge_field: int | None = Field(None, ge=1) + any_multiple: int | None = None + gt_field: int | None = Field(None, ge=-1) + fraction_multiple: int | None = Field(None, multiple_of=3) + le_field: int | None = Field(None, le=2) + lt_field: int | None = Field(None, le=2) + combined_min: int | None = Field(None, ge=3) + combined_max: int | None = Field(None, le=2) diff --git a/tests/data/expected/main/jsonschema/non_finite_number_values.py b/tests/data/expected/main/jsonschema/non_finite_number_values.py new file mode 100644 index 000000000..cff2dd426 --- /dev/null +++ b/tests/data/expected/main/jsonschema/non_finite_number_values.py @@ -0,0 +1,13 @@ +# generated by datamodel-codegen: +# filename: non_finite_number_values.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, confloat + + +class Model(BaseModel): + big_default: float | None = float('inf') + small_default: float | None = float('-inf') + big_min: confloat(ge=float('inf')) | None = None diff --git a/tests/data/expected/main/jsonschema/pattern_escaped_quote.py b/tests/data/expected/main/jsonschema/pattern_escaped_quote.py new file mode 100644 index 000000000..32ae36428 --- /dev/null +++ b/tests/data/expected/main/jsonschema/pattern_escaped_quote.py @@ -0,0 +1,11 @@ +# generated by datamodel-codegen: +# filename: pattern_escaped_quote.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from pydantic import BaseModel, constr + + +class Model(BaseModel): + x: constr(pattern="^don\\'t$") | None = None diff --git a/tests/data/expected/main/jsonschema/strict_types_with_constraints.py b/tests/data/expected/main/jsonschema/strict_types_with_constraints.py index 13df807f9..c74640359 100644 --- a/tests/data/expected/main/jsonschema/strict_types_with_constraints.py +++ b/tests/data/expected/main/jsonschema/strict_types_with_constraints.py @@ -13,7 +13,7 @@ class StrictTypesCoverage(BaseModel): int_with_range: conint(ge=1, le=100, strict=True) | None = None float_with_range: confloat(ge=1.5, le=99.9, strict=True) | None = None decimal_value: Decimal | None = None - decimal_with_range: condecimal(ge=0, le=1000) | None = None + decimal_with_range: condecimal(ge=Decimal('0'), le=Decimal('1000')) | None = None hostname_value: ( constr( pattern=r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$', diff --git a/tests/data/expected/main/protobuf/directory/example_legacy_legacy_message.py b/tests/data/expected/main/protobuf/directory/example_legacy_legacy_message.py index 4fa0d8df6..86c68dbf2 100644 --- a/tests/data/expected/main/protobuf/directory/example_legacy_legacy_message.py +++ b/tests/data/expected/main/protobuf/directory/example_legacy_legacy_message.py @@ -4,7 +4,6 @@ from __future__ import annotations -from math import inf from pydantic import BaseModel, ConfigDict from . import example_legacy_legacy_message_legacy_group, example_legacy_legacy_status @@ -18,7 +17,7 @@ class ExampleLegacyLegacyMessage(BaseModel): count: int | None = 7 enabled: bool | None = True tags: list[str] | None = [] - ratio: float | None = inf + ratio: float | None = float('inf') status: example_legacy_legacy_status.ExampleLegacyLegacyStatus | None = ( 'LEGACY_ACTIVE' ) diff --git a/tests/data/expected/main/protobuf/directory/example_spec_proto2_spec_proto2.py b/tests/data/expected/main/protobuf/directory/example_spec_proto2_spec_proto2.py index e1231f51e..ec4f2d3fd 100644 --- a/tests/data/expected/main/protobuf/directory/example_spec_proto2_spec_proto2.py +++ b/tests/data/expected/main/protobuf/directory/example_spec_proto2_spec_proto2.py @@ -4,7 +4,6 @@ from __future__ import annotations -from math import inf, nan from pydantic import BaseModel, ConfigDict, Field, conint from . import ( @@ -22,9 +21,9 @@ class ExampleSpecProto2SpecProto2(BaseModel): decimal_default: int | None = -42 hex_default: int | None = 42 octal_default: conint(ge=0, le=4294967295) | None = 42 - pos_inf: float | None = inf - neg_inf: float | None = -inf - not_a_number: float | None = nan + pos_inf: float | None = float('inf') + neg_inf: float | None = float('-inf') + not_a_number: float | None = float('nan') enabled: bool | None = False single_quoted: str | None = 'single' escaped_bytes: bytes | None = b'\x01\x02' diff --git a/tests/data/expected/main/protobuf/directory_black_lt_24/example_legacy_legacy_message.py b/tests/data/expected/main/protobuf/directory_black_lt_24/example_legacy_legacy_message.py index d4f572830..34153ef87 100644 --- a/tests/data/expected/main/protobuf/directory_black_lt_24/example_legacy_legacy_message.py +++ b/tests/data/expected/main/protobuf/directory_black_lt_24/example_legacy_legacy_message.py @@ -4,7 +4,6 @@ from __future__ import annotations -from math import inf from pydantic import BaseModel, ConfigDict from . import example_legacy_legacy_message_legacy_group, example_legacy_legacy_status @@ -18,7 +17,7 @@ class ExampleLegacyLegacyMessage(BaseModel): count: int | None = 7 enabled: bool | None = True tags: list[str] | None = [] - ratio: float | None = inf + ratio: float | None = float('inf') status: example_legacy_legacy_status.ExampleLegacyLegacyStatus | None = ( 'LEGACY_ACTIVE' ) diff --git a/tests/data/expected/main/protobuf/directory_black_lt_24/example_spec_proto2_spec_proto2.py b/tests/data/expected/main/protobuf/directory_black_lt_24/example_spec_proto2_spec_proto2.py index 2d731aeb9..a0cb9cced 100644 --- a/tests/data/expected/main/protobuf/directory_black_lt_24/example_spec_proto2_spec_proto2.py +++ b/tests/data/expected/main/protobuf/directory_black_lt_24/example_spec_proto2_spec_proto2.py @@ -4,7 +4,6 @@ from __future__ import annotations -from math import inf, nan from pydantic import BaseModel, ConfigDict, Field, conint from . import ( @@ -22,9 +21,9 @@ class ExampleSpecProto2SpecProto2(BaseModel): decimal_default: int | None = -42 hex_default: int | None = 42 octal_default: conint(ge=0, le=4294967295) | None = 42 - pos_inf: float | None = inf - neg_inf: float | None = -inf - not_a_number: float | None = nan + pos_inf: float | None = float('inf') + neg_inf: float | None = float('-inf') + not_a_number: float | None = float('nan') enabled: bool | None = False single_quoted: str | None = 'single' escaped_bytes: bytes | None = b'\x01\x02' diff --git a/tests/data/expected/main/protobuf/proto2.py b/tests/data/expected/main/protobuf/proto2.py index 01a88d774..e22f04735 100644 --- a/tests/data/expected/main/protobuf/proto2.py +++ b/tests/data/expected/main/protobuf/proto2.py @@ -4,7 +4,6 @@ from __future__ import annotations -from math import inf from enum import Enum from pydantic import BaseModel, ConfigDict @@ -30,7 +29,7 @@ class ExampleLegacyLegacyMessage(BaseModel): count: int | None = 7 enabled: bool | None = True tags: list[str] | None = [] - ratio: float | None = inf + ratio: float | None = float('inf') status: ExampleLegacyLegacyStatus | None = 'LEGACY_ACTIVE' title: str | None = 'legacy' annotated: str | None = 'kept' diff --git a/tests/data/expected/main/protobuf/spec_proto2.py b/tests/data/expected/main/protobuf/spec_proto2.py index 81a4f3c34..a8cbb83b2 100644 --- a/tests/data/expected/main/protobuf/spec_proto2.py +++ b/tests/data/expected/main/protobuf/spec_proto2.py @@ -4,7 +4,6 @@ from __future__ import annotations -from math import inf, nan from enum import Enum from pydantic import BaseModel, ConfigDict, Field, conint @@ -39,9 +38,9 @@ class ExampleSpecProto2SpecProto2(BaseModel): decimal_default: int | None = -42 hex_default: int | None = 42 octal_default: conint(ge=0, le=4294967295) | None = 42 - pos_inf: float | None = inf - neg_inf: float | None = -inf - not_a_number: float | None = nan + pos_inf: float | None = float('inf') + neg_inf: float | None = float('-inf') + not_a_number: float | None = float('nan') enabled: bool | None = False single_quoted: str | None = 'single' escaped_bytes: bytes | None = b'\x01\x02' diff --git a/tests/data/expected/main/xmlschema/special_float_bounds.py b/tests/data/expected/main/xmlschema/special_float_bounds.py index 243f98ea9..4e57a5693 100644 --- a/tests/data/expected/main/xmlschema/special_float_bounds.py +++ b/tests/data/expected/main/xmlschema/special_float_bounds.py @@ -4,9 +4,8 @@ from __future__ import annotations -from math import inf from pydantic import BaseModel, confloat class Bounds(BaseModel): - bounded: confloat(ge=-inf, lt=inf) | None = None + bounded: confloat(ge=float('-inf'), lt=float('inf')) | None = None diff --git a/tests/data/expected/main/xmlschema/special_float_defaults.py b/tests/data/expected/main/xmlschema/special_float_defaults.py index 37e3b6e1a..ee8422d56 100644 --- a/tests/data/expected/main/xmlschema/special_float_defaults.py +++ b/tests/data/expected/main/xmlschema/special_float_defaults.py @@ -4,15 +4,14 @@ from __future__ import annotations -from math import inf, nan from pydantic import BaseModel, confloat class Model(BaseModel): finite: float | None = 1.5 - positive: float | None = inf - positiveXsd11: float | None = inf - negative: float | None = -inf - notNumber: float | None = nan - bounded: confloat(ge=-inf, lt=inf) | None = None - limit: float | None = inf + positive: float | None = float('inf') + positiveXsd11: float | None = float('inf') + negative: float | None = float('-inf') + notNumber: float | None = float('nan') + bounded: confloat(ge=float('-inf'), lt=float('inf')) | None = None + limit: float | None = float('inf') diff --git a/tests/data/expected/main_kr/use_decimal_for_multiple_of/output.py b/tests/data/expected/main_kr/use_decimal_for_multiple_of/output.py index a0bba7911..8347d0b0a 100644 --- a/tests/data/expected/main_kr/use_decimal_for_multiple_of/output.py +++ b/tests/data/expected/main_kr/use_decimal_for_multiple_of/output.py @@ -4,11 +4,19 @@ from __future__ import annotations +from decimal import Decimal + from pydantic import BaseModel, condecimal, confloat class Model(BaseModel): - price: condecimal(ge=0, le=99999.99, multiple_of=0.01) | None = None - quantity: condecimal(multiple_of=0.1) | None = None - rate: condecimal(multiple_of=0.001, lt=1.0, gt=0.0) | None = None + price: ( + condecimal(ge=Decimal('0'), le=Decimal('99999.99'), multiple_of=Decimal('0.01')) + | None + ) = None + quantity: condecimal(multiple_of=Decimal('0.1')) | None = None + rate: ( + condecimal(multiple_of=Decimal('0.001'), lt=Decimal('1.0'), gt=Decimal('0.0')) + | None + ) = None simple_float: confloat(ge=0.0, le=100.0) | None = None diff --git a/tests/data/jsonschema/decimal_fractional_constraints.json b/tests/data/jsonschema/decimal_fractional_constraints.json new file mode 100644 index 000000000..be41e6d85 --- /dev/null +++ b/tests/data/jsonschema/decimal_fractional_constraints.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "price": { + "type": "string", + "format": "decimal", + "minimum": 0.5, + "multipleOf": 0.01 + } + } +} diff --git a/tests/data/jsonschema/integer_fractional_constraints.json b/tests/data/jsonschema/integer_fractional_constraints.json new file mode 100644 index 000000000..f0203bb5a --- /dev/null +++ b/tests/data/jsonschema/integer_fractional_constraints.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "ge_field": {"type": "integer", "minimum": 0.5}, + "any_multiple": {"type": "integer", "multipleOf": 0.5}, + "gt_field": {"type": "integer", "exclusiveMinimum": -1.5}, + "fraction_multiple": {"type": "integer", "multipleOf": 1.5}, + "le_field": {"type": "integer", "maximum": 2.5}, + "lt_field": {"type": "integer", "exclusiveMaximum": 2.5}, + "combined_min": {"type": "integer", "minimum": 2.5, "exclusiveMinimum": 1.5}, + "combined_max": {"type": "integer", "maximum": 2.5, "exclusiveMaximum": 4.5} + } +} diff --git a/tests/data/jsonschema/non_finite_number_values.json b/tests/data/jsonschema/non_finite_number_values.json new file mode 100644 index 000000000..d2a7353eb --- /dev/null +++ b/tests/data/jsonschema/non_finite_number_values.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "big_default": {"type": "number", "default": 1e999}, + "small_default": {"type": "number", "default": -1e999}, + "big_min": {"type": "number", "minimum": 1e999} + } +} diff --git a/tests/data/jsonschema/pattern_escaped_quote.json b/tests/data/jsonschema/pattern_escaped_quote.json new file mode 100644 index 000000000..d8cf3b49d --- /dev/null +++ b/tests/data/jsonschema/pattern_escaped_quote.json @@ -0,0 +1,6 @@ +{ + "type": "object", + "properties": { + "x": {"type": "string", "pattern": "^don\\'t$"} + } +} diff --git a/tests/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index a395df040..d776fb5b4 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -11874,6 +11874,122 @@ def test_main_jsonschema_discriminated_oneof_allof_cycle(output_file: Path) -> N ) +def test_main_jsonschema_pattern_escaped_quote(output_file: Path) -> None: + """Test pattern with an escaped quote renders importable code that still matches.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "pattern_escaped_quote.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="pattern_escaped_quote.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel"], + ) + assert_generated_model_json_validation( + output_file, + module_name="generated_pattern_escaped_quote", + model_name="Model", + valid_json='{"x": "don\'t"}', + invalid_json='{"x": "dont"}', + expected_error_type="string_pattern_mismatch", + expected_attribute_path=("x",), + expected_attribute_value="don't", + ) + + +@pytest.mark.parametrize( + ("constraint_args", "module_name", "expected_file"), + [ + ([], "generated_integer_fractional_constraints", "integer_fractional_constraints.py"), + ( + ["--field-constraints"], + "generated_integer_fractional_field_constraints", + "integer_fractional_constraints_field_constraints.py", + ), + ], +) +def test_main_jsonschema_integer_fractional_constraints( + constraint_args: list[str], module_name: str, expected_file: str, output_file: Path +) -> None: + """Test fractional integer constraints normalize to integer-safe bounds.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "integer_fractional_constraints.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file=expected_file, + extra_args=["--output-model-type", "pydantic_v2.BaseModel", *constraint_args], + ) + assert_generated_model_json_validation( + output_file, + module_name=module_name, + model_name="Model", + valid_json=( + '{"ge_field": 1, "any_multiple": 7, "gt_field": -1, "fraction_multiple": 3,' + ' "le_field": 2, "lt_field": 2, "combined_min": 3, "combined_max": 2}' + ), + invalid_json='{"ge_field": 0}', + expected_error_type="greater_than_equal", + expected_attribute_path=("fraction_multiple",), + expected_attribute_value=3, + ) + assert_generated_model_json_invalid( + output_file, + module_name=module_name, + model_name="Model", + invalid_json='{"combined_min": 2}', + expected_error_type="greater_than_equal", + ) + assert_generated_model_json_invalid( + output_file, + module_name=module_name, + model_name="Model", + invalid_json='{"combined_max": 3}', + expected_error_type="less_than_equal", + ) + + +def test_main_jsonschema_non_finite_number_values(output_file: Path) -> None: + """Test non-finite numeric defaults and bounds render as valid Python expressions.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "non_finite_number_values.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="non_finite_number_values.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel"], + ) + assert_generated_model_json_validation( + output_file, + module_name="generated_non_finite_number_values", + model_name="Model", + valid_json="{}", + invalid_json='{"big_min": 1.0}', + expected_error_type="greater_than_equal", + expected_attribute_path=("big_default",), + expected_attribute_value=float("inf"), + ) + + +def test_main_jsonschema_decimal_fractional_constraints(output_file: Path) -> None: + """Test decimal fields keep fractional bounds and multipleOf with field constraints.""" + run_main_and_assert( + input_path=JSON_SCHEMA_DATA_PATH / "decimal_fractional_constraints.json", + output_path=output_file, + input_file_type="jsonschema", + assert_func=assert_file_content, + expected_file="decimal_fractional_constraints.py", + extra_args=["--output-model-type", "pydantic_v2.BaseModel", "--field-constraints"], + ) + assert_generated_model_json_validation( + output_file, + module_name="generated_decimal_fractional_constraints", + model_name="Model", + valid_json='{"price": "0.51"}', + invalid_json='{"price": "0.4"}', + expected_error_type="greater_than_equal", + ) + + def test_main_msgspec_inherited_optional_default_uses_kw_only(output_file: Path) -> None: """Test msgspec allOf child with required fields stays importable via kw_only.""" run_main_and_assert( diff --git a/tests/main/payload_validation/constants.py b/tests/main/payload_validation/constants.py index 388cecfac..056b0577a 100644 --- a/tests/main/payload_validation/constants.py +++ b/tests/main/payload_validation/constants.py @@ -84,6 +84,15 @@ } EXCLUDED_CASES: dict[str, str] = { "jsonschema/all_of_any_of_base_class_ref.json": "hypothesis-jsonschema cannot satisfy the allOf/anyOf constraints", + "jsonschema/decimal_fractional_constraints.json": ( + "format decimal strings from hypothesis-jsonschema are arbitrary text that Decimal cannot parse" + ), + "jsonschema/integer_fractional_constraints.json": ( + "hypothesis-jsonschema emits integers near float precision limits where multipleOf checks are unstable" + ), + "jsonschema/non_finite_number_values.json": ( + "non-finite bounds cannot be satisfied by any JSON payload hypothesis-jsonschema generates" + ), "jsonschema/prefix_items_fixed_unevaluated_tail_schema.json": ( "hypothesis-jsonschema cannot satisfy fixed prefixItems with unevaluatedItems tail constraints" ), diff --git a/tests/test_format.py b/tests/test_format.py index 057af5c95..a19a562ea 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -738,6 +738,22 @@ def test_apply_builtin_formatter_parenthesizes_union_annotation_with_long_defaul ) +def test_apply_builtin_formatter_parenthesizes_constrained_call_union_annotation_with_default() -> None: + """Test built-in formatter matches black for constrained call union annotations.""" + code = ( + "class Model:\n" + " price: condecimal(ge=Decimal('0'), le=Decimal('99999.99'), multiple_of=Decimal('0.01')) | None = None\n" + ) + + assert apply_builtin_formatter(code, line_length=88) == ( + "class Model:\n" + " price: (\n" + " condecimal(ge=Decimal('0'), le=Decimal('99999.99'), multiple_of=Decimal('0.01'))\n" + " | None\n" + " ) = None\n" + ) + + def test_apply_builtin_formatter_parenthesizes_union_annotation_with_string_default() -> None: """Test built-in formatter matches black for long union annotations with string defaults.""" code = "class Model:\n typename__: Literal['Notification'] | None = 'Notification'\n" diff --git a/tests/test_main_kr.py b/tests/test_main_kr.py index feff9ce48..a03ae6e62 100644 --- a/tests/test_main_kr.py +++ b/tests/test_main_kr.py @@ -22,7 +22,7 @@ create_assert_file_content, freeze_time, ) -from tests.main.conftest import run_main_and_assert, run_main_url_and_assert, run_main_with_args +from tests.main.conftest import LEGACY_BLACK_SKIP, run_main_and_assert, run_main_url_and_assert, run_main_with_args DATA_PATH: Path = Path(__file__).parent / "data" OPEN_API_DATA_PATH: Path = DATA_PATH / "openapi" @@ -1284,6 +1284,7 @@ def test_allof_with_description_generates_class_not_alias(output_file: Path) -> cli_args=["--use-decimal-for-multiple-of"], golden_output="main_kr/use_decimal_for_multiple_of/output.py", ) +@LEGACY_BLACK_SKIP @freeze_time("2019-07-26") def test_use_decimal_for_multiple_of(output_file: Path) -> None: """Generate Decimal types for fields with multipleOf constraint. diff --git a/tests/test_types.py b/tests/test_types.py index aa711c511..5b07b9bc9 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -2,16 +2,23 @@ from __future__ import annotations +from decimal import Decimal + import pytest +from datamodel_code_generator.model.base import repr_set_sorted +from datamodel_code_generator.parser._math_imports import add_math_imports_for_non_finite_literals +from datamodel_code_generator.python_literal import PythonCode, represent_python_value from datamodel_code_generator.reference import Reference from datamodel_code_generator.types import ( DataType, + _contains_decimal, _remove_none_from_union, extract_qualified_names, get_optional_type, get_subscript_args, get_type_base_name, + normalize_integer_constraint, ) @@ -201,6 +208,36 @@ def test_datatype_remove_reference_detaches_compatibility_child() -> None: assert [child is data_type for child in reference.children] == [] +def test_python_literal_helpers_render_code_and_tuple_values() -> None: + """Test Python literal rendering for raw code and tuple containers.""" + raw = PythonCode("datetime_module.date.fromisoformat('2026-01-01')", "2026-01-01") + + assert repr(raw) == "datetime_module.date.fromisoformat('2026-01-01')" + assert represent_python_value((raw,)) == "(datetime_module.date.fromisoformat('2026-01-01'),)" + assert represent_python_value((1, "two")) == "(1, 'two')" + assert repr_set_sorted(set()) == "set()" + + +def test_add_math_imports_inserts_after_generated_header() -> None: + """Test non-finite math imports are inserted after headers and future imports.""" + body = "# generated\nfrom __future__ import annotations\n\nvalue = inf\n" + + assert add_math_imports_for_non_finite_literals(body) == ( + "# generated\nfrom __future__ import annotations\n\nfrom math import inf\nvalue = inf" + ) + + +def test_decimal_detection_and_integer_constraint_edges() -> None: + """Test Decimal detection and integer constraint normalization edge cases.""" + sentinel = object() + + assert _contains_decimal([Decimal(1)]) + assert normalize_integer_constraint("ge", sentinel) == ("ge", sentinel) + assert normalize_integer_constraint("le", 1.5) == ("le", 1) + assert normalize_integer_constraint("lt", 1.5) == ("le", 1) + assert normalize_integer_constraint("unknown", 1.5) == ("unknown", 1.5) + + def test_datatype_deepcopy_with_nested_data_types() -> None: """Test that DataType.__deepcopy__ properly copies nested data_types.""" from copy import deepcopy