https://github.com/python/cpython/commit/495f589363b236599f266a1a3974435b112506f1
commit: 495f589363b236599f266a1a3974435b112506f1
branch: main
author: Semyon Moroz <[email protected]>
committer: pganssle <[email protected]>
date: 2025-09-19T10:25:31+01:00
summary:
gh-121237: Add `%:z` directive to datetime.strptime (#136961)
files:
A Misc/NEWS.d/next/Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst
M Doc/library/datetime.rst
M Lib/_strptime.py
M Lib/test/datetimetester.py
M Lib/test/test_strptime.py
diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst
index 7010f99c54da0a..c0ae4d66b76a7b 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:: 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
diff --git a/Lib/_strptime.py b/Lib/_strptime.py
index cdc55e8daaffa6..a0117493954956 100644
--- a/Lib/_strptime.py
+++ b/Lib/_strptime.py
@@ -371,7 +371,9 @@ def __init__(self, locale_time=None):
# 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 @@ 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("""\
@@ -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 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 +683,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 2299d1fab2e73d..55cf1fa6bee6c3 100644
--- a/Lib/test/datetimetester.py
+++ b/Lib/test/datetimetester.py
@@ -2907,6 +2907,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])):
@@ -2985,7 +2991,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)
@@ -4053,6 +4059,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])):
@@ -4082,9 +4094,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..d12816c90840ad 100644
--- a/Lib/test/test_strptime.py
+++ b/Lib/test/test_strptime.py
@@ -406,37 +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)
- (*_, 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
index 00000000000000..f6c86f105daa15
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-07-21-20-00-42.gh-issue-121237.DyxNqo.rst
@@ -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.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]