]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
GH-70647: Deprecate strptime day of month parsing without a year present to avoid...
authorGregory P. Smith <greg@krypto.org>
Wed, 3 Apr 2024 12:19:49 +0000 (05:19 -0700)
committerGitHub <noreply@github.com>
Wed, 3 Apr 2024 12:19:49 +0000 (14:19 +0200)
Doc/library/datetime.rst
Lib/_strptime.py
Lib/test/datetimetester.py
Lib/test/test_time.py
Lib/test/test_unittest/test_assertions.py
Lib/unittest/case.py
Misc/NEWS.d/next/Library/2024-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst [new file with mode: 0644]

index 1905c9e1ca755d9e9d2f3f5434ac50bdbc75a999..047427d3269027886718e4fd81100701b5ecb35a 100644 (file)
@@ -1079,6 +1079,24 @@ Other constructors, all class methods:
    time tuple.  See also :ref:`strftime-strptime-behavior` and
    :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:
@@ -2657,6 +2675,25 @@ Notes:
    for  formats ``%d``, ``%m``, ``%H``, ``%I``, ``%M``, ``%S``, ``%j``, ``%U``,
    ``%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
 
 .. [#] If, that is, we ignore the effects of Relativity
index 798cf9f9d3fffe44ec225dc20234b4f589740376..e42af75af74bf554fbcab8c2bac38a6cb886b53f 100644 (file)
@@ -10,6 +10,7 @@ FUNCTIONS:
     strptime -- Calculates the time struct represented by the passed-in string
 
 """
+import os
 import time
 import locale
 import calendar
@@ -250,12 +251,30 @@ class TimeRE(dict):
         format = regex_chars.sub(r"\\\1", format)
         whitespace_replacement = re_compile(r'\s+')
         format = whitespace_replacement.sub(r'\\s+', format)
+        year_in_format = False
+        day_of_month_in_format = False
         while '%' in format:
             directive_index = format.index('%')+1
+            format_char = format[directive_index]
             processed_format = "%s%s%s" % (processed_format,
                                            format[:directive_index-1],
-                                           self[format[directive_index]])
+                                           self[format_char])
             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)
 
     def compile(self, format):
index 31fc383e29707a717aa0833175d10d41e426a566..c77263998c99f51abe5ac3bfe623081a45e54da9 100644 (file)
@@ -2793,6 +2793,19 @@ class TestDateTime(TestDate):
                 newdate = strptime(string, format)
                 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):
         # This tests fields beyond those tested by the TestDate.test_timetuple.
         t = self.theclass(2004, 12, 31, 6, 22, 33)
index fb234b7bc5962adbce4ecc2541a65530f0cbee55..293799ff68ea05e83b0e9d838ecf0f14ecd353c4 100644 (file)
@@ -277,6 +277,8 @@ class TimeTestCase(unittest.TestCase):
                           'j', 'm', 'M', 'p', 'S',
                           'U', 'w', 'W', 'x', 'X', 'y', 'Y', 'Z', '%'):
             format = '%' + directive
+            if directive == 'd':
+                format += ',%Y'  # Avoid GH-70647.
             strf_output = time.strftime(format, tt)
             try:
                 time.strptime(strf_output, format)
@@ -299,6 +301,12 @@ class TimeTestCase(unittest.TestCase):
             time.strptime('19', '%Y %')
         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):
         time.asctime(time.gmtime(self.t))
 
index 5c1a28ecda5b49ec459e6c877088fb853e250242..1dec947ea76d239b53f56ae4dc6ee013aec67f50 100644 (file)
@@ -386,6 +386,16 @@ class TestLongMessage(unittest.TestCase):
                                '^UserWarning not triggered$',
                                '^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):
         # test error not raised
         self.assertMessagesCM('assertWarnsRegex', (UserWarning, 'unused regex'),
index 001b640dc43ad69aa4ea7d304c5a667424d031f8..36daa61fa31adb829d9fdfb06f5498b14be2c5f8 100644 (file)
@@ -332,6 +332,23 @@ class _AssertWarnsContext(_AssertRaisesBaseContext):
             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):
     def __iter__(self):
         seen = set()
@@ -811,6 +828,11 @@ class TestCase(object):
         context = _AssertWarnsContext(expected_warning, self)
         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):
         """Fail unless a log message of level *level* or higher is emitted
         on *logger_name* or its children.  If omitted, *level* defaults to
diff --git a/Misc/NEWS.d/next/Library/2024-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst b/Misc/NEWS.d/next/Library/2024-03-20-16-10-29.gh-issue-70647.FpD6Ar.rst
new file mode 100644 (file)
index 0000000..a9094df
--- /dev/null
@@ -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.