Skip to content

Commit 875b3cf

Browse files
torarvidkoxudaxi
andauthored
Support --use-annotated *and* --use-non-positive-negative-number-constrained-types (#3015)
* Add test that shows non-negative ints not working with use-annotated * Simplify use_annotated support for constrained types * Remove silly debug stuff * Lint check for test_main_kr.py * Attempt to not degrade perf in CI * Allow NonNegative/NonPositive types with additional numeric constraints * Move CONSTRAINED_TYPE_CONSUMED_KEYS to DataTypeManager * Replace manual assertions with golden file e2e tests * Consolidate non-positive/non-negative test inputs with threshold coverage --------- Co-authored-by: Koudai Aono <koxudaxi@gmail.com>
1 parent bff6a30 commit 875b3cf

8 files changed

Lines changed: 317 additions & 40 deletions

File tree

docs/cli-reference/typing-customization.md

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3635,25 +3635,75 @@ conint/confloat with ge/le parameters.
36353635
"title": "NumberConstraints",
36363636
"type": "object",
36373637
"properties": {
3638-
"non_negative_count": {
3638+
"non_negative_int": {
36393639
"type": "integer",
36403640
"minimum": 0,
3641-
"description": "A count that cannot be negative"
3641+
"description": "NonNegativeInt: minimum=0 only"
36423642
},
3643-
"non_positive_balance": {
3643+
"non_positive_int": {
36443644
"type": "integer",
36453645
"maximum": 0,
3646-
"description": "A balance that cannot be positive"
3646+
"description": "NonPositiveInt: maximum=0 only"
36473647
},
3648-
"non_negative_amount": {
3648+
"non_negative_float": {
36493649
"type": "number",
36503650
"minimum": 0,
3651-
"description": "An amount that cannot be negative"
3651+
"description": "NonNegativeFloat: minimum=0 only"
36523652
},
3653-
"non_positive_score": {
3653+
"non_positive_float": {
36543654
"type": "number",
36553655
"maximum": 0,
3656-
"description": "A score that cannot be positive"
3656+
"description": "NonPositiveFloat: maximum=0 only"
3657+
},
3658+
"positive_int": {
3659+
"type": "integer",
3660+
"exclusiveMinimum": 0,
3661+
"description": "PositiveInt: exclusiveMinimum=0"
3662+
},
3663+
"negative_int": {
3664+
"type": "integer",
3665+
"exclusiveMaximum": 0,
3666+
"description": "NegativeInt: exclusiveMaximum=0"
3667+
},
3668+
"positive_float": {
3669+
"type": "number",
3670+
"exclusiveMinimum": 0,
3671+
"description": "PositiveFloat: exclusiveMinimum=0"
3672+
},
3673+
"negative_float": {
3674+
"type": "number",
3675+
"exclusiveMaximum": 0,
3676+
"description": "NegativeFloat: exclusiveMaximum=0"
3677+
},
3678+
"bounded_non_negative_int": {
3679+
"type": "integer",
3680+
"minimum": 0,
3681+
"maximum": 100,
3682+
"description": "NonNegativeInt with additional upper bound"
3683+
},
3684+
"bounded_non_positive_int": {
3685+
"type": "integer",
3686+
"maximum": 0,
3687+
"minimum": -100,
3688+
"description": "NonPositiveInt with additional lower bound"
3689+
},
3690+
"bounded_non_negative_float": {
3691+
"type": "number",
3692+
"minimum": 0,
3693+
"maximum": 1.0,
3694+
"description": "NonNegativeFloat with additional upper bound"
3695+
},
3696+
"bounded_non_positive_float": {
3697+
"type": "number",
3698+
"maximum": 0,
3699+
"minimum": -1.0,
3700+
"description": "NonPositiveFloat with additional lower bound"
3701+
},
3702+
"plain_constrained_int": {
3703+
"type": "integer",
3704+
"minimum": 5,
3705+
"maximum": 100,
3706+
"description": "No zero bound: should remain conint/Field"
36573707
}
36583708
}
36593709
}
@@ -3671,25 +3721,58 @@ conint/confloat with ge/le parameters.
36713721
from pydantic import (
36723722
BaseModel,
36733723
Field,
3724+
NegativeFloat,
3725+
NegativeInt,
36743726
NonNegativeFloat,
36753727
NonNegativeInt,
36763728
NonPositiveFloat,
36773729
NonPositiveInt,
3730+
PositiveFloat,
3731+
PositiveInt,
3732+
confloat,
3733+
conint,
36783734
)
36793735

36803736

36813737
class NumberConstraints(BaseModel):
3682-
non_negative_count: NonNegativeInt | None = Field(
3683-
None, description='A count that cannot be negative'
3738+
non_negative_int: NonNegativeInt | None = Field(
3739+
None, description='NonNegativeInt: minimum=0 only'
3740+
)
3741+
non_positive_int: NonPositiveInt | None = Field(
3742+
None, description='NonPositiveInt: maximum=0 only'
3743+
)
3744+
non_negative_float: NonNegativeFloat | None = Field(
3745+
None, description='NonNegativeFloat: minimum=0 only'
3746+
)
3747+
non_positive_float: NonPositiveFloat | None = Field(
3748+
None, description='NonPositiveFloat: maximum=0 only'
3749+
)
3750+
positive_int: PositiveInt | None = Field(
3751+
None, description='PositiveInt: exclusiveMinimum=0'
3752+
)
3753+
negative_int: NegativeInt | None = Field(
3754+
None, description='NegativeInt: exclusiveMaximum=0'
3755+
)
3756+
positive_float: PositiveFloat | None = Field(
3757+
None, description='PositiveFloat: exclusiveMinimum=0'
3758+
)
3759+
negative_float: NegativeFloat | None = Field(
3760+
None, description='NegativeFloat: exclusiveMaximum=0'
3761+
)
3762+
bounded_non_negative_int: conint(ge=0, le=100) | None = Field(
3763+
None, description='NonNegativeInt with additional upper bound'
3764+
)
3765+
bounded_non_positive_int: conint(ge=-100, le=0) | None = Field(
3766+
None, description='NonPositiveInt with additional lower bound'
36843767
)
3685-
non_positive_balance: NonPositiveInt | None = Field(
3686-
None, description='A balance that cannot be positive'
3768+
bounded_non_negative_float: confloat(ge=0.0, le=1.0) | None = Field(
3769+
None, description='NonNegativeFloat with additional upper bound'
36873770
)
3688-
non_negative_amount: NonNegativeFloat | None = Field(
3689-
None, description='An amount that cannot be negative'
3771+
bounded_non_positive_float: confloat(ge=-1.0, le=0.0) | None = Field(
3772+
None, description='NonPositiveFloat with additional lower bound'
36903773
)
3691-
non_positive_score: NonPositiveFloat | None = Field(
3692-
None, description='A score that cannot be positive'
3774+
plain_constrained_int: conint(ge=5, le=100) | None = Field(
3775+
None, description='No zero bound: should remain conint/Field'
36933776
)
36943777
```
36953778

src/datamodel_code_generator/model/pydantic/types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ class DataTypeManager(_DataTypeManager):
173173

174174
PATTERN_KEY: ClassVar[str] = "regex"
175175
HOSTNAME_REGEX: ClassVar[str] = HOSTNAME_REGEX
176+
CONSTRAINED_TYPE_CONSUMED_KEYS: ClassVar[dict[str, tuple[str, ...]]] = {
177+
"PositiveInt": ("exclusiveMinimum",),
178+
"NegativeInt": ("exclusiveMaximum",),
179+
"NonNegativeInt": ("minimum",),
180+
"NonPositiveInt": ("maximum",),
181+
"PositiveFloat": ("exclusiveMinimum",),
182+
"NegativeFloat": ("exclusiveMaximum",),
183+
"NonNegativeFloat": ("minimum",),
184+
"NonPositiveFloat": ("maximum",),
185+
}
176186

177187
def __init__( # noqa: PLR0913, PLR0917
178188
self,

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,10 @@ def get_object_field( # noqa: PLR0913
12261226
has_default = effective_has_default if effective_has_default is not None else field.has_default
12271227

12281228
constraints = model_dump(field, exclude_none=True) if self.is_constraints_field(field) else None
1229+
consumed = self.data_type_manager.CONSTRAINED_TYPE_CONSUMED_KEYS
1230+
if constraints is not None and field_type.type in consumed:
1231+
for key in consumed[field_type.type]:
1232+
constraints.pop(key, None)
12291233
if constraints is not None and self.field_constraints and field.format == "hostname":
12301234
constraints["pattern"] = self.data_type_manager.HOSTNAME_REGEX
12311235
# Suppress minItems/maxItems for fixed-length tuples
@@ -1283,10 +1287,31 @@ def get_data_type(self, obj: JsonSchemaObject) -> DataType:
12831287
)
12841288

12851289
def _get_data_type(type_: str, format__: str) -> DataType:
1290+
if self.field_constraints:
1291+
# To prevent type manager from generating conint/confloat,
1292+
# we only pass constraints that perfectly match specialized types
1293+
# (like NonNegativeInt -> minimum: 0).
1294+
# Other constraints should remain on Field(), so we pass {}
1295+
kwargs_to_pass = {}
1296+
number_keys = ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum")
1297+
number_kwargs: dict[str, int | float | bool] = {}
1298+
for key in number_keys:
1299+
value = getattr(obj, key)
1300+
if value is not None:
1301+
number_kwargs[key] = value.value if isinstance(value, UnionIntFloat) else value
1302+
1303+
if self.data_type_manager.use_non_positive_negative_number_constrained_types:
1304+
zero_bound_keys = [k for k, v in number_kwargs.items() if v == 0]
1305+
if len(zero_bound_keys) == 1:
1306+
key = zero_bound_keys[0]
1307+
kwargs_to_pass = {key: number_kwargs[key]}
1308+
else:
1309+
kwargs_to_pass = model_dump(obj)
1310+
12861311
return self.data_type_manager.get_data_type(
12871312
self._get_type_with_mappings(type_, format__),
12881313
field_constraints=self.field_constraints,
1289-
**model_dump(obj) if not self.field_constraints else {},
1314+
**kwargs_to_pass,
12901315
)
12911316

12921317
if isinstance(obj.type, list):

src/datamodel_code_generator/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,7 @@ class DataTypeManager(ABC):
10041004
r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.)*"
10051005
r"([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9])$"
10061006
)
1007+
CONSTRAINED_TYPE_CONSUMED_KEYS: ClassVar[dict[str, tuple[str, ...]]] = {}
10071008

10081009
def __init__( # noqa: PLR0913, PLR0917
10091010
self,

tests/data/expected/main_kr/use_non_positive_negative/output.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,56 @@
77
from pydantic import (
88
BaseModel,
99
Field,
10+
NegativeFloat,
11+
NegativeInt,
1012
NonNegativeFloat,
1113
NonNegativeInt,
1214
NonPositiveFloat,
1315
NonPositiveInt,
16+
PositiveFloat,
17+
PositiveInt,
18+
confloat,
19+
conint,
1420
)
1521

1622

1723
class NumberConstraints(BaseModel):
18-
non_negative_count: NonNegativeInt | None = Field(
19-
None, description='A count that cannot be negative'
24+
non_negative_int: NonNegativeInt | None = Field(
25+
None, description='NonNegativeInt: minimum=0 only'
2026
)
21-
non_positive_balance: NonPositiveInt | None = Field(
22-
None, description='A balance that cannot be positive'
27+
non_positive_int: NonPositiveInt | None = Field(
28+
None, description='NonPositiveInt: maximum=0 only'
2329
)
24-
non_negative_amount: NonNegativeFloat | None = Field(
25-
None, description='An amount that cannot be negative'
30+
non_negative_float: NonNegativeFloat | None = Field(
31+
None, description='NonNegativeFloat: minimum=0 only'
2632
)
27-
non_positive_score: NonPositiveFloat | None = Field(
28-
None, description='A score that cannot be positive'
33+
non_positive_float: NonPositiveFloat | None = Field(
34+
None, description='NonPositiveFloat: maximum=0 only'
35+
)
36+
positive_int: PositiveInt | None = Field(
37+
None, description='PositiveInt: exclusiveMinimum=0'
38+
)
39+
negative_int: NegativeInt | None = Field(
40+
None, description='NegativeInt: exclusiveMaximum=0'
41+
)
42+
positive_float: PositiveFloat | None = Field(
43+
None, description='PositiveFloat: exclusiveMinimum=0'
44+
)
45+
negative_float: NegativeFloat | None = Field(
46+
None, description='NegativeFloat: exclusiveMaximum=0'
47+
)
48+
bounded_non_negative_int: conint(ge=0, le=100) | None = Field(
49+
None, description='NonNegativeInt with additional upper bound'
50+
)
51+
bounded_non_positive_int: conint(ge=-100, le=0) | None = Field(
52+
None, description='NonPositiveInt with additional lower bound'
53+
)
54+
bounded_non_negative_float: confloat(ge=0.0, le=1.0) | None = Field(
55+
None, description='NonNegativeFloat with additional upper bound'
56+
)
57+
bounded_non_positive_float: confloat(ge=-1.0, le=0.0) | None = Field(
58+
None, description='NonPositiveFloat with additional lower bound'
59+
)
60+
plain_constrained_int: conint(ge=5, le=100) | None = Field(
61+
None, description='No zero bound: should remain conint/Field'
2962
)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# generated by datamodel-codegen:
2+
# filename: use_non_positive_negative.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated
8+
9+
from pydantic import (
10+
BaseModel,
11+
Field,
12+
NegativeFloat,
13+
NegativeInt,
14+
NonNegativeFloat,
15+
NonNegativeInt,
16+
NonPositiveFloat,
17+
NonPositiveInt,
18+
PositiveFloat,
19+
PositiveInt,
20+
)
21+
22+
23+
class NumberConstraints(BaseModel):
24+
non_negative_int: Annotated[
25+
NonNegativeInt | None, Field(description='NonNegativeInt: minimum=0 only')
26+
] = None
27+
non_positive_int: Annotated[
28+
NonPositiveInt | None, Field(description='NonPositiveInt: maximum=0 only')
29+
] = None
30+
non_negative_float: Annotated[
31+
NonNegativeFloat | None, Field(description='NonNegativeFloat: minimum=0 only')
32+
] = None
33+
non_positive_float: Annotated[
34+
NonPositiveFloat | None, Field(description='NonPositiveFloat: maximum=0 only')
35+
] = None
36+
positive_int: Annotated[
37+
PositiveInt | None, Field(description='PositiveInt: exclusiveMinimum=0')
38+
] = None
39+
negative_int: Annotated[
40+
NegativeInt | None, Field(description='NegativeInt: exclusiveMaximum=0')
41+
] = None
42+
positive_float: Annotated[
43+
PositiveFloat | None, Field(description='PositiveFloat: exclusiveMinimum=0')
44+
] = None
45+
negative_float: Annotated[
46+
NegativeFloat | None, Field(description='NegativeFloat: exclusiveMaximum=0')
47+
] = None
48+
bounded_non_negative_int: Annotated[
49+
NonNegativeInt | None,
50+
Field(description='NonNegativeInt with additional upper bound', le=100),
51+
] = None
52+
bounded_non_positive_int: Annotated[
53+
NonPositiveInt | None,
54+
Field(description='NonPositiveInt with additional lower bound', ge=-100),
55+
] = None
56+
bounded_non_negative_float: Annotated[
57+
NonNegativeFloat | None,
58+
Field(description='NonNegativeFloat with additional upper bound', le=1),
59+
] = None
60+
bounded_non_positive_float: Annotated[
61+
NonPositiveFloat | None,
62+
Field(description='NonPositiveFloat with additional lower bound', ge=-1),
63+
] = None
64+
plain_constrained_int: Annotated[
65+
int | None,
66+
Field(description='No zero bound: should remain conint/Field', ge=5, le=100),
67+
] = None

0 commit comments

Comments
 (0)