Skip to content

Commit 1b97b9b

Browse files
pgansslecorona10
andauthored
bpo-24416: Return named tuple from date.isocalendar() (GH-20113)
{date, datetime}.isocalendar() now return a private custom named tuple object IsoCalendarDate rather than a simple tuple. In order to leave IsocalendarDate as a private class and to improve what backwards compatibility is offered for pickling the result of a datetime.isocalendar() call, add a __reduce__ method to the named tuples that reduces them to plain tuples. (This is the part of this PR most likely to cause problems — if it causes major issues, switching to a strucseq or equivalent would be prudent). The pure python implementation of IsoCalendarDate uses positional-only arguments, since it is private and only constructed by position anyway; the equivalent change in the argument clinic on the C side would require us to move the forward declaration of the type above the clinic import for whatever reason, so it seems preferable to hold off on that for now. bpo-24416: https://bugs.python.org/issue24416 Original PR by Dong-hee Na with only minor alterations by Paul Ganssle. Co-authored-by: Dong-hee Na <[email protected]>
1 parent aa92a7c commit 1b97b9b

File tree

7 files changed

+297
-29
lines changed

7 files changed

+297
-29
lines changed

Doc/library/datetime.rst

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,8 @@ Instance methods:
670670

671671
.. method:: date.isocalendar()
672672

673-
Return a 3-tuple, (ISO year, ISO week number, ISO weekday).
673+
Return a :term:`named tuple` object with three components: ``year``,
674+
``week`` and ``weekday``.
674675

