Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bpo-36004: Add date.fromisocalendar #11888

Merged
merged 14 commits into from
Apr 29, 2019
Merged
17 changes: 17 additions & 0 deletions Doc/library/datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,13 @@ Other constructors, all class methods:
.. versionadded:: 3.7


.. classmethod:: date.fromisocalendar(year, week, day)

Return a :class:`date` corresponding to the ISO calendar date specified by
year, week and day. This is the inverse of the function :meth:`date.isocalendar`.

.. versionadded:: 3.8


Class attributes:

Expand Down Expand Up @@ -854,6 +861,16 @@ Other constructors, all class methods:

.. versionadded:: 3.7


.. classmethod:: datetime.fromisocalendar(year, week, day)

Return a :class:`datetime` corresponding to the ISO calendar date specified
by year, week and day. The non-date components of the datetime are populated
with their normal default values. This is the inverse of the function
:meth:`datetime.isocalendar`.

.. versionadded:: 3.8

.. classmethod:: datetime.strptime(date_string, format)

Return a :class:`.datetime` corresponding to *date_string*, parsed according to
Expand Down
10 changes: 10 additions & 0 deletions Doc/whatsnew/3.8.rst
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,16 @@ where the DLL is stored (if a full or partial path is used to load the initial
DLL) and paths added by :func:`~os.add_dll_directory`.


datetime
--------

Added new alternate constructors :meth:`datetime.date.fromisocalendar` and
:meth:`datetime.datetime.fromisocalendar`, which construct :class:`date` and
:class:`datetime` objects respectively from ISO year, week number and weekday;
these are the inverse of each class's ``isocalendar`` method.
(Contributed by Paul Ganssle in :issue:`36004`.)


gettext
-------

Expand Down
35 changes: 35 additions & 0 deletions Lib/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,40 @@ def fromisoformat(cls, date_string):
except Exception:
raise ValueError(f'Invalid isoformat string: {date_string!r}')

@classmethod
def fromisocalendar(cls, year, week, day):
"""Construct a date from the ISO year, week number and weekday.

This is the inverse of the date.isocalendar() function"""
# Year is bounded this way because 9999-12-31 is (9999, 52, 5)
if not MINYEAR <= year <= MAXYEAR:
raise ValueError(f"Year is out of range: {year}")

if not 0 < week < 53:
out_of_range = True

if week == 53:
# ISO years have 53 weeks in them on years starting with a
# Thursday and leap years starting on a Wednesday
first_weekday = _ymd2ord(year, 1, 1) % 7
if (first_weekday == 4 or (first_weekday == 3 and
_is_leap(year))):
out_of_range = False

if out_of_range:
raise ValueError(f"Invalid week: {week}")

if not 0 < day < 8:
raise ValueError(f"Invalid weekday: {day} (range is [1, 7])")

# Now compute the offset from (Y, 1, 1) in days:
day_offset = (week - 1) * 7 + (day - 1)

# Calculate the ordinal day for monday, week 1
day_1 = _isoweek1monday(year)
ord_day = day_1 + day_offset

return cls(*_ord2ymd(ord_day))

# Conversions to string

Expand Down Expand Up @@ -2141,6 +2175,7 @@ def _isoweek1monday(year):
week1monday += 7
return week1monday


class timezone(tzinfo):
__slots__ = '_offset', '_name'

Expand Down
76 changes: 76 additions & 0 deletions Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -1795,6 +1795,82 @@ def test_fromisoformat_fails_typeerror(self):
with self.assertRaises(TypeError):
self.theclass.fromisoformat(bad_type)

def test_fromisocalendar(self):
# For each test case, assert that fromisocalendar is the
# inverse of the isocalendar function
dates = [
(2016, 4, 3),
(2005, 1, 2), # (2004, 53, 7)
(2008, 12, 30), # (2009, 1, 2)
(2010, 1, 2), # (2009, 53, 6)
(2009, 12, 31), # (2009, 53, 4)
(1900, 1, 1), # Unusual non-leap year (year % 100 == 0)
(1900, 12, 31),
(2000, 1, 1), # Unusual leap year (year % 400 == 0)
(2000, 12, 31),
(2004, 1, 1), # Leap year
(2004, 12, 31),
(1, 1, 1),
(9999, 12, 31),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use MINYEAR/MAXYEAR here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of converting these over, I have just added a MINYEAR and MAXYEAR test. Even though it's redundant, I like to have the explicit callout of the current boundaries, so that if MINYEAR or MAXYEAR get modified in a way that breaks backwards compatibility, it will raise an error.

(MINYEAR, 1, 1),
(MAXYEAR, 12, 31),
]

