Skip to content

Commit 33ee5cb

Browse files
authored
GH-70647: Deprecate strptime day of month parsing without a year present to avoid leap-year bugs (GH-117107)
1 parent 595bb49 commit 33ee5cb

File tree

7 files changed

+117
-1
lines changed

7 files changed

+117
-1
lines changed

Doc/library/datetime.rst

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,24 @@ Other constructors, all class methods:
10791079
time tuple. See also :ref:`strftime-strptime-behavior` and
10801080
:meth:`datetime.fromisoformat`.
10811081

1082+
.. versionchanged:: 3.13
1083+
1084+
If *format* specifies a day of month without a year a
1085+
:exc:`DeprecationWarning` is now emitted. This is to avoid a quadrennial
1086+
leap year bug in code seeking to parse only a month and day as the
1087+
default year used in absence of one in the format is not a leap year.
1088+
Such *format* values may raise an error as of Python 3.15. The
1089+
workaround is to always include a year in your *format*. If parsing
1090+
*date_string* values that do not have a year, explicitly add a year that
1091+
is a leap year before parsing:
1092+
1093+
.. doctest::
1094+
1095+
>>> from datetime import datetime
1096+
>>> date_string = "02/29"
1097+
>>> when = datetime.strptime(f"{date_string};1984", "%m/%d;%Y") # Avoids leap year bug.
1098+
>>> when.strftime("%B %d") # doctest: +SKIP
1099+
'February 29'
10821100

10831101

10841102
Class attributes:
@@ -2657,6 +2675,25 @@ Notes:
26572675
for formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``,
26582676
``%W``, and ``%V``. Format ``%y`` does require a leading zero.
26592677

2678+
(10)
2679+
When parsing a month and day using :meth:`~.datetime.strptime`, always
2680+
include a year in the format. If the value you need to parse lacks a year,
2681+
append an explicit dummy leap year. Otherwise your code will raise an
2682+
exception when it encounters leap day because the default year used by the
2683+
parser is not a leap year. Users run into this bug every four years...
2684+
2685+
.. doctest::
2686+
2687+
>>> month_day = "02/29"
2688+
>>> datetime.strptime(f"{month_day};1984", "%m/%d;%Y") # No leap year bug.
2689+
datetime.datetime(1984, 2, 29, 0, 0)
2690+
2691+
.. deprecated-removed:: 3.13 3.15
2692+
:meth:`~.datetime.strptime` calls using a format string containing
2693+
a day of month without a year now emit a
2694+
:exc:`DeprecationWarning`. In 3.15 or later we may change this into
2695+
an error or change the default year to a leap year. See :gh:`70647`.
2696+
26602697
.. rubric:: Footnotes
26612698

26622699
.. [#] If, that is, we ignore the effects of Relativity

Lib/_strptime.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
strptime -- Calculates the time struct represented by the passed-in string
1111
1212
"""
13+
import os
1314
import time
1415
import locale
1516
import calendar
@@ -250,12 +251,30 @@ def pattern(self, format):
250251
format = regex_chars.sub(r"\\\1", format)
251252
whitespace_replacement = re_compile(r'\s+')
252253
format = whitespace_replacement.sub(r'\\s+', format)
254+
year_in_format = False
255+
day_of_month_in_format = False
253256
while '%' in format:
254257
directive_index = format.index('%')+1
258+
format_char = format[directive_index]
255259
processed_format = "%s%s%s" % (processed_format,
256260
format[:directive_index-1],
257-
self[format[directive_index]])
261+
self[format_char])
258262
format = format[directive_index+1:]
263+
match format_char:
264+
case 'Y' | 'y' | 'G':
265+
year_in_format = True
266+
case 'd':
267+
day_of_month_in_format = True
268+
if day_of_month_in_format and not year_in_format:
269+
import warnings
270+
warnings.warn("""\
271+
Parsing dates involving a day of month without a year specified is ambiguious
272+
and fails to parse leap day. The default behavior will change in Python 3.15
273+
to either always raise an exception or to use a different default year (TBD).
274+
To avoid trouble, add a specific year to the input & format.
275+
See https://github.com/python/cpython/issues/70647.""",
276+
DeprecationWarning,
277+
skip_file_prefixes=(os.path.dirname(__file__),))
259278
return "%s%s" % (processed_format, format)
260279

261280
def compile(self, format):

Lib/test/datetimetester.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2793,6 +2793,19 @@ def test_strptime_single_digit(self):
27932793
newdate = strptime(string, format)
27942794
self.assertEqual(newdate, target, msg=reason)
27952795

