]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-70647: Remove support for `%d` (and deprecate for `%e`) without year in `strptime...
authorStan Ulbrych <stan@python.org>
Wed, 15 Apr 2026 00:15:27 +0000 (01:15 +0100)
committerGitHub <noreply@github.com>
Wed, 15 Apr 2026 00:15:27 +0000 (17:15 -0700)
* Add deprecation for %e with no year
* schedule `%e` for 3.17, and remove `%d` now

Doc/deprecations/pending-removal-in-3.17.rst
Doc/library/datetime.rst
Doc/whatsnew/3.15.rst
Lib/_strptime.py
Lib/test/datetimetester.py
Lib/test/test_strptime.py
Lib/test/test_time.py
Misc/NEWS.d/next/Library/2026-02-07-12-54-20.gh-issue-70647.Bja_Lk.rst [new file with mode: 0644]

index e769c9d371e133c9b5b372f95b8eb7aff1937db5..ea9fb93ddd8c84ce7e5012fe8b1c70c7c0dff78a 100644 (file)
@@ -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.
index 8993049a720b1c8e6580dce86a879c795ab14250..f3c4ef9199075c9235c278ae00212aa778ae92e3 100644 (file)
@@ -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:
 
@@ -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:
 
@@ -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,            |       |
@@ -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
 
index fe2ddfdcd0e9174b57d6fa7ddadde32e096b293e..0253bb6cb717f340f14fc07a142787956a624e1a 100644 (file)
@@ -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
 ------
 
index 3367ac485a590c0b25e157ba5010aa65ee070528..746b0907c1d9f4eb2ea92ae05a19fd2fe0936ef4 100644 (file)
@@ -464,7 +464,8 @@ class TimeRE(dict):
         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:
@@ -472,20 +473,30 @@ class TimeRE(dict):
                     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):
index bb8695541ac81d99bab0dbf6dd0db8dd5bedfc85..5d5b8e415f3cd21bd92a4ad99c6524a5760eaa20 100644 (file)
@@ -22,7 +22,7 @@ from operator import lt, le, gt, ge, eq, ne, truediv, floordiv, mod
 
 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
@@ -1206,15 +1206,20 @@ class TestDateOnly(unittest.TestCase):
                 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
@@ -3119,19 +3124,24 @@ class TestDateTime(TestDate):
                 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'):
index dfc8ef6d2c5b7ee70469a625bd968ef3205758b2..5ac28870455f4d88556cb6a433313aab34f70510 100644 (file)
@@ -8,7 +8,6 @@ import os
 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
 
@@ -639,15 +638,11 @@ class StrptimeTests(unittest.TestCase):
         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"
index be8f6b057654c2816fb8f4251dc74fa6fb48852e..1850f053aaffd66416766c88a9baf3abb2221180 100644 (file)
@@ -358,11 +358,11 @@ class TimeTestCase(unittest.TestCase):
         # 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:
@@ -387,10 +387,13 @@ class TimeTestCase(unittest.TestCase):
         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))
diff --git a/Misc/NEWS.d/next/Library/2026-02-07-12-54-20.gh-issue-70647.Bja_Lk.rst b/Misc/NEWS.d/next/Library/2026-02-07-12-54-20.gh-issue-70647.Bja_Lk.rst
new file mode 100644 (file)
index 0000000..9fd3974
--- /dev/null
@@ -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`.