]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-121237: Add `%:z` directive to datetime.strptime (#136961)
authorSemyon Moroz <donbarbos@proton.me>
Fri, 19 Sep 2025 09:25:31 +0000 (13:25 +0400)
committerGitHub <noreply@github.com>
Fri, 19 Sep 2025 09:25:31 +0000 (10:25 +0100)
Doc/library/datetime.rst
Lib/_strptime.py
Lib/test/datetimetester.py
Lib/test/test_strptime.py
Misc/NEWS.d/next/Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst [new file with mode: 0644]

index 7010f99c54da0a327de7d489274927528d6f3d9a..c0ae4d66b76a7b169e0d93325c4f15529839af5c 100644 (file)
@@ -2629,7 +2629,10 @@ differences between platforms in handling of unsupported format specifiers.
    ``%G``, ``%u`` and ``%V`` were added.
 
 .. versionadded:: 3.12
-   ``%:z`` was added.
+   ``%:z`` was added for :meth:`~.datetime.strftime`
+
+.. versionadded:: next
+   ``%:z`` was added for :meth:`~.datetime.strptime`
 
 Technical Detail
 ^^^^^^^^^^^^^^^^
@@ -2724,12 +2727,18 @@ Notes:
       When the ``%z`` directive is provided to the  :meth:`~.datetime.strptime` method,
       the UTC offsets can have a colon as a separator between hours, minutes
       and seconds.
-      For example, ``'+01:00:00'`` will be parsed as an offset of one hour.
-      In addition, providing ``'Z'`` is identical to ``'+00:00'``.
+      For example, both ``'+010000'`` and ``'+01:00:00'`` will be parsed as an offset
+      of one hour. In addition, providing ``'Z'`` is identical to ``'+00:00'``.
 
    ``%:z``
-      Behaves exactly as ``%z``, but has a colon separator added between
-      hours, minutes and seconds.
+      When used with :meth:`~.datetime.strftime`, behaves exactly as ``%z``,
+      except that a colon separator is added between hours, minutes and seconds.
+
+      When used with :meth:`~.datetime.strptime`, the UTC offset is *required*
+      to have a colon as a separator between hours, minutes and seconds.
+      For example, ``'+01:00:00'`` (but *not* ``'+010000'``) will be parsed as
+      an offset of one hour. In addition, providing ``'Z'`` is identical to
+      ``'+00:00'``.
 
    ``%Z``
       In :meth:`~.datetime.strftime`, ``%Z`` is replaced by an empty string if
index cdc55e8daaffa6d3dbdccd445574b5e3ce9911ed..a0117493954956f32748eb1075565cbf1bf4e227 100644 (file)
@@ -371,7 +371,9 @@ class TimeRE(dict):
             # W is set below by using 'U'
             'y': r"(?P<y>\d\d)",
             'Y': r"(?P<Y>\d\d\d\d)",
+            # See gh-121237: "z" must support colons for backwards compatibility.
             'z': r"(?P<z>([+-]\d\d:?[0-5]\d(:?[0-5]\d(\.\d{1,6})?)?)|(?-i:Z))?",
+            ':z': r"(?P<colon_z>([+-]\d\d:[0-5]\d(:[0-5]\d(\.\d{1,6})?)?)|(?-i:Z))?",
             'A': self.__seqToRE(self.locale_time.f_weekday, 'A'),
             'a': self.__seqToRE(self.locale_time.a_weekday, 'a'),
             'B': self.__seqToRE(_fixmonths(self.locale_time.f_month[1:]), 'B'),
@@ -459,16 +461,16 @@ class TimeRE(dict):
         year_in_format = False
         day_of_month_in_format = False
         def repl(m):
-            format_char = m[1]
-            match format_char:
+            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
-            return self[format_char]
-        format = re_sub(r'%[-_0^#]*[0-9]*([OE]?\\?.?)', repl, format)
+            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("""\
@@ -555,8 +557,17 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
         raise ValueError("time data %r does not match format %r" %
                          (data_string, format))
     if len(data_string) != found.end():
-        raise ValueError("unconverted data remains: %s" %
-                          data_string[found.end():])
+        rest = data_string[found.end():]
+        # Specific check for '%:z' directive
+        if (
+            "colon_z" in found.re.groupindex
+            and found.group("colon_z") is not None
+            and rest[0] != ":"
+        ):
+            raise ValueError(
+                f"Missing colon in %:z before '{rest}', got '{data_string}'"
+            )
+        raise ValueError("unconverted data remains: %s" % rest)
 
     iso_year = year = None
     month = day = 1
@@ -662,8 +673,8 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
                 week_of_year_start = 0
         elif group_key == 'V':
             iso_week = int(found_dict['V'])
-        elif group_key == 'z':
-            z = found_dict['z']
+        elif group_key in ('z', 'colon_z'):
+            z = found_dict[group_key]
             if z:
                 if z == 'Z':
                     gmtoff = 0
@@ -672,7 +683,7 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
                         z = z[:3] + z[4:]
                         if len(z) > 5:
                             if z[5] != ':':
-                                msg = f"Inconsistent use of : in {found_dict['z']}"
+                                msg = f"Inconsistent use of : in {found_dict[group_key]}"
                                 raise ValueError(msg)
                             z = z[:5] + z[6:]
                     hours = int(z[1:3])
index 2299d1fab2e73d9805af8dbea903977a8b667fa1..55cf1fa6bee6c3672be2f0c18298352b704a60dd 100644 (file)
@@ -2907,6 +2907,12 @@ class TestDateTime(TestDate):
             strptime("-00:02:01.000003", "%z").utcoffset(),
             -timedelta(minutes=2, seconds=1, microseconds=3)
         )
+        self.assertEqual(strptime("+01:07", "%:z").utcoffset(),
+                         1 * HOUR + 7 * MINUTE)
+        self.assertEqual(strptime("-10:02", "%:z").utcoffset(),
+                         -(10 * HOUR + 2 * MINUTE))
+        self.assertEqual(strptime("-00:00:01.00001", "%:z").utcoffset(),
+                         -timedelta(seconds=1, microseconds=10))
         # Only local timezone and UTC are supported
         for tzseconds, tzname in ((0, 'UTC'), (0, 'GMT'),
                                  (-_time.timezone, _time.tzname[0])):
@@ -2985,7 +2991,7 @@ class TestDateTime(TestDate):
             self.theclass.strptime('02-29,2024', '%m-%d,%Y')
 
     def test_strptime_z_empty(self):
-        for directive in ('z',):
+        for directive in ('z', ':z'):
             string = '2025-04-25 11:42:47'
             format = f'%Y-%m-%d %H:%M:%S%{directive}'
             target = self.theclass(2025, 4, 25, 11, 42, 47)
@@ -4053,6 +4059,12 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase):
             strptime("-00:02:01.000003", "%z").utcoffset(),
             -timedelta(minutes=2, seconds=1, microseconds=3)
         )
+        self.assertEqual(strptime("+01:07", "%:z").utcoffset(),
+                         1 * HOUR + 7 * MINUTE)
+        self.assertEqual(strptime("-10:02", "%:z").utcoffset(),
+                         -(10 * HOUR + 2 * MINUTE))
+        self.assertEqual(strptime("-00:00:01.00001", "%:z").utcoffset(),
+                         -timedelta(seconds=1, microseconds=10))
         # Only local timezone and UTC are supported
         for tzseconds, tzname in ((0, 'UTC'), (0, 'GMT'),
                                  (-_time.timezone, _time.tzname[0])):
@@ -4082,9 +4094,11 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase):
         self.assertEqual(strptime("UTC", "%Z").tzinfo, None)
 
     def test_strptime_errors(self):
-        for tzstr in ("-2400", "-000", "z"):
+        for tzstr in ("-2400", "-000", "z", "24:00"):
             with self.assertRaises(ValueError):
                 self.theclass.strptime(tzstr, "%z")
+            with self.assertRaises(ValueError):
+                self.theclass.strptime(tzstr, "%:z")
 
     def test_strptime_single_digit(self):
         # bpo-34903: Check that single digit times are allowed.
index 0241e543cd7ddedc6a39f0a7b414defe4f60e625..d12816c90840ad07df9f97a829177e36fda5b96f 100644 (file)
@@ -406,37 +406,50 @@ class StrptimeTests(unittest.TestCase):
         (*_, offset), _, offset_fraction = _strptime._strptime("-013030.000001", "%z")
         self.assertEqual(offset, -(one_hour + half_hour + half_minute))
         self.assertEqual(offset_fraction, -1)
-        (*_, offset), _, offset_fraction = _strptime._strptime("+01:00", "%z")
-        self.assertEqual(offset, one_hour)
-        self.assertEqual(offset_fraction, 0)
-        (*_, offset), _, offset_fraction = _strptime._strptime("-01:30", "%z")
-        self.assertEqual(offset, -(one_hour + half_hour))
-        self.assertEqual(offset_fraction, 0)
-        (*_, offset), _, offset_fraction = _strptime._strptime("-01:30:30", "%z")
-        self.assertEqual(offset, -(one_hour + half_hour + half_minute))
-        self.assertEqual(offset_fraction, 0)
-        (*_, offset), _, offset_fraction = _strptime._strptime("-01:30:30.000001", "%z")
-        self.assertEqual(offset, -(one_hour + half_hour + half_minute))
-        self.assertEqual(offset_fraction, -1)
-        (*_, offset), _, offset_fraction = _strptime._strptime("+01:30:30.001", "%z")
-        self.assertEqual(offset, one_hour + half_hour + half_minute)
-        self.assertEqual(offset_fraction, 1000)
-        (*_, offset), _, offset_fraction = _strptime._strptime("Z", "%z")
-        self.assertEqual(offset, 0)
-        self.assertEqual(offset_fraction, 0)
+
+        cases = [
+            ("+01:00", one_hour, 0),
+            ("-01:30", -(one_hour + half_hour), 0),
+            ("-01:30:30", -(one_hour + half_hour + half_minute), 0),
+            ("-01:30:30.000001", -(one_hour + half_hour + half_minute), -1),
+            ("+01:30:30.001", +(one_hour + half_hour + half_minute), 1000),
+            ("Z", 0, 0),
+        ]
+        for directive in ("%z", "%:z"):
+            for offset_str, expected_offset, expected_fraction in cases:
+                with self.subTest(offset_str=offset_str, directive=directive):
+                    (*_, offset), _, offset_fraction = _strptime._strptime(
+                        offset_str, directive
+                    )
+                    self.assertEqual(offset, expected_offset)
+                    self.assertEqual(offset_fraction, expected_fraction)
 
     def test_bad_offset(self):
-        with self.assertRaises(ValueError):
-            _strptime._strptime("-01:30:30.", "%z")
-        with self.assertRaises(ValueError):
-            _strptime._strptime("-0130:30", "%z")
-        with self.assertRaises(ValueError):
-            _strptime._strptime("-01:30:30.1234567", "%z")
-        with self.assertRaises(ValueError):
-            _strptime._strptime("-01:30:30:123456", "%z")
+        error_cases_any_z = [
+            "-01:30:30.",         # Decimal point not followed with digits
+            "-01:30:30.1234567",  # Too many digits after decimal point
+            "-01:30:30:123456",   # Colon as decimal separator
+            "-0130:30",           # Incorrect use of colons
+        ]
+        for directive in ("%z", "%:z"):
+            for timestr in error_cases_any_z:
+                with self.subTest(timestr=timestr, directive=directive):
+                    with self.assertRaises(ValueError):
+                        _strptime._strptime(timestr, directive)
+
+        required_colons_cases = ["-013030", "+0130", "-01:3030.123456"]
+        for timestr in required_colons_cases:
+            with self.subTest(timestr=timestr):
+                with self.assertRaises(ValueError):
+                    _strptime._strptime(timestr, "%:z")
+
         with self.assertRaises(ValueError) as err:
             _strptime._strptime("-01:3030", "%z")
         self.assertEqual("Inconsistent use of : in -01:3030", str(err.exception))
+        with self.assertRaises(ValueError) as err:
+            _strptime._strptime("-01:3030", "%:z")
+        self.assertEqual("Missing colon in %:z before '30', got '-01:3030'",
+                         str(err.exception))
 
     @skip_if_buggy_ucrt_strfptime
     def test_timezone(self):
diff --git a/Misc/NEWS.d/next/Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst b/Misc/NEWS.d/next/Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst
new file mode 100644 (file)
index 0000000..f6c86f1
--- /dev/null
@@ -0,0 +1,3 @@
+Support ``%:z`` directive for :meth:`datetime.datetime.strptime`,
+:meth:`datetime.time.strptime` and :func:`time.strptime`.
+Patch by Lucas Esposito and Semyon Moroz.