Skip to content

Commit 8710579

Browse files
committed
implement ItemizedDateDelta.total()
1 parent 6290080 commit 8710579

File tree

5 files changed

+91
-9
lines changed

5 files changed

+91
-9
lines changed

pysrc/whenever/__init__.pyi

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,13 @@ class ItemizedDateDelta:
735735
] = "trunc",
736736
round_increment: int = 1,
737737
) -> ItemizedDateDelta: ...
738+
def total(
739+
self,
740+
unit: Literal["years", "months", "weeks", "days"],
741+
/,
742+
*,
743+
relative_to: Date,
744+
) -> int: ...
738745
def __iter__(self) -> Iterable[int]: ...
739746
def __len__(self) -> int: ...
740747
def __getitem__(

pysrc/whenever/_math.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def years_diff(_a: _date, b: InterimDate, increment: int, /) -> _AbsoluteDiff:
4848
assert isinstance(b, _date)
4949
diff = (_a.year - b.year) // increment * increment
5050
shift = _replace_year(b, b.year + diff)
51-
sign = 1 if diff >= 0 else -1
51+
sign = 1 if _a >= b else -1
5252

5353
# Check if we overshot
5454
if (diff > 0 and resolve_leap_day(shift) > _a) or (
@@ -76,7 +76,7 @@ def months_diff(a: _date, b: InterimDate, increment: int, /) -> _AbsoluteDiff:
7676
((a.year - b.year) * 12 + (a.month - b.month)) // increment
7777
) * increment
7878
shift = _add_months(b, diff)
79-
sign = 1 if diff >= 0 else -1
79+
sign = 1 if a >= resolve_leap_day(b) else -1
8080

8181
# Check if we overshot
8282
if (diff > 0 and shift > a) or (diff < 0 and shift < a):

pysrc/whenever/_pywhenever.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,6 @@ def _calendar_unit_index(u: str) -> int:
188188
)
189189

190190

191-
# if SPHINXBUILD:
192-
# type(_CalendarUnitPlural).__repr__= lambda self: "Literal[...]"
193-
# type(_CalendarUnitPlural).__str__= lambda self: "Literal[...]"
194-
# breakpoint()
195-
196191
# Metaclass ugh...it proved the most lightweight way to achieve the following:
197192
# Allowing the constructors of many classes to take an ISO string as well as the
198193
# regular arguments (i.e. the __init__ signature).
@@ -665,6 +660,9 @@ def since(
665660
trunc_date = resolve_leap_day(trunc)
666661
limit = (expand_date - trunc_date).days
667662
remainder = (self._py_date - trunc_date).days
663+
assert (limit >= 0 and remainder >= 0) or (
664+
limit <= 0 and remainder <= 0
665+
) # they should have the same sign, or be zero
668666
# DROP-PY39: match-case
669667
if round_mode == "expand":
670668
do_expand = remainder * sign > 0
@@ -1601,6 +1599,7 @@ class TimeDelta(_Base):
16011599
__slots__ = ("_total_ns",)
16021600

16031601
# FUTURE: allow weeks and days (with 24 hour warning)?
1602+
# TODO: allow passing py timedelta (also for other classes)
16041603
def __init__(
16051604
self,
16061605
*,
@@ -1650,6 +1649,7 @@ def total(
16501649
"microseconds",
16511650
"nanoseconds",
16521651
],
1652+
# TODO: allow other local time types?
16531653
relative_to: ZonedDateTime = _UNSET,
16541654
) -> float:
16551655
"""The total size in the given unit, as a floating point number
@@ -4074,8 +4074,25 @@ def subtract(
40744074
def total(
40754075
self, unit: _CalendarUnitPlural, /, *, relative_to: Date
40764076
) -> float:
4077-
"""Return the total duration expressed in the specified unit as a float"""
4078-
raise NotImplementedError() # TODO
4077+
"""Return the total duration expressed in the specified unit as a float
4078+
4079+
>>> ItemizedDateDelta(years=1, months=6).total("months", relative_to=Date(2020, 1, 31))
4080+
18.0
4081+
>>> ItemizedDateDelta(days=1000).total("years", relative_to=Date(2020, 4, 10))
4082+
2.73972602739726
4083+
"""
4084+
shifted = relative_to.add(self)
4085+
trunc_amount, trunc_date_interim, expand_date_interim = DIFF_FUNCS[
4086+
unit
4087+
](shifted._py_date, relative_to._py_date, 1)
4088+
4089+
trunc_date = resolve_leap_day(trunc_date_interim)
4090+
expand_date = resolve_leap_day(expand_date_interim)
4091+
4092+
return (
4093+
trunc_amount
4094+
+ ((shifted._py_date - trunc_date) / (expand_date - trunc_date))
4095+
) * self._sign
40794096

40804097
# A private constructor. Checks bounds but *not* signs or presence of > 0 fields.
40814098
@classmethod

tests/test_date.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,21 @@ class TestSinceAndUntil:
959959
8,
960960
{"round_mode": "half_ceil", "round_increment": 8},
961961
),
962+
# regression tests for negative deltas close to 0
963+
(
964+
Date(2020, 1, 1),
965+
Date(2020, 12, 31),
966+
"years",
967+
-1,
968+
{"round_mode": "expand"},
969+
),
970+
(
971+
Date(2020, 12, 1),
972+
Date(2020, 12, 31),
973+
"months",
974+
-1,
975+
{"round_mode": "expand"},
976+
),
962977
],
963978
)
964979
def test_single_unit(self, d1: Date, d2: Date, unit, delta: int, kwargs):

tests/test_itemized_date_delta.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,49 @@ def test_floor_round_mode_behaves_correctly_on_negative(self):
469469
).exact_eq(ItemizedDateDelta(years=-3, months=-10))
470470

471471

472+
class TestTotal:
473+
474+
@pytest.mark.parametrize(
475+
"d, relative_to, unit, expected",
476+
[
477+
(
478+
ItemizedDateDelta(years=2, months=3, weeks=4, days=5),
479+
Date("2021-12-31"),
480+
"months",
481+
28.096774193548388,
482+
),
483+
(
484+
ItemizedDateDelta(weeks=2, days=16),
485+
Date("2021-04-30"),
486+
"months",
487+
1.0,
488+
),
489+
(
490+
ItemizedDateDelta(weeks=-2, days=-18),
491+
Date("2021-04-30"),
492+
"years",
493+
-0.08767123287671233,
494+
),
495+
(
496+
ItemizedDateDelta(weeks=-2, days=-18),
497+
Date("2021-04-30"),
498+
"days",
499+
-32,
500+
),
501+
],
502+
)
503+
def test_valid(
504+
self,
505+
d: ItemizedDateDelta,
506+
relative_to: Date,
507+
unit: Literal["years", "months", "weeks", "days"],
508+
expected: float,
509+
):
510+
assert d.total(unit, relative_to=relative_to) == pytest.approx(
511+
expected
512+
)
513+
514+
472515
def test_abs():
473516
d = ItemizedDateDelta(days=-5, weeks=-3)
474517
assert abs(d).exact_eq(ItemizedDateDelta(days=5, weeks=3))

0 commit comments

Comments
 (0)