Skip to content

Commit d0e7c6a

Browse files
GH-70647: Remove support for %d (and deprecate for %e) without year in strptime() (GH-144570)
* Add deprecation for %e with no year * schedule `%e` for 3.17, and remove `%d` now
1 parent 94d42bf commit d0e7c6a

File tree

8 files changed

+84
-46
lines changed

8 files changed

+84
-46
lines changed

Doc/deprecations/pending-removal-in-3.17.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
Pending removal in Python 3.17
22
------------------------------
33

4+
* :mod:`datetime`:
5+
6+
* :meth:`~datetime.datetime.strptime` calls using a format string containing
7+
``%e`` (day of month) without a year.
8+
This has been deprecated since Python 3.15.
9+
(Contributed by Stan Ulbrych in :gh:`70647`.)
10+
11+
412
* :mod:`collections.abc`:
513

614
- :class:`collections.abc.ByteString` is scheduled for removal in Python 3.17.

Doc/library/datetime.rst

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -606,12 +606,11 @@ Other constructors, all class methods:
606606

607607
.. note::
608608

609-
If *format* specifies a day of month without a year a
610-
:exc:`DeprecationWarning` is emitted. This is to avoid a quadrennial
609+
If *format* specifies a day of month (``%d``) without a year,
610+
:exc:`ValueError` is raised. This is to avoid a quadrennial
611611
leap year bug in code seeking to parse only a month and day as the
612612
default year used in absence of one in the format is not a leap year.
613-
Such *format* values may raise an error as of Python 3.15. The
614-
workaround is to always include a year in your *format*. If parsing
613+
The workaround is to always include a year in your *format*. If parsing
615614
*date_string* values that do not have a year, explicitly add a year that
616615
is a leap year before parsing:
617616

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

1183-
.. versionchanged:: 3.13
1182+
.. versionchanged:: 3.15
11841183

1185-
If *format* specifies a day of month without a year a
1186-
:exc:`DeprecationWarning` is now emitted. This is to avoid a quadrennial
1184+
If *format* specifies a day of month (``%d``) without a year,
1185+
:exc:`ValueError` is raised. This is to avoid a quadrennial
11871186
leap year bug in code seeking to parse only a month and day as the
11881187
default year used in absence of one in the format is not a leap year.
1189-
Such *format* values may raise an error as of Python 3.15. The
1190-
workaround is to always include a year in your *format*. If parsing
1188+
The workaround is to always include a year in your *format*. If parsing
11911189
*date_string* values that do not have a year, explicitly add a year that
11921190
is a leap year before parsing:
11931191

@@ -2572,13 +2570,13 @@ requires, and these work on all supported platforms.
25722570
| | truncated to an integer as a | | |
25732571
| | zero-padded decimal number. | | |
25742572
+-----------+--------------------------------+------------------------+-------+
2575-
| ``%d`` | Day of the month as a | 01, 02, ..., 31 | \(9) |
2576-
| | zero-padded decimal number. | | |
2573+
| ``%d`` | Day of the month as a | 01, 02, ..., 31 | \(9), |
2574+
| | zero-padded decimal number. | | \(10) |
25772575
+-----------+--------------------------------+------------------------+-------+
25782576
| ``%D`` | Equivalent to ``%m/%d/%y``. | 11/28/25 | \(9) |
25792577
| | | | |
25802578
+-----------+--------------------------------+------------------------+-------+
2581-
| ``%e`` | The day of the month as a | ␣1, ␣2, ..., 31 | |
2579+
| ``%e`` | The day of the month as a | ␣1, ␣2, ..., 31 | \(10) |
25822580
| | space-padded decimal number. | | |
25832581
+-----------+--------------------------------+------------------------+-------+
25842582
| ``%F`` | Equivalent to ``%Y-%m-%d``, | 2025-10-11, | |
@@ -2919,11 +2917,12 @@ Notes:
29192917
>>> dt.datetime.strptime(f"{month_day};1984", "%m/%d;%Y") # No leap year bug.
29202918
datetime.datetime(1984, 2, 29, 0, 0)
29212919

2922-
.. deprecated-removed:: 3.13 3.15
2920+
.. versionchanged:: 3.15
2921+
Using ``%d`` without a year now raises :exc:`ValueError`.
2922+
2923+
.. deprecated-removed:: 3.15 3.17
29232924
:meth:`~.datetime.strptime` calls using a format string containing
2924-
a day of month without a year now emit a
2925-
:exc:`DeprecationWarning`. In 3.15 or later we may change this into
2926-
an error or change the default year to a leap year. See :gh:`70647`.
2925+
``%e`` without a year now emit a :exc:`DeprecationWarning`.
29272926

