From 4b929e4c3a7ab8ff13c08caac06ed10a341816a4 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Wed, 20 Mar 2024 16:10:35 -0700 Subject: [PATCH 1/9] Deprecate strptime leap-year ambiguous MM-DD parsing. --- Lib/_strptime.py | 22 +++++++++++++++++- Lib/test/datetimetester.py | 13 +++++++++++ Lib/test/test_time.py | 8 +++++++ Lib/unittest/case.py | 23 +++++++++++++++++++ ...4-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst | 7 ++++++ 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2024-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst diff --git a/Lib/_strptime.py b/Lib/_strptime.py index 798cf9f9d3fffe..596ce0b5e41af1 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -250,12 +250,32 @@ def pattern(self, format): format = regex_chars.sub(r"\\\1", format) whitespace_replacement = re_compile(r'\s+') format = whitespace_replacement.sub(r'\\s+', format) + year_in_format = False + day_of_month_in_format = False while '%' in format: directive_index = format.index('%')+1 + format_char = format[directive_index] processed_format = "%s%s%s" % (processed_format, format[:directive_index-1], - self[format[directive_index]]) + self[format_char]) format = format[directive_index+1:] + match format_char: + case 'Y' | 'y' | 'G': + year_in_format = True + case 'd': + day_of_month_in_format = True + if day_of_month_in_format and not year_in_format: + import warnings + warnings.warn("""\ +Parsing dates involving a day of month without a year specified is ambiguious +and fails to parse leap day. The default behavior will change in Python 3.15 +to either always raise an exception or to use a different default year (TBD). +To avoid trouble, add a specific year to the input & format. For example: + strptime(f"{mm_dd},{datetime.now().year}", "%m-%d,%y") +See https://github.com/python/cpython/issues/70647.""", + DeprecationWarning, + # -> .compile -> {time,datetime}.strptime -> User + stacklevel=3) return "%s%s" % (processed_format, format) def compile(self, format): diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 31fc383e29707a..c77263998c99f5 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -2793,6 +2793,19 @@ def test_strptime_single_digit(self): newdate = strptime(string, format) self.assertEqual(newdate, target, msg=reason) + def test_strptime_leap_year(self): + # GH-70647: warns if parsing a format with a day and no year. + with self.assertRaises(ValueError): + # The existing behavior that GH-70647 seeks to change. + self.theclass.strptime('02-29', '%m-%d') + with self.assertWarnsRegex(DeprecationWarning, + r'.*day of month without a year.*'): + self.theclass.strptime('03-14.159265', '%m-%d.%f') + with self._assertNotWarns(DeprecationWarning): + self.theclass.strptime('20-03-14.159265', '%y-%m-%d.%f') + with self._assertNotWarns(DeprecationWarning): + self.theclass.strptime('02-29,2024', '%m-%d,%Y') + def test_more_timetuple(self): # This tests fields beyond those tested by the TestDate.test_timetuple. t = self.theclass(2004, 12, 31, 6, 22, 33) diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index fb234b7bc5962a..293799ff68ea05 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -277,6 +277,8 @@ def test_strptime(self): 'j', 'm', 'M', 'p', 'S', 'U', 'w', 'W', 'x', 'X', 'y', 'Y', 'Z', '%'): format = '%' + directive + if directive == 'd': + format += ',%Y' # Avoid GH-70647. strf_output = time.strftime(format, tt) try: time.strptime(strf_output, format) @@ -299,6 +301,12 @@ def test_strptime_exception_context(self): time.strptime('19', '%Y %') self.assertIs(e.exception.__suppress_context__, True) + def test_strptime_leap_year(self): + # GH-70647: warns if parsing a format with a day and no year. + with self.assertWarnsRegex(DeprecationWarning, + r'.*day of month without a year.*'): + time.strptime('02-07 18:28', '%m-%d %H:%M') + def test_asctime(self): time.asctime(time.gmtime(self.t)) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index 001b640dc43ad6..ad033630d5520d 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -332,6 +332,24 @@ def __exit__(self, exc_type, exc_value, tb): self._raiseFailure("{} not triggered".format(exc_name)) +class _AssertNotWarnsContext(_AssertWarnsContext): + + def __exit__(self, exc_type, exc_value, tb): + self.warnings_manager.__exit__(exc_type, exc_value, tb) + if exc_type is not None: + # let unexpected exceptions pass through + return + try: + exc_name = self.expected.__name__ + except AttributeError: + exc_name = str(self.expected) + for m in self.warnings: + w = m.message + if isinstance(w, self.expected): + self._raiseFailure( + f"{exc_name} triggered by {self.obj_namel}: {w}") + + class _OrderedChainMap(collections.ChainMap): def __iter__(self): seen = set() @@ -811,6 +829,11 @@ def assertWarns(self, expected_warning, *args, **kwargs): context = _AssertWarnsContext(expected_warning, self) return context.handle('assertWarns', args, kwargs) + def _assertNotWarns(self, expected_warning, *args, **kwargs): + """The opposite of assertWarns. Private due to low demand.""" + context = _AssertNotWarnsContext(expected_warning, self) + return context.handle('_assertNotWarns', args, kwargs) + def assertLogs(self, logger=None, level=None): """Fail unless a log message of level *level* or higher is emitted on *logger_name* or its children. If omitted, *level* defaults to diff --git a/Misc/NEWS.d/next/Library/2024-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst b/Misc/NEWS.d/next/Library/2024-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst new file mode 100644 index 00000000000000..a9094df06037cd --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst @@ -0,0 +1,7 @@ +Start the deprecation period for the current behavior of +:func:`datetime.datetime.strptime` and :func:`time.strptime` which always +fails to parse a date string with a :exc:`ValueError` involving a day of +month such as ``strptime("02-29", "%m-%d")`` when a year is **not** +specified and the date happen to be February 29th. This should help avoid +users finding new bugs every four years due to a natural mistaken assumption +about the API when parsing partial date values. From b0eacf9db3bf7e6cb1a1d7e4db1068f3402c967e Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Wed, 20 Mar 2024 16:18:04 -0700 Subject: [PATCH 2/9] Fix the stacklevel and remove example from the warning message. --- Lib/_strptime.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Lib/_strptime.py b/Lib/_strptime.py index 596ce0b5e41af1..323c976c5cffd1 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -270,12 +270,10 @@ def pattern(self, format): Parsing dates involving a day of month without a year specified is ambiguious and fails to parse leap day. The default behavior will change in Python 3.15 to either always raise an exception or to use a different default year (TBD). -To avoid trouble, add a specific year to the input & format. For example: - strptime(f"{mm_dd},{datetime.now().year}", "%m-%d,%y") +To avoid trouble, add a specific year to the input & format. See https://github.com/python/cpython/issues/70647.""", DeprecationWarning, - # -> .compile -> {time,datetime}.strptime -> User - stacklevel=3) + stacklevel=5) return "%s%s" % (processed_format, format) def compile(self, format): From 5851b0be1b2d27d7427b7107959331fd4caf7564 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Wed, 20 Mar 2024 16:28:37 -0700 Subject: [PATCH 3/9] Simplify _assertNotWarns error message, removing a typo. --- Lib/unittest/case.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index ad033630d5520d..9ff624631daea2 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -346,8 +346,7 @@ def __exit__(self, exc_type, exc_value, tb): for m in self.warnings: w = m.message if isinstance(w, self.expected): - self._raiseFailure( - f"{exc_name} triggered by {self.obj_namel}: {w}") + self._raiseFailure(f"{exc_name} triggered: {w}") class _OrderedChainMap(collections.ChainMap): From a068dac1763e4aa832f0dbe2944c2185bd3822ff Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Wed, 20 Mar 2024 16:44:33 -0700 Subject: [PATCH 4/9] add a unittest for _assertNotWarns. --- Lib/test/test_unittest/test_assertions.py | 10 ++++++++++ Lib/unittest/case.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_unittest/test_assertions.py b/Lib/test/test_unittest/test_assertions.py index 5c1a28ecda5b49..1dec947ea76d23 100644 --- a/Lib/test/test_unittest/test_assertions.py +++ b/Lib/test/test_unittest/test_assertions.py @@ -386,6 +386,16 @@ def testAssertWarns(self): '^UserWarning not triggered$', '^UserWarning not triggered : oops$']) + def test_assertNotWarns(self): + def warn_future(): + warnings.warn('xyz', FutureWarning, stacklevel=2) + self.assertMessagesCM('_assertNotWarns', (FutureWarning,), + warn_future, + ['^FutureWarning triggered$', + '^oops$', + '^FutureWarning triggered$', + '^FutureWarning triggered : oops$']) + def testAssertWarnsRegex(self): # test error not raised self.assertMessagesCM('assertWarnsRegex', (UserWarning, 'unused regex'), diff --git a/Lib/unittest/case.py b/Lib/unittest/case.py index 9ff624631daea2..36daa61fa31adb 100644 --- a/Lib/unittest/case.py +++ b/Lib/unittest/case.py @@ -346,7 +346,7 @@ def __exit__(self, exc_type, exc_value, tb): for m in self.warnings: w = m.message if isinstance(w, self.expected): - self._raiseFailure(f"{exc_name} triggered: {w}") + self._raiseFailure(f"{exc_name} triggered") class _OrderedChainMap(collections.ChainMap): From 333ba5789d638650cf4ec41c47c29ce22d43aaa5 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Thu, 28 Mar 2024 17:57:48 -0700 Subject: [PATCH 5/9] Document the behavior deprecation. --- Doc/library/datetime.rst | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 1905c9e1ca755d..63aec66da5a7e7 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -1079,6 +1079,24 @@ Other constructors, all class methods: time tuple. See also :ref:`strftime-strptime-behavior` and :meth:`datetime.fromisoformat`. + .. versionchanged:: 3.13 + + If *format* specifies a day of month without a year a + :exc:`DeprecationWarning` is now emitted. This is to avoid a quadrennial + leap year bug in code seeking to parse only a month and day as the + default year used in absence of one in the format is not a leap year. + Such *format* values may raise an error as of Python 3.15. The + workaround is to always include a year in your *format*. If parsing + *date_string* values that do not have a year, explicitly add a year that + is a leap year before parsing: + + .. doctest:: + + >>> from datetime import datetime + >>> date_string = "02/29" + >>> when = datetime.strptime(f"{date_string};1984", "%m/%d;%Y") + >>> when.strftime("%B %d") # doctest: +SKIP + 'February 29' Class attributes: @@ -2657,6 +2675,21 @@ Notes: for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``, ``%W``, and ``%V``. Format ``%y`` does require a leading zero. +(10) + When parsing a month and day using :meth:`~.datetime.strptime`, always + include a year in the format. If the value you need to parse lacks a year, + just append an explicit dummy leap year. Otherwise your code will raise an + exception when it encounters leap day as the default year used by the parser + is not a leap year. Users run into this bug every four years... + + >>> strptime(f"{month_day};1984", "%m/%d;%Y") # problem mitigated! + + .. deprecated-removed:: 3.13 3.15 + :meth:`~.datetime.strptime` calls using a format string containing + a day of month without a year now emit a + :exc:`DeprecationWarning`. In 3.15 or later we may change this into + an error or change the default year to a leapyear. See :gh:`70647`. + .. rubric:: Footnotes .. [#] If, that is, we ignore the effects of Relativity From b12e7e5b387a8507c7d6ea807c079cfe8a141d5d Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Mon, 1 Apr 2024 16:31:59 -0700 Subject: [PATCH 6/9] minor doc cleanup. --- Doc/library/datetime.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 63aec66da5a7e7..20c46458df82e2 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2678,9 +2678,9 @@ Notes: (10) When parsing a month and day using :meth:`~.datetime.strptime`, always include a year in the format. If the value you need to parse lacks a year, - just append an explicit dummy leap year. Otherwise your code will raise an - exception when it encounters leap day as the default year used by the parser - is not a leap year. Users run into this bug every four years... + append an explicit dummy leap year. Otherwise your code will raise an + exception when it encounters leap day because the default year used by the + parser is not a leap year. Users run into this bug every four years... >>> strptime(f"{month_day};1984", "%m/%d;%Y") # problem mitigated! @@ -2688,7 +2688,7 @@ Notes: :meth:`~.datetime.strptime` calls using a format string containing a day of month without a year now emit a :exc:`DeprecationWarning`. In 3.15 or later we may change this into - an error or change the default year to a leapyear. See :gh:`70647`. + an error or change the default year to a leap year. See :gh:`70647`. .. rubric:: Footnotes From d4c994eb1a378198b540cb7688cf6d46cc0c2b4f Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Mon, 1 Apr 2024 16:34:50 -0700 Subject: [PATCH 7/9] adjust example code comments. To passively encourage people to include such comments in their own code when adopting this mitigation... --- Doc/library/datetime.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 20c46458df82e2..7e599dd98913fe 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -1094,7 +1094,7 @@ Other constructors, all class methods: >>> from datetime import datetime >>> date_string = "02/29" - >>> when = datetime.strptime(f"{date_string};1984", "%m/%d;%Y") + >>> when = datetime.strptime(f"{date_string};1984", "%m/%d;%Y") # Avoids leap year bug. >>> when.strftime("%B %d") # doctest: +SKIP 'February 29' @@ -2682,7 +2682,7 @@ Notes: exception when it encounters leap day because the default year used by the parser is not a leap year. Users run into this bug every four years... - >>> strptime(f"{month_day};1984", "%m/%d;%Y") # problem mitigated! + >>> strptime(f"{month_day};1984", "%m/%d;%Y") # No leap year bug. .. deprecated-removed:: 3.13 3.15 :meth:`~.datetime.strptime` calls using a format string containing From 92e30b81d8c12a4418792ef237dd01dccb803b96 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 2 Apr 2024 11:56:45 +0200 Subject: [PATCH 8/9] Fix doctest --- Doc/library/datetime.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 7e599dd98913fe..047427d3269027 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2682,7 +2682,11 @@ Notes: exception when it encounters leap day because the default year used by the parser is not a leap year. Users run into this bug every four years... - >>> strptime(f"{month_day};1984", "%m/%d;%Y") # No leap year bug. + .. doctest:: + + >>> month_day = "02/29" + >>> datetime.strptime(f"{month_day};1984", "%m/%d;%Y") # No leap year bug. + datetime.datetime(1984, 2, 29, 0, 0) .. deprecated-removed:: 3.13 3.15 :meth:`~.datetime.strptime` calls using a format string containing From f1bd314ce7dc430775365838376b44da27e100c3 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 3 Apr 2024 13:28:16 +0200 Subject: [PATCH 9/9] Use skip_file_prefixes for the warning --- Lib/_strptime.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/_strptime.py b/Lib/_strptime.py index 323c976c5cffd1..e42af75af74bf5 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -10,6 +10,7 @@ strptime -- Calculates the time struct represented by the passed-in string """ +import os import time import locale import calendar @@ -273,7 +274,7 @@ def pattern(self, format): To avoid trouble, add a specific year to the input & format. See https://github.com/python/cpython/issues/70647.""", DeprecationWarning, - stacklevel=5) + skip_file_prefixes=(os.path.dirname(__file__),)) return "%s%s" % (processed_format, format) def compile(self, format):