Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Lib/_strptime.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ def __pattern_with_lax_year(self, format):
than the usual four or two digits, if the number is small enough
(e.g., '999' instead of `0999', or '9' instead of '09').

Note that this helper is not used to generate the regex patterns
Note that this helper is *not* used to prepare the regex patterns
for %Y and %y (these two still match, respectively, only four or
two digits, exactly).

Expand Down
172 changes: 54 additions & 118 deletions Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -1185,40 +1185,6 @@ def test_strptime_leap_year(self):
date.strptime('20-03-14', '%y-%m-%d')
date.strptime('02-29,2024', '%m-%d,%Y')

def test_strftime_strptime_roundtrip(self):
for fmt in [
'%c',
'%x',
'%Y%m%d',
'm:%m d:%d y:%y H:%H M:%M S:%S f:%f and some text',
]:
with self.subTest(fmt=fmt):
sample = date(1999, 3, 17).strftime(fmt)
if '1999' in sample:
year_seq = [
1, 9, 10, 99, 100, 999, # <- gh-124529 (ad %c/%x)
1000, 1410, 1989, 2024, 2095, 9999]
elif '99' in sample:
year_seq = [
1969, 1999,
2000, 2001, 2009, # <- gh-124529 (ad %c/%x)
2068]
else:
self.skipTest(f"these subtests need locale for which "
f"{fmt!r} includes year in some variant")
for year in year_seq:
for instance in [
date(year, 1, 1),
date(year, 6, 4),
date(year, 12, 31),
]:
reason = (f'strftime/strptime roundtrip '
f'for {fmt=} and {year=}')
with self.subTest(reason=reason, instance=instance):
formatted = instance.strftime(fmt)
parsed = date.strptime(formatted, fmt)
self.assertEqual(parsed, instance, msg=reason)

class SubclassDate(date):
sub_var = 1

Expand Down Expand Up @@ -2158,34 +2124,72 @@ def test_fromisocalendar_type_errors(self):
with self.assertRaises(TypeError):
self.theclass.fromisocalendar(*isocal)

def test_strptime_accepting_year_with_fewer_digits(self): # gh-124529
concerned_formats = '%c', '%x'
def test_strftime_strptime_roundtrip_concerning_locale_specific_year(self):
concerned_formats = '%c', '%x' # gh-124529

def run_subtest():
reason = (f'strptime accepting year with fewer '
f'digits for {fmt=} and {input_string=}')
reason = (f'test strftime/strptime roundtrip concerning '
f'locale-specific year representation '
f'- for {fmt=} and {year=}')
initial = expected = self.theclass.strptime(f'{year:04}', '%Y')
with self.subTest(reason=reason):
expected = prototype_inst.replace(year=year)
parsed = self.theclass.strptime(input_string, fmt)
formatted = initial.strftime(fmt)
parsed = self.theclass.strptime(formatted, fmt)
self.assertEqual(parsed, expected, msg=reason)

prototype_inst = self.theclass.strptime('1999', '%Y')
sample = self.theclass.strptime(f'1999-03-17', '%Y-%m-%d')
for fmt in concerned_formats:
with self.subTest(fmt=fmt):
sample = prototype_inst.strftime(fmt)
if (sample_4digits := '1999') in sample:
sample_str = sample.strftime(fmt)
if '1999' in sample_str:
for year in [
1, 9, 10, 99, 100, 999, # <- gh-124529
1000, 1410, 1989, 2024, 2095, 9999,
]:
run_subtest()
elif '99' in sample_str:
for year in [
1969, 1999,
2000, 2001, 2009, # <- gh-124529
2068,
]:
run_subtest()
else:
self.fail(f"it seems that {sample.strftime(fmt)=} "
f"does not include year={sample.year!r} "
f"in any variant (is there something "
f"severely wrong with current locale?)")

def test_strptime_accepting_locale_specific_year_with_fewer_digits(self):
concerned_formats = '%c', '%x' # gh-124529

def run_subtest():
input_str = sample_str.replace(sample_digits, year_digits)
reason = (f'test strptime accepting locale-specific '
f'year representation with fewer digits '
f'- for {fmt=} and {input_str=} ({year=})')
expected = sample.replace(year=year)
with self.subTest(reason=reason):
parsed = self.theclass.strptime(input_str, fmt)
self.assertEqual(parsed, expected, msg=reason)

sample = self.theclass.strptime(f'1999-03-17', '%Y-%m-%d')
for fmt in concerned_formats:
with self.subTest(fmt=fmt):
sample_str = sample.strftime(fmt)
if (sample_digits := '1999') in sample_str:
for year in [1, 9, 10, 99, 100, 999]:
y_digits = str(year)
input_string = sample.replace(sample_4digits, y_digits)
year_digits = str(year)
run_subtest()
elif (sample_2digits := '99') in sample:
elif (sample_digits := '99') in sample_str:
for year in [2000, 2001, 2009]:
y_digits = str(year - 2000)
input_string = sample.replace(sample_2digits, y_digits)
year_digits = str(year - 2000)
run_subtest()
else:
self.skipTest(f"these subtests need locale for which "
f"{fmt!r} includes year in some variant")
self.fail(f"it seems that {sample.strftime(fmt)=} "
f"does not include year={sample.year!r} "
f"in any variant (is there something "
f"severely wrong with current locale?)")


#############################################################################
Expand Down Expand Up @@ -3018,48 +3022,6 @@ def test_more_strftime(self):
except UnicodeEncodeError:
pass

def test_strftime_strptime_roundtrip(self):
for tz in [
None,
UTC,
timezone(timedelta(hours=2)),
timezone(timedelta(hours=-7)),
]:
fmt_suffix = '' if tz is None else ' %z'
for fmt in [
'%c %f',
'%x %X %f',
'%Y%m%d%H%M%S%f',
'm:%m d:%d y:%y H:%H M:%M S:%S f:%f and some text',
]:
fmt += fmt_suffix
with self.subTest(fmt=fmt):
sample = self.theclass(1999, 3, 17, 0, 0).strftime(fmt)
if '1999' in sample:
year_seq = [
1, 9, 10, 99, 100, 999, # <- gh-124529 (ad %c/%x)
1000, 1410, 1989, 2024, 2095, 9999]
elif '99' in sample:
year_seq = [
1969, 1999,
2000, 2001, 2009, # <- gh-124529 (ad %c/%x)
2068]
else:
self.skipTest(f"these subtests need locale for which "
f"{fmt!r} includes year in some variant")
for year in year_seq:
for instance in [
self.theclass(year, 1, 1, 0, 0, 0, tzinfo=tz),
self.theclass(year, 6, 4, 1, 42, 7, 99, tzinfo=tz),
self.theclass(year, 12, 31, 23, 59, 59, tzinfo=tz),
]:
reason = (f'strftime/strptime roundtrip '
f'for {fmt=} and {year=}')
with self.subTest(reason=reason, instance=instance):
formatted = instance.strftime(fmt)
parsed = self.theclass.strptime(formatted, fmt)
self.assertEqual(parsed, instance, msg=reason)

def test_extract(self):
dt = self.theclass(2002, 3, 4, 18, 45, 3, 1234)
self.assertEqual(dt.date(), date(2002, 3, 4))
Expand Down Expand Up @@ -4006,32 +3968,6 @@ def test_strptime_single_digit(self):
newdate = self.theclass.strptime(string, format)
self.assertEqual(newdate, target, msg=reason)

def test_strftime_strptime_roundtrip(self):
for tz in [
None,
UTC,
timezone(timedelta(hours=2)),
timezone(timedelta(hours=-7)),
]:
fmt_suffix = '' if tz is None else ' %z'
for fmt in [
'%c %f',
'%X %f',
'%H%M%S%f',
'm:%m d:%d y:%y H:%H M:%M S:%S f:%f and some text',
]:
fmt += fmt_suffix
for instance in [
self.theclass(0, 0, 0, tzinfo=tz),
self.theclass(1, 42, 7, tzinfo=tz),
self.theclass(23, 59, 59, 654321, tzinfo=tz),
]:
reason = f'strftime/strptime round trip for {fmt=}'
with self.subTest(reason=reason, instance=instance):
formatted = instance.strftime(fmt)
parsed = self.theclass.strptime(formatted, fmt)
self.assertEqual(parsed, instance, msg=reason)

def test_bool(self):
# time is always True.
cls = self.theclass
Expand Down
42 changes: 21 additions & 21 deletions Lib/test/test_strptime.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,8 +846,8 @@ def _input_str_and_expected_year_for_few_digits_year(fmt):
# where:
# * <strptime input string> -- is a `strftime(fmt)`-result-like str
# containing a year number which is *shorter* than the usual four
# or two digits (namely: the contained year number consist of just
# one digit: 7; the choice of this particular digit is arbitrary);
# or two digits (namely: here the contained year number consist of
# one digit: 7; that's an arbitrary choice);
# * <expected year> -- is an int representing the year number that
# is expected to be part of the result of a `strptime(<strptime
# input string>, fmt)` call (namely: either 7 or 2007, depending
Expand All @@ -856,36 +856,36 @@ def _input_str_and_expected_year_for_few_digits_year(fmt):
# part (for the given format string and current locale).

# 1. Prepare auxiliary *magic* time data (note that the magic values
# we use here are guaranteed to be compatible with `time.strftime()`
# and also well distinguishable within a formatted string, thanks to
# the fact that the amount of overloaded numbers is minimized, as in
# `_strptime.LocaleTime.__calc_date_time()`...):
# we use here are guaranteed to be compatible with `time.strftime()`,
# and are also intended to be well distinguishable within a formatted
# string, thanks to the fact that the amount of overloaded numbers is
# minimized, as in `_strptime.LocaleTime.__calc_date_time()`):
magic_year = 1999
magic_tt = (magic_year, 3, 17, 22, 44, 55, 2, 76, 0)
magic_4digits = str(magic_year)
magic_2digits = magic_4digits[-2:]

# 2. Pick our example year whose representation
# is shorter than the usual four or two digits:
# 2. Pick an arbitrary year number representation that
# is always *shorter* than the usual four or two digits:
input_year_str = '7'

# 3. Determine the <strptime input string> part of the return value:
# 3. Obtain the resultant 2-tuple:

input_string = time.strftime(fmt, magic_tt)
if (index_4digits := input_string.find(magic_4digits)) != -1:
expected_year = None

magic_4digits = str(magic_year)
if found_4digits := (magic_4digits in input_string):
# `input_string` contains up-to-4-digit year representation
input_string = input_string.replace(magic_4digits, input_year_str)
if (index_2digits := input_string.find(magic_2digits)) != -1:
expected_year = int(input_year_str)

magic_2digits = str(magic_year)[-2:]
if magic_2digits in input_string:
# `input_string` contains up-to-2-digit year representation
if found_4digits:
raise RuntimeError(f'case not supported by this helper: {fmt=} '
f'(includes both 2-digit and 4-digit year)')
input_string = input_string.replace(magic_2digits, input_year_str)

# 4. Determine the <expected year> part of the return value:
if index_4digits > index_2digits:
expected_year = int(input_year_str)
elif index_4digits < index_2digits:
expected_year = 2000 + int(input_year_str)
else:
assert index_4digits == index_2digits == -1
expected_year = None

return input_string, expected_year

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
Fix :meth:`datetime.datetime.strptime`, :meth:`datetime.date.strptime` as
well as :func:`time.strptime` (by modifying :class:`_strptime.TimeRE`) to
make ``%c`` and ``%x`` accept year number representations consisting of
fewer digits than the usual four or two (note that nothing changes for
``%Y`` and ``%y``). Thanks to that, certain ``strftime/strptime`` round
trips (such as ``datetime.strptime(dt.strftime("%c"), "%c"))`` for
``dt.year`` less than 1000) no longer raise ``ValueError`` for some
locales/platforms (this was the case, e.g., on Linux -- for various
locales, including ``C``/``C.UTF-8``).
Fix :meth:`datetime.datetime.strptime`, :meth:`datetime.date.strptime`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should write this

Fixed ``%c``/``%x`` in :meth:`datetime.datetime.strptime`, :meth:`datetime.date.strptime` and :func:`time.strptime` to accept years with  fewer digits than the usual 4 or 2 (not zero-padded).

This will be more concise and intuitive.

Copy link
Contributor Author

@zuo zuo Oct 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I've shortened it; yet I'd prefer to keep the information that the fix prevents the error for certain cases of a strftime/strptime round trip (this is the key element of the gh-124529 issue's description).

and :func:`time.strptime` (by altering the underlying mechanism) to make
``%c``/``%x`` accept year numbers with fewer digits than the usual 4 or
2 (not zero-padded), so that some ``strftime/strptime`` round trip cases
(e.g., ``date.strptime(d.strftime('%c'),'%c'))`` for ``d.year < 1000``,
with C-like locales on Linux) no longer raise :exc:`!ValueError`. Patch
by Jan Kaliszewski.
Loading