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

This commit is contained in:
Gregory P. Smith 2024-04-03 05:19:49 -07:00 committed by GitHub
parent 595bb496b0
commit 33ee5cb3e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 117 additions and 1 deletions

View file

@ -10,6 +10,7 @@ FUNCTIONS:
strptime -- Calculates the time struct represented by the passed-in string
"""
import os
import time
import locale
import calendar
@ -250,12 +251,30 @@ class TimeRE(dict):
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):

View file

@ -2793,6 +2793,19 @@ class TestDateTime(TestDate):
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)

View file

@ -277,6 +277,8 @@ class TimeTestCase(unittest.TestCase):
'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 @@ class TimeTestCase(unittest.TestCase):
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))

View file

@ -386,6 +386,16 @@ class TestLongMessage(unittest.TestCase):
'^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'),

View file

@ -332,6 +332,23 @@ class _AssertWarnsContext(_AssertRaisesBaseContext):
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 @@ class TestCase(object):
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