Skip to content

Commit 88c0937

Browse files
pgansslevstinner
authored andcommitted
bpo-36004: Add date.fromisocalendar (GH-11888)
This commit implements the first version of date.fromisocalendar, the inverse function for date.isocalendar.
1 parent a86e064 commit 88c0937

File tree

6 files changed

+209
-0
lines changed

6 files changed

+209
-0
lines changed

Doc/library/datetime.rst

+17
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,13 @@ Other constructors, all class methods:
458458
.. versionadded:: 3.7
459459

460460

461+
.. classmethod:: date.fromisocalendar(year, week, day)
462+
463+
Return a :class:`date` corresponding to the ISO calendar date specified by
464+
year, week and day. This is the inverse of the function :meth:`date.isocalendar`.
465+
466+
.. versionadded:: 3.8
467+
461468

462469
Class attributes:
463470

@@ -854,6 +861,16 @@ Other constructors, all class methods:
854861

855862
.. versionadded:: 3.7
856863

864+
865+
.. classmethod:: datetime.fromisocalendar(year, week, day)
866+
867+
Return a :class:`datetime` corresponding to the ISO calendar date specified
868+
by year, week and day. The non-date components of the datetime are populated
869+
with their normal default values. This is the inverse of the function
870+
:meth:`datetime.isocalendar`.
871+
872+
.. versionadded:: 3.8
873+
857874
.. classmethod:: datetime.strptime(date_string, format)
858875

859876
Return a :class:`.datetime` corresponding to *date_string*, parsed according to

Doc/whatsnew/3.8.rst

+10
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,16 @@ where the DLL is stored (if a full or partial path is used to load the initial
244244
DLL) and paths added by :func:`~os.add_dll_directory`.
245245

246246

247+
datetime
248+
--------
249+
250+
Added new alternate constructors :meth:`datetime.date.fromisocalendar` and
251+
:meth:`datetime.datetime.fromisocalendar`, which construct :class:`date` and
252+
:class:`datetime` objects respectively from ISO year, week number and weekday;
253+
these are the inverse of each class's ``isocalendar`` method.
254+
(Contributed by Paul Ganssle in :issue:`36004`.)
255+
256+
247257
gettext
248258
-------
249259

Lib/datetime.py

+35
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,40 @@ def fromisoformat(cls, date_string):
884884
except Exception:
885885
raise ValueError(f'Invalid isoformat string: {date_string!r}')
886886

887+
@classmethod
888+
def fromisocalendar(cls, year, week, day):
889+
"""Construct a date from the ISO year, week number and weekday.
890+
891+
This is the inverse of the date.isocalendar() function"""
892+
# Year is bounded this way because 9999-12-31 is (9999, 52, 5)
893+
if not MINYEAR <= year <= MAXYEAR:
894+
raise ValueError(f"Year is out of range: {year}")
895+
896+
if not 0 < week < 53:
897+
out_of_range = True
898+
899+
if week == 53:
900+
# ISO years have 53 weeks in them on years starting with a
901+
# Thursday and leap years starting on a Wednesday
902+
first_weekday = _ymd2ord(year, 1, 1) % 7
903+
if (first_weekday == 4 or (first_weekday == 3 and
904+
_is_leap(year))):
905+
out_of_range = False
906+
907+
if out_of_range:
908+
raise ValueError(f"Invalid week: {week}")
909+
910+
if not 0 < day < 8:
911+
raise ValueError(f"Invalid weekday: {day} (range is [1, 7])")
912+
913+
# Now compute the offset from (Y, 1, 1) in days:
914+
day_offset = (week - 1) * 7 + (day - 1)
915+
916+
# Calculate the ordinal day for monday, week 1
917+
day_1 = _isoweek1monday(year)
918+
ord_day = day_1 + day_offset
919+
920+
return cls(*_ord2ymd(ord_day))
887921

888922
# Conversions to string
889923

@@ -2141,6 +2175,7 @@ def _isoweek1monday(year):
21412175
week1monday += 7
21422176
return week1monday
21432177

2178+
21442179
class timezone(tzinfo):
21452180
__slots__ = '_offset', '_name'
21462181

Lib/test/datetimetester.py

+76
Original file line numberDiff line numberDiff line change
@@ -1795,6 +1795,82 @@ def test_fromisoformat_fails_typeerror(self):
17951795
with self.assertRaises(TypeError):
17961796
self.theclass.fromisoformat(bad_type)
17971797

