Mailing List Archive

GH-70647: Deprecate strptime day of month parsing without a year present to avoid leap-year bugs (GH-117107)
https://github.com/python/cpython/commit/33ee5cb3e92ea8798e7f1a2f3a13b92b39cee6d6
commit: 33ee5cb3e92ea8798e7f1a2f3a13b92b39cee6d6
branch: main
author: Gregory P. Smith <greg@krypto.org>
committer: encukou <encukou@gmail.com>
date: 2024-04-03T14:19:49+02:00
summary:

GH-70647: Deprecate strptime day of month parsing without a year present to avoid leap-year bugs (GH-117107)

files:
A Misc/NEWS.d/next/Library/2024-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst
M Doc/library/datetime.rst
M Lib/_strptime.py
M Lib/test/datetimetester.py
M Lib/test/test_time.py
M Lib/test/test_unittest/test_assertions.py
M Lib/unittest/case.py

diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst
index 1905c9e1ca755d..047427d3269027 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") # Avoids leap year bug.
+ >>> when.strftime("%B %d") # doctest: +SKIP
+ 'February 29'


Class attributes:
@@ -2657,6 +2675,25 @@ 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,
+ 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...
+
+ .. 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
+ 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 leap year. See :gh:`70647`.
+
.. rubric:: Footnotes

.. [#] If, that is, we ignore the effects of Relativity
diff --git a/Lib/_strptime.py b/Lib/_strptime.py
index 798cf9f9d3fffe..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
@@ -250,12 +251,30 @@ 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.
+See https://github.com/python/cpython/issues/70647.""",
+ DeprecationWarning,
+ skip_file_prefixes=(os.path.dirname(__file__),))
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/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 001b640dc43ad6..36daa61fa31adb 100644
--- a/Lib/unittest/case.py
+++ b/Lib/unittest/case.py
@@ -332,6 +332,23 @@ 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")
+
+
class _OrderedChainMap(collections.ChainMap):
def __iter__(self):
seen = set()
@@ -811,6 +828,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.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-leave@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: list-python-checkins@lists.gossamer-threads.com