for datecomps in dates:
with self.subTest(datecomps=datecomps):
dobj = self.theclass(*datecomps)
isocal = dobj.isocalendar()

d_roundtrip = self.theclass.fromisocalendar(*isocal)

self.assertEqual(dobj, d_roundtrip)

def test_fromisocalendar_value_errors(self):
isocals = [
(2019, 0, 1),
(2019, -1, 1),
(2019, 54, 1),
(2019, 1, 0),
(2019, 1, -1),
(2019, 1, 8),
(2019, 53, 1),
(10000, 1, 1),
(0, 1, 1),
(9999999, 1, 1),
(2<<32, 1, 1),
(2019, 2<<32, 1),
(2019, 1, 2<<32),
]

for isocal in isocals:
with self.subTest(isocal=isocal):
with self.assertRaises(ValueError):
self.theclass.fromisocalendar(*isocal)

def test_fromisocalendar_type_errors(self):
err_txformers = [
str,
float,
lambda x: None,
]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to use 3 loops to test all combinations, rather than generate these combinations manualy?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I've converted it over. I think it's a little harder to understand what's going on in the version where the test cases are generated (compared to manual), but using a loop also has its advantages.


# Take a valid base tuple and transform it to contain one argument
# with the wrong type. Repeat this for each argument, e.g.
# [("2019", 1, 1), (2019, "1", 1), (2019, 1, "1"), ...]
isocals = []
base = (2019, 1, 1)
for i in range(3):
for txformer in err_txformers:
err_val = list(base)
err_val[i] = txformer(err_val[i])
isocals.append(tuple(err_val))

for isocal in isocals:
with self.subTest(isocal=isocal):
with self.assertRaises(TypeError):
self.theclass.fromisocalendar(*isocal)


#############################################################################
# datetime tests

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Added new alternate constructors :meth:`datetime.date.fromisocalendar` and
:meth:`datetime.datetime.fromisocalendar`, which construct date objects from
ISO year, week number and weekday; these are the inverse of each class's
``isocalendar`` method. Patch by Paul Ganssle.
67 changes: 67 additions & 0 deletions Modules/_datetimemodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -3003,6 +3003,67 @@ date_fromisoformat(PyObject *cls, PyObject *dtstr)
return NULL;
}


static PyObject *
date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw)
{
static char *keywords[] = {
"year", "week", "day", NULL
};

int year, week, day;
if (PyArg_ParseTupleAndKeywords(args, kw, "iii:fromisocalendar",
keywords,
&year, &week, &day) == 0) {
if (PyErr_ExceptionMatches(PyExc_OverflowError)) {
PyErr_Format(PyExc_ValueError,
"ISO calendar component out of range");

}
return NULL;
}

// Year is bounded to 0 < year < 10000 because 9999-12-31 is (9999, 52, 5)
if (year < MINYEAR || year > MAXYEAR) {
PyErr_Format(PyExc_ValueError, "Year is out of range: %d", year);
return NULL;
}

if (week <= 0 || week >= 53) {
int out_of_range = 1;
if (week == 53) {
// ISO years have 53 weeks in it on years starting with a Thursday
// and on leap years starting on Wednesday
int first_weekday = weekday(year, 1, 1);
if (first_weekday == 3 || (first_weekday == 2 && is_leap(year))) {
out_of_range = 0;
}
}

if (out_of_range) {
PyErr_Format(PyExc_ValueError, "Invalid week: %d", week);
return NULL;
}
}

if (day <= 0 || day >= 8) {
PyErr_Format(PyExc_ValueError, "Invalid day: %d (range is [1, 7])",
day);
return NULL;
}

// Convert (Y, W, D) to (Y, M, D) in-place
int day_1 = iso_week1_monday(year);

int month = week;
int day_offset = (month - 1)*7 + day - 1;

ord_to_ymd(day_1 + day_offset, &year, &month, &day);

return new_date_subclass_ex(year, month, day, cls);
}


/*
* Date arithmetic.
*/
Expand Down Expand Up @@ -3296,6 +3357,12 @@ static PyMethodDef date_methods[] = {
METH_CLASS,
PyDoc_STR("str -> Construct a date from the output of date.isoformat()")},

{"fromisocalendar", (PyCFunction)(void(*)(void))date_fromisocalendar,
METH_VARARGS | METH_KEYWORDS | METH_CLASS,
PyDoc_STR("int, int, int -> Construct a date from the ISO year, week "
"number and weekday.\n\n"
"This is the inverse of the date.isocalendar() function")},

{"today", (PyCFunction)date_today, METH_NOARGS | METH_CLASS,
PyDoc_STR("Current date or datetime: same as "
"self.__class__.fromtimestamp(time.time()).")},
Expand Down