From e8425195d07b43840d2ab3c4abd35e471870944a Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 21 Jul 2025 21:47:18 +0400 Subject: [PATCH 1/8] 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/8] 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/8] 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 From ed63eed147e62f93e9303d43258213855693e207 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 15 Sep 2025 19:13:19 +0400 Subject: [PATCH 4/8] Accept suggestions --- Lib/_strptime.py | 3 +-- .../Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/_strptime.py b/Lib/_strptime.py index 63125fbae6db02..8d1dd27cf22e6c 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -371,8 +371,7 @@ 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. + # See gh-121237: "z" must support colons for backwards compatibility. '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'), 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 0b9757df950c6b..ea6725d3495ada 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` and -:meth:`~datetime.time.strptime`. Patch by Semyon Moroz. +:meth:`~datetime.time.strptime`. Patch by Lucas Esposito and Semyon Moroz. From f499deed6bb7633f6607732af3d9966c5416f385 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 15 Sep 2025 20:43:21 +0400 Subject: [PATCH 5/8] Accept suggestions from @StanFromIreland --- Doc/library/datetime.rst | 2 +- Lib/test/test_strptime.py | 7 ++++--- .../Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index ca74412477be57..a32e451f7f246c 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2631,7 +2631,7 @@ differences between platforms in handling of unsupported format specifiers. .. versionadded:: 3.12 ``%:z`` was added for :meth:`~.datetime.strftime` -.. versionadded:: 3.15 +.. versionadded:: next ``%:z`` was added for :meth:`~.datetime.strptime` Technical Detail diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 325b04d81b03d4..182b3cd29744db 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -406,9 +406,6 @@ 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("Z", "%z") - self.assertEqual(offset, 0) - self.assertEqual(offset_fraction, 0) for directive in ("%z", "%:z"): (*_, offset), _, offset_fraction = _strptime._strptime("+01:00", directive) @@ -430,6 +427,10 @@ def test_offset(self): directive) self.assertEqual(offset, one_hour + half_hour + half_minute) self.assertEqual(offset_fraction, 1000) + (*_, offset), _, offset_fraction = _strptime._strptime("Z", + directive) + self.assertEqual(offset, 0) + self.assertEqual(offset_fraction, 0) def test_bad_offset(self): for directive in ("%z", "%:z"): 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 ea6725d3495ada..1398b7e698bbda 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,3 @@ -Support ``%:z`` directive for :meth:`~datetime.datetime.strptime` and -:meth:`~datetime.time.strptime`. Patch by Lucas Esposito and Semyon Moroz. +Support ``%:z`` directive for :meth:`datetime.datetime.strptime`, +:meth:`datetime.time.strptime` and :meth:`time.strptime`. +Patch by Lucas Esposito and Semyon Moroz. From 75c1bd10a49d15e4618bce5ce49f8bafcd519ff1 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Mon, 15 Sep 2025 22:17:08 +0400 Subject: [PATCH 6/8] Add to time docs --- Doc/library/time.rst | 20 +++++++++++++++++++ ...-07-21-20-00-42.gh-issue-121237.DyxNqo.rst | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Doc/library/time.rst b/Doc/library/time.rst index df9be68bf4f69d..7b20cbe90f21b1 100644 --- a/Doc/library/time.rst +++ b/Doc/library/time.rst @@ -533,6 +533,10 @@ Functions | ``%Z`` | Time zone name (no characters if no time zone | | | | exists). Deprecated. [1]_ | | +-----------+------------------------------------------------+-------+ + | ``%:z`` | Behaves exactly as ``%z``, but has a colon | \(5) | + | | separator added between hours, minutes and | | + | | seconds. | | + +-----------+------------------------------------------------+-------+ | ``%G`` | ISO 8601 year (similar to ``%Y`` but follows | | | | the rules for the ISO 8601 calendar year). | | | | The year starts with the week that contains | | @@ -569,6 +573,16 @@ Functions When used with the :func:`strptime` function, ``%U`` and ``%W`` are only used in calculations when the day of the week and the year are specified. + (5) + When used with :func:`strftime`, behaves exactly as ``%z``, except that + a colon separator is added between hours, minutes and seconds. + + When used with :func:`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'``. + Here is an example, a format for dates compatible with that specified in the :rfc:`2822` Internet email standard. [1]_ :: @@ -586,6 +600,12 @@ Functions this is also not portable. The field width is normally 2 except for ``%j`` where it is 3. + .. versionadded:: 3.12 + ``%:z`` was added for :func:`strftime` + + .. versionadded:: next + ``%:z`` was added for :func:`strptime` + .. index:: single: % (percent); datetime format 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 1398b7e698bbda..f6c86f105daa15 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,3 +1,3 @@ Support ``%:z`` directive for :meth:`datetime.datetime.strptime`, -:meth:`datetime.time.strptime` and :meth:`time.strptime`. +:meth:`datetime.time.strptime` and :func:`time.strptime`. Patch by Lucas Esposito and Semyon Moroz. From fa4d19cc4a44ad802e6e9d416c4176040da0e920 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Tue, 16 Sep 2025 18:32:59 +0400 Subject: [PATCH 7/8] Remove from time doc --- Doc/library/time.rst | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/Doc/library/time.rst b/Doc/library/time.rst index 885be15b9b6377..b05c0a312dbe34 100644 --- a/Doc/library/time.rst +++ b/Doc/library/time.rst @@ -533,10 +533,6 @@ Functions | ``%Z`` | Time zone name (no characters if no time zone | | | | exists). Deprecated. [1]_ | | +-----------+------------------------------------------------+-------+ - | ``%:z`` | Behaves exactly as ``%z``, but has a colon | \(5) | - | | separator added between hours, minutes and | | - | | seconds. | | - +-----------+------------------------------------------------+-------+ | ``%G`` | ISO 8601 year (similar to ``%Y`` but follows | | | | the rules for the ISO 8601 calendar year). | | | | The year starts with the week that contains | | @@ -573,16 +569,6 @@ Functions When used with the :func:`strptime` function, ``%U`` and ``%W`` are only used in calculations when the day of the week and the year are specified. - (5) - When used with :func:`strftime`, behaves exactly as ``%z``, except that - a colon separator is added between hours, minutes and seconds. - - When used with :func:`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'``. - Here is an example, a format for dates compatible with that specified in the :rfc:`2822` Internet email standard. [1]_ :: @@ -600,12 +586,6 @@ Functions this is also not portable. The field width is normally 2 except for ``%j`` where it is 3. - .. versionadded:: 3.12 - ``%:z`` was added for :func:`strftime` - - .. versionadded:: next - ``%:z`` was added for :func:`strptime` - .. index:: single: % (percent); datetime format From 4bc518106e6d3ba05994dd0022b013a0d76da745 Mon Sep 17 00:00:00 2001 From: donBarbos Date: Wed, 17 Sep 2025 22:23:54 +0400 Subject: [PATCH 8/8] Apply suggestions * Update tests * Add test cases for missing colons * Check missing colons --- Lib/_strptime.py | 13 ++++++-- Lib/test/test_strptime.py | 69 +++++++++++++++++++++------------------ 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/Lib/_strptime.py b/Lib/_strptime.py index 8d1dd27cf22e6c..a0117493954956 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -557,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 diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 182b3cd29744db..d12816c90840ad 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -406,45 +406,50 @@ 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) + + 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"): - (*_, 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) - (*_, offset), _, offset_fraction = _strptime._strptime("Z", - directive) - self.assertEqual(offset, 0) - self.assertEqual(offset_fraction, 0) + 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): + 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"): - 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") + 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):