From e8425195d07b43840d2ab3c4abd35e471870944a Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 21 Jul 2025 21:47:18 +0400 Subject: [PATCH 1/3] gh-121237: Add %:z directive to strptime --- Doc/library/datetime.rst | 19 +++++-- Lib/_strptime.py | 17 ++++--- Lib/test/datetimetester.py | 18 ++++++- Lib/test/test_strptime.py | 49 +++++++++++-------- ...-07-21-20-00-42.gh-issue-121237.DyxNqo.rst | 2 + 5 files changed, 70 insertions(+), 35 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 16ed3215bc2c1a..06109dbf0bc16b 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -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:: 3.15 + ``%: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.stpftime`, 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 diff --git a/Lib/_strptime.py b/Lib/_strptime.py index cdc55e8daaffa6..63125fbae6db02 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -371,7 +371,10 @@ def __init__(self, locale_time=None): # W is set below by using 'U' 'y': r"(?P\d\d)", 'Y': r"(?P\d\d\d\d)", + # See gh-121237: "z" might not support colons, if designed from scratch. + # However, for backwards compatibility, we must keep them. 'z': r"(?P([+-]\d\d:?[0-5]\d(:?[0-5]\d(\.\d{1,6})?)?)|(?-i:Z))?", + ':z': r"(?P([+-]\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 +462,16 @@ def pattern(self, format): 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("""\ @@ -662,8 +665,8 @@ def parse_int(s): 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 +675,7 @@ def parse_int(s): 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]) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 93b3382b9c654e..73ec229e8a3795 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -2895,6 +2895,12 @@ def test_strptime(self): 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])): @@ -2973,7 +2979,7 @@ def test_strptime_leap_year(self): 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) @@ -4041,6 +4047,12 @@ def test_strptime_tz(self): 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])): @@ -4070,9 +4082,11 @@ def test_strptime_tz(self): 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. diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 0241e543cd7dde..325b04d81b03d4 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -406,34 +406,41 @@ def test_offset(self): (*_, 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) + for directive in ("%z", "%:z"): + (*_, offset), _, offset_fraction = _strptime._strptime("+01:00", + directive) + self.assertEqual(offset, one_hour) + self.assertEqual(offset_fraction, 0) + (*_, offset), _, offset_fraction = _strptime._strptime("-01:30", + directive) + self.assertEqual(offset, -(one_hour + half_hour)) + self.assertEqual(offset_fraction, 0) + (*_, offset), _, offset_fraction = _strptime._strptime("-01:30:30", + directive) + self.assertEqual(offset, -(one_hour + half_hour + half_minute)) + self.assertEqual(offset_fraction, 0) + (*_, offset), _, offset_fraction = _strptime._strptime("-01:30:30.000001", + directive) + self.assertEqual(offset, -(one_hour + half_hour + half_minute)) + self.assertEqual(offset_fraction, -1) + (*_, offset), _, offset_fraction = _strptime._strptime("+01:30:30.001", + directive) + self.assertEqual(offset, one_hour + half_hour + half_minute) + self.assertEqual(offset_fraction, 1000) def test_bad_offset(self): - with self.assertRaises(ValueError): - _strptime._strptime("-01:30:30.", "%z") + for directive in ("%z", "%:z"): + with self.assertRaises(ValueError): + _strptime._strptime("-01:30:30.", directive) + with self.assertRaises(ValueError): + _strptime._strptime("-01:30:30.1234567", directive) + with self.assertRaises(ValueError): + _strptime._strptime("-01:30:30:123456", directive) 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") with self.assertRaises(ValueError) as err: _strptime._strptime("-01:3030", "%z") self.assertEqual("Inconsistent use of : in -01:3030", str(err.exception)) 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 index 00000000000000..dd6a62af3c9c6b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst @@ -0,0 +1,2 @@ +Support ``%:z`` directive for :meth:`~datetime.datetime.strptime`. Patch by +Semyon Moroz. From 221ba98602f780ca88edd60e5686e78448cb6dd1 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Tue, 22 Jul 2025 09:27:26 +0400 Subject: [PATCH 2/3] and time.strptime --- .../Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index dd6a62af3c9c6b..0b9757df950c6b 100644 --- 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 @@ -1,2 +1,2 @@ -Support ``%:z`` directive for :meth:`~datetime.datetime.strptime`. Patch by -Semyon Moroz. +Support ``%:z`` directive for :meth:`~datetime.datetime.strptime` and +:meth:`~datetime.time.strptime`. Patch by Semyon Moroz. From 57098db2b6027ffef053a3a7e1aff4b8a369c708 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Tue, 22 Jul 2025 09:39:52 +0400 Subject: [PATCH 3/3] Fix method name --- Doc/library/datetime.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 06109dbf0bc16b..ca74412477be57 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2734,7 +2734,7 @@ Notes: 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.stpftime`, the UTC offset is *required* + 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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy