From 3ae4449eaa8a3edcf2d4b2f337f55a9e438eafc3 Mon Sep 17 00:00:00 2001 From: lucasesposito Date: Mon, 22 Jul 2024 23:54:19 +0200 Subject: [PATCH 1/3] gh-121237: Add %:z directive to strptime --- Doc/library/datetime.rst | 16 ++++++++- Lib/_strptime.py | 24 ++++++++----- Lib/test/test_strptime.py | 34 +++++++++++++++---- ...-07-22-23-18-57.gh-issue-121237.WkbIpy.rst | 1 + 4 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2024-07-22-23-18-57.gh-issue-121237.WkbIpy.rst diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 558900dd3b9a4d..c19797e9bdec13 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -2538,7 +2538,21 @@ 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.13 + ``%:z`` was added for :meth:`~.datetime.strptime` + +.. warning:: + + Since version 3.12, when ``%z`` directive is used in :meth:`~.datetime.strptime`, + strings formatted according ``%z`` directive are accepted and parsed correctly, + as well as strings formatted according to ``%:z``. + The later part of the behavior is unintended but it's still kept for backwards + compatibility. + Nonetheless, it's encouraged to use ``%z`` directive only to parse strings + formatted according to ``%z`` directive, while using ``%:z`` directive + for strings formatted according to ``%:z``. Technical Detail ^^^^^^^^^^^^^^^^ diff --git a/Lib/_strptime.py b/Lib/_strptime.py index e42af75af74bf5..4ae381a2d7fdc7 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -202,7 +202,10 @@ def __init__(self, locale_time=None): #XXX: Does 'Y' need to worry about having less or more than # 4 digits? 'Y': r"(?P\d\d\d\d)", + # "z" shouldn't support colons. Both ":?" should be removed. However, for backwards + # compatibility, we must keep them (see gh-121237) '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(self.locale_time.f_month[1:], 'B'), @@ -254,13 +257,16 @@ def pattern(self, format): year_in_format = False day_of_month_in_format = False while '%' in format: - directive_index = format.index('%')+1 - format_char = format[directive_index] + directive_index = format.index('%') + 1 + directive = format[directive_index] + if directive == ":": + # Special case for "%:z", which has an extra character + directive += format[directive_index + 1] processed_format = "%s%s%s" % (processed_format, - format[:directive_index-1], - self[format_char]) - format = format[directive_index+1:] - match format_char: + format[:directive_index - 1], + self[directive]) + format = format[directive_index + len(directive):] + match directive: case 'Y' | 'y' | 'G': year_in_format = True case 'd': @@ -446,8 +452,8 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"): 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 == 'Z': gmtoff = 0 else: @@ -455,7 +461,7 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"): 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 {z}" raise ValueError(msg) z = z[:5] + z[6:] hours = int(z[1:3]) diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 038746e26c24ad..077495c0d3daaa 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -12,6 +12,9 @@ import _strptime +from Lib.test.test_zipfile._path._test_params import parameterize + + class getlang_Tests(unittest.TestCase): """Test _getlang""" def test_basic(self): @@ -354,7 +357,7 @@ def test_julian(self): # Test julian directives self.helper('j', 7) - def test_offset(self): + def test_z_directive_offset(self): one_hour = 60 * 60 half_hour = 30 * 60 half_minute = 30 @@ -370,22 +373,39 @@ 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") + + @parameterize( + ["directive"], + [ + ("%z",), + ("%:z",), + ] + ) + def test_iso_offset(self, directive: str): + """ + Tests offset for the '%:z' directive from ISO 8601. + Since '%z' directive also accepts '%:z'-formatted strings for backwards compatibility, + we're testing that here too. + """ + one_hour = 60 * 60 + half_hour = 30 * 60 + half_minute = 30 + (*_, 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", "%z") + (*_, 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", "%z") + (*_, 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", "%z") + (*_, 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", "%z") + (*_, 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", "%z") + (*_, offset), _, offset_fraction = _strptime._strptime("Z", directive) self.assertEqual(offset, 0) self.assertEqual(offset_fraction, 0) diff --git a/Misc/NEWS.d/next/Library/2024-07-22-23-18-57.gh-issue-121237.WkbIpy.rst b/Misc/NEWS.d/next/Library/2024-07-22-23-18-57.gh-issue-121237.WkbIpy.rst new file mode 100644 index 00000000000000..6d9a0c04c8f0a5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-07-22-23-18-57.gh-issue-121237.WkbIpy.rst @@ -0,0 +1 @@ +Accept "%:z" in strptime From 4a7323169951551a85641103f6b6cfc398e493b2 Mon Sep 17 00:00:00 2001 From: lucasesposito Date: Tue, 23 Jul 2024 00:29:01 +0200 Subject: [PATCH 2/3] Fix error message --- Lib/_strptime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_strptime.py b/Lib/_strptime.py index 4ae381a2d7fdc7..9e85d94c6da8ee 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -461,7 +461,7 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"): z = z[:3] + z[4:] if len(z) > 5: if z[5] != ':': - msg = f"Inconsistent use of : in {z}" + msg = f"Inconsistent use of : in {found_dict[group_key]}" raise ValueError(msg) z = z[:5] + z[6:] hours = int(z[1:3]) From 43a8c13ca8e25d82737596d64e5d7153ac2a8661 Mon Sep 17 00:00:00 2001 From: lucasesposito Date: Tue, 23 Jul 2024 01:08:39 +0200 Subject: [PATCH 3/3] Moved some tests utilities from 'test_zipfile/...' to the general 'support' folder --- Lib/test/support/itertools.py | 12 +++ .../parameterize.py} | 78 +++++++++---------- Lib/test/test_strptime.py | 3 +- Lib/test/test_zipfile/_path/_itertools.py | 14 ---- Lib/test/test_zipfile/_path/test_path.py | 2 +- 5 files changed, 53 insertions(+), 56 deletions(-) create mode 100644 Lib/test/support/itertools.py rename Lib/test/{test_zipfile/_path/_test_params.py => support/parameterize.py} (91%) diff --git a/Lib/test/support/itertools.py b/Lib/test/support/itertools.py new file mode 100644 index 00000000000000..8994d810f124ba --- /dev/null +++ b/Lib/test/support/itertools.py @@ -0,0 +1,12 @@ +# from more_itertools v8.13.0 +def always_iterable(obj, base_type=(str, bytes)): + if obj is None: + return iter(()) + + if (base_type is not None) and isinstance(obj, base_type): + return iter((obj,)) + + try: + return iter(obj) + except TypeError: + return iter((obj,)) diff --git a/Lib/test/test_zipfile/_path/_test_params.py b/Lib/test/support/parameterize.py similarity index 91% rename from Lib/test/test_zipfile/_path/_test_params.py rename to Lib/test/support/parameterize.py index bc95b4ebf4a168..1a116d75906001 100644 --- a/Lib/test/test_zipfile/_path/_test_params.py +++ b/Lib/test/support/parameterize.py @@ -1,39 +1,39 @@ -import types -import functools - -from ._itertools import always_iterable - - -def parameterize(names, value_groups): - """ - Decorate a test method to run it as a set of subtests. - - Modeled after pytest.parametrize. - """ - - def decorator(func): - @functools.wraps(func) - def wrapped(self): - for values in value_groups: - resolved = map(Invoked.eval, always_iterable(values)) - params = dict(zip(always_iterable(names), resolved)) - with self.subTest(**params): - func(self, **params) - - return wrapped - - return decorator - - -class Invoked(types.SimpleNamespace): - """ - Wrap a function to be invoked for each usage. - """ - - @classmethod - def wrap(cls, func): - return cls(func=func) - - @classmethod - def eval(cls, cand): - return cand.func() if isinstance(cand, cls) else cand +import types +import functools + +from .itertools import always_iterable + + +def parameterize(names, value_groups): + """ + Decorate a test method to run it as a set of subtests. + + Modeled after pytest.parametrize. + """ + + def decorator(func): + @functools.wraps(func) + def wrapped(self): + for values in value_groups: + resolved = map(Invoked.eval, always_iterable(values)) + params = dict(zip(always_iterable(names), resolved)) + with self.subTest(**params): + func(self, **params) + + return wrapped + + return decorator + + +class Invoked(types.SimpleNamespace): + """ + Wrap a function to be invoked for each usage. + """ + + @classmethod + def wrap(cls, func): + return cls(func=func) + + @classmethod + def eval(cls, cand): + return cand.func() if isinstance(cand, cls) else cand diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 077495c0d3daaa..5ec50f2a406cdb 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -8,12 +8,11 @@ import sys from test import support from test.support import skip_if_buggy_ucrt_strfptime, warnings_helper +from test.support.parameterize import parameterize from datetime import date as datetime_date import _strptime -from Lib.test.test_zipfile._path._test_params import parameterize - class getlang_Tests(unittest.TestCase): """Test _getlang""" diff --git a/Lib/test/test_zipfile/_path/_itertools.py b/Lib/test/test_zipfile/_path/_itertools.py index f735dd21733006..d52402c755f21d 100644 --- a/Lib/test/test_zipfile/_path/_itertools.py +++ b/Lib/test/test_zipfile/_path/_itertools.py @@ -29,20 +29,6 @@ def __next__(self): return result -# from more_itertools v8.13.0 -def always_iterable(obj, base_type=(str, bytes)): - if obj is None: - return iter(()) - - if (base_type is not None) and isinstance(obj, base_type): - return iter((obj,)) - - try: - return iter(obj) - except TypeError: - return iter((obj,)) - - # from more_itertools v9.0.0 def consume(iterator, n=None): """Advance *iterable* by *n* steps. If *n* is ``None``, consume it diff --git a/Lib/test/test_zipfile/_path/test_path.py b/Lib/test/test_zipfile/_path/test_path.py index 99842ffd63a64e..fc784dd7c55f3e 100644 --- a/Lib/test/test_zipfile/_path/test_path.py +++ b/Lib/test/test_zipfile/_path/test_path.py @@ -10,11 +10,11 @@ import zipfile._path from test.support.os_helper import temp_dir, FakePath +from test.support.parameterize import parameterize, Invoked from ._functools import compose from ._itertools import Counter -from ._test_params import parameterize, Invoked class jaraco: