Skip to content

Commit 6252687

Browse files
author
Rodrigo Roldán
committed
feature: add support for delta in __add__ & __sub__
1 parent 5a5c2de commit 6252687

2 files changed

Lines changed: 62 additions & 26 deletions

File tree

src/eones/core/date.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44

55
from calendar import monthrange
66
from datetime import datetime, timedelta, timezone
7-
from typing import Any, Literal, Optional, Union
7+
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
88
from zoneinfo import ZoneInfo
99

1010
from eones.constants import VALID_KEYS
1111
from eones.humanize import diff_for_humans as _diff_for_humans
1212

13+
if TYPE_CHECKING: # pragma: no cover - import for type checking only
14+
from eones.core.delta import Delta
15+
1316

1417
class Date: # pylint: disable=too-many-public-methods
1518
"""
@@ -90,15 +93,49 @@ def shift(self, delta: timedelta) -> Date:
9093
"""Return a new Date shifted by the given timedelta."""
9194
return self._with(self._dt + delta)
9295

93-
def __add__(self, delta: timedelta) -> Date:
94-
return self.shift(delta)
96+
def __add__(self, delta: Union[timedelta, "Delta"]) -> Date:
97+
"""Return a new :class:`Date` offset by ``delta``.
98+
99+
Both :class:`datetime.timedelta` and :class:`eones.core.delta.Delta`
100+
instances are accepted. The operation returns a new ``Date`` shifted
101+
forward by the provided amount.
102+
103+
Returns:
104+
Date: The resulting shifted date.
105+
"""
106+
from eones.core.delta import Delta
107+
108+
if isinstance(delta, Delta):
109+
return delta.apply(self)
110+
111+
if isinstance(delta, timedelta):
112+
return self.shift(delta)
113+
114+
return NotImplemented
115+
116+
def __sub__(self, other: Union[Date, timedelta, "Delta"]) -> Union[Date, timedelta]:
117+
"""Subtract ``other`` from this date.
118+
119+
``other`` may be a :class:`datetime.timedelta` or a
120+
:class:`eones.core.delta.Delta`. Timedelta or Delta instances yield a new
121+
``Date`` shifted backward. When another ``Date`` is provided, the result
122+
is the difference as a ``timedelta``.
123+
124+
Returns:
125+
Union[Date, timedelta]: ``Date`` or time difference depending on the
126+
operand type.
127+
"""
128+
from eones.core.delta import Delta
129+
130+
if isinstance(other, Delta):
131+
return other.invert().apply(self)
95132

96-
def __sub__(self, other: Union[Date, timedelta]) -> Union[Date, timedelta]:
97133
if isinstance(other, timedelta):
98134
return self.shift(-other)
99135

100136
if isinstance(other, Date):
101137
return self._dt - other.to_datetime()
138+
102139
return NotImplemented
103140

104141
@classmethod

tests/test_date.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
from eones.core.date import Date
8+
from eones.core.delta import Delta
89

910
# ==== Fixtures ====
1011

@@ -37,9 +38,6 @@ def test_previous_weekday_variants(start, target, expected_day):
3738
assert prev.to_datetime().day == expected_day
3839

3940

40-
# (todo el resto del archivo como ya está, pero sin repeticiones)
41-
42-
4341
# ==== truncate / round ====
4442

4543

@@ -156,6 +154,14 @@ def test_date_shift_and_add_operator():
156154
assert added.to_datetime().day == 6
157155

158156

157+
def test_date_add_delta():
158+
d = Date(datetime(2024, 1, 31, tzinfo=ZoneInfo("UTC")))
159+
result = d + Delta(months=1)
160+
dt = result.to_datetime()
161+
assert dt.month == 2
162+
assert dt.day == 29
163+
164+
159165
def test_date_subtract_timedelta():
160166
d = Date(datetime(2024, 1, 10, tzinfo=ZoneInfo("UTC")))
161167
result = d - timedelta(days=3)
@@ -169,6 +175,14 @@ def test_date_subtract_another_date():
169175
assert delta.days == 5
170176

171177

178+
def test_date_subtract_delta():
179+
d = Date(datetime(2024, 3, 31, tzinfo=ZoneInfo("UTC")))
180+
result = d - Delta(months=1)
181+
dt = result.to_datetime()
182+
assert dt.month == 2
183+
assert dt.day == 29
184+
185+
172186
def test_date_sub_invalid_type_triggers_not_implemented():
173187
d = Date(datetime(2024, 1, 1, tzinfo=ZoneInfo("UTC")))
174188
with pytest.raises(TypeError):
@@ -264,7 +278,7 @@ def test_date_less_than_another():
264278
def test_date_less_than_invalid_type():
265279
dt = Date(datetime(2024, 1, 1, tzinfo=ZoneInfo("UTC")))
266280
with pytest.raises(TypeError):
267-
_ = dt < "2024-01-02" # activa NotImplemented
281+
_ = dt < "2024-01-02"
268282

269283

270284
def test_date_equality_with_another_date():
@@ -275,7 +289,7 @@ def test_date_equality_with_another_date():
275289

276290
def test_date_equality_with_other_type():
277291
dt = Date(datetime(2024, 1, 1, tzinfo=ZoneInfo("UTC")))
278-
assert (dt == "2024-01-01") is False # activa return NotImplemented
292+
assert (dt == "2024-01-01") is False
279293

280294

281295
def test_date_hash():
@@ -286,49 +300,41 @@ def test_date_hash():
286300
@pytest.mark.parametrize(
287301
"unit, dt_kwargs, expected",
288302
[
289-
# Año
290303
(
291304
"year",
292305
{"year": 2024, "month": 1, "day": 1},
293306
{"month": 12, "day": 31, "hour": 23, "minute": 59},
294307
),
295-
# Mes normal
296308
(
297309
"month",
298310
{"year": 2024, "month": 4, "day": 1},
299311
{"day": 30, "hour": 23, "minute": 59},
300312
),
301-
# Mes bisiesto febrero
302313
(
303314
"month",
304315
{"year": 2024, "month": 2, "day": 1},
305316
{"day": 29, "hour": 23, "minute": 59},
306317
),
307-
# Semana que arranca lunes y debe terminar domingo
308318
(
309319
"week",
310320
{"year": 2024, "month": 4, "day": 1},
311321
{"weekday": 6, "hour": 23, "minute": 59},
312322
),
313-
# Día
314323
(
315324
"day",
316325
{"year": 2024, "month": 4, "day": 1, "hour": 10},
317326
{"hour": 23, "minute": 59, "second": 59},
318327
),
319-
# Hora
320328
(
321329
"hour",
322330
{"year": 2024, "month": 4, "day": 1, "hour": 10},
323331
{"minute": 59, "second": 59},
324332
),
325-
# Minuto
326333
(
327334
"minute",
328335
{"year": 2024, "month": 4, "day": 1, "hour": 10, "minute": 25},
329336
{"second": 59},
330337
),
331-
# Segundo
332338
(
333339
"second",
334340
{
@@ -346,18 +352,13 @@ def test_date_hash():
346352
def test_ceil_units(unit, dt_kwargs, expected):
347353
base = Date(datetime(**dt_kwargs, tzinfo=ZoneInfo("UTC")))
348354
result = base.ceil(unit).to_datetime()
349-
350-
# Validación del tipo de retorno
351355
assert isinstance(result, datetime)
352-
353-
# Validaciones específicas por atributo
354356
for attr, value in expected.items():
355357
if attr == "weekday":
356358
assert result.weekday() == value
357359
else:
358360
assert getattr(result, attr) == value
359361

360-
# Validación general: el resultado debe ser igual o posterior
361362
assert result >= base.to_datetime()
362363

363364

@@ -382,17 +383,15 @@ def test_ceil_second_sets_microsecond():
382383

383384

384385
def test_ceil_invalid_unit_only_in_ceil():
385-
# "decade" es aceptado por floor() si no lo valida, pero no por ceil()
386386
d = Date(datetime(2024, 1, 1, tzinfo=ZoneInfo("UTC")))
387-
d._dt = d._dt.replace(tzinfo=ZoneInfo("UTC")) # asegurar zona válida
387+
d._dt = d._dt.replace(tzinfo=ZoneInfo("UTC"))
388388
with pytest.raises(ValueError, match="Unsupported unit: decade"):
389389
d.ceil("decade")
390390

391391

392392
def test_ceil_final_else_branch_direct():
393393
class Dummy(Date):
394394
def floor(self, unit):
395-
# Simula un floor válido para forzar ejecución del bloque final de ceil
396395
return self
397396

398397
d = Dummy(datetime(2024, 1, 1, tzinfo=ZoneInfo("UTC")))
@@ -401,7 +400,7 @@ def floor(self, unit):
401400

402401

403402
def test_date_naive_unknown_mode_raises():
404-
dt = datetime(2024, 1, 1, 12, 0) # sin tzinfo
403+
dt = datetime(2024, 1, 1, 12, 0)
405404
with pytest.raises(ValueError, match="Invalid 'naive' value"):
406405
Date(dt, tz="UTC", naive="xyz")
407406

0 commit comments

Comments
 (0)