Skip to content

Commit 1305285

Browse files
gh-53203: Fix strptime(..,'%c') on locales with short month names
In some locales (for example French and Hebrew), the default month used in __calc_date_time has the same name in full and abbreviated form. So the code failed to correctly distinguish formats %b and %B. Co-authored-by: Eli Bendersky <[email protected]>
1 parent c066bf5 commit 1305285

File tree

4 files changed

+137
-44
lines changed

4 files changed

+137
-44
lines changed

Lib/_strptime.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,19 @@ def __calc_date_time(self):
119119
date_time[1] = time.strftime("%x", time_tuple).lower()
120120
date_time[2] = time.strftime("%X", time_tuple).lower()
121121
replacement_pairs = [('%', '%%'), (self.f_weekday[2], '%A'),
122-
(self.f_month[3], '%B'), (self.a_weekday[2], '%a'),
123-
(self.a_month[3], '%b'), (self.am_pm[1], '%p'),
122+
(self.a_weekday[2], '%a'),
123+
(self.am_pm[1], '%p'),
124124
('1999', '%Y'), ('99', '%y'), ('22', '%H'),
125125
('44', '%M'), ('55', '%S'), ('76', '%j'),
126126
('17', '%d'), ('03', '%m'), ('3', '%m'),
127127
# '3' needed for when no leading zero.
128128
('2', '%w'), ('10', '%I')]
129+
# The month format is treated specially because of a possible
130+
# ambiguity in some locales where the full and abbreviated
131+
# month names are equal. See doc of __find_month_format for more
132+
# details.
133+
#
134+
month_format = self.__find_month_format()
129135
replacement_pairs.extend([(tz, "%Z") for tz_values in self.timezone
130136
for tz in tz_values])
131137
for offset,directive in ((0,'%c'), (1,'%x'), (2,'%X')):
@@ -137,6 +143,8 @@ def __calc_date_time(self):
137143
# strings (e.g., MacOS 9 having timezone as ('','')).
138144
if old:
139145
current_format = current_format.replace(old, new)
146+
for month_str in (self.f_month[3], self.a_month[3]):
147+
current_format = current_format.replace(month_str, month_format)
140148
# If %W is used, then Sunday, 2005-01-03 will fall on week 0 since
141149
# 2005-01-03 occurs before the first Monday of the year. Otherwise
142150
# %U is used.
@@ -150,6 +158,26 @@ def __calc_date_time(self):
150158
self.LC_date = date_time[1]
151159
self.LC_time = date_time[2]
152160

161+
def __find_month_format(self):
162+
"""Find the month format appropriate for the current locale.
163+
164+
In some locales (for example French and Hebrew), the default month
165+
used in __calc_date_time has the same name in full and abbreviated
166+
form. Thus, cycle months of the year until a month is found where
167+
these representations differ, and check the datetime string created
168+
by strftime against this month, to make sure we select the correct
169+
format specifier.
170+
"""
171+
for m in range(1, 13):
172+
if self.f_month[m] != self.a_month[m]:
173+
time_tuple = time.struct_time((1999, m, 17, 22, 44, 55, 2, 76, 0))
174+
datetime = time.strftime('%c', time_tuple).lower()
175+
if datetime.find(self.f_month[m]) >= 0:
176+
return '%B'
177+
elif datetime.find(self.a_month[m]) >= 0:
178+
return '%b'
179+
return '%B'
180+
153181
def __calc_timezone(self):
154182
# Set self.timezone by using time.tzname.
155183
# Do not worry about possibility of time.tzname[0] == time.tzname[1]

Lib/test/support/__init__.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -930,8 +930,8 @@ def check_sizeof(test, o, size):
930930
test.assertEqual(result, size, msg)
931931

932932
#=======================================================================
933-
# Decorator for running a function in a different locale, correctly resetting
934-
# it afterwards.
933+
# Decorator/context manager for running a code in a different locale,
934+
# correctly resetting it afterwards.
935935

936936
@contextlib.contextmanager
937937
def run_with_locale(catstr, *locales):
@@ -959,6 +959,46 @@ def run_with_locale(catstr, *locales):
959959
if locale and orig_locale:
960960
locale.setlocale(category, orig_locale)
961961

