``%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
^^^^^^^^^^^^^^^^
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
# 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'),
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("""\
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
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
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])
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])):
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)
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])):
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.
(*_, 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):
--- /dev/null
+Support ``%:z`` directive for :meth:`datetime.datetime.strptime`,
+:meth:`datetime.time.strptime` and :func:`time.strptime`.
+Patch by Lucas Esposito and Semyon Moroz.