diff --git a/peprock/datetime/__init__.py b/peprock/datetime/__init__.py index 91e7fa2..71c3a29 100644 --- a/peprock/datetime/__init__.py +++ b/peprock/datetime/__init__.py @@ -1,8 +1,8 @@ -"""Date/time and related helpers and constants. +"""Date/time and related models, helpers and constants. Complements the datetime package from the standard library -(https://docs.python.org/3/library/datetime.html), adding timezone awareness helpers and -timedelta constants. +(https://docs.python.org/3/library/datetime.html), adding datetime period models, +timezone awareness helpers and timedelta constants. """ # noinspection PyProtectedMember @@ -18,6 +18,9 @@ ONE_SECOND, ONE_WEEK, ) +from .period import ( + Period, +) __all__ = [ "__version__", @@ -32,4 +35,5 @@ "is_aware", "EnsureAwareError", "ensure_aware", + "Period", ] diff --git a/peprock/datetime/period.py b/peprock/datetime/period.py new file mode 100644 index 0000000..07c7f5e --- /dev/null +++ b/peprock/datetime/period.py @@ -0,0 +1,88 @@ +"""Datetime period model. + +Examples +-------- +>>> period = Period( +... start=datetime.datetime(2022, 1, 1, 12, 0), +... end=datetime.datetime(2022, 1, 2, 12, 0), +... ) +>>> period.duration +datetime.timedelta(days=1) +>>> period.midpoint +datetime.datetime(2022, 1, 2, 0, 0) +>>> datetime.datetime(2022, 1, 1) in period +False +>>> period.start in period +True +>>> period.midpoint in period +True +>>> period.end in period +True +>>> datetime.datetime(2022, 1, 3) in period +False +>>> period in period +True +>>> Period( +... start=datetime.datetime(2022, 1, 1), +... end=datetime.datetime(2022, 1, 3), +... ) in period +False + +""" +import collections.abc +import dataclasses +import datetime +import functools +import sys + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + + +@dataclasses.dataclass(frozen=True) +class Period( + collections.abc.Container, +): + """Datetime period supporting arithmetic operations.""" + + start: datetime.datetime + end: datetime.datetime + + def _validate(self: Self) -> None: + if self.end < self.start: + msg = "end must be greater than or equal to start" + raise ValueError(msg) + + def __post_init__(self: Self) -> None: + """Validate period.""" + self._validate() + + @functools.cached_property + def duration(self: Self) -> datetime.timedelta: + """Return duration of period.""" + return self.end - self.start + + @functools.cached_property + def midpoint(self: Self) -> datetime.datetime: + """Return midpoint of period.""" + return self.start + self.duration / 2 + + def __contains__(self: Self, item: object) -> bool: + """Return True if item is in period.""" + match item: + case Period(): + # noinspection PyUnresolvedReferences + return self.start <= item.start and item.end <= self.end + case datetime.datetime(): + # noinspection PyTypeChecker + return self.start <= item <= self.end + + msg: str = f"expected peprock.datetime.Period | datetime.datetime, got {item!r}" + raise TypeError(msg) + + +__all__ = [ + "Period", +] diff --git a/tests/datetime/test_period.py b/tests/datetime/test_period.py new file mode 100644 index 0000000..8fa44f6 --- /dev/null +++ b/tests/datetime/test_period.py @@ -0,0 +1,508 @@ +# ruff: noqa: DTZ001 + +import datetime +import typing +import zoneinfo + +import pytest + +import peprock.datetime + +_OFFSET: typing.Final[datetime.timedelta] = peprock.datetime.ONE_HOUR +_NAIVE_DATETIME_1: typing.Final[datetime.datetime] = datetime.datetime( + 2023, + 12, + 24, + 12, + 34, + 56, +) +_NAIVE_DATETIME_2: typing.Final[datetime.datetime] = _NAIVE_DATETIME_1 + _OFFSET +_AWARE_DATETIME_1: typing.Final[datetime.datetime] = _NAIVE_DATETIME_1.replace( + tzinfo=datetime.timezone.utc, +) +_AWARE_DATETIME_2: typing.Final[datetime.datetime] = _AWARE_DATETIME_1 + _OFFSET +_VARIABLE_OFFSET_ZONE_INFO: typing.Final[zoneinfo.ZoneInfo] = zoneinfo.ZoneInfo( + "Europe/Paris", +) + + +class TestGenericPeriod: + @pytest.mark.parametrize( + ("start", "end", "expected"), + [ + ( + _NAIVE_DATETIME_1, + _NAIVE_DATETIME_1, + (datetime.timedelta(), _NAIVE_DATETIME_1), + ), + ( + _NAIVE_DATETIME_1, + _NAIVE_DATETIME_2, + (_OFFSET, _NAIVE_DATETIME_1 + _OFFSET / 2), + ), + ( + _NAIVE_DATETIME_1, + _AWARE_DATETIME_1, + TypeError, + ), + ( + _NAIVE_DATETIME_1, + _AWARE_DATETIME_2, + TypeError, + ), + ( + _NAIVE_DATETIME_2, + _NAIVE_DATETIME_1, + ValueError, + ), + ( + _NAIVE_DATETIME_2, + _NAIVE_DATETIME_2, + (datetime.timedelta(), _NAIVE_DATETIME_2), + ), + ( + _NAIVE_DATETIME_2, + _AWARE_DATETIME_1, + TypeError, + ), + ( + _NAIVE_DATETIME_2, + _AWARE_DATETIME_2, + TypeError, + ), + ( + _AWARE_DATETIME_1, + _NAIVE_DATETIME_1, + TypeError, + ), + ( + _AWARE_DATETIME_1, + _NAIVE_DATETIME_2, + TypeError, + ), + ( + _AWARE_DATETIME_1, + _AWARE_DATETIME_1, + (datetime.timedelta(), _AWARE_DATETIME_1), + ), + ( + _AWARE_DATETIME_1, + _AWARE_DATETIME_2, + (_OFFSET, _AWARE_DATETIME_1 + _OFFSET / 2), + ), + ( + _AWARE_DATETIME_2, + _NAIVE_DATETIME_1, + TypeError, + ), + ( + _AWARE_DATETIME_2, + _NAIVE_DATETIME_2, + TypeError, + ), + ( + _AWARE_DATETIME_2, + _AWARE_DATETIME_1, + ValueError, + ), + ( + _AWARE_DATETIME_2, + _AWARE_DATETIME_2, + (datetime.timedelta(), _AWARE_DATETIME_2), + ), + ( + _AWARE_DATETIME_1.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + _AWARE_DATETIME_2.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + (_OFFSET, _AWARE_DATETIME_1 + _OFFSET / 2), + ), + ( + _AWARE_DATETIME_1.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + _AWARE_DATETIME_2, + (_OFFSET, _AWARE_DATETIME_1 + _OFFSET / 2), + ), + ( + _AWARE_DATETIME_1, + _AWARE_DATETIME_2.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + (_OFFSET, _AWARE_DATETIME_1 + _OFFSET / 2), + ), + ], + ) + def test_init_and_properties( + self, + start, + end, + expected, + ) -> None: + match expected: + case (duration, midpoint): + period = peprock.datetime.Period( # type: ignore[unreachable] + start=start, + end=end, + ) + assert period.duration == duration + assert period.midpoint == midpoint + case _: + with pytest.raises(expected): + peprock.datetime.Period( + start=start, + end=end, + ) + + @pytest.mark.parametrize( + ("item", "period", "expected"), + [ + ( + _OFFSET, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + TypeError, + ), + ( + _NAIVE_DATETIME_1 - _OFFSET, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + False, + ), + ( + _NAIVE_DATETIME_1, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + True, + ), + ( + _NAIVE_DATETIME_2, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + False, + ), + ( + _NAIVE_DATETIME_1 - _OFFSET, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + False, + ), + ( + _NAIVE_DATETIME_1, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + True, + ), + ( + _NAIVE_DATETIME_2, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + True, + ), + ( + _NAIVE_DATETIME_2 + _OFFSET, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + False, + ), + ( + _AWARE_DATETIME_1, + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + TypeError, + ), + ( + peprock.datetime.Period( + start=_NAIVE_DATETIME_1 - _OFFSET, + end=_NAIVE_DATETIME_1, + ), + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + False, + ), + ( + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + True, + ), + ( + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + False, + ), + ( + peprock.datetime.Period( + start=_NAIVE_DATETIME_1 - _OFFSET, + end=_NAIVE_DATETIME_1, + ), + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + False, + ), + ( + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_1, + ), + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + True, + ), + ( + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + True, + ), + ( + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2 + _OFFSET, + ), + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + False, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + TypeError, + ), + ( + _AWARE_DATETIME_1 - _OFFSET, + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_1, + ), + False, + ), + ( + _AWARE_DATETIME_1, + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_1, + ), + True, + ), + ( + _AWARE_DATETIME_2, + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_1, + ), + False, + ), + ( + _AWARE_DATETIME_1 - _OFFSET, + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + False, + ), + ( + _AWARE_DATETIME_1, + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + True, + ), + ( + _AWARE_DATETIME_2, + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + True, + ), + ( + _AWARE_DATETIME_2 + _OFFSET, + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + False, + ), + ( + _NAIVE_DATETIME_1, + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + TypeError, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1 - _OFFSET, + end=_AWARE_DATETIME_1, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_1, + ), + False, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_1, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_1, + ), + True, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_1, + ), + False, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1 - _OFFSET, + end=_AWARE_DATETIME_1, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + False, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_1, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + True, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + True, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2 + _OFFSET, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + False, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + end=_AWARE_DATETIME_2.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + True, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + end=_AWARE_DATETIME_2.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + ), + True, + ), + ( + peprock.datetime.Period( + start=_AWARE_DATETIME_1.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + end=_AWARE_DATETIME_2.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + end=_AWARE_DATETIME_2.astimezone(_VARIABLE_OFFSET_ZONE_INFO), + ), + True, + ), + ( + peprock.datetime.Period( + start=_NAIVE_DATETIME_1, + end=_NAIVE_DATETIME_2, + ), + peprock.datetime.Period( + start=_AWARE_DATETIME_1, + end=_AWARE_DATETIME_2, + ), + TypeError, + ), + ], + ) + def test_contains(self, item, period, expected) -> None: + match expected: + case bool(): + assert (item in period) is expected + case _: + with pytest.raises(expected): + assert item in period