962+
#=======================================================================
963+
# Decorator for running a function in multiple locales (if they are
964+
# availasble) and resetting the original locale afterwards.
965+
966+
def run_with_locales(catstr, *locales):
967+
def deco(func):
968+
@functools.wraps(func)
969+
def wrapper(self, /, *args, **kwargs):
970+
dry_run = True
971+
try:
972+
import locale
973+
category = getattr(locale, catstr)
974+
orig_locale = locale.setlocale(category)
975+
except AttributeError:
976+
# if the test author gives us an invalid category string
977+
raise
978+
except:
979+
# cannot retrieve original locale, so do nothing
980+
pass
981+
else:
982+
try:
983+
for loc in locales:
984+
with self.subTest(locale=loc):
985+
try:
986+
locale.setlocale(category, loc)
987+
except:
988+
self.skipTest(f'no locale {loc!r}')
989+
else:
990+
dry_run = False
991+
func(self, *args, **kwargs)
992+
finally:
993+
locale.setlocale(category, orig_locale)
994+
if dry_run:
995+
# no locales available, so just run the test
996+
# with the current locale
997+
with self.subTest(locale=None):
998+
func(self, *args, **kwargs)
999+
return wrapper
1000+
return deco
1001+
9621002
#=======================================================================
9631003
# Decorator for running a function in a specific timezone, correctly
9641004
# resetting it afterwards.

Lib/test/test_strptime.py

Lines changed: 63 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import os
88
import sys
99
from test import support
10-
from test.support import skip_if_buggy_ucrt_strfptime, warnings_helper
10+
from test.support import warnings_helper
11+
from test.support import skip_if_buggy_ucrt_strfptime, run_with_locales
1112
from datetime import date as datetime_date
1213

1314
import _strptime
@@ -289,54 +290,64 @@ def test_unconverteddata(self):
289290
# Check ValueError is raised when there is unconverted data
290291
self.assertRaises(ValueError, _strptime._strptime_time, "10 12", "%m")
291292

292-
def helper(self, directive, position):
293+
def roundtrip(self, fmt, position, time_tuple=None):
293294
"""Helper fxn in testing."""
294-
fmt = "%d %Y" if directive == 'd' else "%" + directive
295-
strf_output = time.strftime(fmt, self.time_tuple)
295+
if time_tuple is None:
296+
time_tuple = self.time_tuple
297+
strf_output = time.strftime(fmt, time_tuple)
296298
strp_output = _strptime._strptime_time(strf_output, fmt)
297-
self.assertTrue(strp_output[position] == self.time_tuple[position],
298-
"testing of '%s' directive failed; '%s' -> %s != %s" %
299-
(directive, strf_output, strp_output[position],
300-
self.time_tuple[position]))
299+
self.assertEqual(strp_output[position], time_tuple[position],
300+
"testing of %r format failed; %r -> %r != %r" %
301+
(fmt, strf_output, strp_output[position],
302+
time_tuple[position]))
303+
if support.verbose >= 3:
304+
print("testing of %r format: %r -> %r" %
305+
(fmt, strf_output, strp_output[position]))
301306

302307
def test_year(self):
303308
# Test that the year is handled properly
304-
for directive in ('y', 'Y'):
305-
self.helper(directive, 0)
309+
self.roundtrip('%Y', 0)
310+
self.roundtrip('%y', 0)
311+
self.roundtrip('%Y', 0, (1900, 1, 1, 0, 0, 0, 0, 1, 0))
312+
306313
# Must also make sure %y values are correct for bounds set by Open Group
307-
for century, bounds in ((1900, ('69', '99')), (2000, ('00', '68'))):
308-
for bound in bounds:
309-
strp_output = _strptime._strptime_time(bound, '%y')
310-
expected_result = century + int(bound)
311-
self.assertTrue(strp_output[0] == expected_result,
312-
"'y' test failed; passed in '%s' "
313-
"and returned '%s'" % (bound, strp_output[0]))
314+
strptime = _strptime._strptime_time
315+
self.assertEqual(strptime('00', '%y')[0], 2000)
316+
self.assertEqual(strptime('68', '%y')[0], 2068)
317+
self.assertEqual(strptime('69', '%y')[0], 1969)
318+
self.assertEqual(strptime('99', '%y')[0], 1999)
314319

