Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added le and ge bounds to constrained numerics. #194

Merged
merged 4 commits into from
Jun 8, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ v0.10.0 (2018-XX-XX)
* **breaking change**: new errors format #179
* **breaking change**: removed ``Config.min_number_size`` and ``Config.max_number_size`` #183
* **breaking change**: correct behaviour of ``lt`` and ``gt`` arguments to ``conint`` etc. #188
for the old behaviour use ``le`` and ``ge`` # 194
* added error context and ability to redefine error message templates using ``Config.error_msg_templates`` #183
* fix typo in validator exception #150
* copy defaults to model values, so different models don't share objects #154
Expand Down
3 changes: 3 additions & 0 deletions docs/examples/exotic.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Model(BaseModel):
neg_int: NegativeInt = None

big_float: confloat(gt=1000, lt=1024) = None
unit_interval: confloat(ge=0, le=1) = None
pos_float: PositiveFloat = None
neg_float: NegativeFloat = None

Expand Down Expand Up @@ -56,6 +57,7 @@ class Model(BaseModel):
big_float=1002.1,
pos_float=2.2,
neg_float=-2.3,
unit_interval=0.5,
email_address='Samuel Colvin <s@muelcolvin.com >',
email_and_name='Samuel Colvin <s@muelcolvin.com >',
decimal=Decimal('42.24'),
Expand All @@ -82,6 +84,7 @@ class Model(BaseModel):
'big_float': 1002.1,
'pos_float': 2.2,
'neg_float': -2.3,
'unit_interval': 0.5,
'email_address': 's@muelcolvin.com',
'email_and_name': <NameEmail("Samuel Colvin <s@muelcolvin.com>")>,
...
Expand Down
21 changes: 15 additions & 6 deletions pydantic/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,20 +120,29 @@ def __init__(self, *, pattern: str) -> None:
super().__init__(pattern=pattern)


class NumberNotGtError(PydanticValueError):
class _NumberBoundError(PydanticValueError):
def __init__(self, *, limit_value: Union[int, float, Decimal]) -> None:
super().__init__(limit_value=limit_value)


class NumberNotGtError(_NumberBoundError):
code = 'number.not_gt'
msg_template = 'ensure this value is greater than {limit_value}'

def __init__(self, *, limit_value: Union[int, float, Decimal]) -> None:
super().__init__(limit_value=limit_value)

class NumberNotGeError(_NumberBoundError):
code = 'number.not_ge'
msg_template = 'ensure this value is greater than or equal to {limit_value}'


class NumberNotLtError(PydanticValueError):
class NumberNotLtError(_NumberBoundError):
code = 'number.not_lt'
msg_template = 'ensure this value is less than {limit_value}'

def __init__(self, *, limit_value: Union[int, float, Decimal]) -> None:
super().__init__(limit_value=limit_value)

class NumberNotLeError(_NumberBoundError):
code = 'number.not_le'
msg_template = 'ensure this value is less than or equal to {limit_value}'


class DecimalError(PydanticTypeError):
Expand Down
38 changes: 28 additions & 10 deletions pydantic/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,19 +175,33 @@ def validate(cls, value, values, **kwarg):
return make_dsn(**kwargs)


class ConstrainedInt(int):
class ConstrainedNumberMeta(type):
def __new__(cls, name, bases, dct):
new_cls = type.__new__(cls, name, bases, dct)

if new_cls.gt is not None and new_cls.ge is not None:
raise errors.ConfigError(f'Bounds gt and ge cannot be specified at the same time.')
if new_cls.lt is not None and new_cls.le is not None:
raise errors.ConfigError(f'Bounds lt and le cannot be specified at the same time.')

return new_cls


class ConstrainedInt(int, metaclass=ConstrainedNumberMeta):
gt: Optional[int] = None
ge: Optional[int] = None
lt: Optional[int] = None
le: Optional[int] = None

@classmethod
def get_validators(cls):
yield int_validator
yield number_size_validator


def conint(*, gt=None, lt=None) -> Type[int]:
def conint(*, gt=None, ge=None, lt=None, le=None) -> Type[int]:
# use kwargs then define conf in a dict to aid with IDE type hinting
namespace = dict(gt=gt, lt=lt)
namespace = dict(gt=gt, ge=ge, lt=lt, le=le)
return type('ConstrainedIntValue', (ConstrainedInt,), namespace)


Expand All @@ -199,19 +213,21 @@ class NegativeInt(ConstrainedInt):
lt = 0


