Skip to content

Commit 89427cd

Browse files
pganssleabalkin
authored andcommitted
bpo-32417: Make timedelta arithmetic respect subclasses (python#10902)
* Make timedelta return subclass types Previously timedelta would always return the `date` and `datetime` types, regardless of what it is added to. This makes it return an object of the type it was added to. * Add tests for timedelta arithmetic on subclasses * Make pure python timedelta return subclass types * Add test for fromtimestamp with tz argument * Add tests for subclass behavior in now * Add news entry. Fixes: bpo-32417 bpo-35364 * More descriptive variable names in tests Addresses Victor's comments
1 parent ca7d293 commit 89427cd

File tree

4 files changed

+90
-19
lines changed

4 files changed

+90
-19
lines changed

Lib/datetime.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,7 @@ def __add__(self, other):
10141014
if isinstance(other, timedelta):
10151015
o = self.toordinal() + other.days
10161016
if 0 < o <= _MAXORDINAL:
1017-
return date.fromordinal(o)
1017+
return type(self).fromordinal(o)
10181018
raise OverflowError("result out of range")
10191019
return NotImplemented
10201020

@@ -2024,10 +2024,10 @@ def __add__(self, other):
20242024
hour, rem = divmod(delta.seconds, 3600)
20252025
minute, second = divmod(rem, 60)
20262026
if 0 < delta.days <= _MAXORDINAL:
2027-
return datetime.combine(date.fromordinal(delta.days),
2028-
time(hour, minute, second,
2029-
delta.microseconds,
2030-
tzinfo=self._tzinfo))
2027+
return type(self).combine(date.fromordinal(delta.days),
2028+
time(hour, minute, second,
2029+
delta.microseconds,
2030+
tzinfo=self._tzinfo))
20312031
raise OverflowError("result out of range")
20322032

20332033
__radd__ = __add__

Lib/test/datetimetester.py

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,44 @@ def as_hours(self):
820820
self.assertEqual(str(t3), str(t4))
821821
self.assertEqual(t4.as_hours(), -1)
822822

823+
def test_subclass_date(self):
824+
class DateSubclass(date):
825+
pass
826+
827+
d1 = DateSubclass(2018, 1, 5)
828+
td = timedelta(days=1)
829+
830+
tests = [
831+
('add', lambda d, t: d + t, DateSubclass(2018, 1, 6)),
832+
('radd', lambda d, t: t + d, DateSubclass(2018, 1, 6)),
833+
('sub', lambda d, t: d - t, DateSubclass(2018, 1, 4)),
834+
]
835+
836+
for name, func, expected in tests:
837+
with self.subTest(name):
838+
act = func(d1, td)
839+
self.assertEqual(act, expected)
840+
self.assertIsInstance(act, DateSubclass)
841+
842+
def test_subclass_datetime(self):
843+
class DateTimeSubclass(datetime):
844+
pass
845+
846+
d1 = DateTimeSubclass(2018, 1, 5, 12, 30)
847+
td = timedelta(days=1, minutes=30)
848+
849+
tests = [
850+
('add', lambda d, t: d + t, DateTimeSubclass(2018, 1, 6, 13)),
851+
('radd', lambda d, t: t + d, DateTimeSubclass(2018, 1, 6, 13)),
852+
('sub', lambda d, t: d - t, DateTimeSubclass(2018, 1, 4, 12)),
853+
]
854+
855+
for name, func, expected in tests:
856+
with self.subTest(name):
857+
act = func(d1, td)
858+
self.assertEqual(act, expected)
859+
self.assertIsInstance(act, DateTimeSubclass)
860+
823861
def test_division(self):
824862
t = timedelta(hours=1, minutes=24, seconds=19)
825863
second = timedelta(seconds=1)
@@ -2604,33 +2642,58 @@ def __new__(cls, *args, **kwargs):
26042642
ts = base_d.timestamp()
26052643

26062644
test_cases = [
2607-
('fromtimestamp', (ts,)),
2645+
('fromtimestamp', (ts,), base_d),
26082646
# See https://bugs.python.org/issue32417
2609-
# ('fromtimestamp', (ts, timezone.utc)),
2610-
('utcfromtimestamp', (utc_ts,)),
2611-
('fromisoformat', (d_isoformat,)),
2612-
('strptime', (d_isoformat, '%Y-%m-%dT%H:%M:%S.%f')),
2613-
('combine', (date(*args[0:3]), time(*args[3:]))),
2647+
('fromtimestamp', (ts, timezone.utc),
2648+
base_d.astimezone(timezone.utc)),
2649+
('utcfromtimestamp', (utc_ts,), base_d),
2650+
('fromisoformat', (d_isoformat,), base_d),
2651+
('strptime', (d_isoformat, '%Y-%m-%dT%H:%M:%S.%f'), base_d),
2652+
('combine', (date(*args[0:3]), time(*args[3:])), base_d),
26142653
]
26152654

2616-
for constr_name, constr_args in test_cases:
2655+
for constr_name, constr_args, expected in test_cases:
26172656
for base_obj in (DateTimeSubclass, base_d):
26182657
# Test both the classmethod and method
26192658
with self.subTest(base_obj_type=type(base_obj),
26202659
constr_name=constr_name):
2621-
constr = getattr(base_obj, constr_name)
2660+
constructor = getattr(base_obj, constr_name)
26222661

2623-
dt = constr(*constr_args)
2662+
dt = constructor(*constr_args)
26242663

26252664
# Test that it creates the right subclass
26262665
self.assertIsInstance(dt, DateTimeSubclass)
26272666

26282667
# Test that it's equal to the base object
2629-
self.assertEqual(dt, base_d.replace(tzinfo=None))
2668+
self.assertEqual(dt, expected)
26302669

26312670
# Test that it called the constructor
26322671
self.assertEqual(dt.extra, 7)
26332672

2673+
def test_subclass_now(self):
2674+
# Test that alternate constructors call the constructor
2675+
class DateTimeSubclass(self.theclass):
2676+
def __new__(cls, *args, **kwargs):
2677+
result = self.theclass.__new__(cls, *args, **kwargs)
2678+
result.extra = 7
2679+
2680+
return result
2681+
2682+
test_cases = [
2683+
('now', 'now', {}),
2684+
('utcnow', 'utcnow', {}),
2685+
('now_utc', 'now', {'tz': timezone.utc}),
2686+
('now_fixed', 'now', {'tz': timezone(timedelta(hours=-5), "EST")}),
2687+
]
2688+
2689+
for name, meth_name, kwargs in test_cases:
2690+
with self.subTest(name):
2691+
constr = getattr(DateTimeSubclass, meth_name)
2692+
dt = constr(**kwargs)
2693+
2694+
self.assertIsInstance(dt, DateTimeSubclass)
2695+
self.assertEqual(dt.extra, 7)
2696+
26342697
def test_fromisoformat_datetime(self):
26352698
# Test that isoformat() is reversible
26362699
base_dates = [
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Performing arithmetic between :class:`datetime.datetime` subclasses and
2+
:class:`datetime.timedelta` now returns an object of the same type as the
3+
:class:`datetime.datetime` subclass. As a result,
4+
:meth:`datetime.datetime.astimezone` and alternate constructors like
5+
:meth:`datetime.datetime.now` and :meth:`datetime.fromtimestamp` called with
6+
a ``tz`` argument now *also* retain their subclass.

Modules/_datetimemodule.c

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3004,7 +3004,8 @@ add_date_timedelta(PyDateTime_Date *date, PyDateTime_Delta *delta, int negate)
30043004
int day = GET_DAY(date) + (negate ? -deltadays : deltadays);
30053005

30063006
if (normalize_date(&year, &month, &day) >= 0)
3007-
result = new_date(year, month, day);
3007+
result = new_date_subclass_ex(year, month, day,
3008+
(PyObject* )Py_TYPE(date));
30083009
return result;
30093010
}
30103011

@@ -5166,9 +5167,10 @@ add_datetime_timedelta(PyDateTime_DateTime *date, PyDateTime_Delta *delta,
51665167
return NULL;
51675168
}
51685169

5169-
return new_datetime(year, month, day,
5170-
hour, minute, second, microsecond,
5171-
HASTZINFO(date) ? date->tzinfo : Py_None, 0);
5170+
return new_datetime_subclass_ex(year, month, day,
5171+
hour, minute, second, microsecond,
5172+
HASTZINFO(date) ? date->tzinfo : Py_None,
5173+
(PyObject *)Py_TYPE(date));
51725174
}
51735175

51745176
static PyObject *

0 commit comments

Comments
 (0)