From 9979ac8e2a1ebedb14523ca237a076d31809235c Mon Sep 17 00:00:00 2001 From: "Peter J. Holzer" Date: Mon, 18 Apr 2022 11:34:00 +0000 Subject: [PATCH] Initial commit --- .gitignore | 2 + README.md | 10 ++ src/conftest.py | 0 src/timedeltacal.py | 79 +++++++++ tests/test_add.py | 231 +++++++++++++++++++++++++++ tests/test_create_from_components.py | 30 ++++ tests/test_create_from_datetimes.py | 71 ++++++++ 7 files changed, 423 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 src/conftest.py create mode 100644 src/timedeltacal.py create mode 100644 tests/test_add.py create mode 100644 tests/test_create_from_components.py create mode 100644 tests/test_create_from_datetimes.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d22b40f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +.*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d4dd4c --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +timedeltacal +============ + +A "calendaric" alternative to datetime.timedelta. + +It adds months as a basic unit (since a month has a variable number of +days) and doesn't normalize hours to days (since a day has a variable +number of hours, so 1 day is not always the same as 24 hours). +This should align better with user's expecations for arithmetic on +timezone aware datetimes. diff --git a/src/conftest.py b/src/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/src/timedeltacal.py b/src/timedeltacal.py new file mode 100644 index 0000000..b981783 --- /dev/null +++ b/src/timedeltacal.py @@ -0,0 +1,79 @@ +from datetime import datetime, date, timedelta, timezone + +class timedeltacal: + def __init__(self, **kwargs): + if "t0" in kwargs: + # two timestamps + # result must satisfy t0 + self == t1 + + # XXX - Very naive implementation. + # Very likely wrong, and even if it is correct, + # we would probably want all the fields to have the same sign. + t0 = kwargs["t0"] + t1 = kwargs["t1"] + self.years = t1.year - t0.year + self.months = t1.month - t0.month + self.days = t1.day - t0.day + self.hours = t1.hour - t0.hour + self.minutes = t1.minute - t0.minute + self.seconds = t1.second - t0.second + self.microseconds = t1.microsecond - t0.microsecond + # Adjust + t2 = t0 + self + if t2 != t1: + self.hours -= t2.hour - t1.hour + + else: + self.years = kwargs.get("years", 0) + self.months = kwargs.get("months", 0) + self.days = kwargs.get("weeks", 0) * 7 + kwargs.get("days", 0) + self.hours = kwargs.get("hours", 0) + self.minutes = kwargs.get("minutes", 0) + self.seconds = kwargs.get("seconds", 0) + self.microseconds = kwargs.get("milliseconds", 0) * 1000 + kwargs.get("microseconds", 0) + + def __radd__(self, t): + if isinstance(t, datetime): + # Add years and months. + m = t.month + self.years * 12 + self.months + y = t.year + m // 12 + m = m % 12 + + # cap days. + d = t.day + if m in (4, 6, 9, 11) and t.day > 30: + d = 30 + elif m == 2: + if y % 4 == 0 and (y % 100 != 0 or y % 400 == 0): + if d > 29: + d = 29 + else: + if d > 28: + d = 28 + + # Then add days + + dt = date(y, m, d) + timedelta(days=self.days) + + # And finally add hours and lesser units. We want one hour to + # always be 3600 seconds, so we convert to UTC and back + dt1 = t.replace(year=dt.year, month=dt.month, day=dt.day) + dt2 = dt1.astimezone(timezone.utc) + dt3 = dt2 + timedelta(hours=self.hours, minutes=self.minutes, seconds=self.seconds, microseconds=self.microseconds) + dt4 = dt3.astimezone(t.tzinfo) + + return dt4 + elif isinstance(t, date): + return date( + t.year + self.years, t.month + self.months, t.day + self.days, + t.tzinfo) + else: + raise TypeError() + + def __repr__(self): + parts = [ + f"{p}={getattr(self, p)}" + for p in ("years", "months", "days", "hours", "minutes", "seconds", "microseconds") + if getattr(self, p) + ] + return "timedeltacal(" + ", ".join(parts) + ")" diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 0000000..3e1789d --- /dev/null +++ b/tests/test_add.py @@ -0,0 +1,231 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from timedeltacal import timedeltacal + +# Some basic tests. These serve as a specification. All the tests use timezone +# aware datetimes in a timezone with DST, since this is the most complex case. +# Naive datetimes and dates should work analogously. + +def test_add_same_day(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(hours=2, minutes=34, seconds=56, milliseconds=789) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == t0.month + assert t1.day == t0.day + assert t1.hour == 13 + assert t1.minute == 12 + assert t1.second == 17 + assert t1.microsecond == 789000 + assert t1.tzinfo is t0.tzinfo + + +def test_add_next_day(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(hours=13, minutes=34, seconds=56, milliseconds=789) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == t0.month + assert t1.day == 18 + assert t1.hour == 0 + assert t1.minute == 12 + assert t1.second == 17 + assert t1.microsecond == 789000 + assert t1.tzinfo is t0.tzinfo + +def test_add_days(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(days=1) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == t0.month + assert t1.day == 18 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +# Add days -> next month +def test_add_days_to_next_month(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(days=20) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == 5 + assert t1.day == 7 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +# Add months +def test_add_months(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(months=1) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == 5 + assert t1.day == 17 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +# Add years +def test_add_years(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(years=1) + t1 = t0 + d + assert t1.year == 2023 + assert t1.month == 4 + assert t1.day == 17 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + + +# Same tests, but crossing a DST boundary +def test_add_same_day_dst(): + t0 = datetime(2022, 3, 27, 1, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(hours=2, minutes=34, seconds=56, milliseconds=789) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == t0.month + assert t1.day == t0.day + assert t1.hour == 5 + assert t1.minute == 12 + assert t1.second == 17 + assert t1.microsecond == 789000 + assert t1.tzinfo is t0.tzinfo + +def test_add_next_day_dst(): + t0 = datetime(2022, 3, 26, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(hours=23, minutes=34, seconds=56, milliseconds=789) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == t0.month + assert t1.day == 27 + assert t1.hour == 11 + assert t1.minute == 12 + assert t1.second == 17 + assert t1.microsecond == 789000 + assert t1.tzinfo is t0.tzinfo + +def test_add_days_dst(): + t0 = datetime(2022, 3, 26, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(days=1) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == t0.month + assert t1.day == 27 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +# Add days -> next month +def test_add_days_to_next_month_dst(): + t0 = datetime(2022, 3, 26, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(days=20) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == 4 + assert t1.day == 15 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +# Add months +def test_add_months_dst(): + t0 = datetime(2022, 3, 26, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(months=1) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == 4 + assert t1.day == t0.day + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +# Add years +def test_add_years_dst(): + t0 = datetime(2022, 3, 26, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(years=1) + t1 = t0 + d + assert t1.year == 2023 + assert t1.month == 3 + assert t1.day == 26 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +# Month arithmetic is saturating: If the arithmetic result would result in a +# day of the month which doesn't exist, we choose the last day of the month +# instead. + +def test_add_months_feb(): + t0 = datetime(2022, 1, 31, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(months=1) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == 2 + assert t1.day == 28 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +def test_add_months_mar(): + t0 = datetime(2022, 1, 31, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(months=2) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == 3 + assert t1.day == 31 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +def test_add_months_apr(): + t0 = datetime(2022, 1, 31, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(months=3) + t1 = t0 + d + assert t1.year == t0.year + assert t1.month == 4 + assert t1.day == 30 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + +def test_add_months_feb_leap(): + t0 = datetime(2022, 1, 31, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(months=25) + t1 = t0 + d + assert t1.year == 2024 + assert t1.month == 2 + assert t1.day == 29 + assert t1.hour == t0.hour + assert t1.minute == t0.minute + assert t1.second == t0.second + assert t1.microsecond == t0.microsecond + assert t1.tzinfo is t0.tzinfo + + diff --git a/tests/test_create_from_components.py b/tests/test_create_from_components.py new file mode 100644 index 0000000..9b276d1 --- /dev/null +++ b/tests/test_create_from_components.py @@ -0,0 +1,30 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from timedeltacal import timedeltacal + +# Some basic tests. These serve as a specification. All the tests use timezone +# aware datetimes in a timezone with DST, since this is the most complex case. +# Naive datetimes and dates should work analogously. + +def test_create_from_components(): + d = timedeltacal( + years=1, + months=2, + weeks=3, + days=4, + hours=5, + minutes=6, + seconds=7, + microseconds=8 + ) + # I haven't decided on canonicalizing years to months, so for now I accept + # both: + assert d.months in (2, 14) + # But I am sure that I want to canonicalize weeks to days + assert d.days == 25 + # Rest is straightforward: + assert d.hours == 5 + assert d.minutes == 6 + assert d.seconds == 7 + assert d.microseconds == 8 diff --git a/tests/test_create_from_datetimes.py b/tests/test_create_from_datetimes.py new file mode 100644 index 0000000..06f2156 --- /dev/null +++ b/tests/test_create_from_datetimes.py @@ -0,0 +1,71 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from timedeltacal import timedeltacal + +# Some basic tests. These serve as a specification. All the tests use timezone +# aware datetimes in a timezone with DST, since this is the most complex case. +# Naive datetimes and dates should work analogously. + +def test_create_from_datetimes(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + t1 = datetime(2022, 4, 17, 10, 37, 51, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(t0=t0, t1=t1) + assert d.months == 0 + assert d.days == 0 + assert d.hours == 0 + assert d.minutes == 0 + assert d.seconds == 30 + assert d.microseconds == 0 + +# Subtract two datetimes - various combinations + +def test_sub_same_day(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + t1 = datetime(2022, 4, 17, 13, 12, 17, 789000, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(t0=t0, t1=t1) + t2 = t0 + d + assert t1 == t2 + +def test_sub_next_day(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + t1 = datetime(2022, 4, 18, 0, 12, 17, 789000, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(t0=t0, t1=t1) + t2 = t0 + d + assert t1 == t2 + +def test_sub_days(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + t1 = datetime(2022, 4, 18, 10, 37, 21, 0, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(t0=t0, t1=t1) + t2 = t0 + d + assert t1 == t2 + +def test_sub_days_to_next_month(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + t1 = datetime(2022, 5, 7, 10, 37, 21, 0, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(t0=t0, t1=t1) + t2 = t0 + d + assert t1 == t2 + +def test_sub_months(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + t1 = datetime(2022, 5, 17, 10, 37, 21, 0, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(t0=t0, t1=t1) + t2 = t0 + d + assert t1 == t2 + +def test_sub_years(): + t0 = datetime(2022, 4, 17, 10, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + t1 = datetime(2023, 4, 17, 10, 37, 21, 0, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(t0=t0, t1=t1) + t2 = t0 + d + assert t1 == t2 + +def test_sub_same_day_dst(): + t0 = datetime(2022, 3, 27, 1, 37, 21, tzinfo=ZoneInfo("Europe/Vienna")) + t1 = datetime(2022, 3, 27, 5, 12, 17, tzinfo=ZoneInfo("Europe/Vienna")) + d = timedeltacal(t0=t0, t1=t1) + t2 = t0 + d + assert t1 == t2 +