nominaldelta

nominal difference of date/datetime
git clone https://git.ce9e.org/nominaldelta.git

commit
c05b095fdf33b32fcfd455fd0996b4d9b7edfcae
parent
63ca397657ff8ea15d841403495b10d6762b608c
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2024-08-31 00:42
init

Diffstat

A nominaldelta.py 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A test.py 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

2 files changed, 321 insertions, 0 deletions


diff --git a/nominaldelta.py b/nominaldelta.py

@@ -0,0 +1,150 @@
   -1     1 from datetime import date
   -1     2 from datetime import datetime
   -1     3 from typing import Self
   -1     4 
   -1     5 
   -1     6 def date_to_timestamp(d):
   -1     7     return datetime(d.year, d.month, d.day).timestamp()
   -1     8 
   -1     9 
   -1    10 def date_add(dt, delta):
   -1    11     total_months = dt.year * 12 + dt.month + delta.months
   -1    12     year, month = divmod(total_months, 12)
   -1    13     if month == 0:
   -1    14         year -= 1
   -1    15         month = 12
   -1    16 
   -1    17     # clip day to month
   -1    18     day = dt.day
   -1    19     while day > 0:
   -1    20         try:
   -1    21             tmp = date(year, month, day)
   -1    22             break
   -1    23         except ValueError:
   -1    24             day -= 1
   -1    25 
   -1    26     return date.fromordinal(tmp.toordinal() + delta.days)
   -1    27 
   -1    28 
   -1    29 def dt_add(dt, delta):
   -1    30     d = date_add(dt.date(), delta)
   -1    31     offset = dt.timestamp() - date_to_timestamp(dt.date())
   -1    32     return datetime.fromtimestamp(
   -1    33         date_to_timestamp(d) + offset + delta.seconds, tz=dt.tzinfo
   -1    34     )
   -1    35 
   -1    36 
   -1    37 def binary_search(a, b, delta):
   -1    38     lower = 0
   -1    39     upper = 1
   -1    40     while a + delta * upper <= b:
   -1    41         lower = upper
   -1    42         upper <<= 1
   -1    43     while lower + 1 < upper:
   -1    44         tmp = (lower + upper) // 2
   -1    45         if a + delta * tmp <= b:
   -1    46             lower = tmp
   -1    47         else:
   -1    48             upper = tmp
   -1    49     return lower * delta
   -1    50 
   -1    51 
   -1    52 def date_diff(a, b):
   -1    53     if a > b:
   -1    54         return -date_diff(b, a)
   -1    55     delta = binary_search(a, b, NominalDelta(months=1))
   -1    56     days = b.toordinal() - (a + delta).toordinal()
   -1    57     return delta + NominalDelta(days=days)
   -1    58 
   -1    59 
   -1    60 def dt_diff(a, b):
   -1    61     delta = date_diff(a, b)
   -1    62     seconds = b.timestamp() - (a + delta).timestamp()
   -1    63     return delta + NominalDelta(seconds=seconds)
   -1    64 
   -1    65 
   -1    66 class NominalDelta:
   -1    67     def __init__(
   -1    68         self: Self,
   -1    69         *,
   -1    70         years: int = 0,
   -1    71         months: int = 0,
   -1    72         weeks: int = 0,
   -1    73         days: int = 0,
   -1    74         hours: int = 0,
   -1    75         minutes: int = 0,
   -1    76         seconds: float = 0,
   -1    77     ):
   -1    78         self.months = years * 12 + months
   -1    79         self.days = weeks * 7 + days
   -1    80         self.seconds = hours * 3600 + minutes * 60 + seconds
   -1    81 
   -1    82     def __repr__(self):
   -1    83         return (
   -1    84             f'NominalDelta(months={self.months}, days={self.days}, '
   -1    85             f'seconds={self.seconds})'
   -1    86         )
   -1    87 
   -1    88     @property
   -1    89     def years(self: Self) -> int:
   -1    90         return self.months // 12
   -1    91 
   -1    92     def __eq__(self, other: Self) -> Self:
   -1    93         if isinstance(other, NominalDelta):
   -1    94             return (
   -1    95                 self.months == other.months
   -1    96                 and self.days == other.days
   -1    97                 and self.seconds == other.seconds
   -1    98             )
   -1    99         return NotImplemented
   -1   100 
   -1   101     def __add__(self: Self, other: Self) -> Self:
   -1   102         if isinstance(other, NominalDelta):
   -1   103             return NominalDelta(
   -1   104                 months=self.months + other.months,
   -1   105                 days=self.days + other.days,
   -1   106                 seconds=self.seconds + other.seconds,
   -1   107             )
   -1   108         return NotImplemented
   -1   109 
   -1   110     def __sub__(self: Self, other: Self) -> Self:
   -1   111         if isinstance(other, NominalDelta):
   -1   112             return NominalDelta(
   -1   113                 months=self.months - other.months,
   -1   114                 days=self.days - other.days,
   -1   115                 seconds=self.seconds - other.seconds,
   -1   116             )
   -1   117         return NotImplemented
   -1   118 
   -1   119     def __neg__(self: Self) -> Self:
   -1   120         return NominalDelta() - self
   -1   121 
   -1   122     def __mul__(self: Self, factor: int) -> Self:
   -1   123         if isinstance(factor, int):
   -1   124             return NominalDelta(
   -1   125                 months=self.months * factor,
   -1   126                 days=self.days * factor,
   -1   127                 seconds=self.seconds * factor,
   -1   128             )
   -1   129         return NotImplemented
   -1   130 
   -1   131     def __rmul__(self: Self, factor: int) -> Self:
   -1   132         return self * factor
   -1   133 
   -1   134     def __radd__(self: Self, other: date) -> date:
   -1   135         if isinstance(other, datetime):
   -1   136             return dt_add(other, self)
   -1   137         elif isinstance(other, date):
   -1   138             return date_add(other, self)
   -1   139         return NotImplemented
   -1   140 
   -1   141     def __rsub__(self: Self, other: date) -> date:
   -1   142         return (-self).__radd__(other)
   -1   143 
   -1   144     @classmethod
   -1   145     def diff(cls, a: date, b: date):
   -1   146         if isinstance(a, datetime) and isinstance(b, datetime):
   -1   147             return dt_diff(a, b)
   -1   148         elif isinstance(a, date) and isinstance(b, date):
   -1   149             return date_diff(a, b)
   -1   150         raise TypeError('Unsupported types')