675676
The ISO calendar is a widely used variant of the Gregorian calendar. [#]_
676677

@@ -682,11 +683,14 @@ Instance methods:
682683
For example, 2004 begins on a Thursday, so the first week of ISO year 2004
683684
begins on Monday, 29 Dec 2003 and ends on Sunday, 4 Jan 2004::
684685

685-
>>> from datetime import date
686-
>>> date(2003, 12, 29).isocalendar()
687-
(2004, 1, 1)
688-
>>> date(2004, 1, 4).isocalendar()
689-
(2004, 1, 7)
686+
>>> from datetime import date
687+
>>> date(2003, 12, 29).isocalendar()
688+
datetime.IsoCalendarDate(year=2004, week=1, weekday=1)
689+
>>> date(2004, 1, 4).isocalendar()
690+
datetime.IsoCalendarDate(year=2004, week=1, weekday=7)
691+
692+
.. versionchanged:: 3.9
693+
Result changed from a tuple to a :term:`named tuple`.
690694

691695
.. method:: date.isoformat()
692696

@@ -1397,8 +1401,8 @@ Instance methods:
13971401

13981402
.. method:: datetime.isocalendar()
13991403

1400-
Return a 3-tuple, (ISO year, ISO week number, ISO weekday). The same as
1401-
``self.date().isocalendar()``.
1404+
Return a :term:`named tuple` with three components: ``year``, ``week``
1405+
and ``weekday``. The same as ``self.date().isocalendar()``.
14021406

14031407

14041408
.. method:: datetime.isoformat(sep='T', timespec='auto')

Doc/whatsnew/3.9.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,13 @@ Add :func:`curses.get_escdelay`, :func:`curses.set_escdelay`,
281281
:func:`curses.get_tabsize`, and :func:`curses.set_tabsize` functions.
282282
(Contributed by Anthony Sottile in :issue:`38312`.)
283283

284+
datetime
285+
--------
286+
The :meth:`~datetime.date.isocalendar()` of :class:`datetime.date`
287+
and :meth:`~datetime.datetime.isocalendar()` of :class:`datetime.datetime`
288+
methods now returns a :func:`~collections.namedtuple` instead of a :class:`tuple`.
289+
(Contributed by Dong-hee Na in :issue:`24416`.)
290+
284291
fcntl
285292
-----
286293

Lib/datetime.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,7 +1095,7 @@ def isoweekday(self):
10951095
return self.toordinal() % 7 or 7
10961096

10971097
def isocalendar(self):
1098-
"""Return a 3-tuple containing ISO year, week number, and weekday.
1098+
"""Return a named tuple containing ISO year, week number, and weekday.
10991099
11001100
The first ISO week of the year is the (Mon-Sun) week
11011101
containing the year's first Thursday; everything else derives
@@ -1120,7 +1120,7 @@ def isocalendar(self):
11201120
if today >= _isoweek1monday(year+1):
11211121
year += 1
11221122
week = 0
1123-
return year, week+1, day+1
1123+
return _IsoCalendarDate(year, week+1, day+1)
11241124

11251125
# Pickle support.
11261126

@@ -1210,6 +1210,36 @@ def __reduce__(self):
12101210
else:
12111211
return (self.__class__, args, state)
12121212

1213+
1214+
class IsoCalendarDate(tuple):
1215+
1216+
def __new__(cls, year, week, weekday, /):
1217+
return super().__new__(cls, (year, week, weekday))
1218+
1219+
@property
1220+
def year(self):
1221+
return self[0]
1222+
1223+
@property
1224+
def week(self):
1225+
return self[1]
1226+
1227+
@property
1228+
def weekday(self):
1229+
return self[2]
1230+
1231+
def __reduce__(self):
1232+
# This code is intended to pickle the object without making the
1233+
# class public. See https://bugs.python.org/msg352381
1234+
return (tuple, (tuple(self),))
1235+
1236+
def __repr__(self):
1237+
return (f'{self.__class__.__name__}'
1238+
f'(year={self[0]}, week={self[1]}, weekday={self[2]})')
1239+
1240+
1241+
_IsoCalendarDate = IsoCalendarDate
1242+
del IsoCalendarDate
12131243
_tzinfo_class = tzinfo
12141244

12151245
class time:
@@ -1559,6 +1589,7 @@ def __reduce__(self):
15591589
time.max = time(23, 59, 59, 999999)
15601590
time.resolution = timedelta(microseconds=1)
15611591

1592+
15621593
class datetime(date):
15631594
"""datetime(year, month, day[, hour[, minute[, second[, microsecond[,tzinfo]]]]])
15641595
@@ -2514,7 +2545,7 @@ def _name_from_offset(delta):
25142545
_format_time, _format_offset, _is_leap, _isoweek1monday, _math,
25152546
_ord2ymd, _time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord,
25162547
_divide_and_round, _parse_isoformat_date, _parse_isoformat_time,
2517-
_parse_hh_mm_ss_ff)
2548+
_parse_hh_mm_ss_ff, _IsoCalendarDate)
25182549
# XXX Since import * above excludes names that start with _,
25192550
# docstring does not get overwritten. In the future, it may be
25202551
# appropriate to maintain a single module level docstring and

Lib/test/datetimetester.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
See http://www.zope.org/Members/fdrake/DateTimeUncyclo/TestCases
44
"""
5+
import io
56
import itertools
67
import bisect
78
import copy
@@ -1355,19 +1356,43 @@ def test_weekday(self):
13551356
def test_isocalendar(self):
13561357
# Check examples from
13571358
# http://www.phys.uu.nl/~vgent/calendar/isocalendar.htm
1358-
for i in range(7):
1359-
d = self.theclass(2003, 12, 22+i)
1360-
self.assertEqual(d.isocalendar(), (2003, 52, i+1))
1361-
d = self.theclass(2003, 12, 29) + timedelta(i)
1362-
self.assertEqual(d.isocalendar(), (2004, 1, i+1))
1363-
d = self.theclass(2004, 1, 5+i)
1364-
self.assertEqual(d.isocalendar(), (2004, 2, i+1))
1365-
d = self.theclass(2009, 12, 21+i)
1366-
self.assertEqual(d.isocalendar(), (2009, 52, i+1))
1367-
d = self.theclass(2009, 12, 28) + timedelta(i)
1368-
self.assertEqual(d.isocalendar(), (2009, 53, i+1))
1369-
d = self.theclass(2010, 1, 4+i)
1370-
self.assertEqual(d.isocalendar(), (2010, 1, i+1))
1359+
week_mondays = [
1360+
((2003, 12, 22), (2003, 52, 1)),
1361+
((2003, 12, 29), (2004, 1, 1)),
1362+
((2004, 1, 5), (2004, 2, 1)),
1363+
((2009, 12, 21), (2009, 52, 1)),
1364+
((2009, 12, 28), (2009, 53, 1)),
1365+
((2010, 1, 4), (2010, 1, 1)),
1366+
]
1367+
1368+
test_cases = []
1369+
for cal_date, iso_date in week_mondays:
1370+
base_date = self.theclass(*cal_date)
1371+
# Adds one test case for every day of the specified weeks
1372+
for i in range(7):
1373+
new_date = base_date + timedelta(i)
1374+
new_iso = iso_date[0:2] + (iso_date[2] + i,)
1375+
test_cases.append((new_date, new_iso))
1376+
1377+
for d, exp_iso in test_cases:
1378+
with self.subTest(d=d, comparison="tuple"):
1379+
self.assertEqual(d.isocalendar(), exp_iso)
1380+
1381+
# Check that the tuple contents are accessible by field name
1382+
with self.subTest(d=d, comparison="fields"):
1383+
t = d.isocalendar()
1384+
self.assertEqual((t.year, t.week, t.weekday), exp_iso)
1385+
1386+
def test_isocalendar_pickling(self):
1387+
"""Test that the result of datetime.isocalendar() can be pickled.
1388+
1389+
The result of a round trip should be a plain tuple.
1390+
"""
1391+
d = self.theclass(2019, 1, 1)
1392+
p = pickle.dumps(d.isocalendar())
1393+
res = pickle.loads(p)
1394+
self.assertEqual(type(res), tuple)
1395+
self.assertEqual(res, (2019, 1, 2))
13711396

13721397
def test_iso_long_years(self):
13731398
# Calculate long ISO years and compare to table from
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The ``isocalendar()`` methods of :class:`datetime.date` and
2+
:class:`datetime.datetime` now return a :term:`named tuple`
3+
instead of a :class:`tuple`.

0 commit comments

Comments
 (0)