315320
def test_month(self):
316321
# Test for month directives
317-
for directive in ('B', 'b', 'm'):
318-
self.helper(directive, 1)
322+
self.roundtrip('%m', 1)
323+
324+
@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', 'he_IL', '')
325+
def test_month_locale(self):
326+
# Test for month directives
327+
self.roundtrip('%B', 1)
328+
self.roundtrip('%b', 1)
319329

320330
def test_day(self):
321331
# Test for day directives
322-
self.helper('d', 2)
332+
self.roundtrip('%d %Y', 2)
323333

324334
def test_hour(self):
325335
# Test hour directives
326-
self.helper('H', 3)
327-
strf_output = time.strftime("%I %p", self.time_tuple)
328-
strp_output = _strptime._strptime_time(strf_output, "%I %p")
329-
self.assertTrue(strp_output[3] == self.time_tuple[3],
330-
"testing of '%%I %%p' directive failed; '%s' -> %s != %s" %
331-
(strf_output, strp_output[3], self.time_tuple[3]))
336+
self.roundtrip('%H', 3)
337+
338+
# NB: Only works on locales with AM/PM
339+
@run_with_locales('LC_TIME', 'en_US', 'ja_JP')
340+
def test_hour_locale(self):
341+
# Test hour directives
342+
self.roundtrip('%I %p', 3)
332343

333344
def test_minute(self):
334345
# Test minute directives
335-
self.helper('M', 4)
346+
self.roundtrip('%M', 4)
336347

337348
def test_second(self):
338349
# Test second directives
339-
self.helper('S', 5)
350+
self.roundtrip('%S', 5)
340351

341352
def test_fraction(self):
342353
# Test microseconds
@@ -347,12 +358,18 @@ def test_fraction(self):
347358

348359
def test_weekday(self):
349360
# Test weekday directives
350-
for directive in ('A', 'a', 'w', 'u'):
351-
self.helper(directive,6)
361+
self.roundtrip('%w', 6)
362+
self.roundtrip('%u', 6)
363+
364+
@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '')
365+
def test_weekday_locale(self):
366+
# Test weekday directives
367+
self.roundtrip('%A', 6)
368+
self.roundtrip('%a', 6)
352369

353370
def test_julian(self):
354371
# Test julian directives
355-
self.helper('j', 7)
372+
self.roundtrip('%j', 7)
356373

357374
def test_offset(self):
358375
one_hour = 60 * 60
@@ -449,20 +466,26 @@ def test_bad_timezone(self):
449466
"time.daylight set to %s and passing in %s" %
450467
(time.tzname, tz_value, time.daylight, tz_name))
451468

452-
def test_date_time(self):
469+
# NB: Does not roundtrip on some locales like hif_FJ.
470+
@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', 'he_IL', '')
471+
def test_date_time_locale(self):
453472
# Test %c directive
454-
for position in range(6):
455-
self.helper('c', position)
473+
self.roundtrip('%c', slice(0, 6))
474+
self.roundtrip('%c', slice(0, 6), (1900, 1, 1, 0, 0, 0, 0, 1, 0))
456475

457-
def test_date(self):
476+
# NB: Dates before 1969 do not work on locales: C, POSIX,
477+
# az_IR, fa_IR, sd_PK, uk_UA.
478+
@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP')
479+
def test_date_locale(self):
458480
# Test %x directive
459-
for position in range(0,3):
460-
self.helper('x', position)
481+
self.roundtrip('%x', slice(0, 3))
482+
self.roundtrip('%x', slice(0, 3), (1900, 1, 1, 0, 0, 0, 0, 1, 0))
461483

462-
def test_time(self):
484+
# NB: Does not distinguish AM/PM time on a number of locales.
485+
@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP')
486+
def test_time_locale(self):
463487
# Test %X directive
464-
for position in range(3,6):
465-
self.helper('X', position)
488+
self.roundtrip('%X', slice(3, 6))
466489

467490
def test_percent(self):
468491
# Make sure % signs are handled properly
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix :func:`time.strptime` for ``%c`` format in locales with a short March
2+
month name, such as French or Hebrew.

0 commit comments

Comments
 (0)