class ConstrainedFloat(float):
class ConstrainedFloat(float, metaclass=ConstrainedNumberMeta):
gt: Union[None, int, float] = None
ge: Union[None, int, float] = None
lt: Union[None, int, float] = None
le: Union[None, int, float] = None

@classmethod
def get_validators(cls):
yield float_validator
yield number_size_validator


def confloat(*, gt=None, lt=None) -> Type[float]:
def confloat(*, gt=None, ge=None, lt=None, le=None) -> Type[float]:
# use kwargs then define conf in a dict to aid with IDE type hinting
namespace = dict(gt=gt, lt=lt)
namespace = dict(gt=gt, ge=ge, lt=lt, le=le)
return type('ConstrainedFloatValue', (ConstrainedFloat,), namespace)


Expand All @@ -223,9 +239,11 @@ class NegativeFloat(ConstrainedFloat):
lt = 0


class ConstrainedDecimal(Decimal):
class ConstrainedDecimal(Decimal, metaclass=ConstrainedNumberMeta):
gt: Union[None, int, float, Decimal] = None
ge: Union[None, int, float, Decimal] = None
lt: Union[None, int, float, Decimal] = None
le: Union[None, int, float, Decimal] = None
max_digits: Optional[int] = None
decimal_places: Optional[int] = None

Expand Down Expand Up @@ -273,11 +291,11 @@ def validate(cls, value: Decimal) -> Decimal:
return value


def condecimal(*, gt=None, lt=None, max_digits=None, decimal_places=None) -> Type[Decimal]:
def condecimal(*, gt=None, ge=None, lt=None, le=None, max_digits=None, decimal_places=None) -> Type[Decimal]:
# use kwargs then define conf in a dict to aid with IDE type hinting
namespace = dict(
gt=gt,
lt=lt,
gt=gt, ge=ge,
lt=lt, le=le,
max_digits=max_digits,
decimal_places=decimal_places
)
Expand Down
8 changes: 6 additions & 2 deletions pydantic/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,15 @@ def float_validator(v) -> float:


def number_size_validator(v, field, config, **kwargs):
if field.type_.gt is not None and v <= field.type_.gt:
if field.type_.gt is not None and not v > field.type_.gt:
raise errors.NumberNotGtError(limit_value=field.type_.gt)
elif field.type_.ge is not None and not v >= field.type_.ge:
raise errors.NumberNotGeError(limit_value=field.type_.ge)

if field.type_.lt is not None and v >= field.type_.lt:
if field.type_.lt is not None and not v < field.type_.lt:
raise errors.NumberNotLtError(limit_value=field.type_.lt)
if field.type_.le is not None and not v <= field.type_.le:
raise errors.NumberNotLeError(limit_value=field.type_.le)

return v

Expand Down
108 changes: 91 additions & 17 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

import pytest

from pydantic import (DSN, UUID1, UUID3, UUID4, UUID5, BaseModel, EmailStr, NameEmail, NegativeFloat, NegativeInt,
PositiveFloat, PositiveInt, PyObject, StrictStr, ValidationError, condecimal, confloat, conint,
constr, create_model)
from pydantic import (DSN, UUID1, UUID3, UUID4, UUID5, BaseModel, ConfigError, EmailStr, NameEmail, NegativeFloat,
NegativeInt, PositiveFloat, PositiveInt, PyObject, StrictStr, ValidationError, condecimal,
confloat, conint, constr, create_model)

try:
import email_validator
Expand Down Expand Up @@ -509,12 +509,13 @@ class Model(BaseModel):
a: PositiveInt = None
b: NegativeInt = None
c: conint(gt=4, lt=10) = None
d: conint(ge=0, le=10) = None

m = Model(a=5, b=-5, c=5)
assert m == {'a': 5, 'b': -5, 'c': 5}
m = Model(a=5, b=-5, c=5, d=0)
assert m == {'a': 5, 'b': -5, 'c': 5, 'd': 0}

with pytest.raises(ValidationError) as exc_info:
Model(a=-5, b=5, c=-5)
Model(a=-5, b=5, c=-5, d=11)
assert exc_info.value.flatten_errors() == [
{
'loc': ('a',),
Expand All @@ -540,6 +541,14 @@ class Model(BaseModel):
'limit_value': 4,
},
},
{
'loc': ('d',),
'msg': 'ensure this value is less than or equal to 10',
'type': 'value_error.number.not_le',
'ctx': {
'limit_value': 10,
},
},
]


