diff --git a/changes/2425-Kludex.md b/changes/2425-Kludex.md new file mode 100644 index 0000000..0dde080 --- /dev/null +++ b/changes/2425-Kludex.md @@ -0,0 +1 @@ +Add `PastDate` and `FutureDate` types. diff --git a/docs/usage/types.md b/docs/usage/types.md index fb05dc9..dfc3dca 100644 --- a/docs/usage/types.md +++ b/docs/usage/types.md @@ -104,7 +104,7 @@ with custom properties and validation. : see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation `subclass of typing.TypedDict` -: Same as `dict` but _pydantic_ will validate the dictionary since keys are annotated. +: Same as `dict` but _pydantic_ will validate the dictionary since keys are annotated. See [Annotated Types](#annotated-types) below for more detail on parsing and validation `typing.Set` @@ -444,6 +444,12 @@ _(This script is complete, it should run "as is")_ `DirectoryPath` : like `Path`, but the path must exist and be a directory +`PastDate` +: like `date`, but the date should be in the past + +`FutureDate` +: like `date`, but the date should be in the future + `EmailStr` : requires [email-validator](https://github.com/JoshData/python-email-validator) to be installed; the input string must be a valid email address, and the output is a simple string @@ -829,16 +835,16 @@ The following arguments are available when using the `conbytes` type function ## Strict Types -You can use the `StrictStr`, `StrictBytes`, `StrictInt`, `StrictFloat`, and `StrictBool` types +You can use the `StrictStr`, `StrictBytes`, `StrictInt`, `StrictFloat`, and `StrictBool` types to prevent coercion from compatible types. These types will only pass validation when the validated value is of the respective type or is a subtype of that type. -This behavior is also exposed via the `strict` field of the `ConstrainedStr`, `ConstrainedBytes`, +This behavior is also exposed via the `strict` field of the `ConstrainedStr`, `ConstrainedBytes`, `ConstrainedFloat` and `ConstrainedInt` classes and can be combined with a multitude of complex validation rules. The following caveats apply: - `StrictBytes` (and the `strict` option of `ConstrainedBytes`) will accept both `bytes`, - and `bytearray` types. + and `bytearray` types. - `StrictInt` (and the `strict` option of `ConstrainedInt`) will not accept `bool` types, even though `bool` is a subclass of `int` in Python. Other subclasses will work. - `StrictFloat` (and the `strict` option of `ConstrainedFloat`) will not accept `int`. diff --git a/pydantic/__init__.py b/pydantic/__init__.py index 19e5432..16846d0 100644 --- a/pydantic/__init__.py +++ b/pydantic/__init__.py @@ -108,6 +108,8 @@ __all__ = [ 'PaymentCardNumber', 'PrivateAttr', 'ByteSize', + 'PastDate', + 'FutureDate', # version 'VERSION', ] diff --git a/pydantic/errors.py b/pydantic/errors.py index db2df4f..1c129b9 100644 --- a/pydantic/errors.py +++ b/pydantic/errors.py @@ -66,6 +66,8 @@ __all__ = ( 'DecimalWholeDigitsError', 'DateTimeError', 'DateError', + 'DateNotInThePastError', + 'DateNotInTheFutureError', 'TimeError', 'DurationError', 'HashableError', @@ -438,6 +440,16 @@ class DateError(PydanticValueError): msg_template = 'invalid date format' +class DateNotInThePastError(PydanticValueError): + code = 'date.not_in_the_past' + msg_template = 'date is not in the past' + + +class DateNotInTheFutureError(PydanticValueError): + code = 'date.not_in_the_future' + msg_template = 'date is not in the future' + + class TimeError(PydanticValueError): msg_template = 'invalid time format' diff --git a/pydantic/types.py b/pydantic/types.py index d5a3147..aede7f8 100644 --- a/pydantic/types.py +++ b/pydantic/types.py @@ -1,6 +1,7 @@ import math import re import warnings +from datetime import date from decimal import Decimal from enum import Enum from pathlib import Path @@ -26,6 +27,7 @@ from uuid import UUID from weakref import WeakSet from . import errors +from .datetime_parse import parse_date from .utils import import_string, update_not_none from .validators import ( bytes_validator, @@ -93,6 +95,8 @@ __all__ = [ 'StrictFloat', 'PaymentCardNumber', 'ByteSize', + 'PastDate', + 'FutureDate', ] NoneStr = Optional[str] @@ -1017,3 +1021,37 @@ class ByteSize(int): raise errors.InvalidByteSizeUnit(unit=unit) return self / unit_div + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ DATE TYPES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +if TYPE_CHECKING: + PastDate = date + FutureDate = date +else: + + class PastDate(date): + @classmethod + def __get_validators__(cls) -> 'CallableGenerator': + yield parse_date + yield cls.validate + + @classmethod + def validate(cls, value: date) -> date: + if value >= date.today(): + raise errors.DateNotInThePastError() + + return value + + class FutureDate(date): + @classmethod + def __get_validators__(cls) -> 'CallableGenerator': + yield parse_date + yield cls.validate + + @classmethod + def validate(cls, value: date) -> date: + if value <= date.today(): + raise errors.DateNotInTheFutureError() + + return value diff --git a/tests/mypy/modules/success.py b/tests/mypy/modules/success.py index 4d8d0c1..55c6879 100644 --- a/tests/mypy/modules/success.py +++ b/tests/mypy/modules/success.py @@ -5,7 +5,7 @@ Do a little skipping about with types to demonstrate its usage. """ import json import sys -from datetime import date, datetime +from datetime import date, datetime, timedelta from pathlib import Path from typing import Any, Dict, Generic, List, Optional, TypeVar from uuid import UUID @@ -15,6 +15,7 @@ from pydantic import ( BaseModel, DirectoryPath, FilePath, + FutureDate, Json, NegativeFloat, NegativeInt, @@ -23,6 +24,7 @@ from pydantic import ( NonNegativeInt, NonPositiveFloat, NonPositiveInt, + PastDate, PositiveFloat, PositiveInt, PyObject, @@ -220,6 +222,9 @@ class PydanticTypes(BaseModel): my_dir_path_str: DirectoryPath = '.' # type: ignore # Json my_json: Json = '{"hello": "world"}' + # Date + my_past_date: PastDate = date.today() - timedelta(1) + my_future_date: FutureDate = date.today() + timedelta(1) class Config: validate_all = True diff --git a/tests/test_types.py b/tests/test_types.py index 3ddd502..d40f885 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -40,6 +40,7 @@ from pydantic import ( EmailStr, Field, FilePath, + FutureDate, Json, NameEmail, NegativeFloat, @@ -48,6 +49,7 @@ from pydantic import ( NonNegativeInt, NonPositiveFloat, NonPositiveInt, + PastDate, PositiveFloat, PositiveInt, PyObject, @@ -2813,3 +2815,82 @@ def test_none(value_type): {'loc': ('my_none_dict', 'a'), 'msg': 'value is not None', 'type': 'type_error.not_none'}, {'loc': ('my_json_none',), 'msg': 'value is not None', 'type': 'type_error.not_none'}, ] + + +@pytest.mark.parametrize( + 'value,result', + ( + ('1996-01-22', date(1996, 1, 22)), + (date(1996, 1, 22), date(1996, 1, 22)), + ), +) +def test_past_date_validation_success(value, result): + class Model(BaseModel): + foo: PastDate + + assert Model(foo=value).foo == result + + +@pytest.mark.parametrize( + 'value', + ( + date.today(), + date.today() + timedelta(1), + datetime.today(), + datetime.today() + timedelta(1), + '2064-06-01', + ), +) +def test_past_date_validation_fails(value): + class Model(BaseModel): + foo: PastDate + + with pytest.raises(ValidationError) as exc_info: + Model(foo=value) + assert exc_info.value.errors() == [ + { + 'loc': ('foo',), + 'msg': 'date is not in the past', + 'type': 'value_error.date.not_in_the_past', + } + ] + + +@pytest.mark.parametrize( + 'value,result', + ( + (date.today() + timedelta(1), date.today() + timedelta(1)), + (datetime.today() + timedelta(1), date.today() + timedelta(1)), + ('2064-06-01', date(2064, 6, 1)), + ), +) +def test_future_date_validation_success(value, result): + class Model(BaseModel): + foo: FutureDate + + assert Model(foo=value).foo == result + + +@pytest.mark.parametrize( + 'value', + ( + date.today(), + date.today() - timedelta(1), + datetime.today(), + datetime.today() - timedelta(1), + '1996-01-22', + ), +) +def test_future_date_validation_fails(value): + class Model(BaseModel): + foo: FutureDate + + with pytest.raises(ValidationError) as exc_info: + Model(foo=value) + assert exc_info.value.errors() == [ + { + 'loc': ('foo',), + 'msg': 'date is not in the future', + 'type': 'value_error.date.not_in_the_future', + } + ]