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 1 commit
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
3 changes: 3 additions & 0 deletions pydantic/__init__.py
Expand Up @@ -98,6 +98,9 @@
'NegativeFloat',
'NonNegativeFloat',
'NonPositiveFloat',
'NonNanFloat',
'NonInfFloat',
'JSONFloat',
'ConstrainedDecimal',
'condecimal',
'UUID1',
Expand Down
10 changes: 10 additions & 0 deletions pydantic/errors.py
Expand Up @@ -417,6 +417,16 @@ class NumberNotLeError(_NumberBoundError):
msg_template = 'ensure this value is less than or equal to {limit_value}'


class NumberIsNanError(PydanticValueError):
code = 'number.is_nan'
msg_template = 'ensure this value is a number and not NaN'


class NumberIsInfError(PydanticValueError):
code = 'number.is_inf'
msg_template = 'ensure this value is a number and not infinity (+inf or -inf)'


class NumberNotMultipleError(PydanticValueError):
code = 'number.not_multiple'
msg_template = 'ensure this value is a multiple of {multiple_of}'
Expand Down
14 changes: 14 additions & 0 deletions pydantic/fields.py
Expand Up @@ -110,6 +110,8 @@ class FieldInfo(Representation):
'lt',
'le',
'multiple_of',
'allow_nan',
'allow_inf',
'max_digits',
'decimal_places',
'min_items',
Expand All @@ -134,6 +136,8 @@ class FieldInfo(Representation):
'ge': None,
'le': None,
'multiple_of': None,
'allow_nan': None,
'allow_inf': None,
'max_digits': None,
'decimal_places': None,
'min_items': None,
Expand All @@ -157,6 +161,8 @@ 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_nan = kwargs.pop('allow_nan', None)
self.allow_inf = kwargs.pop('allow_inf', 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 @@ -226,6 +232,8 @@ def Field(
lt: float = None,
le: float = None,
multiple_of: float = None,
allow_nan: bool = None,
allow_inf: bool = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only question is: do we need both of these options?

I can't imagine a scenario where we want to allow inf but not nan or visa. versa.???

I would say we should have one allow_nan_inf argument for clarity.

@tiangolo what do you think?

max_digits: int = None,
decimal_places: int = None,
min_items: int = None,
Expand Down Expand Up @@ -265,6 +273,10 @@ 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_nan: only applies to numbers, allows the field to be NaN, which is a valid Python float. Set
it to False for compatibility with JSON.
:param allow_inf: only applies to numbers, allows the field to be infinity (+inf or -inf), which is a valid
Python float. Set it 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 @@ -302,6 +314,8 @@ def Field(
lt=lt,
le=le,
multiple_of=multiple_of,
allow_nan=allow_nan,
allow_inf=allow_inf,
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 @@ -1104,6 +1104,8 @@ def constraint_func(**kw: Any) -> Type[Any]:
):
# Is numeric type
attrs = ('gt', 'lt', 'ge', 'le', 'multiple_of')
if issubclass(type_, float):
attrs += ('allow_nan', 'allow_inf')
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
28 changes: 27 additions & 1 deletion pydantic/types.py
Expand Up @@ -36,6 +36,8 @@
constr_lower,
constr_strip_whitespace,
decimal_validator,
float_inf_validator,
float_nan_validator,
float_validator,
frozenset_validator,
int_validator,
Expand Down Expand Up @@ -81,6 +83,9 @@
'NegativeFloat',
'NonNegativeFloat',
'NonPositiveFloat',
'NonNanFloat',
'NonInfFloat',
'JSONFloat',
'ConstrainedDecimal',
'condecimal',
'UUID1',
Expand Down Expand Up @@ -257,6 +262,8 @@ class ConstrainedFloat(float, metaclass=ConstrainedNumberMeta):
lt: OptionalIntFloat = None
le: OptionalIntFloat = None
multiple_of: OptionalIntFloat = None
allow_nan: Optional[bool] = None
allow_inf: Optional[bool] = None

@classmethod
def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None:
Expand All @@ -283,6 +290,8 @@ def __get_validators__(cls) -> 'CallableGenerator':
yield strict_float_validator if cls.strict else float_validator
yield number_size_validator
yield number_multiple_validator
yield float_nan_validator
yield float_inf_validator


def confloat(
Expand All @@ -293,9 +302,13 @@ def confloat(
lt: float = None,
le: float = None,
multiple_of: float = None,
allow_nan: bool = None,
allow_inf: bool = None,
) -> 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_nan=allow_nan, allow_inf=allow_inf
)
return type('ConstrainedFloatValue', (ConstrainedFloat,), namespace)


Expand All @@ -305,6 +318,9 @@ def confloat(
NonPositiveFloat = float
NonNegativeFloat = float
StrictFloat = float
NonNanFloat = float
NonInfFloat = float
JSONFloat = float
else:

class PositiveFloat(ConstrainedFloat):
Expand All @@ -322,6 +338,16 @@ class NonNegativeFloat(ConstrainedFloat):
class StrictFloat(ConstrainedFloat):
strict = True

class NonNanFloat(ConstrainedFloat):
allow_nan = False

class NonInfFloat(ConstrainedFloat):
allow_inf = False

class JSONFloat(ConstrainedFloat):
allow_nan = False
allow_inf = False


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

Expand Down
15 changes: 15 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,20 @@ def strict_float_validator(v: Any) -> float:
raise errors.FloatError()


def float_nan_validator(v: 'Number', field: 'ModelField') -> 'Number':
field_type: ConstrainedFloat = field.type_
if field_type.allow_nan is False and math.isnan(v):
raise errors.NumberIsNanError()
return v


def float_inf_validator(v: 'Number', field: 'ModelField') -> 'Number':
field_type: ConstrainedFloat = field.type_
if field_type.allow_inf is False and math.isinf(v):
raise errors.NumberIsInfError()
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