Initial commit
This commit is contained in:
commit
9979ac8e2a
|
@ -0,0 +1,2 @@
|
|||
__pycache__
|
||||
.*.swp
|
|
@ -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,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) + ")"
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
Loading…
Reference in New Issue