Initial commit

This commit is contained in:
Peter J. Holzer 2022-04-18 11:34:00 +00:00
commit 9979ac8e2a
7 changed files with 423 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__
.*.swp

10
README.md Normal file
View File

@ -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.

0
src/conftest.py Normal file
View File

79
src/timedeltacal.py Normal file
View File

@ -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) + ")"

231
tests/test_add.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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