2796+
def test_strptime_leap_year(self):
2797+
# GH-70647: warns if parsing a format with a day and no year.
2798+
with self.assertRaises(ValueError):
2799+
# The existing behavior that GH-70647 seeks to change.
2800+
self.theclass.strptime('02-29', '%m-%d')
2801+
with self.assertWarnsRegex(DeprecationWarning,
2802+
r'.*day of month without a year.*'):
2803+
self.theclass.strptime('03-14.159265', '%m-%d.%f')
2804+
with self._assertNotWarns(DeprecationWarning):
2805+
self.theclass.strptime('20-03-14.159265', '%y-%m-%d.%f')
2806+
with self._assertNotWarns(DeprecationWarning):
2807+
self.theclass.strptime('02-29,2024', '%m-%d,%Y')
2808+
27962809
def test_more_timetuple(self):
27972810
# This tests fields beyond those tested by the TestDate.test_timetuple.
27982811
t = self.theclass(2004, 12, 31, 6, 22, 33)

Lib/test/test_time.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,8 @@ def test_strptime(self):
277277
'j', 'm', 'M', 'p', 'S',
278278
'U', 'w', 'W', 'x', 'X', 'y', 'Y', 'Z', '%'):
279279
format = '%' + directive
280+
if directive == 'd':
281+
format += ',%Y' # Avoid GH-70647.
280282
strf_output = time.strftime(format, tt)
281283
try:
282284
time.strptime(strf_output, format)
@@ -299,6 +301,12 @@ def test_strptime_exception_context(self):
299301
time.strptime('19', '%Y %')
300302
self.assertIs(e.exception.__suppress_context__, True)
301303

304+
def test_strptime_leap_year(self):
305+
# GH-70647: warns if parsing a format with a day and no year.
306+
with self.assertWarnsRegex(DeprecationWarning,
307+
r'.*day of month without a year.*'):
308+
time.strptime('02-07 18:28', '%m-%d %H:%M')
309+
302310
def test_asctime(self):
303311
time.asctime(time.gmtime(self.t))
304312

Lib/test/test_unittest/test_assertions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,16 @@ def testAssertWarns(self):
386386
'^UserWarning not triggered$',
387387
'^UserWarning not triggered : oops$'])
388388

389+
def test_assertNotWarns(self):
390+
def warn_future():
391+
warnings.warn('xyz', FutureWarning, stacklevel=2)
392+
self.assertMessagesCM('_assertNotWarns', (FutureWarning,),
393+
warn_future,
394+
['^FutureWarning triggered$',
395+
'^oops$',
396+
'^FutureWarning triggered$',
397+
'^FutureWarning triggered : oops$'])
398+
389399
def testAssertWarnsRegex(self):
390400
# test error not raised
391401
self.assertMessagesCM('assertWarnsRegex', (UserWarning, 'unused regex'),

Lib/unittest/case.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,23 @@ def __exit__(self, exc_type, exc_value, tb):
332332
self._raiseFailure("{} not triggered".format(exc_name))
333333

334334

335+
class _AssertNotWarnsContext(_AssertWarnsContext):
336+
337+
def __exit__(self, exc_type, exc_value, tb):
338+
self.warnings_manager.__exit__(exc_type, exc_value, tb)
339+
if exc_type is not None:
340+
# let unexpected exceptions pass through
341+
return
342+
try:
343+
exc_name = self.expected.__name__
344+
except AttributeError:
345+
exc_name = str(self.expected)
346+
for m in self.warnings:
347+
w = m.message
348+
if isinstance(w, self.expected):
349+
self._raiseFailure(f"{exc_name} triggered")
350+
351+
335352
class _OrderedChainMap(collections.ChainMap):
336353
def __iter__(self):
337354
seen = set()
@@ -811,6 +828,11 @@ def assertWarns(self, expected_warning, *args, **kwargs):
811828
context = _AssertWarnsContext(expected_warning, self)
812829
return context.handle('assertWarns', args, kwargs)
813830

831+
def _assertNotWarns(self, expected_warning, *args, **kwargs):
832+
"""The opposite of assertWarns. Private due to low demand."""
833+
context = _AssertNotWarnsContext(expected_warning, self)
834+
return context.handle('_assertNotWarns', args, kwargs)
835+
814836
def assertLogs(self, logger=None, level=None):
815837
"""Fail unless a log message of level *level* or higher is emitted
816838
on *logger_name* or its children. If omitted, *level* defaults to
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Start the deprecation period for the current behavior of
2+
:func:`datetime.datetime.strptime` and :func:`time.strptime` which always
3+
fails to parse a date string with a :exc:`ValueError` involving a day of
4+
month such as ``strptime("02-29", "%m-%d")`` when a year is **not**
5+
specified and the date happen to be February 29th. This should help avoid
6+
users finding new bugs every four years due to a natural mistaken assumption
7+
about the API when parsing partial date values.

0 commit comments

Comments
 (0)