mirror of https://github.com/python/cpython
GH-70647: Deprecate strptime day of month parsing without a year present to avoid leap-year bugs (GH-117107)
This commit is contained in:
parent
595bb496b0
commit
33ee5cb3e9
|
@ -1079,6 +1079,24 @@ Other constructors, all class methods:
|
||||||
time tuple. See also :ref:`strftime-strptime-behavior` and
|
time tuple. See also :ref:`strftime-strptime-behavior` and
|
||||||
:meth:`datetime.fromisoformat`.
|
: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:
|
Class attributes:
|
||||||
|
@ -2657,6 +2675,25 @@ Notes:
|
||||||
for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``,
|
for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``,
|
||||||
``%W``, and ``%V``. Format ``%y`` does require a leading zero.
|
``%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
|
.. rubric:: Footnotes
|
||||||
|
|
||||||
.. [#] If, that is, we ignore the effects of Relativity
|
.. [#] If, that is, we ignore the effects of Relativity
|
||||||
|
|
|
@ -10,6 +10,7 @@ FUNCTIONS:
|
||||||
strptime -- Calculates the time struct represented by the passed-in string
|
strptime -- Calculates the time struct represented by the passed-in string
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import locale
|
import locale
|
||||||
import calendar
|
import calendar
|
||||||
|
@ -250,12 +251,30 @@ class TimeRE(dict):
|
||||||
format = regex_chars.sub(r"\\\1", format)
|
format = regex_chars.sub(r"\\\1", format)
|
||||||
whitespace_replacement = re_compile(r'\s+')
|
whitespace_replacement = re_compile(r'\s+')
|
||||||
format = whitespace_replacement.sub(r'\\s+', format)
|
format = whitespace_replacement.sub(r'\\s+', format)
|
||||||
|
year_in_format = False
|
||||||
|
day_of_month_in_format = False
|
||||||
while '%' in format:
|
while '%' in format:
|
||||||
directive_index = format.index('%')+1
|
directive_index = format.index('%')+1
|
||||||
|
format_char = format[directive_index]
|
||||||
processed_format = "%s%s%s" % (processed_format,
|
processed_format = "%s%s%s" % (processed_format,
|
||||||
format[:directive_index-1],
|
format[:directive_index-1],
|
||||||
self[format[directive_index]])
|
self[format_char])
|
||||||
format = format[directive_index+1:]
|
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)
|
return "%s%s" % (processed_format, format)
|
||||||
|
|
||||||
def compile(self, format):
|
def compile(self, format):
|
||||||
|
|
|
@ -2793,6 +2793,19 @@ class TestDateTime(TestDate):
|
||||||
newdate = strptime(string, format)
|
newdate = strptime(string, format)
|
||||||
self.assertEqual(newdate, target, msg=reason)
|
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):
|
def test_more_timetuple(self):
|
||||||
# This tests fields beyond those tested by the TestDate.test_timetuple.
|
# This tests fields beyond those tested by the TestDate.test_timetuple.
|
||||||
t = self.theclass(2004, 12, 31, 6, 22, 33)
|
t = self.theclass(2004, 12, 31, 6, 22, 33)
|
||||||
|
|
|
@ -277,6 +277,8 @@ class TimeTestCase(unittest.TestCase):
|
||||||
'j', 'm', 'M', 'p', 'S',
|
'j', 'm', 'M', 'p', 'S',
|
||||||
'U', 'w', 'W', 'x', 'X', 'y', 'Y', 'Z', '%'):
|
'U', 'w', 'W', 'x', 'X', 'y', 'Y', 'Z', '%'):
|
||||||
format = '%' + directive
|
format = '%' + directive
|
||||||
|
if directive == 'd':
|
||||||
|
format += ',%Y' # Avoid GH-70647.
|
||||||
strf_output = time.strftime(format, tt)
|
strf_output = time.strftime(format, tt)
|
||||||
try:
|
try:
|
||||||
time.strptime(strf_output, format)
|
time.strptime(strf_output, format)
|
||||||
|
@ -299,6 +301,12 @@ class TimeTestCase(unittest.TestCase):
|
||||||
time.strptime('19', '%Y %')
|
time.strptime('19', '%Y %')
|
||||||
self.assertIs(e.exception.__suppress_context__, True)
|
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):
|
def test_asctime(self):
|
||||||
time.asctime(time.gmtime(self.t))
|
time.asctime(time.gmtime(self.t))
|
||||||
|
|
||||||
|
|
|
@ -386,6 +386,16 @@ class TestLongMessage(unittest.TestCase):
|
||||||
'^UserWarning not triggered$',
|
'^UserWarning not triggered$',
|
||||||
'^UserWarning not triggered : oops$'])
|
'^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):
|
def testAssertWarnsRegex(self):
|
||||||
# test error not raised
|
# test error not raised
|
||||||
self.assertMessagesCM('assertWarnsRegex', (UserWarning, 'unused regex'),
|
self.assertMessagesCM('assertWarnsRegex', (UserWarning, 'unused regex'),
|
||||||
|
|
|
@ -332,6 +332,23 @@ class _AssertWarnsContext(_AssertRaisesBaseContext):
|
||||||
self._raiseFailure("{} not triggered".format(exc_name))
|
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):
|
class _OrderedChainMap(collections.ChainMap):
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
seen = set()
|
seen = set()
|
||||||
|
@ -811,6 +828,11 @@ class TestCase(object):
|
||||||
context = _AssertWarnsContext(expected_warning, self)
|
context = _AssertWarnsContext(expected_warning, self)
|
||||||
return context.handle('assertWarns', args, kwargs)
|
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):
|
def assertLogs(self, logger=None, level=None):
|
||||||
"""Fail unless a log message of level *level* or higher is emitted
|
"""Fail unless a log message of level *level* or higher is emitted
|
||||||
on *logger_name* or its children. If omitted, *level* defaults to
|
on *logger_name* or its children. If omitted, *level* defaults to
|
||||||
|
|
|
@ -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.
|
Loading…
Reference in New Issue