Skip to content

Commit 495f589

Browse files
authored
gh-121237: Add %:z directive to datetime.strptime (#136961)
1 parent ac5c5d4 commit 495f589

File tree

5 files changed

+92
-42
lines changed

5 files changed

+92
-42
lines changed

Doc/library/datetime.rst

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2629,7 +2629,10 @@ differences between platforms in handling of unsupported format specifiers.
26292629
``%G``, ``%u`` and ``%V`` were added.
26302630

26312631
.. versionadded:: 3.12
2632-
``%:z`` was added.
2632+
``%:z`` was added for :meth:`~.datetime.strftime`
2633+
2634+
.. versionadded:: next
2635+
``%:z`` was added for :meth:`~.datetime.strptime`
26332636

26342637
Technical Detail
26352638
^^^^^^^^^^^^^^^^
@@ -2724,12 +2727,18 @@ Notes:
27242727
When the ``%z`` directive is provided to the :meth:`~.datetime.strptime` method,
27252728
the UTC offsets can have a colon as a separator between hours, minutes
27262729
and seconds.
2727-
For example, ``'+01:00:00'`` will be parsed as an offset of one hour.
2728-
In addition, providing ``'Z'`` is identical to ``'+00:00'``.
2730+
For example, both ``'+010000'`` and ``'+01:00:00'`` will be parsed as an offset
2731+
of one hour. In addition, providing ``'Z'`` is identical to ``'+00:00'``.
27292732

27302733
``%:z``
2731-
Behaves exactly as ``%z``, but has a colon separator added between
2732-
hours, minutes and seconds.
2734+
When used with :meth:`~.datetime.strftime`, behaves exactly as ``%z``,
2735+
except that a colon separator is added between hours, minutes and seconds.
2736+
2737+
When used with :meth:`~.datetime.strptime`, the UTC offset is *required*
2738+
to have a colon as a separator between hours, minutes and seconds.
2739+
For example, ``'+01:00:00'`` (but *not* ``'+010000'``) will be parsed as
2740+
an offset of one hour. In addition, providing ``'Z'`` is identical to
2741+
``'+00:00'``.
27332742

27342743
``%Z``
27352744
In :meth:`~.datetime.strftime`, ``%Z`` is replaced by an empty string if

Lib/_strptime.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,9 @@ def __init__(self, locale_time=None):
371371
# W is set below by using 'U'
372372
'y': r"(?P<y>\d\d)",
373373
'Y': r"(?P<Y>\d\d\d\d)",
374+
# See gh-121237: "z" must support colons for backwards compatibility.
374375
'z': r"(?P<z>([+-]\d\d:?[0-5]\d(:?[0-5]\d(\.\d{1,6})?)?)|(?-i:Z))?",
376+
':z': r"(?P<colon_z>([+-]\d\d:[0-5]\d(:[0-5]\d(\.\d{1,6})?)?)|(?-i:Z))?",
375377
'A': self.__seqToRE(self.locale_time.f_weekday, 'A'),
376378
'a': self.__seqToRE(self.locale_time.a_weekday, 'a'),
377379
'B': self.__seqToRE(_fixmonths(self.locale_time.f_month[1:]), 'B'),
@@ -459,16 +461,16 @@ def pattern(self, format):
459461
year_in_format = False
460462
day_of_month_in_format = False
461463
def repl(m):
462-
format_char = m[1]
463-
match format_char:
464+
directive = m.group()[1:] # exclude `%` symbol
465+
match directive:
464466
case 'Y' | 'y' | 'G':
465467
nonlocal year_in_format
466468
year_in_format = True
467469
case 'd':
468470
nonlocal day_of_month_in_format
469471
day_of_month_in_format = True
470-
return self[format_char]
471-
format = re_sub(r'%[-_0^#]*[0-9]*([OE]?\\?.?)', repl, format)
472+
return self[directive]
473+
format = re_sub(r'%[-_0^#]*[0-9]*([OE]?[:\\]?.?)', repl, format)
472474
if day_of_month_in_format and not year_in_format:
473475
import warnings
474476
warnings.warn("""\
@@ -555,8 +557,17 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"):
555557
raise ValueError("time data %r does not match format %r" %
556558
(data_string, format))
557559
if len(data_string) != found.end():
558-
raise ValueError("unconverted data remains: %s" %
559-
data_string[found.end():])
560+
rest = data_string[found.end():]
561+
# Specific check for '%:z' directive
562+
if (
563+
"colon_z" in found.re.groupindex
564+
and found.group("colon_z") is not None
565+
and rest[0] != ":"
566+
):
567+
raise ValueError(
568+
f"Missing colon in %:z before '{rest}', got '{data_string}'"
569+
)
570+
raise ValueError("unconverted data remains: %s" % rest)
560571

561572
iso_year = year = None
562573
month = day = 1
@@ -662,8 +673,8 @@ def parse_int(s):
662673
week_of_year_start = 0
663674
elif group_key == 'V':
664675
iso_week = int(found_dict['V'])
665-
elif group_key == 'z':
666-
z = found_dict['z']
676+
elif group_key in ('z', 'colon_z'):
677+
z = found_dict[group_key]
667678
if z:
668679
if z == 'Z':
669680
gmtoff = 0
@@ -672,7 +683,7 @@ def parse_int(s):
672683
z = z[:3] + z[4:]
673684
if len(z) > 5:
674685
if z[5] != ':':
675-
msg = f"Inconsistent use of : in {found_dict['z']}"
686+
msg = f"Inconsistent use of : in {found_dict[group_key]}"
676687
raise ValueError(msg)
677688
z = z[:5] + z[6:]
678689
hours = int(z[1:3])

Lib/test/datetimetester.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2907,6 +2907,12 @@ def test_strptime(self):
29072907
strptime("-00:02:01.000003", "%z").utcoffset(),
29082908
-timedelta(minutes=2, seconds=1, microseconds=3)
29092909
)
2910+
self.assertEqual(strptime("+01:07", "%:z").utcoffset(),
2911+
1 * HOUR + 7 * MINUTE)
2912+
self.assertEqual(strptime("-10:02", "%:z").utcoffset(),
2913+
-(10 * HOUR + 2 * MINUTE))
2914+
self.assertEqual(strptime("-00:00:01.00001", "%:z").utcoffset(),
2915+
-timedelta(seconds=1, microseconds=10))
29102916
# Only local timezone and UTC are supported
29112917
for tzseconds, tzname in ((0, 'UTC'), (0, 'GMT'),
29122918
(-_time.timezone, _time.tzname[0])):
@@ -2985,7 +2991,7 @@ def test_strptime_leap_year(self):
29852991
self.theclass.strptime('02-29,2024', '%m-%d,%Y')
29862992

29872993
def test_strptime_z_empty(self):
2988-
for directive in ('z',):
2994+
for directive in ('z', ':z'):
29892995
string = '2025-04-25 11:42:47'
29902996
format = f'%Y-%m-%d %H:%M:%S%{directive}'
29912997
target = self.theclass(2025, 4, 25, 11, 42, 47)
@@ -4053,6 +4059,12 @@ def test_strptime_tz(self):
40534059
strptime("-00:02:01.000003", "%z").utcoffset(),
40544060
-timedelta(minutes=2, seconds=1, microseconds=3)
40554061
)
4062+
self.assertEqual(strptime("+01:07", "%:z").utcoffset(),
4063+
1 * HOUR + 7 * MINUTE)
4064+
self.assertEqual(strptime("-10:02", "%:z").utcoffset(),
4065+
-(10 * HOUR + 2 * MINUTE))
4066+
self.assertEqual(strptime("-00:00:01.00001", "%:z").utcoffset(),
4067+
-timedelta(seconds=1, microseconds=10))
40564068
# Only local timezone and UTC are supported
40574069
for tzseconds, tzname in ((0, 'UTC'), (0, 'GMT'),
40584070
(-_time.timezone, _time.tzname[0])):
@@ -4082,9 +4094,11 @@ def test_strptime_tz(self):
40824094
self.assertEqual(strptime("UTC", "%Z").tzinfo, None)
40834095

40844096
def test_strptime_errors(self):
4085-
for tzstr in ("-2400", "-000", "z"):
4097+
for tzstr in ("-2400", "-000", "z", "24:00"):
40864098
with self.assertRaises(ValueError):
40874099
self.theclass.strptime(tzstr, "%z")
4100+
with self.assertRaises(ValueError):
4101+
self.theclass.strptime(tzstr, "%:z")
40884102

40894103
def test_strptime_single_digit(self):
40904104
# bpo-34903: Check that single digit times are allowed.

Lib/test/test_strptime.py

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -406,37 +406,50 @@ def test_offset(self):
406406
(*_, offset), _, offset_fraction = _strptime._strptime("-013030.000001", "%z")
407407
self.assertEqual(offset, -(one_hour + half_hour + half_minute))
408408
self.assertEqual(offset_fraction, -1)
409-
(*_, offset), _, offset_fraction = _strptime._strptime("+01:00", "%z")
410-
self.assertEqual(offset, one_hour)
411-
self.assertEqual(offset_fraction, 0)
412-
(*_, offset), _, offset_fraction = _strptime._strptime("-01:30", "%z")
413-
self.assertEqual(offset, -(one_hour + half_hour))
414-
self.assertEqual(offset_fraction, 0)
415-
(*_, offset), _, offset_fraction = _strptime._strptime("-01:30:30", "%z")
416-
self.assertEqual(offset, -(one_hour + half_hour + half_minute))
417-
self.assertEqual(offset_fraction, 0)
418-
(*_, offset), _, offset_fraction = _strptime._strptime("-01:30:30.000001", "%z")
419-
self.assertEqual(offset, -(one_hour + half_hour + half_minute))
420-
self.assertEqual(offset_fraction, -1)
421-
(*_, offset), _, offset_fraction = _strptime._strptime("+01:30:30.001", "%z")
422-
self.assertEqual(offset, one_hour + half_hour + half_minute)
423-
self.assertEqual(offset_fraction, 1000)
424-
(*_, offset), _, offset_fraction = _strptime._strptime("Z", "%z")
425-
self.assertEqual(offset, 0)
426-
self.assertEqual(offset_fraction, 0)
409+
410+
cases = [
411+
("+01:00", one_hour, 0),
412+
("-01:30", -(one_hour + half_hour), 0),
413+
("-01:30:30", -(one_hour + half_hour + half_minute), 0),
414+
("-01:30:30.000001", -(one_hour + half_hour + half_minute), -1),
415+
("+01:30:30.001", +(one_hour + half_hour + half_minute), 1000),
416+
("Z", 0, 0),
417+
]
418+
for directive in ("%z", "%:z"):
419+
for offset_str, expected_offset, expected_fraction in cases:
420+
with self.subTest(offset_str=offset_str, directive=directive):
421+
(*_, offset), _, offset_fraction = _strptime._strptime(
422+
offset_str, directive
423+
)
424+
self.assertEqual(offset, expected_offset)
425+
self.assertEqual(offset_fraction, expected_fraction)
427426

428427
def test_bad_offset(self):
429-
with self.assertRaises(ValueError):
430-
_strptime._strptime("-01:30:30.", "%z")
431-
with self.assertRaises(ValueError):
432-
_strptime._strptime("-0130:30", "%z")
433-
with self.assertRaises(ValueError):
434-
_strptime._strptime("-01:30:30.1234567", "%z")
435-
with self.assertRaises(ValueError):
436-
_strptime._strptime("-01:30:30:123456", "%z")
428+
error_cases_any_z = [
429+
"-01:30:30.", # Decimal point not followed with digits
430+
"-01:30:30.1234567", # Too many digits after decimal point
431+
"-01:30:30:123456", # Colon as decimal separator
432+
"-0130:30", # Incorrect use of colons
433+
]
434+
for directive in ("%z", "%:z"):
435+
for timestr in error_cases_any_z:
436+
with self.subTest(timestr=timestr, directive=directive):
437+
with self.assertRaises(ValueError):
438+
_strptime._strptime(timestr, directive)
439+
440+
required_colons_cases = ["-013030", "+0130", "-01:3030.123456"]
441+
for timestr in required_colons_cases:
442+
with self.subTest(timestr=timestr):
443+
with self.assertRaises(ValueError):
444+
_strptime._strptime(timestr, "%:z")
445+
437446
with self.assertRaises(ValueError) as err:
438447
_strptime._strptime("-01:3030", "%z")
439448
self.assertEqual("Inconsistent use of : in -01:3030", str(err.exception))
449+
with self.assertRaises(ValueError) as err:
450+
_strptime._strptime("-01:3030", "%:z")
451+
self.assertEqual("Missing colon in %:z before '30', got '-01:3030'",
452+
str(err.exception))
440453

441454
@skip_if_buggy_ucrt_strfptime
442455
def test_timezone(self):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Support ``%:z`` directive for :meth:`datetime.datetime.strptime`,
2+
:meth:`datetime.time.strptime` and :func:`time.strptime`.
3+
Patch by Lucas Esposito and Semyon Moroz.

0 commit comments

Comments
 (0)