Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Doc/deprecations/pending-removal-in-3.17.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Pending removal in Python 3.17
------------------------------

* :mod:`datetime`:

* :meth:`~datetime.datetime.strptime` calls using a format string containing
``%e`` (day of month) without a year.
This has been deprecated since Python 3.15.
(Contributed by Stan Ulbrych in :gh:`70647`.)


* :mod:`collections.abc`:

- :class:`collections.abc.ByteString` is scheduled for removal in Python 3.17.
Expand Down
31 changes: 15 additions & 16 deletions Doc/library/datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -606,12 +606,11 @@ Other constructors, all class methods:

.. note::

If *format* specifies a day of month without a year a
:exc:`DeprecationWarning` is emitted. This is to avoid a quadrennial
If *format* specifies a day of month (``%d``) without a year,
:exc:`ValueError` is raised. 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
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:

Expand Down Expand Up @@ -1180,14 +1179,13 @@ Other constructors, all class methods:
time tuple. See also :ref:`strftime-strptime-behavior` and
:meth:`datetime.fromisoformat`.

.. versionchanged:: 3.13
.. versionchanged:: 3.15

If *format* specifies a day of month without a year a
:exc:`DeprecationWarning` is now emitted. This is to avoid a quadrennial
If *format* specifies a day of month (``%d``) without a year,
:exc:`ValueError` is raised. 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
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:

Expand Down Expand Up @@ -2572,13 +2570,13 @@ requires, and these work on all supported platforms.
| | truncated to an integer as a | | |
| | zero-padded decimal number. | | |
+-----------+--------------------------------+------------------------+-------+
| ``%d`` | Day of the month as a | 01, 02, ..., 31 | \(9) |
| | zero-padded decimal number. | | |
| ``%d`` | Day of the month as a | 01, 02, ..., 31 | \(9), |
| | zero-padded decimal number. | | \(10) |
+-----------+--------------------------------+------------------------+-------+
| ``%D`` | Equivalent to ``%m/%d/%y``. | 11/28/25 | \(9) |
| | | | |
+-----------+--------------------------------+------------------------+-------+
| ``%e`` | The day of the month as a | ␣1, ␣2, ..., 31 | |
| ``%e`` | The day of the month as a | ␣1, ␣2, ..., 31 | \(10) |
| | space-padded decimal number. | | |
+-----------+--------------------------------+------------------------+-------+
| ``%F`` | Equivalent to ``%Y-%m-%d``, | 2025-10-11, | |
Expand Down Expand Up @@ -2919,11 +2917,12 @@ Notes:
>>> dt.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
.. versionchanged:: 3.15
Using ``%d`` without a year now raises :exc:`ValueError`.

