From 92db9d457b66f607944e5c8362603258e83f6936 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 19:12:21 +0000 Subject: [PATCH 01/15] Fix pydantic constraint rendering --- src/datamodel_code_generator/model/base.py | 5 +- .../model/pydantic_base.py | 43 +++++---- .../model/pydantic_v2/types.py | 25 ++++- src/datamodel_code_generator/types.py | 96 ++++++++++++++++++- tests/main/test_main_general.py | 81 ++++++++++++++++ 5 files changed, 226 insertions(+), 24 deletions(-) diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index 1d3695a12..f12308965 100644 --- a/src/datamodel_code_generator/model/base.py +++ b/src/datamodel_code_generator/model/base.py @@ -36,6 +36,7 @@ Nullable, chain_as_tuple, get_optional_type, + represent_python_value, ) if TYPE_CHECKING: @@ -463,9 +464,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 bcacad8d0..e7bedd097 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -20,8 +20,13 @@ 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.model.base import UNDEFINED +from datamodel_code_generator.types import ( + UnionIntFloat, + chain_as_tuple, + normalize_integer_constraint, + represent_python_value, +) # 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 +73,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,9 +144,9 @@ def _has_field_statement(self) -> bool: return True return bool(self.nullable and self.required and not self.use_default_with_required) - def _get_strict_field_constraint_value(self, constraint: str, value: Any) -> Any: - if value is None or constraint not in self._COMPARE_EXPRESSIONS: - return value + def _get_strict_field_constraint(self, constraint: str, value: Any) -> tuple[str, Any] | None: + if value is None or constraint not in self._INTEGER_CONSTRAINTS: + return constraint, value is_float_type = any( data_type.type == "float" @@ -148,14 +154,18 @@ def _get_strict_field_constraint_value(self, constraint: str, value: Any) -> Any for data_type in self.data_type.all_data_types ) 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) + return constraint, float(value) + + is_int_type = any( + data_type.type == "int" or (data_type.strict and data_type.import_ and "Int" in data_type.import_.import_) + for data_type in self.data_type.all_data_types + ) + if is_int_type: + return normalize_integer_constraint(constraint, value) + if isinstance(value, int) and not isinstance(value, bool): - return value - return int(value) + return constraint, value + return constraint, int(value) def _get_default_factory_for_optional_nested_model(self) -> str | None: """Get default_factory for optional nested Pydantic model fields. @@ -197,8 +207,9 @@ 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) else { - k: self._get_strict_field_constraint_value(k, v) + normalized[0]: normalized[1] for k, v in self.constraints.model_dump(exclude_unset=True).items() + if (normalized := self._get_strict_field_constraint(k, v)) is not None } ), } @@ -238,7 +249,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: @@ -258,13 +269,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 b05db9412..75e42bcc3 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 @@ -67,9 +68,11 @@ ) from datamodel_code_generator.types import ( DataType, + PythonCode, StrictTypes, Types, UnionIntFloat, + normalize_integer_constraints, ) from datamodel_code_generator.types import DataTypeManager as _DataTypeManagerBase @@ -188,6 +191,16 @@ def strict_type_map_factory(data_type: type[DataType]) -> dict[StrictTypes, Data ) +def _get_regex_literal(pattern: str) -> PythonCode | 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) + + class _PydanticDataTypeManager(_DataTypeManagerBase): """Base data type manager for Pydantic models with constrained types.""" @@ -299,7 +312,13 @@ 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()} + kwargs = normalize_integer_constraints(data_type_kwargs) + if not 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 +381,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]) diff --git a/src/datamodel_code_generator/types.py b/src/datamodel_code_generator/types.py index 80ddfa664..3d8ee575f 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -12,8 +12,10 @@ from abc import ABC, abstractmethod from copy import deepcopy from enum import Enum, auto +from fractions import Fraction from functools import lru_cache from itertools import chain +from math import isfinite, isnan from re import Pattern from typing import ( TYPE_CHECKING, @@ -165,6 +167,98 @@ def validate(cls, v: Any) -> UnionIntFloat: return cls(v) +class PythonCode: + """Python expression rendered without extra quoting.""" + + __slots__ = ("code",) + + def __init__(self, code: str) -> None: + """Initialize with a raw Python expression.""" + self.code = code + + def __repr__(self) -> str: + """Render the wrapped expression.""" + return self.code + + +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 + + if constraint == "multiple_of": + if fraction.numerator == 1: + return None + return constraint, abs(fraction.numerator) + + if fraction.denominator == 1: + return constraint, int(fraction) + + match constraint: + 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 + case _: + return constraint, value + + +def normalize_integer_constraints(constraints: dict[str, Any]) -> dict[str, Any]: + """Return integer-safe pydantic constraints.""" + return { + normalized[0]: normalized[1] + for key, value in constraints.items() + if (normalized := normalize_integer_constraint(key, value)) is not None + } + + +def represent_python_value(value: Any) -> str: # noqa: PLR0911 + """Render a value as a Python expression safe for generated source.""" + if isinstance(value, PythonCode): + return value.code + if isinstance(value, float): + if isnan(value): + return "float('nan')" + if not isfinite(value): + return "float('inf')" if value > 0 else "float('-inf')" + if isinstance(value, dict): + rendered_items = ", ".join( + f"{represent_python_value(k)}: {represent_python_value(v)}" for k, v in value.items() + ) + return f"{{{rendered_items}}}" + if isinstance(value, list): + return "[" + ", ".join(represent_python_value(item) for item in value) + "]" + if isinstance(value, 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})" + if isinstance(value, set): + if not value: + return "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) + + def chain_as_tuple(*iterables: Iterable[T]) -> tuple[T, ...]: """Chain multiple iterables and return as a tuple. @@ -820,7 +914,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/main/test_main_general.py b/tests/main/test_main_general.py index 898cea541..a283bd638 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib.util import json import sys import warnings @@ -12,6 +13,7 @@ import black import pytest from packaging import version +from pydantic import ValidationError import datamodel_code_generator from datamodel_code_generator import ( @@ -71,6 +73,32 @@ assert_file_content = create_assert_file_content(EXPECTED_MAIN_PATH) +def _generate_pydantic_v2_code(schema: dict[str, Any], **kwargs: Any) -> str: + result = generate( + schema, + input_file_type=InputFileType.JsonSchema, + output_model_type=DataModelType.PydanticV2BaseModel, + disable_timestamp=True, + formatters=[Formatter.BLACK, Formatter.ISORT], + **kwargs, + ) + assert isinstance(result, str) + return result + + +def _import_generated_code(code: str, tmp_path: Path) -> Any: + module_path = tmp_path / "generated_model.py" + module_path.write_text(code, encoding="utf-8") + module_name = f"generated_model_{tmp_path.name}_{abs(hash(code))}" + spec = importlib.util.spec_from_file_location(module_name, module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + def test_debug(mocker: MockerFixture) -> None: """Test debug flag functionality.""" with pytest.raises(expected_exception=SystemExit): @@ -134,6 +162,59 @@ def test_generated_pydantic_v2_model_accepts_runtime_value(output_file: Path) -> ) +def test_pydantic_v2_pattern_with_escaped_quote_imports(tmp_path: Path) -> None: + """Render regex patterns containing backslash-quote without generating invalid syntax.""" + code = _generate_pydantic_v2_code({ + "type": "object", + "properties": {"x": {"type": "string", "pattern": r"^don\'t$"}}, + }) + + module = _import_generated_code(code, tmp_path) + module.Model.model_validate({"x": "don't"}) + with pytest.raises(ValidationError): + module.Model.model_validate({"x": "dont"}) + + +@pytest.mark.parametrize("field_constraints", [False, True]) +def test_pydantic_v2_integer_decimal_constraints_are_integer_safe(field_constraints: bool, tmp_path: Path) -> None: + """Normalize decimal integer constraints instead of truncating them.""" + code = _generate_pydantic_v2_code( + { + "type": "object", + "properties": { + "a": {"type": "integer", "minimum": 0.5}, + "c": {"type": "integer", "multipleOf": 0.5}, + "neg_gt": {"type": "integer", "exclusiveMinimum": -1.5}, + }, + }, + field_constraints=field_constraints, + ) + + assert "multiple_of=0" not in code + module = _import_generated_code(code, tmp_path) + module.Model.model_validate({"a": 1, "c": 123, "neg_gt": -1}) + with pytest.raises(ValidationError): + module.Model.model_validate({"a": 0}) + with pytest.raises(ValidationError): + module.Model.model_validate({"neg_gt": -2}) + + +def test_pydantic_v2_non_finite_values_render_as_python_expressions(tmp_path: Path) -> None: + """Render non-finite floats without emitting undefined names.""" + code = _generate_pydantic_v2_code({ + "type": "object", + "properties": { + "big_default": {"type": "number", "default": 1e999}, + "big_min": {"type": "number", "minimum": 1e999}, + }, + }) + + assert " = inf" not in code + assert "ge=inf" not in code + module = _import_generated_code(code, tmp_path) + assert module.Model().big_default == float("inf") + + @pytest.mark.allow_direct_assert def test_list_deprecations(capsys: pytest.CaptureFixture[str]) -> None: """List registered deprecations without requiring an input schema.""" From d5d2773730f68c66a166c187f538fdaaa146be77 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 19:25:16 +0000 Subject: [PATCH 02/15] Address pydantic review comments --- src/datamodel_code_generator/model/base.py | 2 +- .../model/pydantic_base.py | 2 +- .../model/pydantic_v2/types.py | 2 +- .../python_literal.py | 48 +++++++++++++++++++ src/datamodel_code_generator/types.py | 47 +----------------- 5 files changed, 53 insertions(+), 48 deletions(-) create mode 100644 src/datamodel_code_generator/python_literal.py diff --git a/src/datamodel_code_generator/model/base.py b/src/datamodel_code_generator/model/base.py index f12308965..fd332216f 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, @@ -36,7 +37,6 @@ Nullable, chain_as_tuple, get_optional_type, - represent_python_value, ) if TYPE_CHECKING: diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index e7bedd097..7f29d7fbe 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -21,11 +21,11 @@ _rebuild_model_with_datamodel_namespace, ) from datamodel_code_generator.model.base import UNDEFINED +from datamodel_code_generator.python_literal import represent_python_value from datamodel_code_generator.types import ( UnionIntFloat, chain_as_tuple, normalize_integer_constraint, - represent_python_value, ) # Defined here instead of importing from pydantic_v2.imports to avoid circular import diff --git a/src/datamodel_code_generator/model/pydantic_v2/types.py b/src/datamodel_code_generator/model/pydantic_v2/types.py index 75e42bcc3..8c25659df 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/types.py +++ b/src/datamodel_code_generator/model/pydantic_v2/types.py @@ -66,9 +66,9 @@ IMPORT_UUID4, IMPORT_UUID5, ) +from datamodel_code_generator.python_literal import PythonCode from datamodel_code_generator.types import ( DataType, - PythonCode, StrictTypes, Types, UnionIntFloat, diff --git a/src/datamodel_code_generator/python_literal.py b/src/datamodel_code_generator/python_literal.py new file mode 100644 index 000000000..d7c47f705 --- /dev/null +++ b/src/datamodel_code_generator/python_literal.py @@ -0,0 +1,48 @@ +"""Helpers for rendering Python source literals.""" + +from __future__ import annotations + +from math import isfinite, isnan +from typing import Any + + +class PythonCode: + """Python expression rendered without extra quoting.""" + + __slots__ = ("code",) + + def __init__(self, code: str) -> None: + """Initialize with a raw Python expression.""" + self.code = code + + 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.""" + if isinstance(value, PythonCode): + return value.code + if isinstance(value, float): + if isnan(value): + return "float('nan')" + if not isfinite(value): + return "float('inf')" if value > 0 else "float('-inf')" + if isinstance(value, dict): + rendered_items = ", ".join( + f"{represent_python_value(key)}: {represent_python_value(item)}" for key, item in value.items() + ) + return f"{{{rendered_items}}}" + if isinstance(value, list): + return "[" + ", ".join(represent_python_value(item) for item in value) + "]" + if isinstance(value, 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})" + if isinstance(value, set): + if not value: + return "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 3d8ee575f..896b1c0b4 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -15,7 +15,6 @@ from fractions import Fraction from functools import lru_cache from itertools import chain -from math import isfinite, isnan from re import Pattern from typing import ( TYPE_CHECKING, @@ -55,6 +54,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") @@ -167,20 +167,6 @@ def validate(cls, v: Any) -> UnionIntFloat: return cls(v) -class PythonCode: - """Python expression rendered without extra quoting.""" - - __slots__ = ("code",) - - def __init__(self, code: str) -> None: - """Initialize with a raw Python expression.""" - self.code = code - - def __repr__(self) -> str: - """Render the wrapped expression.""" - return self.code - - def _get_fraction_floor(value: Fraction) -> int: return value.numerator // value.denominator @@ -218,8 +204,7 @@ def normalize_integer_constraint(constraint: str, value: Any) -> tuple[str, Any] return "le", _get_fraction_floor(fraction) case "lt": return "le", _get_fraction_ceil(fraction) - 1 - case _: - return constraint, value + return constraint, value def normalize_integer_constraints(constraints: dict[str, Any]) -> dict[str, Any]: @@ -231,34 +216,6 @@ def normalize_integer_constraints(constraints: dict[str, Any]) -> dict[str, Any] } -def represent_python_value(value: Any) -> str: # noqa: PLR0911 - """Render a value as a Python expression safe for generated source.""" - if isinstance(value, PythonCode): - return value.code - if isinstance(value, float): - if isnan(value): - return "float('nan')" - if not isfinite(value): - return "float('inf')" if value > 0 else "float('-inf')" - if isinstance(value, dict): - rendered_items = ", ".join( - f"{represent_python_value(k)}: {represent_python_value(v)}" for k, v in value.items() - ) - return f"{{{rendered_items}}}" - if isinstance(value, list): - return "[" + ", ".join(represent_python_value(item) for item in value) + "]" - if isinstance(value, 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})" - if isinstance(value, set): - if not value: - return "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) - - def chain_as_tuple(*iterables: Iterable[T]) -> tuple[T, ...]: """Chain multiple iterables and return as a tuple. From 9882d92e7a07e30e8bdf00dde7efe14562dd0b47 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 19:33:41 +0000 Subject: [PATCH 03/15] Address pydantic CodeQL import --- src/datamodel_code_generator/model/__init__.py | 1 + src/datamodel_code_generator/model/pydantic_base.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index 7f29d7fbe..8effe4535 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -15,12 +15,12 @@ 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 from datamodel_code_generator.python_literal import represent_python_value from datamodel_code_generator.types import ( UnionIntFloat, From de5c8eee9fccc3e94b555338770c3a0b4fa8cba4 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 19:40:24 +0000 Subject: [PATCH 04/15] Fix regex literal string handling --- .../model/pydantic_v2/types.py | 4 ++-- src/datamodel_code_generator/python_literal.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/datamodel_code_generator/model/pydantic_v2/types.py b/src/datamodel_code_generator/model/pydantic_v2/types.py index 8c25659df..d4e789dc9 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/types.py +++ b/src/datamodel_code_generator/model/pydantic_v2/types.py @@ -191,14 +191,14 @@ def strict_type_map_factory(data_type: type[DataType]) -> dict[StrictTypes, Data ) -def _get_regex_literal(pattern: str) -> PythonCode | str: +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) + return PythonCode(raw_literal, pattern) class _PydanticDataTypeManager(_DataTypeManagerBase): diff --git a/src/datamodel_code_generator/python_literal.py b/src/datamodel_code_generator/python_literal.py index d7c47f705..29322237c 100644 --- a/src/datamodel_code_generator/python_literal.py +++ b/src/datamodel_code_generator/python_literal.py @@ -5,15 +5,20 @@ from math import isfinite, isnan from typing import Any +from typing_extensions import Self -class PythonCode: + +class PythonCode(str): # noqa: FURB189 - must behave as str for regex consumers. """Python expression rendered without extra quoting.""" + code: str __slots__ = ("code",) - def __init__(self, code: str) -> None: - """Initialize with a raw Python expression.""" - self.code = 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.""" From 70dda570058b1c10f1b2818fda011846e2a2eaa7 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 19:59:12 +0000 Subject: [PATCH 05/15] Fix constrained value imports --- .../model/pydantic_v2/types.py | 6 ++---- src/datamodel_code_generator/types.py | 18 ++++++++++++++++++ .../strict_types_with_constraints.py | 2 +- .../main/xmlschema/special_float_bounds.py | 3 +-- .../main/xmlschema/special_float_defaults.py | 13 ++++++------- .../use_decimal_for_multiple_of/output.py | 14 +++++++++++--- tests/main/test_main_general.py | 17 ++++++++++------- 7 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/datamodel_code_generator/model/pydantic_v2/types.py b/src/datamodel_code_generator/model/pydantic_v2/types.py index d4e789dc9..18f4346d7 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/types.py +++ b/src/datamodel_code_generator/model/pydantic_v2/types.py @@ -129,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 {}), }, ), @@ -543,8 +542,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/types.py b/src/datamodel_code_generator/types.py index 896b1c0b4..34b1d2192 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -11,6 +11,7 @@ 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 lru_cache @@ -42,6 +43,7 @@ IMPORT_ABC_SEQUENCE, IMPORT_ANNOTATED, IMPORT_ANY, + IMPORT_DECIMAL, IMPORT_DICT, IMPORT_FROZEN_SET, IMPORT_LIST, @@ -68,6 +70,20 @@ UNION_DELIMITER = ", " UNION_PATTERN: Pattern[str] = re.compile(r"\s*,\s*") UNION_OPERATOR_DELIMITER = " | " + + +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 + + UNION_OPERATOR_PATTERN: Pattern[str] = re.compile(r"\s*\|\s*") NONE = "None" ANY = "Any" @@ -676,6 +692,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 imports: tuple[tuple[bool, Import], ...] = ( (self.is_optional and not self.use_union_operator, IMPORT_OPTIONAL), 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/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/main/test_main_general.py b/tests/main/test_main_general.py index a283bd638..37d3ca60f 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -82,7 +82,8 @@ def _generate_pydantic_v2_code(schema: dict[str, Any], **kwargs: Any) -> str: formatters=[Formatter.BLACK, Formatter.ISORT], **kwargs, ) - assert isinstance(result, str) + if not isinstance(result, str): # pragma: no cover + pytest.fail("expected generated code as a string") return result @@ -91,8 +92,8 @@ def _import_generated_code(code: str, tmp_path: Path) -> Any: module_path.write_text(code, encoding="utf-8") module_name = f"generated_model_{tmp_path.name}_{abs(hash(code))}" spec = importlib.util.spec_from_file_location(module_name, module_path) - assert spec is not None - assert spec.loader is not None + if spec is None or spec.loader is None: # pragma: no cover + pytest.fail("expected import spec with loader") module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) @@ -190,7 +191,8 @@ def test_pydantic_v2_integer_decimal_constraints_are_integer_safe(field_constrai field_constraints=field_constraints, ) - assert "multiple_of=0" not in code + if "multiple_of=0" in code: + pytest.fail("integer multipleOf constraint was truncated to zero") module = _import_generated_code(code, tmp_path) module.Model.model_validate({"a": 1, "c": 123, "neg_gt": -1}) with pytest.raises(ValidationError): @@ -209,10 +211,11 @@ def test_pydantic_v2_non_finite_values_render_as_python_expressions(tmp_path: Pa }, }) - assert " = inf" not in code - assert "ge=inf" not in code + if " = inf" in code or "ge=inf" in code: + pytest.fail("non-finite floats should render as Python expressions") module = _import_generated_code(code, tmp_path) - assert module.Model().big_default == float("inf") + if module.Model().big_default != float("inf"): + pytest.fail("non-finite default should round-trip as infinity") @pytest.mark.allow_direct_assert From e427a316bc6d7cc6a5d0ef177ba8136fd2b45d22 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 20:06:16 +0000 Subject: [PATCH 06/15] Update protobuf non-finite expectations --- .../protobuf/directory/example_legacy_legacy_message.py | 3 +-- .../protobuf/directory/example_spec_proto2_spec_proto2.py | 7 +++---- .../directory_black_lt_24/example_legacy_legacy_message.py | 3 +-- .../example_spec_proto2_spec_proto2.py | 7 +++---- tests/data/expected/main/protobuf/proto2.py | 3 +-- tests/data/expected/main/protobuf/spec_proto2.py | 7 +++---- 6 files changed, 12 insertions(+), 18 deletions(-) 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' From 54bc4f9f74ebe3d79f2f85f91c426f266cfcc7b9 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 20:24:40 +0000 Subject: [PATCH 07/15] Handle black 23 decimal expectations --- .../output_black_lt_24.py | 20 +++++++++++++++++++ tests/test_main_kr.py | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/data/expected/main_kr/use_decimal_for_multiple_of/output_black_lt_24.py diff --git a/tests/data/expected/main_kr/use_decimal_for_multiple_of/output_black_lt_24.py b/tests/data/expected/main_kr/use_decimal_for_multiple_of/output_black_lt_24.py new file mode 100644 index 000000000..ab6866ecc --- /dev/null +++ b/tests/data/expected/main_kr/use_decimal_for_multiple_of/output_black_lt_24.py @@ -0,0 +1,20 @@ +# generated by datamodel-codegen: +# filename: use_decimal_for_multiple_of.json +# timestamp: 2019-07-26T00:00:00+00:00 + +from __future__ import annotations + +from decimal import Decimal + +from pydantic import BaseModel, condecimal, confloat + + +class Model(BaseModel): + 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/test_main_kr.py b/tests/test_main_kr.py index feff9ce48..627d5772c 100644 --- a/tests/test_main_kr.py +++ b/tests/test_main_kr.py @@ -1292,12 +1292,15 @@ def test_use_decimal_for_multiple_of(output_file: Path) -> None: types for numeric fields that have a `multipleOf` constraint. This ensures precise decimal arithmetic when validating values against the constraint. """ + expected_name = ( + "output_black_lt_24.py" if version.parse(black.__version__) < version.parse("24.0.0") else "output.py" + ) run_main_and_assert( input_path=JSON_SCHEMA_DATA_PATH / "use_decimal_for_multiple_of.json", output_path=output_file, input_file_type="jsonschema", assert_func=assert_file_content, - expected_file=EXPECTED_MAIN_KR_PATH / "use_decimal_for_multiple_of" / "output.py", + expected_file=EXPECTED_MAIN_KR_PATH / "use_decimal_for_multiple_of" / expected_name, extra_args=["--use-decimal-for-multiple-of"], ) From 916a1aee30ae7d40edc9796fa914b7d8d1b0267a Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 20:26:16 +0000 Subject: [PATCH 08/15] Handle decimal return analysis --- src/datamodel_code_generator/types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/datamodel_code_generator/types.py b/src/datamodel_code_generator/types.py index 34b1d2192..123dea59d 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -82,6 +82,7 @@ def _contains_decimal(value: Any) -> bool: return any(_contains_decimal(item) for item in value) case _: return False + return False UNION_OPERATOR_PATTERN: Pattern[str] = re.compile(r"\s*\|\s*") From 4fcd19f95fba07b757143471f805866313ea912a Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 20:38:27 +0000 Subject: [PATCH 09/15] Fix constrained union builtin formatting --- src/datamodel_code_generator/format.py | 23 ++++++++++++------- .../output_black_lt_24.py | 20 ---------------- tests/test_format.py | 16 +++++++++++++ tests/test_main_kr.py | 8 +++---- 4 files changed, 34 insertions(+), 33 deletions(-) delete mode 100644 tests/data/expected/main_kr/use_decimal_for_multiple_of/output_black_lt_24.py 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/tests/data/expected/main_kr/use_decimal_for_multiple_of/output_black_lt_24.py b/tests/data/expected/main_kr/use_decimal_for_multiple_of/output_black_lt_24.py deleted file mode 100644 index ab6866ecc..000000000 --- a/tests/data/expected/main_kr/use_decimal_for_multiple_of/output_black_lt_24.py +++ /dev/null @@ -1,20 +0,0 @@ -# generated by datamodel-codegen: -# filename: use_decimal_for_multiple_of.json -# timestamp: 2019-07-26T00:00:00+00:00 - -from __future__ import annotations - -from decimal import Decimal - -from pydantic import BaseModel, condecimal, confloat - - -class Model(BaseModel): - 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/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 627d5772c..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. @@ -1292,15 +1293,12 @@ def test_use_decimal_for_multiple_of(output_file: Path) -> None: types for numeric fields that have a `multipleOf` constraint. This ensures precise decimal arithmetic when validating values against the constraint. """ - expected_name = ( - "output_black_lt_24.py" if version.parse(black.__version__) < version.parse("24.0.0") else "output.py" - ) run_main_and_assert( input_path=JSON_SCHEMA_DATA_PATH / "use_decimal_for_multiple_of.json", output_path=output_file, input_file_type="jsonschema", assert_func=assert_file_content, - expected_file=EXPECTED_MAIN_KR_PATH / "use_decimal_for_multiple_of" / expected_name, + expected_file=EXPECTED_MAIN_KR_PATH / "use_decimal_for_multiple_of" / "output.py", extra_args=["--use-decimal-for-multiple-of"], ) From f05c37cc66770ccfe50445a17c240a588be3f496 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Tue, 9 Jun 2026 20:51:47 +0000 Subject: [PATCH 10/15] Cover literal constraint helpers --- tests/main/test_main_general.py | 6 +++--- tests/test_types.py | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index 37d3ca60f..0e229e5c6 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -191,7 +191,7 @@ def test_pydantic_v2_integer_decimal_constraints_are_integer_safe(field_constrai field_constraints=field_constraints, ) - if "multiple_of=0" in code: + if "multiple_of=0" in code: # pragma: no cover pytest.fail("integer multipleOf constraint was truncated to zero") module = _import_generated_code(code, tmp_path) module.Model.model_validate({"a": 1, "c": 123, "neg_gt": -1}) @@ -211,10 +211,10 @@ def test_pydantic_v2_non_finite_values_render_as_python_expressions(tmp_path: Pa }, }) - if " = inf" in code or "ge=inf" in code: + if " = inf" in code or "ge=inf" in code: # pragma: no cover pytest.fail("non-finite floats should render as Python expressions") module = _import_generated_code(code, tmp_path) - if module.Model().big_default != float("inf"): + if module.Model().big_default != float("inf"): # pragma: no cover pytest.fail("non-finite default should round-trip as infinity") 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 From 39c777988ddbc234c364fd89f416199b155d02a3 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Wed, 10 Jun 2026 02:08:56 +0000 Subject: [PATCH 11/15] Keep non-integer constraint values for unrecognized numeric types --- .../model/pydantic_base.py | 30 +++---- src/datamodel_code_generator/types.py | 27 +++--- tests/main/test_main_general.py | 84 ------------------- 3 files changed, 25 insertions(+), 116 deletions(-) diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index 8effe4535..f5c2fc547 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -144,28 +144,24 @@ def _has_field_statement(self) -> bool: return True return bool(self.nullable and self.required and not self.use_default_with_required) + 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 == 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(self, constraint: str, value: Any) -> tuple[str, Any] | None: if value is None or constraint not in self._INTEGER_CONSTRAINTS: return constraint, value - - is_float_type = any( - data_type.type == "float" - or (data_type.strict and data_type.import_ and "Float" in data_type.import_.import_) - for data_type in self.data_type.all_data_types - ) - if is_float_type: + if self._has_numeric_data_type("float", "Float"): return constraint, float(value) - - is_int_type = any( - data_type.type == "int" or (data_type.strict and data_type.import_ and "Int" in data_type.import_.import_) - for data_type in self.data_type.all_data_types - ) - if is_int_type: + if self._has_numeric_data_type("int", "Int"): return normalize_integer_constraint(constraint, value) - - if isinstance(value, int) and not isinstance(value, bool): - return constraint, value - return constraint, int(value) + if isinstance(value, float) and value.is_integer(): + return constraint, int(value) + return constraint, value def _get_default_factory_for_optional_nested_model(self) -> str | None: """Get default_factory for optional nested Pydantic model fields. diff --git a/src/datamodel_code_generator/types.py b/src/datamodel_code_generator/types.py index 123dea59d..c2a224c8c 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -70,21 +70,6 @@ UNION_DELIMITER = ", " UNION_PATTERN: Pattern[str] = re.compile(r"\s*,\s*") UNION_OPERATOR_DELIMITER = " | " - - -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 - return False - - UNION_OPERATOR_PATTERN: Pattern[str] = re.compile(r"\s*\|\s*") NONE = "None" ANY = "Any" @@ -184,6 +169,18 @@ 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 diff --git a/tests/main/test_main_general.py b/tests/main/test_main_general.py index 0e229e5c6..898cea541 100644 --- a/tests/main/test_main_general.py +++ b/tests/main/test_main_general.py @@ -2,7 +2,6 @@ from __future__ import annotations -import importlib.util import json import sys import warnings @@ -13,7 +12,6 @@ import black import pytest from packaging import version -from pydantic import ValidationError import datamodel_code_generator from datamodel_code_generator import ( @@ -73,33 +71,6 @@ assert_file_content = create_assert_file_content(EXPECTED_MAIN_PATH) -def _generate_pydantic_v2_code(schema: dict[str, Any], **kwargs: Any) -> str: - result = generate( - schema, - input_file_type=InputFileType.JsonSchema, - output_model_type=DataModelType.PydanticV2BaseModel, - disable_timestamp=True, - formatters=[Formatter.BLACK, Formatter.ISORT], - **kwargs, - ) - if not isinstance(result, str): # pragma: no cover - pytest.fail("expected generated code as a string") - return result - - -def _import_generated_code(code: str, tmp_path: Path) -> Any: - module_path = tmp_path / "generated_model.py" - module_path.write_text(code, encoding="utf-8") - module_name = f"generated_model_{tmp_path.name}_{abs(hash(code))}" - spec = importlib.util.spec_from_file_location(module_name, module_path) - if spec is None or spec.loader is None: # pragma: no cover - pytest.fail("expected import spec with loader") - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module - - def test_debug(mocker: MockerFixture) -> None: """Test debug flag functionality.""" with pytest.raises(expected_exception=SystemExit): @@ -163,61 +134,6 @@ def test_generated_pydantic_v2_model_accepts_runtime_value(output_file: Path) -> ) -def test_pydantic_v2_pattern_with_escaped_quote_imports(tmp_path: Path) -> None: - """Render regex patterns containing backslash-quote without generating invalid syntax.""" - code = _generate_pydantic_v2_code({ - "type": "object", - "properties": {"x": {"type": "string", "pattern": r"^don\'t$"}}, - }) - - module = _import_generated_code(code, tmp_path) - module.Model.model_validate({"x": "don't"}) - with pytest.raises(ValidationError): - module.Model.model_validate({"x": "dont"}) - - -@pytest.mark.parametrize("field_constraints", [False, True]) -def test_pydantic_v2_integer_decimal_constraints_are_integer_safe(field_constraints: bool, tmp_path: Path) -> None: - """Normalize decimal integer constraints instead of truncating them.""" - code = _generate_pydantic_v2_code( - { - "type": "object", - "properties": { - "a": {"type": "integer", "minimum": 0.5}, - "c": {"type": "integer", "multipleOf": 0.5}, - "neg_gt": {"type": "integer", "exclusiveMinimum": -1.5}, - }, - }, - field_constraints=field_constraints, - ) - - if "multiple_of=0" in code: # pragma: no cover - pytest.fail("integer multipleOf constraint was truncated to zero") - module = _import_generated_code(code, tmp_path) - module.Model.model_validate({"a": 1, "c": 123, "neg_gt": -1}) - with pytest.raises(ValidationError): - module.Model.model_validate({"a": 0}) - with pytest.raises(ValidationError): - module.Model.model_validate({"neg_gt": -2}) - - -def test_pydantic_v2_non_finite_values_render_as_python_expressions(tmp_path: Path) -> None: - """Render non-finite floats without emitting undefined names.""" - code = _generate_pydantic_v2_code({ - "type": "object", - "properties": { - "big_default": {"type": "number", "default": 1e999}, - "big_min": {"type": "number", "minimum": 1e999}, - }, - }) - - if " = inf" in code or "ge=inf" in code: # pragma: no cover - pytest.fail("non-finite floats should render as Python expressions") - module = _import_generated_code(code, tmp_path) - if module.Model().big_default != float("inf"): # pragma: no cover - pytest.fail("non-finite default should round-trip as infinity") - - @pytest.mark.allow_direct_assert def test_list_deprecations(capsys: pytest.CaptureFixture[str]) -> None: """List registered deprecations without requiring an input schema.""" From fb9c309f194a088cbfbf241fb4dad6ad28b2564c Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Wed, 10 Jun 2026 02:08:57 +0000 Subject: [PATCH 12/15] Use e2e golden tests for constraint rendering fixes --- .../decimal_fractional_constraints.py | 13 +++ .../integer_fractional_constraints.py | 16 +++ ...ractional_constraints_field_constraints.py | 16 +++ .../jsonschema/non_finite_number_values.py | 13 +++ .../main/jsonschema/pattern_escaped_quote.py | 11 ++ .../decimal_fractional_constraints.json | 11 ++ .../integer_fractional_constraints.json | 11 ++ .../jsonschema/non_finite_number_values.json | 8 ++ .../jsonschema/pattern_escaped_quote.json | 6 ++ tests/main/jsonschema/test_main_jsonschema.py | 101 ++++++++++++++++++ tests/main/payload_validation/constants.py | 9 ++ 11 files changed, 215 insertions(+) create mode 100644 tests/data/expected/main/jsonschema/decimal_fractional_constraints.py create mode 100644 tests/data/expected/main/jsonschema/integer_fractional_constraints.py create mode 100644 tests/data/expected/main/jsonschema/integer_fractional_constraints_field_constraints.py create mode 100644 tests/data/expected/main/jsonschema/non_finite_number_values.py create mode 100644 tests/data/expected/main/jsonschema/pattern_escaped_quote.py create mode 100644 tests/data/jsonschema/decimal_fractional_constraints.json create mode 100644 tests/data/jsonschema/integer_fractional_constraints.json create mode 100644 tests/data/jsonschema/non_finite_number_values.json create mode 100644 tests/data/jsonschema/pattern_escaped_quote.json 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..27a861108 --- /dev/null +++ b/tests/data/expected/main/jsonschema/integer_fractional_constraints.py @@ -0,0 +1,16 @@ +# 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 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..779103d72 --- /dev/null +++ b/tests/data/expected/main/jsonschema/integer_fractional_constraints_field_constraints.py @@ -0,0 +1,16 @@ +# 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) 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/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..e1e6fa43d --- /dev/null +++ b/tests/data/jsonschema/integer_fractional_constraints.json @@ -0,0 +1,11 @@ +{ + "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} + } +} 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 6f7e53d54..9dca43644 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -11849,3 +11849,104 @@ def test_main_jsonschema_discriminated_oneof_allof_cycle(output_file: Path) -> N input_file_type="jsonschema", assert_func=assert_file_content, ) + + +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}' + ), + invalid_json='{"ge_field": 0}', + expected_error_type="greater_than_equal", + expected_attribute_path=("fraction_multiple",), + expected_attribute_value=3, + ) + + +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", + ) 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" ), From ac3494b481e1818eb705d15278492523a4eb5a1f Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Wed, 10 Jun 2026 02:35:11 +0000 Subject: [PATCH 13/15] Keep strongest bound when normalized constraints collide --- .../model/pydantic_base.py | 35 ++++++++++--------- src/datamodel_code_generator/types.py | 18 +++++++--- .../integer_fractional_constraints.py | 2 ++ ...ractional_constraints_field_constraints.py | 2 ++ .../integer_fractional_constraints.json | 4 ++- tests/main/jsonschema/test_main_jsonschema.py | 17 ++++++++- 6 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/datamodel_code_generator/model/pydantic_base.py b/src/datamodel_code_generator/model/pydantic_base.py index cdeeab89a..0264fa1b0 100644 --- a/src/datamodel_code_generator/model/pydantic_base.py +++ b/src/datamodel_code_generator/model/pydantic_base.py @@ -25,6 +25,7 @@ from datamodel_code_generator.types import ( UnionIntFloat, chain_as_tuple, + merge_normalized_constraint, normalize_integer_constraint, ) @@ -165,6 +166,23 @@ def _get_strict_field_constraint( 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. @@ -202,22 +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 - 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 = { - normalized[0]: normalized[1] - 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 - } + constraint_data = self._get_normalized_constraint_data() data = {**data, **constraint_data} if self.use_field_description: diff --git a/src/datamodel_code_generator/types.py b/src/datamodel_code_generator/types.py index 8beb4f915..8121ea0bc 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -221,13 +221,21 @@ def normalize_integer_constraint(constraint: str, value: Any) -> tuple[str, Any] 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.""" + if (current := constraints.get(key)) is None: + constraints[key] = value + return + constraints[key] = max(current, value) if key == "ge" else min(current, value) + + def normalize_integer_constraints(constraints: dict[str, Any]) -> dict[str, Any]: """Return integer-safe pydantic constraints.""" - return { - normalized[0]: normalized[1] - for key, value in constraints.items() - if (normalized := normalize_integer_constraint(key, value)) is not None - } + 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, ...]: diff --git a/tests/data/expected/main/jsonschema/integer_fractional_constraints.py b/tests/data/expected/main/jsonschema/integer_fractional_constraints.py index 27a861108..efa5de8f3 100644 --- a/tests/data/expected/main/jsonschema/integer_fractional_constraints.py +++ b/tests/data/expected/main/jsonschema/integer_fractional_constraints.py @@ -14,3 +14,5 @@ class Model(BaseModel): 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 index 779103d72..53e2c5ee9 100644 --- a/tests/data/expected/main/jsonschema/integer_fractional_constraints_field_constraints.py +++ b/tests/data/expected/main/jsonschema/integer_fractional_constraints_field_constraints.py @@ -14,3 +14,5 @@ class Model(BaseModel): 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/jsonschema/integer_fractional_constraints.json b/tests/data/jsonschema/integer_fractional_constraints.json index e1e6fa43d..f0203bb5a 100644 --- a/tests/data/jsonschema/integer_fractional_constraints.json +++ b/tests/data/jsonschema/integer_fractional_constraints.json @@ -6,6 +6,8 @@ "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} + "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/main/jsonschema/test_main_jsonschema.py b/tests/main/jsonschema/test_main_jsonschema.py index fcb04c523..e8acdb475 100644 --- a/tests/main/jsonschema/test_main_jsonschema.py +++ b/tests/main/jsonschema/test_main_jsonschema.py @@ -11923,13 +11923,28 @@ def test_main_jsonschema_integer_fractional_constraints( 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}' + '{"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: From 318897bb973063a2de91dc33e71e6777f11f8136 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Wed, 10 Jun 2026 04:36:14 +0000 Subject: [PATCH 14/15] Use match statements for literal rendering and constraint merging --- .../model/pydantic_v2/types.py | 3 +- .../python_literal.py | 43 ++++++++++--------- src/datamodel_code_generator/types.py | 28 ++++++------ 3 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/datamodel_code_generator/model/pydantic_v2/types.py b/src/datamodel_code_generator/model/pydantic_v2/types.py index 11019518f..9b9f28095 100644 --- a/src/datamodel_code_generator/model/pydantic_v2/types.py +++ b/src/datamodel_code_generator/model/pydantic_v2/types.py @@ -311,8 +311,7 @@ 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 = normalize_integer_constraints(data_type_kwargs) - if not kwargs: + if not (kwargs := normalize_integer_constraints(data_type_kwargs)): return ( self.copy_data_type(self.strict_type_map[StrictTypes.int]) if strict diff --git a/src/datamodel_code_generator/python_literal.py b/src/datamodel_code_generator/python_literal.py index 29322237c..9675f34bb 100644 --- a/src/datamodel_code_generator/python_literal.py +++ b/src/datamodel_code_generator/python_literal.py @@ -27,27 +27,28 @@ def __repr__(self) -> str: def represent_python_value(value: Any) -> str: # noqa: PLR0911 """Render a value as a Python expression safe for generated source.""" - if isinstance(value, PythonCode): - return value.code - if isinstance(value, float): - if isnan(value): + match value: + case PythonCode(): + return value.code + case float() if isnan(value): return "float('nan')" - if not isfinite(value): + case float() if not isfinite(value): return "float('inf')" if value > 0 else "float('-inf')" - if isinstance(value, dict): - rendered_items = ", ".join( - f"{represent_python_value(key)}: {represent_python_value(item)}" for key, item in value.items() - ) - return f"{{{rendered_items}}}" - if isinstance(value, list): - return "[" + ", ".join(represent_python_value(item) for item in value) + "]" - if isinstance(value, 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})" - if isinstance(value, set): - if not value: + 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()" - 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) + 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) + "}" + case _: + return repr(value) diff --git a/src/datamodel_code_generator/types.py b/src/datamodel_code_generator/types.py index 8121ea0bc..4ca464a6e 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -201,15 +201,13 @@ def normalize_integer_constraint(constraint: str, value: Any) -> tuple[str, Any] except (TypeError, ValueError): return constraint, value - if constraint == "multiple_of": - if fraction.numerator == 1: - return None - return constraint, abs(fraction.numerator) - - if fraction.denominator == 1: - return constraint, int(fraction) - 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": @@ -218,15 +216,19 @@ def normalize_integer_constraint(constraint: str, value: Any) -> tuple[str, Any] return "le", _get_fraction_floor(fraction) case "lt": return "le", _get_fraction_ceil(fraction) - 1 - return constraint, value + case _: + 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.""" - if (current := constraints.get(key)) is None: - constraints[key] = value - return - constraints[key] = max(current, value) if key == "ge" else min(current, value) + 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]: From c336673c283e47236653955d699e9fbc25351478 Mon Sep 17 00:00:00 2001 From: Koudai Aono Date: Wed, 10 Jun 2026 04:42:15 +0000 Subject: [PATCH 15/15] Return fallback values after match blocks --- src/datamodel_code_generator/python_literal.py | 3 +-- src/datamodel_code_generator/types.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/datamodel_code_generator/python_literal.py b/src/datamodel_code_generator/python_literal.py index 9675f34bb..d6c536638 100644 --- a/src/datamodel_code_generator/python_literal.py +++ b/src/datamodel_code_generator/python_literal.py @@ -50,5 +50,4 @@ def represent_python_value(value: Any) -> str: # noqa: PLR0911 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) + "}" - case _: - return repr(value) + return repr(value) diff --git a/src/datamodel_code_generator/types.py b/src/datamodel_code_generator/types.py index 4ca464a6e..9424ff15f 100644 --- a/src/datamodel_code_generator/types.py +++ b/src/datamodel_code_generator/types.py @@ -216,8 +216,7 @@ def normalize_integer_constraint(constraint: str, value: Any) -> tuple[str, Any] return "le", _get_fraction_floor(fraction) case "lt": return "le", _get_fraction_ceil(fraction) - 1 - case _: - return constraint, value + return constraint, value def merge_normalized_constraint(constraints: dict[str, Any], key: str, value: Any) -> None: