Skip to content

Commit 48ff21b

Browse files
authored
Fix pydantic constraint rendering (#3289)
* Fix pydantic constraint rendering * Address pydantic review comments * Address pydantic CodeQL import * Fix regex literal string handling * Fix constrained value imports * Update protobuf non-finite expectations * Handle black 23 decimal expectations * Handle decimal return analysis * Fix constrained union builtin formatting * Cover literal constraint helpers * Keep non-integer constraint values for unrecognized numeric types * Use e2e golden tests for constraint rendering fixes * Keep strongest bound when normalized constraints collide * Use match statements for literal rendering and constraint merging * Return fallback values after match blocks
1 parent 9fa06c7 commit 48ff21b

31 files changed

Lines changed: 537 additions & 78 deletions

src/datamodel_code_generator/format.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -716,10 +716,17 @@ def _is_union(node: ast.AST) -> TypeGuard[ast.Subscript]:
716716
return isinstance(node, ast.Subscript) and _is_name_or_attr(node.value, "Union")
717717

718718

719+
_CONSTRAINED_CALL_NAMES = frozenset({"conbytes", "condecimal", "confloat", "conint", "conlist", "conset", "constr"})
720+
721+
719722
def _is_constrained_string_call(node: ast.AST | None) -> TypeGuard[ast.Call]:
720723
return _is_call(node, "constr")
721724

722725

726+
def _is_constrained_call(node: ast.AST | None) -> TypeGuard[ast.Call]:
727+
return any(_is_call(node, name) for name in _CONSTRAINED_CALL_NAMES)
728+
729+
723730
def _contains_constrained_string_call(node: ast.AST) -> bool:
724731
return any(_is_constrained_string_call(child) for child in ast.walk(node))
725732

@@ -738,7 +745,7 @@ def _is_simple_union_annotation(node: ast.AST) -> bool:
738745
return isinstance(node, ast.Name | ast.Attribute) or (isinstance(node, ast.Constant) and node.value is None)
739746

740747

741-
def _should_format_constrained_string_union(
748+
def _should_format_constrained_call_union(
742749
annotation: ast.AST,
743750
value: ast.AST | None,
744751
annotation_prefix: str,
@@ -747,9 +754,9 @@ def _should_format_constrained_string_union(
747754
) -> TypeGuard[ast.BinOp]:
748755
if not isinstance(annotation, ast.BinOp) or not isinstance(annotation.op, ast.BitOr) or value is None:
749756
return False
750-
if not (_is_constrained_string_call(annotation.left) or _is_constrained_string_call(annotation.right)):
757+
if not (_is_constrained_call(annotation.left) or _is_constrained_call(annotation.right)):
751758
return False
752-
constrained_call = annotation.left if _is_constrained_string_call(annotation.left) else annotation.right
759+
constrained_call = annotation.left if _is_constrained_call(annotation.left) else annotation.right
753760
return (
754761
not _is_call(value, "Field")
755762
or len(annotation_prefix) > line_length
@@ -1010,7 +1017,7 @@ def _format_generated_annotation_assignment( # noqa: PLR0911, PLR0912
10101017
value_prefix = f"{annotation_prefix} = "
10111018
if statement.value is None and _is_list_of_annotated(statement.annotation):
10121019
return f"{target_prefix}{_format_list_of_annotated(statement.annotation, indent, line_length, source)}"
1013-
if _should_format_constrained_string_union(
1020+
if _should_format_constrained_call_union(
10141021
statement.annotation,
10151022
statement.value,
10161023
annotation_prefix,
@@ -1020,7 +1027,7 @@ def _format_generated_annotation_assignment( # noqa: PLR0911, PLR0912
10201027
assert statement.value is not None
10211028
value = _source_segment(source, statement.value)
10221029
return (
1023-
f"{target_prefix}{_format_constrained_string_union(statement.annotation, indent, line_length, source)}"
1030+
f"{target_prefix}{_format_constrained_call_union(statement.annotation, indent, line_length, source)}"
10241031
f" = {value}"
10251032
)
10261033
if _should_format_field_bit_or_annotation_assignment(
@@ -1224,21 +1231,21 @@ def _format_generated_class_statement(
12241231
return None
12251232

12261233

1227-
def _format_constrained_string_union(
1234+
def _format_constrained_call_union(
12281235
annotation: ast.BinOp,
12291236
indent: str,
12301237
line_length: int,
12311238
source: str,
12321239
) -> str:
12331240
left = annotation.left
12341241
right = annotation.right
1235-
if _is_constrained_string_call(left):
1242+
if _is_constrained_call(left):
12361243
formatted_left = _format_constrained_call(left, f"{indent} ", line_length, source)
12371244
inline_union = f"{formatted_left} | {_source_segment(source, right)}"
12381245
if "\n" not in formatted_left and len(f"{indent} {inline_union}") <= line_length:
12391246
return f"(\n{indent} {inline_union}\n{indent})"
12401247
return f"(\n{indent} {formatted_left}\n{indent} | {_source_segment(source, right)}\n{indent})"
1241-
if _is_constrained_string_call(right):
1248+
if _is_constrained_call(right):
12421249
return f"{_source_segment(source, left)} | {_format_constrained_call(right, indent, line_length, source)}"
12431250
return _source_segment(source, annotation) # pragma: no cover
12441251

src/datamodel_code_generator/model/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from datamodel_code_generator import PythonVersion
1313

14+
from .base import UNDEFINED as UNDEFINED
1415
from .base import ConstraintsBase, DataModel, DataModelFieldBase, _rebuild_model_with_datamodel_namespace
1516

1617
if TYPE_CHECKING:

src/datamodel_code_generator/model/base.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
IMPORT_UNION,
2727
Import,
2828
)
29+
from datamodel_code_generator.python_literal import represent_python_value
2930
from datamodel_code_generator.reference import Reference, _BaseModel
3031
from datamodel_code_generator.types import (
3132
ANY,
@@ -468,9 +469,7 @@ def method(self) -> str | None:
468469
@property
469470
def represented_default(self) -> str:
470471
"""Get the repr() string of the default value."""
471-
if isinstance(self.default, set):
472-
return repr_set_sorted(self.default)
473-
return repr(self.default)
472+
return represent_python_value(self.default)
474473

475474
@property
476475
def annotated(self) -> str | None:

src/datamodel_code_generator/model/pydantic_base.py

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@
1515
from datamodel_code_generator import cached_path_exists
1616
from datamodel_code_generator.imports import Import
1717
from datamodel_code_generator.model import (
18+
UNDEFINED,
1819
ConstraintsBase,
1920
DataModel,
2021
DataModelFieldBase,
2122
_rebuild_model_with_datamodel_namespace,
2223
)
23-
from datamodel_code_generator.model.base import UNDEFINED, repr_set_sorted
24-
from datamodel_code_generator.types import UnionIntFloat, chain_as_tuple
24+
from datamodel_code_generator.python_literal import represent_python_value
25+
from datamodel_code_generator.types import (
26+
UnionIntFloat,
27+
chain_as_tuple,
28+
merge_normalized_constraint,
29+
normalize_integer_constraint,
30+
)
2531

2632
# Defined here instead of importing from pydantic_v2.imports to avoid circular import
2733
# (pydantic_base -> pydantic_v2.imports -> pydantic_v2/__init__ -> pydantic_v2.base_model -> pydantic_base)
@@ -68,6 +74,7 @@ class DataModelField(DataModelFieldBase):
6874
"regex",
6975
}
7076
_COMPARE_EXPRESSIONS: ClassVar[set[str]] = {"gt", "ge", "lt", "le"}
77+
_INTEGER_CONSTRAINTS: ClassVar[set[str]] = _COMPARE_EXPRESSIONS | {"multiple_of"}
7178
constraints: Optional[Constraints] = None # noqa: UP045
7279
_PARSE_METHOD: ClassVar[str] = "model_validate"
7380

@@ -138,26 +145,43 @@ def _has_field_statement(self) -> bool:
138145
return True
139146
return bool(self.nullable and self.required and not self.use_default_with_required)
140147

141-
def _has_float_data_type(self) -> bool:
142-
"""Check whether any nested data type is float-like."""
148+
def _has_numeric_data_type(self, type_name: str, strict_import_part: str) -> bool:
149+
"""Return whether any field data type is the given builtin or strict numeric type."""
143150
return any(
144-
data_type.type == "float"
145-
or (data_type.strict and data_type.import_ and "Float" in data_type.import_.import_)
151+
data_type.type == type_name
152+
or (data_type.strict and data_type.import_ and strict_import_part in data_type.import_.import_)
146153
for data_type in self.data_type.all_data_types
147154
)
148155

149-
def _get_strict_field_constraint_value(self, constraint: str, value: Any, *, is_float_type: bool) -> Any:
150-
if value is None or constraint not in self._COMPARE_EXPRESSIONS:
151-
return value
156+
def _get_strict_field_constraint(
157+
self, constraint: str, value: Any, *, is_float_type: bool, is_int_type: bool
158+
) -> tuple[str, Any] | None:
159+
if value is None or constraint not in self._INTEGER_CONSTRAINTS:
160+
return constraint, value
152161
if is_float_type:
153-
return float(value)
154-
str_value = str(value)
155-
if "e" in str_value.lower(): # pragma: no cover
156-
# Scientific notation like 1e-08 - keep as float
157-
return float(value)
158-
if isinstance(value, int) and not isinstance(value, bool):
159-
return value
160-
return int(value)
162+
return constraint, float(value)
163+
if is_int_type:
164+
return normalize_integer_constraint(constraint, value)
165+
if isinstance(value, float) and value.is_integer():
166+
return constraint, int(value)
167+
return constraint, value
168+
169+
def _get_normalized_constraint_data(self) -> dict[str, Any]:
170+
"""Build constraint data with integer-safe values, merging colliding bounds."""
171+
assert self.constraints is not None
172+
dumped = self.constraints._exclude_unset_dump # noqa: SLF001
173+
has_integer_constraints = bool(self._INTEGER_CONSTRAINTS & dumped.keys())
174+
is_float_type = has_integer_constraints and self._has_numeric_data_type("float", "Float")
175+
is_int_type = has_integer_constraints and not is_float_type and self._has_numeric_data_type("int", "Int")
176+
constraint_data: dict[str, Any] = {}
177+
for k, v in dumped.items():
178+
if (
179+
normalized := self._get_strict_field_constraint(
180+
k, v, is_float_type=is_float_type, is_int_type=is_int_type
181+
)
182+
) is not None:
183+
merge_normalized_constraint(constraint_data, normalized[0], normalized[1])
184+
return constraint_data
161185

162186
def _get_default_factory_for_optional_nested_model(self) -> str | None:
163187
"""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]:
196220
if any(d.import_ == IMPORT_ANYURL for d in self.data_type.all_data_types):
197221
constraint_data: dict[str, Any] = {}
198222
else:
199-
dumped = self.constraints._exclude_unset_dump # noqa: SLF001
200-
is_float_type = bool(self._COMPARE_EXPRESSIONS & dumped.keys()) and self._has_float_data_type()
201-
constraint_data = {
202-
k: self._get_strict_field_constraint_value(k, v, is_float_type=is_float_type)
203-
for k, v in dumped.items()
204-
}
223+
constraint_data = self._get_normalized_constraint_data()
205224
data = {**data, **constraint_data}
206225

207226
if self.use_field_description:
@@ -239,7 +258,7 @@ def __str__(self) -> str:
239258
"""Return Field() call with all constraints and metadata."""
240259
data, default_factory = self._get_field_data_and_default_factory()
241260

242-
field_arguments = sorted(f"{k}={v!r}" for k, v in data.items() if v is not None)
261+
field_arguments = sorted(f"{k}={represent_python_value(v)}" for k, v in data.items() if v is not None)
243262

244263
if not field_arguments and not default_factory:
245264
if self.nullable and self.required and not self.use_default_with_required:
@@ -259,13 +278,13 @@ def __str__(self) -> str:
259278
):
260279
field_arguments = ["...", *field_arguments]
261280
elif not default_factory:
262-
default_repr = repr_set_sorted(self.default) if isinstance(self.default, set) else repr(self.default)
281+
default_repr = represent_python_value(self.default)
263282
field_arguments = [default_repr, *field_arguments]
264283

265284
if self.is_class_var:
266285
if self.default is UNDEFINED: # pragma: no cover
267286
return ""
268-
return repr_set_sorted(self.default) if isinstance(self.default, set) else repr(self.default)
287+
return represent_python_value(self.default)
269288

270289
return f"Field({', '.join(field_arguments)})"
271290

src/datamodel_code_generator/model/pydantic_v2/types.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import ast
89
from decimal import Decimal
910
from typing import TYPE_CHECKING, Any, ClassVar
1011

@@ -65,11 +66,13 @@
6566
IMPORT_UUID4,
6667
IMPORT_UUID5,
6768
)
69+
from datamodel_code_generator.python_literal import PythonCode
6870
from datamodel_code_generator.types import (
6971
DataType,
7072
StrictTypes,
7173
Types,
7274
UnionIntFloat,
75+
normalize_integer_constraints,
7376
)
7477
from datamodel_code_generator.types import DataTypeManager as _DataTypeManagerBase
7578

@@ -126,8 +129,7 @@ def type_map_factory(
126129
IMPORT_CONSTR,
127130
strict=StrictTypes.str in strict_types,
128131
kwargs={
129-
pattern_key: r"r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*"
130-
r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])\Z'",
132+
pattern_key: _get_regex_literal(HOSTNAME_REGEX),
131133
**({"strict": True} if StrictTypes.str in strict_types else {}),
132134
},
133135
),
@@ -188,6 +190,16 @@ def strict_type_map_factory(data_type: type[DataType]) -> dict[StrictTypes, Data
188190
)
189191

190192

193+
def _get_regex_literal(pattern: str) -> str:
194+
escaped_regex = pattern.translate(escape_characters)
195+
raw_literal = f"r'{escaped_regex}'"
196+
try:
197+
ast.literal_eval(raw_literal)
198+
except SyntaxError:
199+
return pattern
200+
return PythonCode(raw_literal, pattern)
201+
202+
191203
class _PydanticDataTypeManager(_DataTypeManagerBase):
192204
"""Base data type manager for Pydantic models with constrained types."""
193205

@@ -299,7 +311,12 @@ def get_data_int_type( # noqa: PLR0911
299311
return self.data_type.from_import(IMPORT_NON_NEGATIVE_INT)
300312
if data_type_kwargs == {"le": 0} and self.use_non_positive_negative_number_constrained_types:
301313
return self.data_type.from_import(IMPORT_NON_POSITIVE_INT)
302-
kwargs = {k: int(v) for k, v in data_type_kwargs.items()}
314+
if not (kwargs := normalize_integer_constraints(data_type_kwargs)):
315+
return (
316+
self.copy_data_type(self.strict_type_map[StrictTypes.int])
317+
if strict
318+
else self.copy_data_type(self.type_map[types])
319+
)
303320
if strict:
304321
kwargs["strict"] = True
305322
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:
362379
if strict:
363380
data_type_kwargs["strict"] = True # ty: ignore
364381
if self.PATTERN_KEY in data_type_kwargs:
365-
escaped_regex = data_type_kwargs[self.PATTERN_KEY].translate(escape_characters)
366-
# TODO: remove unneeded escaped characters
367-
data_type_kwargs[self.PATTERN_KEY] = f"r'{escaped_regex}'"
382+
data_type_kwargs[self.PATTERN_KEY] = _get_regex_literal(data_type_kwargs[self.PATTERN_KEY])
368383
return self.data_type.from_import(IMPORT_CONSTR, kwargs=data_type_kwargs)
369384
if strict:
370385
return self.copy_data_type(self.strict_type_map[StrictTypes.str])
@@ -523,8 +538,7 @@ def type_map_factory( # noqa: PLR0913, PLR0917
523538
IMPORT_CONSTR,
524539
strict=StrictTypes.str in strict_types,
525540
kwargs={
526-
pattern_key: r"r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*"
527-
r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$'",
541+
pattern_key: _get_regex_literal(HOSTNAME_REGEX),
528542
**({"strict": True} if StrictTypes.str in strict_types else {}),
529543
},
530544
),
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Helpers for rendering Python source literals."""
2+
3+
from __future__ import annotations
4+
5+
from math import isfinite, isnan
6+
from typing import Any
7+
8+
from typing_extensions import Self
9+
10+
11+
class PythonCode(str): # noqa: FURB189 - must behave as str for regex consumers.
12+
"""Python expression rendered without extra quoting."""
13+
14+
code: str
15+
__slots__ = ("code",)
16+
17+
def __new__(cls, code: str, value: str | None = None) -> Self:
18+
"""Initialize with a raw Python expression and optional string value."""
19+
obj = super().__new__(cls, code if value is None else value)
20+
obj.code = code
21+
return obj
22+
23+
def __repr__(self) -> str:
24+
"""Render the wrapped expression."""
25+
return self.code
26+
27+
28+
def represent_python_value(value: Any) -> str: # noqa: PLR0911
29+
"""Render a value as a Python expression safe for generated source."""
30+
match value:
31+
case PythonCode():
32+
return value.code
33+
case float() if isnan(value):
34+
return "float('nan')"
35+
case float() if not isfinite(value):
36+
return "float('inf')" if value > 0 else "float('-inf')"
37+
case dict():
38+
rendered_items = ", ".join(
39+
f"{represent_python_value(key)}: {represent_python_value(item)}" for key, item in value.items()
40+
)
41+
return f"{{{rendered_items}}}"
42+
case list():
43+
return "[" + ", ".join(represent_python_value(item) for item in value) + "]"
44+
case tuple():
45+
rendered_items = ", ".join(represent_python_value(item) for item in value)
46+
trailing_comma = "," if len(value) == 1 else ""
47+
return f"({rendered_items}{trailing_comma})"
48+
case set() if not value:
49+
return "set()"
50+
case set():
51+
sorted_items = sorted(value, key=lambda item: (type(item).__name__, repr(item)))
52+
return "{" + ", ".join(represent_python_value(item) for item in sorted_items) + "}"
53+
return repr(value)

0 commit comments

Comments
 (0)