Expand All @@ -548,12 +557,13 @@ class Model(BaseModel):
a: PositiveFloat = None
b: NegativeFloat = None
c: confloat(gt=4, lt=12.2) = None
d: confloat(ge=0, le=9.9) = None

m = Model(a=5.1, b=-5.2, c=5.3)
assert m == {'a': 5.1, 'b': -5.2, 'c': 5.3}
m = Model(a=5.1, b=-5.2, c=5.3, d=9.9)
assert m.dict() == {'a': 5.1, 'b': -5.2, 'c': 5.3, 'd': 9.9}

with pytest.raises(ValidationError) as exc_info:
Model(a=-5.1, b=5.2, c=-5.3)
Model(a=-5.1, b=5.2, c=-5.3, d=9.91)
assert exc_info.value.flatten_errors() == [
{
'loc': ('a',),
Expand All @@ -579,6 +589,14 @@ class Model(BaseModel):
'limit_value': 4,
},
},
{
'loc': ('d',),
'msg': 'ensure this value is less than or equal to 9.9',
'type': 'value_error.number.not_le',
'ctx': {
'limit_value': 9.9,
},
},
]


Expand Down Expand Up @@ -721,6 +739,30 @@ class Config:
},
},
]),
(condecimal(ge=Decimal('42.24')), Decimal('43'), Decimal('43')),
(condecimal(ge=Decimal('42.24')), Decimal('42.24'), Decimal('42.24')),
(condecimal(ge=Decimal('42.24')), Decimal('42'), [
{
'loc': ('foo',),
'msg': 'ensure this value is greater than or equal to 42.24',
'type': 'value_error.number.not_ge',
'ctx': {
'limit_value': Decimal('42.24'),
},
},
]),
(condecimal(le=Decimal('42.24')), Decimal('42'), Decimal('42')),
(condecimal(le=Decimal('42.24')), Decimal('42.24'), Decimal('42.24')),
(condecimal(le=Decimal('42.24')), Decimal('43'), [
{
'loc': ('foo',),
'msg': 'ensure this value is less than or equal to 42.24',
'type': 'value_error.number.not_le',
'ctx': {
'limit_value': Decimal('42.24'),
},
},
]),
(condecimal(max_digits=2, decimal_places=2), Decimal('0.99'), Decimal('0.99')),
(condecimal(max_digits=2, decimal_places=1), Decimal('0.99'), [
{
Expand Down Expand Up @@ -838,25 +880,57 @@ class Model(BaseModel):
assert Model(foo=value).foo == result


base_message = r'.*ensure this value is {msg} \(type=value_error.number.not_{ty}; limit_value={value}\).*'


def test_number_gt():
class Model(BaseModel):
a: conint(gt=-1) = 0

assert Model(a=0).dict() == {'a': 0}
with pytest.raises(ValidationError) as exc_info:

message = base_message.format(msg='greater than -1', ty='gt', value=-1)
with pytest.raises(ValidationError, match=message):
Model(a=-1)


def test_number_ge():
class Model(BaseModel):
a: conint(ge=0) = 0

assert Model(a=0).dict() == {'a': 0}

message = base_message.format(msg='greater than or equal to 0', ty='ge', value=0)
with pytest.raises(ValidationError, match=message):
Model(a=-1)
assert (
'ensure this value is greater than -1 (type=value_error.number.not_gt; limit_value=-1)'
) in str(exc_info.value)


def test_number_lt():
class Model(BaseModel):
a: conint(lt=5) = 0

assert Model(a=4).dict() == {'a': 4}
with pytest.raises(ValidationError) as exc_info:

message = base_message.format(msg='less than 5', ty='lt', value=5)
with pytest.raises(ValidationError, match=message):
Model(a=5)
assert (
'ensure this value is less than 5 (type=value_error.number.not_lt; limit_value=5)'
) in str(exc_info.value)


def test_number_le():
class Model(BaseModel):
a: conint(le=5) = 0

assert Model(a=5).dict() == {'a': 5}

message = base_message.format(msg='less than or equal to 5', ty='le', value=5)
with pytest.raises(ValidationError, match=message):
Model(a=6)


@pytest.mark.parametrize('fn', [conint, confloat, condecimal])
def test_bounds_config_exceptions(fn):
with pytest.raises(ConfigError):
fn(gt=0, ge=0)

with pytest.raises(ConfigError):
fn(lt=0, le=0)