Skip to content

Commit ff5de87

Browse files
Strict types now enforce field constraints (#2617)
* Enhance strict type constraints for fields in Pydantic and Msgspec models * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Enhance strict type constraints for float fields in Pydantic models * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Refactor float value handling in base_model to improve type safety --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent a6ea2a4 commit ff5de87

7 files changed

Lines changed: 128 additions & 11 deletions

File tree

src/datamodel_code_generator/model/msgspec.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,12 @@ def annotated(self) -> str | None:
301301
return None
302302

303303
data: dict[str, Any] = {k: v for k, v in self.extras.items() if k in self._META_FIELD_KEYS}
304-
if self.constraints is not None and not self.self_reference() and not self.data_type.strict:
304+
has_type_constraints = self.data_type.kwargs is not None and len(self.data_type.kwargs) > 0
305+
if (
306+
self.constraints is not None
307+
and not self.self_reference()
308+
and not (self.data_type.strict and has_type_constraints)
309+
):
305310
data = {
306311
**data,
307312
**{

src/datamodel_code_generator/model/pydantic/base_model.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,19 @@ def _get_strict_field_constraint_value(self, constraint: str, value: Any) -> Any
107107
if value is None or constraint not in self._COMPARE_EXPRESSIONS:
108108
return value
109109

110-
if any(data_type.type == "float" for data_type in self.data_type.all_data_types):
110+
is_float_type = any(
111+
data_type.type == "float"
112+
or (data_type.strict and data_type.import_ and "Float" in data_type.import_.import_)
113+
for data_type in self.data_type.all_data_types
114+
)
115+
if is_float_type:
116+
return float(value)
117+
str_value = str(value)
118+
if "e" in str_value.lower(): # pragma: no cover
119+
# Scientific notation like 1e-08 - keep as float
111120
return float(value)
121+
if isinstance(value, int) and not isinstance(value, bool): # pragma: no branch
122+
return value
112123
return int(value)
113124

114125
def _get_default_as_pydantic_model(self) -> str | None:
@@ -152,7 +163,12 @@ def __str__(self) -> str: # noqa: PLR0912
152163
data: dict[str, Any] = {k: v for k, v in self.extras.items() if k not in self._EXCLUDE_FIELD_KEYS}
153164
if self.alias:
154165
data["alias"] = self.alias
155-
if self.constraints is not None and not self.self_reference() and not self.data_type.strict:
166+
has_type_constraints = self.data_type.kwargs is not None and len(self.data_type.kwargs) > 0
167+
if (
168+
self.constraints is not None
169+
and not self.self_reference()
170+
and not (self.data_type.strict and has_type_constraints)
171+
):
156172
data = {
157173
**data,
158174
**(

tests/data/expected/main/jsonschema/strict_types_all_field_constraints.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020
class User(BaseModel):
2121
name: Optional[StrictStr] = Field(None, example='ken')
2222
age: Optional[StrictInt] = None
23-
salary: Optional[StrictInt] = None
24-
debt: Optional[StrictInt] = None
25-
loan: Optional[StrictFloat] = None
26-
tel: Optional[StrictStr] = None
27-
height: Optional[StrictFloat] = None
28-
weight: Optional[StrictFloat] = None
29-
score: Optional[StrictFloat] = None
23+
salary: Optional[StrictInt] = Field(None, ge=0)
24+
debt: Optional[StrictInt] = Field(None, le=0)
25+
loan: Optional[StrictFloat] = Field(None, le=0.0)
26+
tel: Optional[StrictStr] = Field(None, regex='^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$')
27+
height: Optional[StrictFloat] = Field(None, ge=0.0)
28+
weight: Optional[StrictFloat] = Field(None, ge=0.0)
29+
score: Optional[StrictFloat] = Field(None, ge=1e-08)
3030
active: Optional[StrictBool] = None
31-
photo: Optional[StrictBytes] = None
31+
photo: Optional[StrictBytes] = Field(None, min_length=100)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# generated by datamodel-codegen:
2+
# filename: strict_types_field_constraints.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated
8+
9+
from msgspec import Meta
10+
from typing_extensions import TypeAlias
11+
12+
Timestamp: TypeAlias = Annotated[int, Meta(ge=1, le=9999999999)]
13+
14+
15+
Score: TypeAlias = Annotated[float, Meta(ge=0.0, le=100.0)]
16+
17+
18+
Name: TypeAlias = Annotated[str, Meta(max_length=100, min_length=1)]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# generated by datamodel-codegen:
2+
# filename: strict_types_field_constraints.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from pydantic import Field, RootModel, StrictFloat, StrictInt, StrictStr
8+
9+
10+
class Timestamp(RootModel[StrictInt]):
11+
root: StrictInt = Field(..., ge=1, le=9999999999)
12+
13+
14+
class Score(RootModel[StrictFloat]):
15+
root: StrictFloat = Field(..., ge=0.0, le=100.0)
16+
17+
18+
class Name(RootModel[StrictStr]):
19+
root: StrictStr = Field(..., max_length=100, min_length=1)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Test API
4+
version: 1.0.0
5+
paths: {}
6+
components:
7+
schemas:
8+
Timestamp:
9+
type: integer
10+
minimum: 1
11+
maximum: 9999999999
12+
Score:
13+
type: number
14+
minimum: 0.0
15+
maximum: 100.0
16+
Name:
17+
type: string
18+
minLength: 1
19+
maxLength: 100

tests/main/openapi/test_main_openapi.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3115,6 +3115,46 @@ def test_main_openapi_dot_notation_deep_inheritance(output_dir: Path) -> None:
31153115
)
31163116

31173117

3118+
def test_main_openapi_strict_types_field_constraints_pydantic_v2(output_file: Path) -> None:
3119+
"""Test strict types with field constraints for pydantic v2 (issue #1884)."""
3120+
run_main_and_assert(
3121+
input_path=OPEN_API_DATA_PATH / "strict_types_field_constraints.yaml",
3122+
output_path=output_file,
3123+
input_file_type="openapi",
3124+
assert_func=assert_file_content,
3125+
expected_file="strict_types_field_constraints_pydantic_v2.py",
3126+
extra_args=[
3127+
"--output-model-type",
3128+
"pydantic_v2.BaseModel",
3129+
"--field-constraints",
3130+
"--strict-types",
3131+
"int",
3132+
"float",
3133+
"str",
3134+
],
3135+
)
3136+
3137+
3138+
def test_main_openapi_strict_types_field_constraints_msgspec(output_file: Path) -> None:
3139+
"""Test strict types with field constraints for msgspec (issue #1884)."""
3140+
run_main_and_assert(
3141+
input_path=OPEN_API_DATA_PATH / "strict_types_field_constraints.yaml",
3142+
output_path=output_file,
3143+
input_file_type="openapi",
3144+
assert_func=assert_file_content,
3145+
expected_file="strict_types_field_constraints_msgspec.py",
3146+
extra_args=[
3147+
"--output-model-type",
3148+
"msgspec.Struct",
3149+
"--field-constraints",
3150+
"--strict-types",
3151+
"int",
3152+
"float",
3153+
"str",
3154+
],
3155+
)
3156+
3157+
31183158
def test_main_openapi_circular_imports_stripe_like(output_dir: Path) -> None:
31193159
"""Test that circular imports between root and submodules are resolved with _internal.py."""
31203160
with freeze_time(TIMESTAMP):

0 commit comments

Comments
 (0)