diff --git a/test.py b/test.py

@@ -0,0 +1,171 @@
   -1     1 import unittest
   -1     2 from datetime import date
   -1     3 from datetime import datetime
   -1     4 from zoneinfo import ZoneInfo
   -1     5 
   -1     6 from nominaldelta import NominalDelta
   -1     7 
   -1     8 
   -1     9 class TestNominalDelta(unittest.TestCase):
   -1    10     def test_years(self):
   -1    11         self.assertEqual(NominalDelta(years=3, months=15).years, 4)
   -1    12         self.assertEqual(NominalDelta(years=3, months=-1).years, 2)
   -1    13 
   -1    14     def test_weeks(self):
   -1    15         self.assertEqual(NominalDelta(weeks=2, days=2).days, 16)
   -1    16         self.assertEqual(NominalDelta(weeks=2, days=-1).days, 13)
   -1    17 
   -1    18     def test_add(self):
   -1    19         self.assertEqual(
   -1    20             NominalDelta(months=1) + NominalDelta(days=2),
   -1    21             NominalDelta(months=1, days=2),
   -1    22         )
   -1    23         self.assertEqual(
   -1    24             NominalDelta(months=1) + NominalDelta(months=2),
   -1    25             NominalDelta(months=3),
   -1    26         )
   -1    27 
   -1    28     def test_sub(self):
   -1    29         self.assertEqual(
   -1    30             NominalDelta(months=1) - NominalDelta(days=2),
   -1    31             NominalDelta(months=1, days=-2),
   -1    32         )
   -1    33         self.assertEqual(
   -1    34             NominalDelta(months=1) - NominalDelta(months=2),
   -1    35             NominalDelta(months=-1),
   -1    36         )
   -1    37 
   -1    38     def test_neg(self):
   -1    39         self.assertEqual(
   -1    40             -NominalDelta(months=1, days=-2),
   -1    41             NominalDelta(months=-1, days=2),
   -1    42         )
   -1    43 
   -1    44     def test_mul(self):
   -1    45         self.assertEqual(
   -1    46             NominalDelta(months=1, days=-2) * 2,
   -1    47             NominalDelta(months=2, days=-4),
   -1    48         )
   -1    49 
   -1    50     def test_rmul(self):
   -1    51         self.assertEqual(
   -1    52             2 * NominalDelta(months=1, days=-2),
   -1    53             NominalDelta(months=2, days=-4),
   -1    54         )
   -1    55 
   -1    56     def test_radd_str_type_error(self):
   -1    57         with self.assertRaises(TypeError):
   -1    58             'asd' + NominalDelta(month=1)
   -1    59 
   -1    60         with self.assertRaises(TypeError):
   -1    61             NominalDelta(month=1) + 'asd'
   -1    62 
   -1    63     def test_radd_date(self):
   -1    64         self.assertEqual(
   -1    65             date(1970, 1, 30) + NominalDelta(months=1),
   -1    66             date(1970, 2, 28),
   -1    67         )
   -1    68 
   -1    69     def test_radd_datetime(self):
   -1    70         self.assertEqual(
   -1    71             datetime(1970, 1, 30, 13) + NominalDelta(months=1),
   -1    72             datetime(1970, 2, 28, 13),
   -1    73         )
   -1    74         self.assertEqual(
   -1    75             datetime(1970, 1, 30, 13) + NominalDelta(hours=2),
   -1    76             datetime(1970, 1, 30, 15),
   -1    77         )
   -1    78         self.assertEqual(
   -1    79             datetime(1970, 1, 30, 13) + NominalDelta(hours=24),
   -1    80             datetime(1970, 1, 31, 13),
   -1    81         )
   -1    82         self.assertEqual(
   -1    83             datetime(1970, 1, 30, 13) + NominalDelta(seconds=80),
   -1    84             datetime(1970, 1, 30, 13, 1, 20),
   -1    85         )
   -1    86 
   -1    87     def test_radd_datetime_dst(self):
   -1    88         tz = ZoneInfo('Europe/Berlin')
   -1    89         self.assertEqual(
   -1    90             datetime(2019, 3, 31, 1, 59, tzinfo=tz) + NominalDelta(minutes=2),
   -1    91             datetime(2019, 3, 31, 3, 1, tzinfo=tz),
   -1    92         )
   -1    93         self.assertEqual(
   -1    94             datetime(2019, 10, 27, 2, 59, tzinfo=tz) + NominalDelta(minutes=2),
   -1    95             datetime(2019, 10, 27, 2, 1, fold=True, tzinfo=tz),
   -1    96         )
   -1    97 
   -1    98     def test_rsub_date(self):
   -1    99         self.assertEqual(
   -1   100             date(1970, 1, 30) - NominalDelta(days=5),
   -1   101             date(1970, 1, 25),
   -1   102         )
   -1   103 
   -1   104     def test_rsub_datetime(self):
   -1   105         self.assertEqual(
   -1   106             datetime(1970, 1, 30, 13) - NominalDelta(days=1),
   -1   107             datetime(1970, 1, 29, 13),
   -1   108         )
   -1   109         self.assertEqual(
   -1   110             datetime(1970, 1, 30, 13) - NominalDelta(hours=2),
   -1   111             datetime(1970, 1, 30, 11),
   -1   112         )
   -1   113         self.assertEqual(
   -1   114             datetime(1970, 1, 30, 13) - NominalDelta(hours=24),
   -1   115             datetime(1970, 1, 29, 13),
   -1   116         )
   -1   117         self.assertEqual(
   -1   118             datetime(1970, 1, 30, 13) - NominalDelta(seconds=80),
   -1   119             datetime(1970, 1, 30, 12, 58, 40),
   -1   120         )
   -1   121 
   -1   122     def test_rsub_datetime_dst(self):
   -1   123         tz = ZoneInfo('Europe/Berlin')
   -1   124         self.assertEqual(
   -1   125             datetime(2019, 3, 31, 3, 1, tzinfo=tz) - NominalDelta(minutes=2),
   -1   126             datetime(2019, 3, 31, 1, 59, tzinfo=tz),
   -1   127         )
   -1   128         self.assertEqual(
   -1   129             datetime(2019, 10, 27, 2, 1, fold=True, tzinfo=tz)
   -1   130             - NominalDelta(minutes=2),
   -1   131             datetime(2019, 10, 27, 2, 59, tzinfo=tz),
   -1   132         )
   -1   133 
   -1   134     def test_diff_date(self):
   -1   135         self.assertEqual(
   -1   136             NominalDelta.diff(date(1970, 1, 15), date(1970, 2, 15)),
   -1   137             NominalDelta(months=1),
   -1   138         )
   -1   139         self.assertEqual(
   -1   140             NominalDelta.diff(date(1970, 2, 15), date(1970, 1, 15)),
   -1   141             NominalDelta(months=-1),
   -1   142         )
   -1   143         self.assertEqual(
   -1   144             NominalDelta.diff(date(1000, 1, 1), date(2000, 1, 1)),
   -1   145             NominalDelta(months=12_000),
   -1   146         )
   -1   147         self.assertEqual(
   -1   148             NominalDelta.diff(date(1970, 1, 15), date(1970, 1, 16)),
   -1   149             NominalDelta(days=1),
   -1   150         )
   -1   151 
   -1   152     def test_diff_datetime(self):
   -1   153         self.assertEqual(
   -1   154             NominalDelta.diff(datetime(1970, 1, 30, 13), datetime(1970, 2, 28, 13)),
   -1   155             NominalDelta(months=1),
   -1   156         )
   -1   157         self.assertEqual(
   -1   158             NominalDelta.diff(datetime(1970, 1, 30, 13), datetime(1970, 1, 31, 13)),
   -1   159             NominalDelta(days=1),
   -1   160         )
   -1   161         self.assertEqual(
   -1   162             NominalDelta.diff(datetime(1970, 1, 30, 13), datetime(1970, 1, 30, 15)),
   -1   163             NominalDelta(hours=2),
   -1   164         )
   -1   165         self.assertEqual(
   -1   166             NominalDelta.diff(
   -1   167                 datetime(1970, 1, 30, 13),
   -1   168                 datetime(1970, 1, 30, 13, 1, 20),
   -1   169             ),
   -1   170             NominalDelta(seconds=80),
   -1   171         )