diff --git a/pydantic_extra_types/pendulum_dt.py b/pydantic_extra_types/pendulum_dt.py index 8a0c338a..0f776be7 100644 --- a/pydantic_extra_types/pendulum_dt.py +++ b/pydantic_extra_types/pendulum_dt.py @@ -19,7 +19,17 @@ from pydantic_core import PydanticCustomError, core_schema -class DateTime(_DateTime): +class DateTimeSettings(type): + def __new__(cls, name, bases, dct, **kwargs): # type: ignore[no-untyped-def] + dct['strict'] = kwargs.pop('strict', True) + return super().__new__(cls, name, bases, dct) + + def __init__(cls, name, bases, dct, **kwargs): # type: ignore[no-untyped-def] + super().__init__(name, bases, dct) + cls.strict = kwargs.get('strict', True) + + +class DateTime(_DateTime, metaclass=DateTimeSettings): """ A `pendulum.DateTime` object. At runtime, this type decomposes into pendulum.DateTime automatically. This type exists because Pydantic throws a fit on unknown types. @@ -54,7 +64,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.datetime_schema()) @classmethod - def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any: + def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'DateTime': """ Validate the datetime object and return it. @@ -68,12 +78,10 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler # if we are passed an existing instance, pass it straight through. if isinstance(value, (_DateTime, datetime)): return DateTime.instance(value) - - # otherwise, parse it. try: - value = parse(value, exact=True) - if not isinstance(value, _DateTime): - raise ValueError(f'value is not a valid datetime it is a {type(value)}') + # probably the best way to have feature parity with + # https://docs.pydantic.dev/latest/api/standard_library_types/#datetimedatetime + value = handler(value) return DateTime( value.year, value.month, @@ -84,8 +92,16 @@ def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler value.microsecond, value.tzinfo, ) - except Exception as exc: - raise PydanticCustomError('value_error', 'value is not a valid timestamp') from exc + except ValueError: + try: + value = parse(value, strict=cls.strict) + if isinstance(value, _DateTime): + return DateTime.instance(value) + raise ValueError(f'value is not a valid datetime it is a {type(value)}') + except ValueError: + raise + except Exception as exc: + raise PydanticCustomError('value_error', 'value is not a valid datetime') from exc class Date(_Date): @@ -123,7 +139,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.date_schema()) @classmethod - def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any: + def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'Date': """ Validate the date object and return it. @@ -183,7 +199,7 @@ def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaH return core_schema.no_info_wrap_validator_function(cls._validate, core_schema.timedelta_schema()) @classmethod - def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any: + def _validate(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> 'Duration': """ Validate the Duration object and return it. diff --git a/pyproject.toml b/pyproject.toml index 5ccb844b..26f15d0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,8 @@ filterwarnings = [ 'error', # This ignore will be removed when pycountry will drop py36 & support py311 'ignore:::pkg_resources', + # This ignore will be removed when pendulum fixes https://github.com/sdispater/pendulum/issues/834 + 'ignore:datetime.datetime.utcfromtimestamp.*:DeprecationWarning' ] # configuring https://github.com/pydantic/hooky diff --git a/tests/test_pendulum_dt.py b/tests/test_pendulum_dt.py index 3965fa5b..1c883397 100644 --- a/tests/test_pendulum_dt.py +++ b/tests/test_pendulum_dt.py @@ -9,11 +9,21 @@ UTC = tz.utc +DtTypeAdapter = TypeAdapter(datetime) + class DtModel(BaseModel): dt: DateTime +class DateTimeNonStrict(DateTime, strict=False): + pass + + +class DtModelNotStrict(BaseModel): + dt: DateTimeNonStrict + + class DateModel(BaseModel): d: Date @@ -119,6 +129,91 @@ def test_pendulum_dt_from_serialized(dt): assert isinstance(model.dt, pendulum.DateTime) +@pytest.mark.parametrize( + 'dt', + [ + pendulum.now().to_iso8601_string(), + pendulum.now().to_w3c_string(), + 'Sat Oct 11 17:13:46 UTC 2003', # date util parsing + pendulum.now().to_iso8601_string()[:5], # actualy valid or pendulum.parse(dt, strict=False) would fail here + ], +) +def test_pendulum_dt_not_strict_from_serialized(dt): + """ + Verifies that building an instance from serialized, well-formed strings decode properly. + """ + dt_actual = pendulum.parse(dt, strict=False) + model = DtModelNotStrict(dt=dt) + assert model.dt == dt_actual + assert type(model.dt) is DateTime + assert isinstance(model.dt, pendulum.DateTime) + + +@pytest.mark.parametrize( + 'dt', + [ + pendulum.now().to_iso8601_string(), + pendulum.now().to_w3c_string(), + 1718096578, + 1718096578.5, + -5, + -5.5, + float('-0'), + '1718096578', + '1718096578.5', + '-5', + '-5.5', + '-0', + '-0.0', + '+0.0', + '+1718096578.5', + float('-2e10') - 1.0, + float('2e10') + 1.0, + -2e10 - 1, + 2e10 + 1, + ], +) +def test_pendulum_dt_from_str_unix_timestamp(dt): + """ + Verifies that building an instance from serialized, well-formed strings decode properly. + """ + dt_actual = pendulum.instance(DtTypeAdapter.validate_python(dt)) + model = DtModel(dt=dt) + assert model.dt == dt_actual + assert type(model.dt) is DateTime + assert isinstance(model.dt, pendulum.DateTime) + + +@pytest.mark.parametrize( + 'dt', + [ + 1718096578, + 1718096578.5, + -5, + -5.5, + float('-0'), + '1718096578', + '1718096578.5', + '-5', + '-5.5', + '-0', + '-0.0', + '+0.0', + '+1718096578.5', + float('-2e10') - 1.0, + float('2e10') + 1.0, + -2e10 - 1, + 2e10 + 1, + ], +) +def test_pendulum_dt_from_str_unix_timestamp_is_utc(dt): + """ + Verifies that without timezone information, it is coerced to UTC. As in pendulum + """ + model = DtModel(dt=dt) + assert model.dt.tzinfo.tzname(model.dt) == 'UTC' + + @pytest.mark.parametrize( 'd', [pendulum.now().date().isoformat(), pendulum.now().to_w3c_string(), pendulum.now().to_iso8601_string()], @@ -155,22 +250,68 @@ def test_pendulum_duration_from_serialized(delta_t_str): assert isinstance(model.delta_t, pendulum.Duration) -@pytest.mark.parametrize('dt', [None, 'malformed', pendulum.now().to_iso8601_string()[:5], 42, 'P10Y10M10D']) +def get_invalid_dt_common(): + return [ + None, + 'malformed', + 'P10Y10M10D', + float('inf'), + float('-inf'), + 'inf', + '-inf', + 'INF', + '-INF', + '+inf', + 'Infinity', + '+Infinity', + '-Infinity', + 'INFINITY', + '+INFINITY', + '-INFINITY', + 'infinity', + '+infinity', + '-infinity', + float('nan'), + 'nan', + 'NaN', + 'NAN', + '+nan', + '-nan', + ] + + +dt_strict = get_invalid_dt_common() +dt_strict.append(pendulum.now().to_iso8601_string()[:5]) + + +@pytest.mark.parametrize( + 'dt', + dt_strict, +) def test_pendulum_dt_malformed(dt): """ - Verifies that the instance fails to validate if malformed dt are passed. + Verifies that the instance fails to validate if malformed dt is passed. """ with pytest.raises(ValidationError): DtModel(dt=dt) -@pytest.mark.parametrize('date', [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 42, 'P10Y10M10D']) -def test_pendulum_date_malformed(date): +@pytest.mark.parametrize('dt', get_invalid_dt_common()) +def test_pendulum_dt_non_strict_malformed(dt): + """ + Verifies that the instance fails to validate if malformed dt are passed. + """ + with pytest.raises(ValidationError): + DtModelNotStrict(dt=dt) + + +@pytest.mark.parametrize('invalid_value', [None, 'malformed', pendulum.today().to_iso8601_string()[:5], 'P10Y10M10D']) +def test_pendulum_date_malformed(invalid_value): """ Verifies that the instance fails to validate if malformed date are passed. """ with pytest.raises(ValidationError): - DateModel(d=date) + DateModel(d=invalid_value) @pytest.mark.parametrize(