From c859ec854388d44ae5fc7759d7bf94caa829ce0b Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 5 May 2017 19:57:15 +0100 Subject: [PATCH] adding datetime, date, time and timedelta validation --- README.rst | 3 +- pydantic/datetime_parse.py | 5 +- pydantic/validators.py | 12 ++++- tests/test_datetime_parse.py | 93 +++++++++++++++++++++--------------- tests/test_types.py | 42 +++++++++++++++- 5 files changed, 110 insertions(+), 45 deletions(-) diff --git a/README.rst b/README.rst index a399f39..bf08b00 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,8 @@ Data validation and settings management using python 3.6 type hinting. TODO: -* datetime types, enums +* allow and disallow extra +* enums * other types: regex, email field * exotic typing: Union, List, Dict * more info: alt names and descriptions diff --git a/pydantic/datetime_parse.py b/pydantic/datetime_parse.py index e2c1156..a6df292 100644 --- a/pydantic/datetime_parse.py +++ b/pydantic/datetime_parse.py @@ -12,7 +12,7 @@ Stolen from https://raw.githubusercontent.com/django/django/master/django/utils/ Changed to: * use standard python datetime types not django.utils.timezone * raise ValueError when regex doesn't match rather than returning None -* support parsing unix timestamps +* support parsing unix timestamps for dates and datetimes """ import re from datetime import date, datetime, time, timedelta, timezone @@ -42,8 +42,7 @@ standard_duration_re = re.compile( r'$' ) -# Support the sections of ISO 8601 date representation that are accepted by -# timedelta +# Support the sections of ISO 8601 date representation that are accepted by timedelta iso8601_duration_re = re.compile( r'^(?P[-+]?)' r'P' diff --git a/pydantic/validators.py b/pydantic/validators.py index 3ad026b..ad1b600 100644 --- a/pydantic/validators.py +++ b/pydantic/validators.py @@ -1,6 +1,9 @@ +from datetime import date, datetime, time, timedelta from pathlib import Path from typing import Optional +from .datetime_parse import parse_date, parse_datetime, parse_duration, parse_time + NoneType = type(None) @@ -73,7 +76,12 @@ VALIDATORS_LOOKUP = { Optional[bytes]: [bytes_validator, anystr_length_validator], bytes: [not_none_validator, bytes_validator, anystr_length_validator], - dict: [not_none_validator, dict_validator] + dict: [not_none_validator, dict_validator], - # TODO list, List, Dict, Union, datetime, date, time, custom types + date: [parse_date], + time: [parse_time], + datetime: [parse_datetime], + timedelta: [parse_duration], + + # TODO list, List, Dict, Union } diff --git a/tests/test_datetime_parse.py b/tests/test_datetime_parse.py index bf8c057..54e1636 100644 --- a/tests/test_datetime_parse.py +++ b/tests/test_datetime_parse.py @@ -17,55 +17,72 @@ def create_tz(minutes): return timezone(timedelta(minutes=minutes)) -@pytest.mark.parametrize('func,value,result', [ +@pytest.mark.parametrize('value,result', [ # Valid inputs - (parse_date, '1494012444.883309', date(2017, 5, 5)), - (parse_date, 1494012444.883309, date(2017, 5, 5)), - (parse_date, '1494012444', date(2017, 5, 5)), - (parse_date, 1494012444, date(2017, 5, 5)), - (parse_date, '2012-04-23', date(2012, 4, 23)), - (parse_date, '2012-4-9', date(2012, 4, 9)), + ('1494012444.883309', date(2017, 5, 5)), + (1494012444.883309, date(2017, 5, 5)), + ('1494012444', date(2017, 5, 5)), + (1494012444, date(2017, 5, 5)), + ('2012-04-23', date(2012, 4, 23)), + ('2012-4-9', date(2012, 4, 9)), # Invalid inputs - (parse_date, 'x20120423', ValueError), - (parse_date, '2012-04-56', ValueError), + ('x20120423', ValueError), + ('2012-04-56', ValueError), +]) +def test_date_parsing(value, result): + if result == ValueError: + with pytest.raises(ValueError): + parse_date(value) + else: + assert parse_date(value) == result + +@pytest.mark.parametrize('value,result', [ # Valid inputs - (parse_time, '09:15:00', time(9, 15)), - (parse_time, '10:10', time(10, 10)), - (parse_time, '10:20:30.400', time(10, 20, 30, 400000)), - (parse_time, '4:8:16', time(4, 8, 16)), + ('09:15:00', time(9, 15)), + ('10:10', time(10, 10)), + ('10:20:30.400', time(10, 20, 30, 400000)), + ('4:8:16', time(4, 8, 16)), # Invalid inputs - (parse_time, '091500', ValueError), - (parse_time, '09:15:90', ValueError), + ('091500', ValueError), + ('09:15:90', ValueError), +]) +def test_time_parsing(value, result): + if result == ValueError: + with pytest.raises(ValueError): + parse_time(value) + else: + assert parse_time(value) == result + +@pytest.mark.parametrize('value,result', [ # Valid inputs # values in seconds - (parse_datetime, '1494012444.883309', datetime(2017, 5, 5, 19, 27, 24, 883309, tzinfo=timezone.utc)), - (parse_datetime, 1494012444.883309, datetime(2017, 5, 5, 19, 27, 24, 883309, tzinfo=timezone.utc)), - (parse_datetime, '1494012444', datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), - (parse_datetime, 1494012444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ('1494012444.883309', datetime(2017, 5, 5, 19, 27, 24, 883309, tzinfo=timezone.utc)), + (1494012444.883309, datetime(2017, 5, 5, 19, 27, 24, 883309, tzinfo=timezone.utc)), + ('1494012444', datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + (1494012444, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), # values in ms - (parse_datetime, '1494012444000.883309', datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), - (parse_datetime, 1494012444000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), + ('1494012444000.883309', datetime(2017, 5, 5, 19, 27, 24, 883, tzinfo=timezone.utc)), + (1494012444000, datetime(2017, 5, 5, 19, 27, 24, tzinfo=timezone.utc)), - (parse_datetime, '2012-04-23T09:15:00', datetime(2012, 4, 23, 9, 15)), - (parse_datetime, '2012-4-9 4:8:16', datetime(2012, 4, 9, 4, 8, 16)), - (parse_datetime, '2012-04-23T09:15:00Z', datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), - (parse_datetime, '2012-4-9 4:8:16-0320', datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), - (parse_datetime, '2012-04-23T10:20:30.400+02:30', datetime(2012, 4, 23, 10, 20, 30, 400000, create_tz(150))), - (parse_datetime, '2012-04-23T10:20:30.400+02', datetime(2012, 4, 23, 10, 20, 30, 400000, create_tz(120))), - (parse_datetime, '2012-04-23T10:20:30.400-02', datetime(2012, 4, 23, 10, 20, 30, 400000, create_tz(-120))), + ('2012-04-23T09:15:00', datetime(2012, 4, 23, 9, 15)), + ('2012-4-9 4:8:16', datetime(2012, 4, 9, 4, 8, 16)), + ('2012-04-23T09:15:00Z', datetime(2012, 4, 23, 9, 15, 0, 0, timezone.utc)), + ('2012-4-9 4:8:16-0320', datetime(2012, 4, 9, 4, 8, 16, 0, create_tz(-200))), + ('2012-04-23T10:20:30.400+02:30', datetime(2012, 4, 23, 10, 20, 30, 400000, create_tz(150))), + ('2012-04-23T10:20:30.400+02', datetime(2012, 4, 23, 10, 20, 30, 400000, create_tz(120))), + ('2012-04-23T10:20:30.400-02', datetime(2012, 4, 23, 10, 20, 30, 400000, create_tz(-120))), # Invalid inputs - (parse_datetime, 'x20120423091500', ValueError), - (parse_datetime, '2012-04-56T09:15:90', ValueError), + ('x20120423091500', ValueError), + ('2012-04-56T09:15:90', ValueError), ]) -def test_parsing(func, value, result): - if isinstance(result, type) and issubclass(result, BaseException): - with pytest.raises(result): - func(value) +def test_datetime_parsing(value, result): + if result == ValueError: + with pytest.raises(ValueError): + parse_datetime(value) else: - print(repr(func(value))) - assert func(value) == result + assert parse_datetime(value) == result @pytest.mark.parametrize('delta', [ @@ -121,8 +138,8 @@ def test_parse_python_format(delta): ('PT0.000005S', timedelta(microseconds=5)), ]) def test_parse_durations(value, result): - if isinstance(result, type) and issubclass(result, BaseException): - with pytest.raises(result): + if result == ValueError: + with pytest.raises(ValueError): parse_duration(value) else: assert parse_duration(value) == result diff --git a/tests/test_types.py b/tests/test_types.py index ff85ffa..c3211b3 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,4 +1,6 @@ import os +from collections import OrderedDict +from datetime import date, datetime, time, timedelta import pytest @@ -123,10 +125,48 @@ class CheckModel(BaseModel): ('float_check', '123', ValidationError), ('float_check', b'123', ValidationError), ]) -def test_bool_validation(field, value, result): +def test_default_validators(field, value, result): kwargs = {field: value} if result == ValidationError: with pytest.raises(ValidationError): CheckModel(**kwargs) else: assert CheckModel(**kwargs).values[field] == result + + +class DatetimeModel(BaseModel): + dt: datetime = ... + date_: date = ... + time_: time = ... + duration: timedelta = ... + + +def test_datetime_successful(): + m = DatetimeModel( + dt='2017-10-5T19:47:07', + date_=1494012000, + time_='10:20:30.400', + duration='15:30.0001', + ) + assert m.dt == datetime(2017, 10, 5, 19, 47, 7) + assert m.date_ == date(2017, 5, 5) + assert m.time_ == time(10, 20, 30, 400000) + assert m.duration == timedelta(minutes=15, seconds=30, microseconds=100) + + +def test_datetime_errors(): + with pytest.raises(ValueError) as exc_info: + DatetimeModel( + dt='2017-13-5T19:47:07', + date_='XX1494012000', + time_='25:20:30.400', + duration='15:30.0001 broken', + ) + assert exc_info.value.args[0].startswith('4 errors validating input:') + assert exc_info.value.errors == OrderedDict( + [ + ('dt', {'type': 'ValueError', 'msg': 'month must be in 1..12', 'validator': 'parse_datetime'}), + ('date_', {'type': 'ValueError', 'msg': 'Invalid date format', 'validator': 'parse_date'}), + ('time_', {'type': 'ValueError', 'msg': 'hour must be in 0..23', 'validator': 'parse_time'}), + ('duration', {'type': 'ValueError', 'msg': 'Invalid duration format', 'validator': 'parse_duration'}) + ])