Skip to content

Commit e842519

Browse files
committed
gh-121237: Add %:z directive to strptime
1 parent 58d305c commit e842519

File tree

5 files changed

+70
-35
lines changed

5 files changed

+70
-35
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:: 3.15
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.stpftime`, 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: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,10 @@ 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" might not support colons, if designed from scratch.
375+
# However, for backwards compatibility, we must keep them.
374376
'z': r"(?P<z>([+-]\d\d:?[0-5]\d(:?[0-5]\d(\.\d{1,6})?)?)|(?-i:Z))?",
377+
':z': r"(?P<colon_z>([+-]\d\d:[0-5]\d(:[0-5]\d(\.\d{1,6})?)?)|(?-i:Z))?",
375378
'A': self.__seqToRE(self.locale_time.f_weekday, 'A'),
376379
'a': self.__seqToRE(self.locale_time.a_weekday, 'a'),
377380
'B': self.__seqToRE(_fixmonths(self.locale_time.f_month[1:]), 'B'),
@@ -459,16 +462,16 @@ def pattern(self, format):
459462
year_in_format = False
460463
day_of_month_in_format = False
461464
def repl(m):
462-
format_char = m[1]
463-
match format_char:
465+
directive = m.group()[1:] # exclude `%` symbol
466+
match directive:
464467
case 'Y' | 'y' | 'G':
465468
nonlocal year_in_format
466469
year_in_format = True
467470
case 'd':
468471
nonlocal day_of_month_in_format
469472
day_of_month_in_format = True
470-
return self[format_char]
471-
format = re_sub(r'%[-_0^#]*[0-9]*([OE]?\\?.?)', repl, format)
473+
return self[directive]
474+
format = re_sub(r'%[-_0^#]*[0-9]*([OE]?[:\\]?.?)', repl, format)
472475
if day_of_month_in_format and not year_in_format:
473476
import warnings
474477
warnings.warn("""\
@@ -662,8 +665,8 @@ def parse_int(s):
662665
week_of_year_start = 0
663666
elif group_key == 'V':
664667
iso_week = int(found_dict['V'])
665-
elif group_key == 'z':
666-
z = found_dict['z']
668+
elif group_key in ('z', 'colon_z'):
669+
z = found_dict[group_key]
667670
if z:
668671
if z == 'Z':
669672
gmtoff = 0
@@ -672,7 +675,7 @@ def parse_int(s):
672675
z = z[:3] + z[4:]
673676
if len(z) > 5:
674677
if z[5] != ':':
675-
msg = f"Inconsistent use of : in {found_dict['z']}"
678+
msg = f"Inconsistent use of : in {found_dict[group_key]}"
676679
raise ValueError(msg)
677680
z = z[:5] + z[6:]
678681
hours = int(z[1:3])

Lib/test/datetimetester.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2895,6 +2895,12 @@ def test_strptime(self):
28952895
strptime("-00:02:01.000003", "%z").utcoffset(),
28962896
-timedelta(minutes=2, seconds=1, microseconds=3)
28972897
)
2898+
self.assertEqual(strptime("+01:07", "%:z").utcoffset(),
2899+
1 * HOUR + 7 * MINUTE)
2900+
self.assertEqual(strptime("-10:02", "%:z").utcoffset(),
2901+
-(10 * HOUR + 2 * MINUTE))
2902+
self.assertEqual(strptime("-00:00:01.00001", "%:z").utcoffset(),
2903+
-timedelta(seconds=1, microseconds=10))
28982904
# Only local timezone and UTC are supported
28992905
for tzseconds, tzname in ((0, 'UTC'), (0, 'GMT'),
29002906
(-_time.timezone, _time.tzname[0])):
@@ -2973,7 +2979,7 @@ def test_strptime_leap_year(self):
29732979
self.theclass.strptime('02-29,2024', '%m-%d,%Y')
29742980

29752981
def test_strptime_z_empty(self):
2976-
for directive in ('z',):
2982+
for directive in ('z', ':z'):
29772983
string = '2025-04-25 11:42:47'
29782984
format = f'%Y-%m-%d %H:%M:%S%{directive}'
29792985
target = self.theclass(2025, 4, 25, 11, 42, 47)
@@ -4041,6 +4047,12 @@ def test_strptime_tz(self):
40414047
strptime("-00:02:01.000003", "%z").utcoffset(),
40424048
-timedelta(minutes=2, seconds=1, microseconds=3)
40434049
)
4050+
self.assertEqual(strptime("+01:07", "%:z").utcoffset(),
4051+
1 * HOUR + 7 * MINUTE)
4052+
self.assertEqual(strptime("-10:02", "%:z").utcoffset(),
4053+
-(10 * HOUR + 2 * MINUTE))
4054+
self.assertEqual(strptime("-00:00:01.00001", "%:z").utcoffset(),
4055+
-timedelta(seconds=1, microseconds=10))
40444056
# Only local timezone and UTC are supported
40454057
for tzseconds, tzname in ((0, 'UTC'), (0, 'GMT'),
40464058
(-_time.timezone, _time.tzname[0])):
@@ -4070,9 +4082,11 @@ def test_strptime_tz(self):
40704082
self.assertEqual(strptime("UTC", "%Z").tzinfo, None)
40714083

40724084
def test_strptime_errors(self):
4073-
for tzstr in ("-2400", "-000", "z"):
4085+
for tzstr in ("-2400", "-000", "z", "24:00"):
40744086
with self.assertRaises(ValueError):
40754087
self.theclass.strptime(tzstr, "%z")
4088+
with self.assertRaises(ValueError):
4089+
self.theclass.strptime(tzstr, "%:z")
40764090

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

Lib/test/test_strptime.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -406,34 +406,41 @@ 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)
424409
(*_, offset), _, offset_fraction = _strptime._strptime("Z", "%z")
425410
self.assertEqual(offset, 0)
426411
self.assertEqual(offset_fraction, 0)
412+
for directive in ("%z", "%:z"):
413+
(*_, offset), _, offset_fraction = _strptime._strptime("+01:00",
414+
directive)
415+
self.assertEqual(offset, one_hour)
416+
self.assertEqual(offset_fraction, 0)
417+
(*_, offset), _, offset_fraction = _strptime._strptime("-01:30",
418+
directive)
419+
self.assertEqual(offset, -(one_hour + half_hour))
420+
self.assertEqual(offset_fraction, 0)
421+
(*_, offset), _, offset_fraction = _strptime._strptime("-01:30:30",
422+
directive)
423+
self.assertEqual(offset, -(one_hour + half_hour + half_minute))
424+
self.assertEqual(offset_fraction, 0)
425+
(*_, offset), _, offset_fraction = _strptime._strptime("-01:30:30.000001",
426+
directive)
427+
self.assertEqual(offset, -(one_hour + half_hour + half_minute))
428+
self.assertEqual(offset_fraction, -1)
429+
(*_, offset), _, offset_fraction = _strptime._strptime("+01:30:30.001",
430+
directive)
431+
self.assertEqual(offset, one_hour + half_hour + half_minute)
432+
self.assertEqual(offset_fraction, 1000)
427433

428434
def test_bad_offset(self):
429-
with self.assertRaises(ValueError):
430-
_strptime._strptime("-01:30:30.", "%z")
435+
for directive in ("%z", "%:z"):
436+
with self.assertRaises(ValueError):
437+
_strptime._strptime("-01:30:30.", directive)
438+
with self.assertRaises(ValueError):
439+
_strptime._strptime("-01:30:30.1234567", directive)
440+
with self.assertRaises(ValueError):
441+
_strptime._strptime("-01:30:30:123456", directive)
431442
with self.assertRaises(ValueError):
432443
_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")
437444
with self.assertRaises(ValueError) as err:
438445
_strptime._strptime("-01:3030", "%z")
439446
self.assertEqual("Inconsistent use of : in -01:3030", str(err.exception))
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Support ``%:z`` directive for :meth:`~datetime.datetime.strptime`. Patch by
2+
Semyon Moroz.

0 commit comments

Comments
 (0)