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: pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy