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

✨ Add JSON-compatible float constraints for NaN and Inf #3994

Merged
merged 6 commits into from Aug 22, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/3994-tiangolo.md
@@ -0,0 +1 @@
Add JSON-compatible float constraint `allow_inf_nan`
3 changes: 3 additions & 0 deletions docs/usage/types.md
Expand Up @@ -833,6 +833,9 @@ The following arguments are available when using the `confloat` type function
- `lt: float = None`: enforces float to be less than the set value
- `le: float = None`: enforces float to be less than or equal to the set value
- `multiple_of: float = None`: enforces float to be a multiple of the set value
- `allow_inf_nan: bool = True`: whether to allows infinity (`+inf` an `-inf`) and NaN values, defaults to `True`,
set to `False` for compatibility with `JSON`,
see [#3994](https://github.com/pydantic/pydantic/pull/3994) for more details, added in **V1.10**

### Arguments to `condecimal`
The following arguments are available when using the `condecimal` type function
Expand Down
1 change: 1 addition & 0 deletions pydantic/__init__.py
Expand Up @@ -100,6 +100,7 @@
'NegativeFloat',
'NonNegativeFloat',
'NonPositiveFloat',
'FiniteFloat',
'ConstrainedDecimal',
'condecimal',
'ConstrainedDate',
Expand Down
5 changes: 5 additions & 0 deletions pydantic/errors.py
Expand Up @@ -417,6 +417,11 @@ class NumberNotLeError(_NumberBoundError):
msg_template = 'ensure this value is less than or equal to {limit_value}'


class NumberNotFiniteError(PydanticValueError):
code = 'number.not_finite_number'
msg_template = 'ensure this value is a finite number'


class NumberNotMultipleError(PydanticValueError):
code = 'number.not_multiple'
msg_template = 'ensure this value is a multiple of {multiple_of}'
Expand Down
7 changes: 7 additions & 0 deletions pydantic/fields.py
Expand Up @@ -114,6 +114,7 @@ class FieldInfo(Representation):
'lt',
'le',
'multiple_of',
'allow_inf_nan',
'max_digits',
'decimal_places',
'min_items',
Expand All @@ -138,6 +139,7 @@ class FieldInfo(Representation):
'ge': None,
'le': None,
'multiple_of': None,
'allow_inf_nan': None,
'max_digits': None,
'decimal_places': None,
'min_items': None,
Expand All @@ -161,6 +163,7 @@ def __init__(self, default: Any = Undefined, **kwargs: Any) -> None:
self.lt = kwargs.pop('lt', None)
self.le = kwargs.pop('le', None)
self.multiple_of = kwargs.pop('multiple_of', None)
self.allow_inf_nan = kwargs.pop('allow_inf_nan', None)
self.max_digits = kwargs.pop('max_digits', None)
self.decimal_places = kwargs.pop('decimal_places', None)
self.min_items = kwargs.pop('min_items', None)
Expand Down Expand Up @@ -231,6 +234,7 @@ def Field(
lt: float = None,
le: float = None,
multiple_of: float = None,
allow_inf_nan: bool = None,
max_digits: int = None,
decimal_places: int = None,
min_items: int = None,
Expand Down Expand Up @@ -270,6 +274,8 @@ def Field(
schema will have a ``maximum`` validation keyword
:param multiple_of: only applies to numbers, requires the field to be "a multiple of". The
schema will have a ``multipleOf`` validation keyword
:param allow_inf_nan: only applies to numbers, allows the field to be NaN or infinity (+inf or -inf),
which is a valid Python float. Default True, set to False for compatibility with JSON.
:param max_digits: only applies to Decimals, requires the field to have a maximum number
of digits within the decimal. It does not include a zero before the decimal point or trailing decimal zeroes.
:param decimal_places: only applies to Decimals, requires the field to have at most a number of decimal places
Expand Down Expand Up @@ -307,6 +313,7 @@ def Field(
lt=lt,
le=le,
multiple_of=multiple_of,
allow_inf_nan=allow_inf_nan,
max_digits=max_digits,
decimal_places=decimal_places,
min_items=min_items,
Expand Down
2 changes: 2 additions & 0 deletions pydantic/schema.py
Expand Up @@ -1115,6 +1115,8 @@ def constraint_func(**kw: Any) -> Type[Any]:
):
# Is numeric type
attrs = ('gt', 'lt', 'ge', 'le', 'multiple_of')
if issubclass(type_, float):
attrs += ('allow_inf_nan',)
if issubclass(type_, Decimal):
attrs += ('max_digits', 'decimal_places')
numeric_type = next(t for t in numeric_types if issubclass(type_, t)) # pragma: no branch
Expand Down
12 changes: 11 additions & 1 deletion pydantic/types.py
Expand Up @@ -38,6 +38,7 @@
constr_strip_whitespace,
constr_upper,
decimal_validator,
float_finite_validator,
float_validator,
frozenset_validator,
int_validator,
Expand Down Expand Up @@ -83,6 +84,7 @@
'NegativeFloat',
'NonNegativeFloat',
'NonPositiveFloat',
'FiniteFloat',
'ConstrainedDecimal',
'condecimal',
'UUID1',
Expand Down Expand Up @@ -265,6 +267,7 @@ class ConstrainedFloat(float, metaclass=ConstrainedNumberMeta):
lt: OptionalIntFloat = None
le: OptionalIntFloat = None
multiple_of: OptionalIntFloat = None
allow_inf_nan: Optional[bool] = None

@classmethod
def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None:
Expand All @@ -291,6 +294,8 @@ def __get_validators__(cls) -> 'CallableGenerator':
yield strict_float_validator if cls.strict else float_validator
yield number_size_validator
yield number_multiple_validator
if cls.allow_inf_nan is False:
yield float_finite_validator


def confloat(
Expand All @@ -301,9 +306,10 @@ def confloat(
lt: float = None,
le: float = None,
multiple_of: float = None,
allow_inf_nan: bool = True,
) -> Type[float]:
# use kwargs then define conf in a dict to aid with IDE type hinting
namespace = dict(strict=strict, gt=gt, ge=ge, lt=lt, le=le, multiple_of=multiple_of)
namespace = dict(strict=strict, gt=gt, ge=ge, lt=lt, le=le, multiple_of=multiple_of, allow_inf_nan=allow_inf_nan)
return type('ConstrainedFloatValue', (ConstrainedFloat,), namespace)


Expand All @@ -313,6 +319,7 @@ def confloat(
NonPositiveFloat = float
NonNegativeFloat = float
StrictFloat = float
FiniteFloat = float
else:

class PositiveFloat(ConstrainedFloat):
Expand All @@ -330,6 +337,9 @@ class NonNegativeFloat(ConstrainedFloat):
class StrictFloat(ConstrainedFloat):
strict = True

class FiniteFloat(ConstrainedFloat):
allow_info_nan = False


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ BYTES TYPES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
7 changes: 7 additions & 0 deletions pydantic/validators.py
@@ -1,3 +1,4 @@
import math
import re
from collections import OrderedDict, deque
from collections.abc import Hashable as CollectionsHashable
Expand Down Expand Up @@ -151,6 +152,12 @@ def strict_float_validator(v: Any) -> float:
raise errors.FloatError()


def float_finite_validator(v: 'Number') -> 'Number':
if math.isnan(v) or math.isinf(v):
raise errors.NumberNotFiniteError()
return v


def number_multiple_validator(v: 'Number', field: 'ModelField') -> 'Number':
field_type: ConstrainedNumber = field.type_
if field_type.multiple_of is not None:
Expand Down
9 changes: 8 additions & 1 deletion tests/mypy/test_mypy.py
Expand Up @@ -118,7 +118,7 @@ def test_success_cases_run(module: str) -> None:
importlib.import_module(f'tests.mypy.modules.{module}')


def test_explicit_reexports() -> None:
def test_explicit_reexports():
from pydantic import __all__ as root_all
from pydantic.main import __all__ as main
from pydantic.networks import __all__ as networks
Expand All @@ -130,6 +130,13 @@ def test_explicit_reexports() -> None:
assert export in root_all, f'{export} is in {name}.__all__ but missing from re-export in __init__.py'


def test_explicit_reexports_exist():
import pydantic

for name in pydantic.__all__:
assert hasattr(pydantic, name), f'{name} is in pydantic.__all__ but missing from pydantic'


@pytest.mark.skipif(mypy_version is None, reason='mypy is not installed')
@pytest.mark.parametrize(
'v_str,v_tuple',
Expand Down
38 changes: 35 additions & 3 deletions tests/test_types.py
@@ -1,4 +1,5 @@
import itertools
import math
import os
import re
import sys
Expand Down Expand Up @@ -1565,12 +1566,13 @@ class Model(BaseModel):
e: confloat(gt=4, lt=12.2) = None
f: confloat(ge=0, le=9.9) = None
g: confloat(multiple_of=0.5) = None
h: confloat(allow_inf_nan=False) = None

m = Model(a=5.1, b=-5.2, c=0, d=0, e=5.3, f=9.9, g=2.5)
assert m.dict() == {'a': 5.1, 'b': -5.2, 'c': 0, 'd': 0, 'e': 5.3, 'f': 9.9, 'g': 2.5}
m = Model(a=5.1, b=-5.2, c=0, d=0, e=5.3, f=9.9, g=2.5, h=42)
assert m.dict() == {'a': 5.1, 'b': -5.2, 'c': 0, 'd': 0, 'e': 5.3, 'f': 9.9, 'g': 2.5, 'h': 42}

with pytest.raises(ValidationError) as exc_info:
Model(a=-5.1, b=5.2, c=-5.1, d=5.1, e=-5.3, f=9.91, g=4.2)
Model(a=-5.1, b=5.2, c=-5.1, d=5.1, e=-5.3, f=9.91, g=4.2, h=float('nan'))
assert exc_info.value.errors() == [
{
'loc': ('a',),
Expand Down Expand Up @@ -1614,6 +1616,36 @@ class Model(BaseModel):
'type': 'value_error.number.not_multiple',
'ctx': {'multiple_of': 0.5},
},
{
'loc': ('h',),
'msg': 'ensure this value is a finite number',
'type': 'value_error.number.not_finite_number',
},
]


def test_finite_float_validation():
class Model(BaseModel):
a: float = None

assert Model(a=float('inf')).a == float('inf')
assert Model(a=float('-inf')).a == float('-inf')
assert math.isnan(Model(a=float('nan')).a)


@pytest.mark.parametrize('value', [float('inf'), float('-inf'), float('nan')])
def test_finite_float_validation_error(value):
class Model(BaseModel):
a: confloat(allow_inf_nan=False)

with pytest.raises(ValidationError) as exc_info:
Model(a=value)
assert exc_info.value.errors() == [
{
'loc': ('a',),
'msg': 'ensure this value is a finite number',
'type': 'value_error.number.not_finite_number',
},
]


Expand Down