.. deprecated-removed:: 3.15 3.17
: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`.
``%e`` without a year now emit a :exc:`DeprecationWarning`.

.. rubric:: Footnotes

Expand Down
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,15 @@ collections.abc
deprecated since Python 3.12, and is scheduled for removal in Python 3.17.


datetime
--------

* :meth:`~datetime.datetime.strptime` now raises :exc:`ValueError` when the
format string contains ``%d`` (day of month) without a year directive.
This has been deprecated since Python 3.13.
(Contributed by Stan Ulbrych and Gregory P. Smith in :gh:`70647`.)


ctypes
------

Expand Down
33 changes: 22 additions & 11 deletions Lib/_strptime.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,28 +464,39 @@ def pattern(self, format):
format = re_sub(r'\s+', r'\\s+', format)
format = re_sub(r"'", "['\u02bc]", format) # needed for br_FR
year_in_format = False
day_of_month_in_format = False
day_d_in_format = False
day_e_in_format = False
def repl(m):
directive = m.group()[1:] # exclude `%` symbol
match directive:
case 'Y' | 'y' | 'G':
nonlocal year_in_format
year_in_format = True
case 'd':
nonlocal day_of_month_in_format
day_of_month_in_format = True
nonlocal day_d_in_format
day_d_in_format = True
case 'e':
nonlocal day_e_in_format
day_e_in_format = True
return self[directive]
format = re_sub(r'%[-_0^#]*[0-9]*([OE]?[:\\]?.?)', repl, format)
if day_of_month_in_format and not year_in_format:
import warnings
warnings.warn("""\
if not year_in_format:
if day_d_in_format:
raise ValueError(
"Day of month directive '%d' may not be used without "
"a year directive. Parsing dates involving a day of "
"month without a year is ambiguous and fails to parse "
"leap day. Add a year to the input and format. "
"See https://github.com/python/cpython/issues/70647.")
if day_e_in_format:
import warnings
warnings.warn("""\
Parsing dates involving a day of month without a year specified is ambiguous
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.
and fails to parse leap day. '%e' without a year will become an error in Python 3.17.
To avoid trouble, add a specific year to the input and format.
See https://github.com/python/cpython/issues/70647.""",
DeprecationWarning,
skip_file_prefixes=(os.path.dirname(__file__),))
DeprecationWarning,
skip_file_prefixes=(os.path.dirname(__file__),))
return format

def compile(self, format):
Expand Down
22 changes: 16 additions & 6 deletions Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from test import support
from test.support import is_resource_enabled, ALWAYS_EQ, LARGEST, SMALLEST
from test.support import os_helper, script_helper, warnings_helper
from test.support import os_helper, script_helper

import datetime as datetime_module
from datetime import MINYEAR, MAXYEAR
Expand Down Expand Up @@ -1206,15 +1206,20 @@ def test_strptime_single_digit(self):
newdate = strptime(string, format)
self.assertEqual(newdate, target, msg=reason)

@warnings_helper.ignore_warnings(category=DeprecationWarning)
def test_strptime_leap_year(self):
# GH-70647: warns if parsing a format with a day and no year.
# GH-70647: %d errors if parsing a format with a day and no year.
with self.assertRaises(ValueError):
# The existing behavior that GH-70647 seeks to change.
date.strptime('02-29', '%m-%d')
# %e without a year is deprecated, scheduled for removal in 3.17.
_strptime._regex_cache.clear()
with self.assertWarnsRegex(DeprecationWarning,
r'.*day of month without a year.*'):
date.strptime('02-01', '%m-%e')
with self._assertNotWarns(DeprecationWarning):
date.strptime('20-03-14', '%y-%m-%d')
date.strptime('02-29,2024', '%m-%d,%Y')
date.strptime('02-29,2024', '%m-%e,%Y')

class SubclassDate(date):
sub_var = 1
Expand Down Expand Up @@ -3119,19 +3124,24 @@ def test_strptime_single_digit(self):
newdate = strptime(string, format)
self.assertEqual(newdate, target, msg=reason)

@warnings_helper.ignore_warnings(category=DeprecationWarning)
def test_strptime_leap_year(self):
# GH-70647: warns if parsing a format with a day and no year.
# GH-70647: %d errors 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.assertRaises(ValueError):
self.theclass.strptime('03-14.159265', '%m-%d.%f')
# %e without a year is deprecated, scheduled for removal in 3.17.
_strptime._regex_cache.clear()
with self.assertWarnsRegex(DeprecationWarning,
r'.*day of month without a year.*'):
self.theclass.strptime('03-14.159265', '%m-%d.%f')
self.theclass.strptime('03-14.159265', '%m-%e.%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')
with self._assertNotWarns(DeprecationWarning):
self.theclass.strptime('02-29,2024', '%m-%e,%Y')

def test_strptime_z_empty(self):
for directive in ('z', ':z'):
Expand Down
13 changes: 4 additions & 9 deletions Lib/test/test_strptime.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import platform
import sys
from test import support
from test.support import warnings_helper
from test.support import skip_if_buggy_ucrt_strfptime, run_with_locales
from datetime import date as datetime_date

Expand Down Expand Up @@ -639,15 +638,11 @@ def test_escaping(self):
need_escaping = r".^$*+?{}\[]|)("
self.assertTrue(_strptime._strptime_time(need_escaping, need_escaping))

@warnings_helper.ignore_warnings(category=DeprecationWarning) # gh-70647
def test_feb29_on_leap_year_without_year(self):
time.strptime("Feb 29", "%b %d")

@warnings_helper.ignore_warnings(category=DeprecationWarning) # gh-70647
def test_mar1_comes_after_feb29_even_when_omitting_the_year(self):
self.assertLess(
time.strptime("Feb 29", "%b %d"),
time.strptime("Mar 1", "%b %d"))
with self.assertRaises(ValueError):
time.strptime("Feb 29", "%b %d")
with self.assertRaises(ValueError):
time.strptime("Mar 1", "%b %d")

def test_strptime_F_format(self):
test_date = "2025-10-26"
Expand Down
11 changes: 7 additions & 4 deletions Lib/test/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,11 @@ def test_strptime(self):
# Should be able to go round-trip from strftime to strptime without
# raising an exception.
tt = time.gmtime(self.t)
for directive in ('a', 'A', 'b', 'B', 'c', 'd', 'D', 'F', 'H', 'I',
for directive in ('a', 'A', 'b', 'B', 'c', 'd', 'D', 'e', 'F', 'H', 'I',
'j', 'm', 'M', 'n', 'p', 'S', 't', 'T',
'U', 'w', 'W', 'x', 'X', 'y', 'Y', 'Z', '%'):
format = '%' + directive
if directive == 'd':
if directive in ('d', 'e'):
format += ',%Y' # Avoid GH-70647.
strf_output = time.strftime(format, tt)
try:
Expand All @@ -387,10 +387,13 @@ def test_strptime_exception_context(self):
self.assertTrue(e.exception.__suppress_context__)

def test_strptime_leap_year(self):
# GH-70647: warns if parsing a format with a day and no year.
# GH-70647: %d errors if parsing a format with a day and no year.
with self.assertRaises(ValueError):
time.strptime('02-07 18:28', '%m-%d %H:%M')
# %e without a year is deprecated, scheduled for removal in 3.17.
with self.assertWarnsRegex(DeprecationWarning,
r'.*day of month without a year.*'):
time.strptime('02-07 18:28', '%m-%d %H:%M')
time.strptime('02-07 18:28', '%m-%e %H:%M')

def test_asctime(self):
time.asctime(time.gmtime(self.t))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:meth:`~datetime.datetime.strptime` now raises :exc:`ValueError` when the
format string contains ``%d`` without a year directive.
Using ``%e`` without a year now emits a :exc:`DeprecationWarning`.
Loading