1798+
def test_fromisocalendar(self):
1799+
# For each test case, assert that fromisocalendar is the
1800+
# inverse of the isocalendar function
1801+
dates = [
1802+
(2016, 4, 3),
1803+
(2005, 1, 2), # (2004, 53, 7)
1804+
(2008, 12, 30), # (2009, 1, 2)
1805+
(2010, 1, 2), # (2009, 53, 6)
1806+
(2009, 12, 31), # (2009, 53, 4)
1807+
(1900, 1, 1), # Unusual non-leap year (year % 100 == 0)
1808+
(1900, 12, 31),
1809+
(2000, 1, 1), # Unusual leap year (year % 400 == 0)
1810+
(2000, 12, 31),
1811+
(2004, 1, 1), # Leap year
1812+
(2004, 12, 31),
1813+
(1, 1, 1),
1814+
(9999, 12, 31),
1815+
(MINYEAR, 1, 1),
1816+
(MAXYEAR, 12, 31),
1817+
]
1818+
1819+
for datecomps in dates:
1820+
with self.subTest(datecomps=datecomps):
1821+
dobj = self.theclass(*datecomps)
1822+
isocal = dobj.isocalendar()
1823+
1824+
d_roundtrip = self.theclass.fromisocalendar(*isocal)
1825+
1826+
self.assertEqual(dobj, d_roundtrip)
1827+
1828+
def test_fromisocalendar_value_errors(self):
1829+
isocals = [
1830+
(2019, 0, 1),
1831+
(2019, -1, 1),
1832+
(2019, 54, 1),
1833+
(2019, 1, 0),
1834+
(2019, 1, -1),
1835+
(2019, 1, 8),
1836+
(2019, 53, 1),
1837+
(10000, 1, 1),
1838+
(0, 1, 1),
1839+
(9999999, 1, 1),
1840+
(2<<32, 1, 1),
1841+
(2019, 2<<32, 1),
1842+
(2019, 1, 2<<32),
1843+
]
1844+
1845+
for isocal in isocals:
1846+
with self.subTest(isocal=isocal):
1847+
with self.assertRaises(ValueError):
1848+
self.theclass.fromisocalendar(*isocal)
1849+
1850+
def test_fromisocalendar_type_errors(self):
1851+
err_txformers = [
1852+
str,
1853+
float,
1854+
lambda x: None,
1855+
]
1856+
1857+
# Take a valid base tuple and transform it to contain one argument
1858+
# with the wrong type. Repeat this for each argument, e.g.
1859+
# [("2019", 1, 1), (2019, "1", 1), (2019, 1, "1"), ...]
1860+
isocals = []
1861+
base = (2019, 1, 1)
1862+
for i in range(3):
1863+
for txformer in err_txformers:
1864+
err_val = list(base)
1865+
err_val[i] = txformer(err_val[i])
1866+
isocals.append(tuple(err_val))
1867+
1868+
for isocal in isocals:
1869+
with self.subTest(isocal=isocal):
1870+
with self.assertRaises(TypeError):
1871+
self.theclass.fromisocalendar(*isocal)
1872+
1873+
17981874
#############################################################################
17991875
# datetime tests
18001876

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added new alternate constructors :meth:`datetime.date.fromisocalendar` and
2+
:meth:`datetime.datetime.fromisocalendar`, which construct date objects from
3+
ISO year, week number and weekday; these are the inverse of each class's
4+
``isocalendar`` method. Patch by Paul Ganssle.

Modules/_datetimemodule.c

+67
Original file line numberDiff line numberDiff line change
@@ -3003,6 +3003,67 @@ date_fromisoformat(PyObject *cls, PyObject *dtstr)
30033003
return NULL;
30043004
}
30053005

3006+
3007+
static PyObject *
3008+
date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw)
3009+
{
3010+
static char *keywords[] = {
3011+
"year", "week", "day", NULL
3012+
};
3013+
3014+
int year, week, day;
3015+
if (PyArg_ParseTupleAndKeywords(args, kw, "iii:fromisocalendar",
3016+
keywords,
3017+
&year, &week, &day) == 0) {
3018+
if (PyErr_ExceptionMatches(PyExc_OverflowError)) {
3019+
PyErr_Format(PyExc_ValueError,
3020+
"ISO calendar component out of range");
3021+
3022+
}
3023+
return NULL;
3024+
}
3025+
3026+
// Year is bounded to 0 < year < 10000 because 9999-12-31 is (9999, 52, 5)
3027+
if (year < MINYEAR || year > MAXYEAR) {
3028+
PyErr_Format(PyExc_ValueError, "Year is out of range: %d", year);
3029+
return NULL;
3030+
}
3031+
3032+
if (week <= 0 || week >= 53) {
3033+
int out_of_range = 1;
3034+
if (week == 53) {
3035+
// ISO years have 53 weeks in it on years starting with a Thursday
3036+
// and on leap years starting on Wednesday
3037+
int first_weekday = weekday(year, 1, 1);
3038+
if (first_weekday == 3 || (first_weekday == 2 && is_leap(year))) {
3039+
out_of_range = 0;
3040+
}
3041+
}
3042+
3043+
if (out_of_range) {
3044+
PyErr_Format(PyExc_ValueError, "Invalid week: %d", week);
3045+
return NULL;
3046+
}
3047+
}
3048+
3049+
if (day <= 0 || day >= 8) {
3050+
PyErr_Format(PyExc_ValueError, "Invalid day: %d (range is [1, 7])",
3051+
day);
3052+
return NULL;
3053+
}
3054+
3055+
// Convert (Y, W, D) to (Y, M, D) in-place
3056+
int day_1 = iso_week1_monday(year);
3057+
3058+
int month = week;
3059+
int day_offset = (month - 1)*7 + day - 1;
3060+
3061+
ord_to_ymd(day_1 + day_offset, &year, &month, &day);
3062+
3063+
return new_date_subclass_ex(year, month, day, cls);
3064+
}
3065+
3066+
30063067
/*
30073068
* Date arithmetic.
30083069
*/
@@ -3296,6 +3357,12 @@ static PyMethodDef date_methods[] = {
32963357
METH_CLASS,
32973358
PyDoc_STR("str -> Construct a date from the output of date.isoformat()")},
32983359

3360+
{"fromisocalendar", (PyCFunction)(void(*)(void))date_fromisocalendar,
3361+
METH_VARARGS | METH_KEYWORDS | METH_CLASS,
3362+
PyDoc_STR("int, int, int -> Construct a date from the ISO year, week "
3363+
"number and weekday.\n\n"
3364+
"This is the inverse of the date.isocalendar() function")},
3365+
32993366
{"today", (PyCFunction)date_today, METH_NOARGS | METH_CLASS,
33003367
PyDoc_STR("Current date or datetime: same as "
33013368
"self.__class__.fromtimestamp(time.time()).")},

0 commit comments

Comments
 (0)