29282927
.. rubric:: Footnotes
29292928

Doc/whatsnew/3.15.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,6 +1494,15 @@ collections.abc
14941494
deprecated since Python 3.12, and is scheduled for removal in Python 3.17.
14951495

14961496

1497+
datetime
1498+
--------
1499+
1500+
* :meth:`~datetime.datetime.strptime` now raises :exc:`ValueError` when the
1501+
format string contains ``%d`` (day of month) without a year directive.
1502+
This has been deprecated since Python 3.13.
1503+
(Contributed by Stan Ulbrych and Gregory P. Smith in :gh:`70647`.)
1504+
1505+
14971506
ctypes
14981507
------
14991508

Lib/_strptime.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -464,28 +464,39 @@ def pattern(self, format):
464464
format = re_sub(r'\s+', r'\\s+', format)
465465
format = re_sub(r"'", "['\u02bc]", format) # needed for br_FR
466466
year_in_format = False
467-
day_of_month_in_format = False
467+
day_d_in_format = False
468+
day_e_in_format = False
468469
def repl(m):
469470
directive = m.group()[1:] # exclude `%` symbol
470471
match directive:
471472
case 'Y' | 'y' | 'G':
472473
nonlocal year_in_format
473474
year_in_format = True
474475
case 'd':
475-
nonlocal day_of_month_in_format
476-
day_of_month_in_format = True
476+
nonlocal day_d_in_format
477+
day_d_in_format = True
478+
case 'e':
479+
nonlocal day_e_in_format
480+
day_e_in_format = True
477481
return self[directive]
478482
format = re_sub(r'%[-_0^#]*[0-9]*([OE]?[:\\]?.?)', repl, format)
479-
if day_of_month_in_format and not year_in_format:
480-
import warnings
481-
warnings.warn("""\
483+
if not year_in_format:
484+
if day_d_in_format:
485+
raise ValueError(
486+
"Day of month directive '%d' may not be used without "
487+
"a year directive. Parsing dates involving a day of "
488+
"month without a year is ambiguous and fails to parse "
489+
"leap day. Add a year to the input and format. "
490+
"See https://github.com/python/cpython/issues/70647.")
491+
if day_e_in_format:
492+
import warnings
493+
warnings.warn("""\
482494
Parsing dates involving a day of month without a year specified is ambiguous
483-
and fails to parse leap day. The default behavior will change in Python 3.15
484-
to either always raise an exception or to use a different default year (TBD).
485-
To avoid trouble, add a specific year to the input & format.
495+
and fails to parse leap day. '%e' without a year will become an error in Python 3.17.
496+
To avoid trouble, add a specific year to the input and format.
486497
See https://github.com/python/cpython/issues/70647.""",
487-
DeprecationWarning,
488-
skip_file_prefixes=(os.path.dirname(__file__),))
498+
DeprecationWarning,
499+
skip_file_prefixes=(os.path.dirname(__file__),))
489500
return format
490501

491502
def compile(self, format):

Lib/test/datetimetester.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from test import support
2424
from test.support import is_resource_enabled, ALWAYS_EQ, LARGEST, SMALLEST
25-
from test.support import os_helper, script_helper, warnings_helper
25+
from test.support import os_helper, script_helper
2626

2727
import datetime as datetime_module
2828
from datetime import MINYEAR, MAXYEAR
@@ -1206,15 +1206,20 @@ def test_strptime_single_digit(self):
12061206
newdate = strptime(string, format)
12071207
self.assertEqual(newdate, target, msg=reason)
12081208

1209-
@warnings_helper.ignore_warnings(category=DeprecationWarning)
12101209
def test_strptime_leap_year(self):
1211-
# GH-70647: warns if parsing a format with a day and no year.
1210+
# GH-70647: %d errors if parsing a format with a day and no year.
12121211
with self.assertRaises(ValueError):
12131212
# The existing behavior that GH-70647 seeks to change.
12141213
date.strptime('02-29', '%m-%d')
1214+
# %e without a year is deprecated, scheduled for removal in 3.17.
1215+
_strptime._regex_cache.clear()
1216+
with self.assertWarnsRegex(DeprecationWarning,
1217+
r'.*day of month without a year.*'):
1218+
date.strptime('02-01', '%m-%e')
12151219
with self._assertNotWarns(DeprecationWarning):
12161220
date.strptime('20-03-14', '%y-%m-%d')
12171221
date.strptime('02-29,2024', '%m-%d,%Y')
1222+
date.strptime('02-29,2024', '%m-%e,%Y')
12181223

