From 6d323ad6cfa4f4bbda4cae0ebb797f4703d33c9f Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Fri, 15 Feb 2019 18:58:37 -0500 Subject: [PATCH 01/14] Add date.fromisocalendar and tests This commit implements the first version of date.fromisocalendar, the inverse function for date.isocalendar. It is currently missing error checking for the case of of invalid iso dates in week 53. bpo-36004: https://bugs.python.org/issue36004 --- Lib/datetime.py | 16 ++++++++++++ Lib/test/datetimetester.py | 50 ++++++++++++++++++++++++++++++++++++++ Modules/_datetimemodule.c | 47 +++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/Lib/datetime.py b/Lib/datetime.py index 85bfa48e05dea4..8a72da9dee7783 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -884,6 +884,22 @@ def fromisoformat(cls, date_string): except Exception: raise ValueError(f'Invalid isoformat string: {date_string!r}') + @classmethod + def fromisocalendar(cls, year, week, day): + if not 0 < week < 54: + 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 diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 617bf9a25587dc..6cd10f738ecd6d 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1795,6 +1795,56 @@ 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), + (2008, 12, 30), + (2010, 1, 2), + ] + + 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): + class KnownFailure(tuple): + pass + + isocals = [ + (2019, 0, 1), + (2019, -1, 1), + (2019, 54, 1), + (2019, 1, 0), + (2019, 1, -1), + (2019, 1, 8), + KnownFailure([2019, 53, 1]), + ] + + for isocal in isocals: + with self.subTest(isocal=isocal): + with self.assertRaises(ValueError): + known_failure = isinstance(isocal, KnownFailure) + self.theclass.fromisocalendar(*isocal) + + if isinstance(isocal, KnownFailure): + known_failure = False + raise ValueError() + + if known_failure: + raise Exception("XPASS: Known failure condition not met") + + + + + ############################################################################# # datetime tests diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index b3954c98ee7df3..41afa1101f2074 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -845,6 +845,17 @@ parse_isoformat_time(const char *dtstr, size_t dtlen, int *hour, int *minute, return rv ? -5 : 1; } +static void +isocalendar_to_ymd(int* year, int* month_week, int* day) { + // Convert (Y, W, D) to (Y, M, D) in-place + int day_1 = iso_week1_monday(*year); + + int day_offset = (*month_week - 1)*7 + *day - 1; + + ord_to_ymd(day_1 + day_offset, year, month_week, day); +} + + /* --------------------------------------------------------------------------- * Create various objects, mostly without range checking. */ @@ -3003,6 +3014,37 @@ 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) { + return NULL; + } + + if (week <= 0 || week >= 54) { + 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])"); + return NULL; + } + + int month = week; + isocalendar_to_ymd(&year, &month, &day); + + return new_date_subclass_ex(year, month, day, cls); +} + + /* * Date arithmetic. */ @@ -3296,6 +3338,11 @@ 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 " + "output of date.isocalendar()")}, + {"today", (PyCFunction)date_today, METH_NOARGS | METH_CLASS, PyDoc_STR("Current date or datetime: same as " "self.__class__.fromtimestamp(time.time()).")}, From fbab728dfb75723f8ee57f146dbdc31613270470 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 16 Feb 2019 20:13:44 -0500 Subject: [PATCH 02/14] Add early year check to fromisocalendar This avoids an overflow error in ordinal calculations in the C implementation. --- Lib/datetime.py | 4 ++++ Lib/test/datetimetester.py | 6 +++--- Modules/_datetimemodule.c | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Lib/datetime.py b/Lib/datetime.py index 8a72da9dee7783..1e7ffd8e7bcbab 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -886,6 +886,10 @@ def fromisoformat(cls, date_string): @classmethod def fromisocalendar(cls, year, week, day): + # Year is bounded this way because 9999-12-31 is (9999, 52, 5) + if not 0 < year < 10000: + raise ValueError(f"Year is out of range: {year}") + if not 0 < week < 54: raise ValueError(f"Invalid week: {week}") diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 6cd10f738ecd6d..162fe048267ce9 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1826,6 +1826,9 @@ class KnownFailure(tuple): (2019, 1, -1), (2019, 1, 8), KnownFailure([2019, 53, 1]), + (10000, 1, 1), + (0, 1, 1), + (9999999, 1, 1), ] for isocal in isocals: @@ -1842,9 +1845,6 @@ class KnownFailure(tuple): raise Exception("XPASS: Known failure condition not met") - - - ############################################################################# # datetime tests diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 41afa1101f2074..5f2edad50c5937 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3028,6 +3028,12 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) { return NULL; } + // Year is bounded to 0 < year < 10000 because 9999-12-31 is (9999, 52, 5) + if (year <= 0 || year >= 10000) { + PyErr_Format(PyExc_ValueError, "Year is out of range: %d", year); + return NULL; + } + if (week <= 0 || week >= 54) { PyErr_Format(PyExc_ValueError, "Invalid week: %d", week); return NULL; @@ -3038,6 +3044,7 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) { return NULL; } + int month = week; isocalendar_to_ymd(&year, &month, &day); From 925de2f0e167fb09612193c3dafd10bfa765f375 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 16 Feb 2019 20:30:34 -0500 Subject: [PATCH 03/14] Covert OverflowError into ValueError All overflow errors in this context are also value errors, so it is preferable to be uniform in the type of error raised. --- Lib/test/datetimetester.py | 14 +++----------- Modules/_datetimemodule.c | 5 +++++ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 162fe048267ce9..355564e9a9d71f 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1815,9 +1815,6 @@ def test_fromisocalendar(self): self.assertEqual(dobj, d_roundtrip) def test_fromisocalendar_value_errors(self): - class KnownFailure(tuple): - pass - isocals = [ (2019, 0, 1), (2019, -1, 1), @@ -1829,21 +1826,16 @@ class KnownFailure(tuple): (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): - known_failure = isinstance(isocal, KnownFailure) self.theclass.fromisocalendar(*isocal) - if isinstance(isocal, KnownFailure): - known_failure = False - raise ValueError() - - if known_failure: - raise Exception("XPASS: Known failure condition not met") - ############################################################################# # datetime tests diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 5f2edad50c5937..45ec0ca1ae714e 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3025,6 +3025,11 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) { 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; } From 3ecf02e569442465a7ffb6a1ddfd7af3ebbe5f2c Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 16 Feb 2019 21:47:20 -0500 Subject: [PATCH 04/14] Add bounds checking for ISO long vs. short weeks --- Lib/datetime.py | 14 +++++++++++--- Lib/test/datetimetester.py | 2 +- Modules/_datetimemodule.c | 31 ++++++++++++++++--------------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Lib/datetime.py b/Lib/datetime.py index 1e7ffd8e7bcbab..0a9167fbaa72f3 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -890,8 +890,11 @@ def fromisocalendar(cls, year, week, day): if not 0 < year < 10000: raise ValueError(f"Year is out of range: {year}") - if not 0 < week < 54: - raise ValueError(f"Invalid week: {week}") + if not 0 < week < 53: + if not (week == 53 and + _iso_long_year_helper(year) == 4 or + _iso_long_year_helper(year - 1) == 3): + raise ValueError(f"Invalid week: {week}") if not 0 < day < 8: raise ValueError(f"Invalid weekday: {day} (range is [1, 7])") @@ -2150,6 +2153,10 @@ def __reduce__(self): datetime.resolution = timedelta(microseconds=1) +def _iso_long_year_helper(year): + return (year + (year // 4) - (year // 100) + (year // 400)) % 7 + + def _isoweek1monday(year): # Helper to calculate the day number of the Monday starting week 1 # XXX This could be done more efficiently @@ -2161,6 +2168,7 @@ def _isoweek1monday(year): week1monday += 7 return week1monday + class timezone(tzinfo): __slots__ = '_offset', '_name' @@ -2492,7 +2500,7 @@ def _name_from_offset(delta): _format_time, _format_offset, _is_leap, _isoweek1monday, _math, _ord2ymd, _time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord, _divide_and_round, _parse_isoformat_date, _parse_isoformat_time, - _parse_hh_mm_ss_ff) + _parse_hh_mm_ss_ff, _iso_long_year_helper) # XXX Since import * above excludes names that start with _, # docstring does not get overwritten. In the future, it may be # appropriate to maintain a single module level docstring and diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 355564e9a9d71f..e74edd1d690c73 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1822,7 +1822,7 @@ def test_fromisocalendar_value_errors(self): (2019, 1, 0), (2019, 1, -1), (2019, 1, 8), - KnownFailure([2019, 53, 1]), + (2019, 53, 1), (10000, 1, 1), (0, 1, 1), (9999999, 1, 1), diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 45ec0ca1ae714e..9f98f887b70f3a 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -845,17 +845,6 @@ parse_isoformat_time(const char *dtstr, size_t dtlen, int *hour, int *minute, return rv ? -5 : 1; } -static void -isocalendar_to_ymd(int* year, int* month_week, int* day) { - // Convert (Y, W, D) to (Y, M, D) in-place - int day_1 = iso_week1_monday(*year); - - int day_offset = (*month_week - 1)*7 + *day - 1; - - ord_to_ymd(day_1 + day_offset, year, month_week, day); -} - - /* --------------------------------------------------------------------------- * Create various objects, mostly without range checking. */ @@ -3015,6 +3004,10 @@ date_fromisoformat(PyObject *cls, PyObject *dtstr) } +static int _iso_long_year_helper(int year) { + return ((year + (year / 4) - (year / 100) + (year / 400)) % 7); +} + static PyObject * date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) { static char *keywords[] = { @@ -3039,9 +3032,13 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) { return NULL; } - if (week <= 0 || week >= 54) { - PyErr_Format(PyExc_ValueError, "Invalid week: %d", week); - return NULL; + if (week <= 0 || week >= 53) { + if (!(week == 53 && + (_iso_long_year_helper(year) == 4 || + _iso_long_year_helper(year - 1) == 3))) { + PyErr_Format(PyExc_ValueError, "Invalid week: %d", week); + return NULL; + } } if (day <= 0 || day >= 8) { @@ -3049,9 +3046,13 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) { return NULL; } + // Convert (Y, W, D) to (Y, M, D) in-place + int day_1 = iso_week1_monday(year); int month = week; - isocalendar_to_ymd(&year, &month, &day); + 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); } From e645cd00d530652b613d315acef5201182d974f2 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sun, 17 Feb 2019 12:18:11 -0500 Subject: [PATCH 05/14] Add more comments with test cases --- Lib/test/datetimetester.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index e74edd1d690c73..199db66cdbf2d6 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1800,9 +1800,18 @@ def test_fromisocalendar(self): # inverse of the isocalendar function dates = [ (2016, 4, 3), - (2005, 1, 2), - (2008, 12, 30), - (2010, 1, 2), + (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), ] for datecomps in dates: From 86edee5eda5acb9975a6c1b8d20322edab4cc24a Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sun, 17 Feb 2019 12:18:43 -0500 Subject: [PATCH 06/14] Switch to new method of detecting ISO long years This is equivalent but uses only existing helper functions and in many cases will be slightly more efficient. --- Lib/datetime.py | 20 ++++++++++++-------- Modules/_datetimemodule.c | 21 +++++++++++++-------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/Lib/datetime.py b/Lib/datetime.py index 0a9167fbaa72f3..3e2cdc28b21bdd 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -891,9 +891,17 @@ def fromisocalendar(cls, year, week, day): raise ValueError(f"Year is out of range: {year}") if not 0 < week < 53: - if not (week == 53 and - _iso_long_year_helper(year) == 4 or - _iso_long_year_helper(year - 1) == 3): + 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: @@ -2153,10 +2161,6 @@ def __reduce__(self): datetime.resolution = timedelta(microseconds=1) -def _iso_long_year_helper(year): - return (year + (year // 4) - (year // 100) + (year // 400)) % 7 - - def _isoweek1monday(year): # Helper to calculate the day number of the Monday starting week 1 # XXX This could be done more efficiently @@ -2500,7 +2504,7 @@ def _name_from_offset(delta): _format_time, _format_offset, _is_leap, _isoweek1monday, _math, _ord2ymd, _time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord, _divide_and_round, _parse_isoformat_date, _parse_isoformat_time, - _parse_hh_mm_ss_ff, _iso_long_year_helper) + _parse_hh_mm_ss_ff) # XXX Since import * above excludes names that start with _, # docstring does not get overwritten. In the future, it may be # appropriate to maintain a single module level docstring and diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 9f98f887b70f3a..67d8b25feaa1e9 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3004,12 +3004,9 @@ date_fromisoformat(PyObject *cls, PyObject *dtstr) } -static int _iso_long_year_helper(int year) { - return ((year + (year / 4) - (year / 100) + (year / 400)) % 7); -} - static PyObject * -date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) { +date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) +{ static char *keywords[] = { "year", "week", "day", NULL }; @@ -3033,9 +3030,17 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) { } if (week <= 0 || week >= 53) { - if (!(week == 53 && - (_iso_long_year_helper(year) == 4 || - _iso_long_year_helper(year - 1) == 3))) { + 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; } From 74fb523e59fd5273bd1a7c6974abbac2f38ec652 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sun, 17 Feb 2019 12:24:36 -0500 Subject: [PATCH 07/14] Fix missing argument in PyErr_Format --- Modules/_datetimemodule.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 67d8b25feaa1e9..c53736cdb75710 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3047,7 +3047,8 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) } if (day <= 0 || day >= 8) { - PyErr_Format(PyExc_ValueError, "Invalid day: %d (range is [1, 7])"); + PyErr_Format(PyExc_ValueError, "Invalid day: %d (range is [1, 7])", + day); return NULL; } From 46c6458bc0fab491b556b8c8b611b7c1203ff0dc Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sun, 17 Feb 2019 12:30:01 -0500 Subject: [PATCH 08/14] Add test for TypeError handling --- Lib/test/datetimetester.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 199db66cdbf2d6..6eddcfef50a8e9 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1845,6 +1845,24 @@ def test_fromisocalendar_value_errors(self): with self.assertRaises(ValueError): self.theclass.fromisocalendar(*isocal) + def test_fromisocalendar_type_errors(self): + isocals = [ + ("2019", 1, 1), + (2019, "1", 1), + (2019, 1, "1"), + (None, 1, 1), + (2019, None, 1), + (2019, 1, None), + (2019.0, 1, 1), + (2019, 1.0, 1), + (2019, 1, 1.0), + ] + + for isocal in isocals: + with self.subTest(isocal=isocal): + with self.assertRaises(TypeError): + self.theclass.fromisocalendar(*isocal) + ############################################################################# # datetime tests From 28c7fb27b9ba0e3378481631ce53dd9d79498073 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sun, 17 Feb 2019 12:50:29 -0500 Subject: [PATCH 09/14] Add documentation for date(time).fromisocalendar --- Doc/library/datetime.rst | 17 +++++++++++++++++ Lib/datetime.py | 3 +++ Modules/_datetimemodule.c | 5 +++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 1ee23c2175a27d..abdc977354803e 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -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: @@ -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 diff --git a/Lib/datetime.py b/Lib/datetime.py index 3e2cdc28b21bdd..9dbf93d998e941 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -886,6 +886,9 @@ def fromisoformat(cls, date_string): @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 0 < year < 10000: raise ValueError(f"Year is out of range: {year}") diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index c53736cdb75710..411f8700fab8c6 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3359,8 +3359,9 @@ static PyMethodDef date_methods[] = { {"fromisocalendar", (PyCFunction)(void(*)(void))date_fromisocalendar, METH_VARARGS | METH_KEYWORDS | METH_CLASS, - PyDoc_STR("int, int, int -> Construct a date from the " - "output of date.isocalendar()")}, + 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 " From 99e75d7fa09adcd04d069b3c4828cdef85d25442 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sun, 17 Feb 2019 12:59:33 -0500 Subject: [PATCH 10/14] Add NEWS entry --- .../next/Library/2019-02-17-12-55-51.bpo-36004.hCt_KK.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2019-02-17-12-55-51.bpo-36004.hCt_KK.rst diff --git a/Misc/NEWS.d/next/Library/2019-02-17-12-55-51.bpo-36004.hCt_KK.rst b/Misc/NEWS.d/next/Library/2019-02-17-12-55-51.bpo-36004.hCt_KK.rst new file mode 100644 index 00000000000000..d2162be82f706c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2019-02-17-12-55-51.bpo-36004.hCt_KK.rst @@ -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. From c269a5c0c3101535c8818c72b49055c42ae02b4e Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sun, 17 Mar 2019 15:17:58 -0400 Subject: [PATCH 11/14] Add What's New entry for fromisocalendar --- Doc/whatsnew/3.8.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Doc/whatsnew/3.8.rst b/Doc/whatsnew/3.8.rst index 90ff72f67ff434..af8aa7ebebfe78 100644 --- a/Doc/whatsnew/3.8.rst +++ b/Doc/whatsnew/3.8.rst @@ -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 ------- From c8444b70f18a6fb9a157c942a07d61f6c3aea8a0 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 29 Apr 2019 07:59:50 -0400 Subject: [PATCH 12/14] Use MINYEAR and MAXYEAR in bounds checks --- Lib/datetime.py | 2 +- Modules/_datetimemodule.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/datetime.py b/Lib/datetime.py index 9dbf93d998e941..0e64815944dbd7 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -890,7 +890,7 @@ def fromisocalendar(cls, year, week, day): This is the inverse of the date.isocalendar() function""" # Year is bounded this way because 9999-12-31 is (9999, 52, 5) - if not 0 < year < 10000: + if not MINYEAR <= year <= MAXYEAR: raise ValueError(f"Year is out of range: {year}") if not 0 < week < 53: diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 411f8700fab8c6..83e43a24395b45 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3024,7 +3024,7 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) } // Year is bounded to 0 < year < 10000 because 9999-12-31 is (9999, 52, 5) - if (year <= 0 || year >= 10000) { + if (year < MINYEAR || year > MAXYEAR) { PyErr_Format(PyExc_ValueError, "Year is out of range: %d", year); return NULL; } From 958ffff45681f263984ba9999097ef1fc8fdcffd Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 29 Apr 2019 08:00:09 -0400 Subject: [PATCH 13/14] Add tests for MINYEAR and MAXYEAR --- Lib/test/datetimetester.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 6eddcfef50a8e9..9ff5cc8a1b013e 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1812,6 +1812,8 @@ def test_fromisocalendar(self): (2004, 12, 31), (1, 1, 1), (9999, 12, 31), + (MINYEAR, 1, 1), + (MAXYEAR, 12, 31), ] for datecomps in dates: From 3307865cb634370486aa5265076369c262e2bc2d Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 29 Apr 2019 08:00:27 -0400 Subject: [PATCH 14/14] Use generated test cases instead of manual list --- Lib/test/datetimetester.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 9ff5cc8a1b013e..9fe32ebc5b395f 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1848,18 +1848,23 @@ def test_fromisocalendar_value_errors(self): self.theclass.fromisocalendar(*isocal) def test_fromisocalendar_type_errors(self): - isocals = [ - ("2019", 1, 1), - (2019, "1", 1), - (2019, 1, "1"), - (None, 1, 1), - (2019, None, 1), - (2019, 1, None), - (2019.0, 1, 1), - (2019, 1.0, 1), - (2019, 1, 1.0), + err_txformers = [ + str, + float, + lambda x: None, ] + # 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):