12191224
class SubclassDate(date):
12201225
sub_var = 1
@@ -3119,19 +3124,24 @@ def test_strptime_single_digit(self):
31193124
newdate = strptime(string, format)
31203125
self.assertEqual(newdate, target, msg=reason)
31213126

3122-
@warnings_helper.ignore_warnings(category=DeprecationWarning)
31233127
def test_strptime_leap_year(self):
3124-
# GH-70647: warns if parsing a format with a day and no year.
3128+
# GH-70647: %d errors if parsing a format with a day and no year.
31253129
with self.assertRaises(ValueError):
31263130
# The existing behavior that GH-70647 seeks to change.
31273131
self.theclass.strptime('02-29', '%m-%d')
3132+
with self.assertRaises(ValueError):
3133+
self.theclass.strptime('03-14.159265', '%m-%d.%f')
3134+
# %e without a year is deprecated, scheduled for removal in 3.17.
3135+
_strptime._regex_cache.clear()
31283136
with self.assertWarnsRegex(DeprecationWarning,
31293137
r'.*day of month without a year.*'):
3130-
self.theclass.strptime('03-14.159265', '%m-%d.%f')
3138+
self.theclass.strptime('03-14.159265', '%m-%e.%f')
31313139
with self._assertNotWarns(DeprecationWarning):
31323140
self.theclass.strptime('20-03-14.159265', '%y-%m-%d.%f')
31333141
with self._assertNotWarns(DeprecationWarning):
31343142
self.theclass.strptime('02-29,2024', '%m-%d,%Y')
3143+
with self._assertNotWarns(DeprecationWarning):
3144+
self.theclass.strptime('02-29,2024', '%m-%e,%Y')
31353145

31363146
def test_strptime_z_empty(self):
31373147
for directive in ('z', ':z'):

Lib/test/test_strptime.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import platform
99
import sys
1010
from test import support
11-
from test.support import warnings_helper
1211
from test.support import skip_if_buggy_ucrt_strfptime, run_with_locales
1312
from datetime import date as datetime_date
1413

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

642-
@warnings_helper.ignore_warnings(category=DeprecationWarning) # gh-70647
643641
def test_feb29_on_leap_year_without_year(self):
644-
time.strptime("Feb 29", "%b %d")
645-
646-
@warnings_helper.ignore_warnings(category=DeprecationWarning) # gh-70647
647-
def test_mar1_comes_after_feb29_even_when_omitting_the_year(self):
648-
self.assertLess(
649-
time.strptime("Feb 29", "%b %d"),
650-
time.strptime("Mar 1", "%b %d"))
642+
with self.assertRaises(ValueError):
643+
time.strptime("Feb 29", "%b %d")
644+
with self.assertRaises(ValueError):
645+
time.strptime("Mar 1", "%b %d")
651646

652647
def test_strptime_F_format(self):
653648
test_date = "2025-10-26"

Lib/test/test_time.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,11 @@ def test_strptime(self):
358358
# Should be able to go round-trip from strftime to strptime without
359359
# raising an exception.
360360
tt = time.gmtime(self.t)
361-
for directive in ('a', 'A', 'b', 'B', 'c', 'd', 'D', 'F', 'H', 'I',
361+
for directive in ('a', 'A', 'b', 'B', 'c', 'd', 'D', 'e', 'F', 'H', 'I',
362362
'j', 'm', 'M', 'n', 'p', 'S', 't', 'T',
363363
'U', 'w', 'W', 'x', 'X', 'y', 'Y', 'Z', '%'):
364364
format = '%' + directive
365-
if directive == 'd':
365+
if directive in ('d', 'e'):
366366
format += ',%Y' # Avoid GH-70647.
367367
strf_output = time.strftime(format, tt)
368368
try:
@@ -387,10 +387,13 @@ def test_strptime_exception_context(self):
387387
self.assertTrue(e.exception.__suppress_context__)
388388

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

395398
def test_asctime(self):
396399
time.asctime(time.gmtime(self.t))
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:meth:`~datetime.datetime.strptime` now raises :exc:`ValueError` when the
2+
format string contains ``%d`` without a year directive.
3+
Using ``%e`` without a year now emits a :exc:`DeprecationWarning`.

0 commit comments

Comments
 (0)