Skip to content

gh-53203: Fix strptime() for %c and %x formats on many locales #124946

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
32 changes: 30 additions & 2 deletions Lib/_strptime.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,19 @@ def __calc_date_time(self):
date_time[1] = time.strftime("%x", time_tuple).lower()
date_time[2] = time.strftime("%X", time_tuple).lower()
replacement_pairs = [('%', '%%'), (self.f_weekday[2], '%A'),
(self.f_month[3], '%B'), (self.a_weekday[2], '%a'),
(self.a_month[3], '%b'), (self.am_pm[1], '%p'),
(self.a_weekday[2], '%a'),
(self.am_pm[1], '%p'),
('1999', '%Y'), ('99', '%y'), ('22', '%H'),
('44', '%M'), ('55', '%S'), ('76', '%j'),
('17', '%d'), ('03', '%m'), ('3', '%m'),
# '3' needed for when no leading zero.
('2', '%w'), ('10', '%I')]
# The month format is treated specially because of a possible
# ambiguity in some locales where the full and abbreviated
# month names are equal. See doc of __find_month_format for more
# details.
#
month_format = self.__find_month_format()
replacement_pairs.extend([(tz, "%Z") for tz_values in self.timezone
for tz in tz_values])
for offset,directive in ((0,'%c'), (1,'%x'), (2,'%X')):
Expand All @@ -137,6 +143,8 @@ def __calc_date_time(self):
# strings (e.g., MacOS 9 having timezone as ('','')).
if old:
current_format = current_format.replace(old, new)
for month_str in (self.f_month[3], self.a_month[3]):
current_format = current_format.replace(month_str, month_format)
# If %W is used, then Sunday, 2005-01-03 will fall on week 0 since
# 2005-01-03 occurs before the first Monday of the year. Otherwise
# %U is used.
Expand All @@ -150,6 +158,26 @@ def __calc_date_time(self):
self.LC_date = date_time[1]
self.LC_time = date_time[2]

def __find_month_format(self):
"""Find the month format appropriate for the current locale.

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. Thus, cycle months of the year until a month is found where
these representations differ, and check the datetime string created
by strftime against this month, to make sure we select the correct
format specifier.
"""
for m in range(1, 13):
if self.f_month[m] != self.a_month[m]:
time_tuple = time.struct_time((1999, m, 17, 22, 44, 55, 2, 76, 0))
datetime = time.strftime('%c', time_tuple).lower()
if datetime.find(self.f_month[m]) >= 0:
return '%B'
elif datetime.find(self.a_month[m]) >= 0:
return '%b'
return '%B'

def __calc_timezone(self):
# Set self.timezone by using time.tzname.
# Do not worry about possibility of time.tzname[0] == time.tzname[1]
Expand Down
44 changes: 42 additions & 2 deletions Lib/test/support/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -930,8 +930,8 @@ def check_sizeof(test, o, size):
test.assertEqual(result, size, msg)

#=======================================================================
# Decorator for running a function in a different locale, correctly resetting
# it afterwards.
# Decorator/context manager for running a code in a different locale,
# correctly resetting it afterwards.

@contextlib.contextmanager
def run_with_locale(catstr, *locales):
Expand Down Expand Up @@ -959,6 +959,46 @@ def run_with_locale(catstr, *locales):
if locale and orig_locale:
locale.setlocale(category, orig_locale)

#=======================================================================
# Decorator for running a function in multiple locales (if they are
# availasble) and resetting the original locale afterwards.

def run_with_locales(catstr, *locales):
def deco(func):
@functools.wraps(func)
def wrapper(self, /, *args, **kwargs):
dry_run = True
try:
import locale
category = getattr(locale, catstr)
orig_locale = locale.setlocale(category)
except AttributeError:
# if the test author gives us an invalid category string
raise
except:
# cannot retrieve original locale, so do nothing
pass
else:
try:
for loc in locales:
with self.subTest(locale=loc):
try:
locale.setlocale(category, loc)
except:
self.skipTest(f'no locale {loc!r}')
else:
dry_run = False
func(self, *args, **kwargs)
finally:
locale.setlocale(category, orig_locale)
if dry_run:
# no locales available, so just run the test
# with the current locale
with self.subTest(locale=None):
func(self, *args, **kwargs)
return wrapper
return deco

#=======================================================================
# Decorator for running a function in a specific timezone, correctly
# resetting it afterwards.
Expand Down
103 changes: 63 additions & 40 deletions Lib/test/test_strptime.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import os
import sys
from test import support
from test.support import skip_if_buggy_ucrt_strfptime, warnings_helper
from test.support import warnings_helper
from test.support import skip_if_buggy_ucrt_strfptime, run_with_locales
from datetime import date as datetime_date

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

def helper(self, directive, position):
def roundtrip(self, fmt, position, time_tuple=None):
"""Helper fxn in testing."""
fmt = "%d %Y" if directive == 'd' else "%" + directive
strf_output = time.strftime(fmt, self.time_tuple)
if time_tuple is None:
time_tuple = self.time_tuple
strf_output = time.strftime(fmt, time_tuple)
strp_output = _strptime._strptime_time(strf_output, fmt)
self.assertTrue(strp_output[position] == self.time_tuple[position],
"testing of '%s' directive failed; '%s' -> %s != %s" %
(directive, strf_output, strp_output[position],
self.time_tuple[position]))
self.assertEqual(strp_output[position], time_tuple[position],
"testing of %r format failed; %r -> %r != %r" %
(fmt, strf_output, strp_output[position],
time_tuple[position]))
if support.verbose >= 3:
print("testing of %r format: %r -> %r" %
(fmt, strf_output, strp_output[position]))

def test_year(self):
# Test that the year is handled properly
for directive in ('y', 'Y'):
self.helper(directive, 0)
self.roundtrip('%Y', 0)
self.roundtrip('%y', 0)
self.roundtrip('%Y', 0, (1900, 1, 1, 0, 0, 0, 0, 1, 0))

# Must also make sure %y values are correct for bounds set by Open Group
for century, bounds in ((1900, ('69', '99')), (2000, ('00', '68'))):
for bound in bounds:
strp_output = _strptime._strptime_time(bound, '%y')
expected_result = century + int(bound)
self.assertTrue(strp_output[0] == expected_result,
"'y' test failed; passed in '%s' "
"and returned '%s'" % (bound, strp_output[0]))
strptime = _strptime._strptime_time
self.assertEqual(strptime('00', '%y')[0], 2000)
self.assertEqual(strptime('68', '%y')[0], 2068)
self.assertEqual(strptime('69', '%y')[0], 1969)
self.assertEqual(strptime('99', '%y')[0], 1999)

def test_month(self):
# Test for month directives
for directive in ('B', 'b', 'm'):
self.helper(directive, 1)
self.roundtrip('%m', 1)

@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', 'he_IL', '')
def test_month_locale(self):
# Test for month directives
self.roundtrip('%B', 1)
self.roundtrip('%b', 1)

def test_day(self):
# Test for day directives
self.helper('d', 2)
self.roundtrip('%d %Y', 2)

def test_hour(self):
# Test hour directives
self.helper('H', 3)
strf_output = time.strftime("%I %p", self.time_tuple)
strp_output = _strptime._strptime_time(strf_output, "%I %p")
self.assertTrue(strp_output[3] == self.time_tuple[3],
"testing of '%%I %%p' directive failed; '%s' -> %s != %s" %
(strf_output, strp_output[3], self.time_tuple[3]))
self.roundtrip('%H', 3)

# NB: Only works on locales with AM/PM
@run_with_locales('LC_TIME', 'en_US', 'ja_JP')
def test_hour_locale(self):
# Test hour directives
self.roundtrip('%I %p', 3)

def test_minute(self):
# Test minute directives
self.helper('M', 4)
self.roundtrip('%M', 4)

def test_second(self):
# Test second directives
self.helper('S', 5)
self.roundtrip('%S', 5)

def test_fraction(self):
# Test microseconds
Expand All @@ -347,12 +358,18 @@ def test_fraction(self):

def test_weekday(self):
# Test weekday directives
for directive in ('A', 'a', 'w', 'u'):
self.helper(directive,6)
self.roundtrip('%w', 6)
self.roundtrip('%u', 6)

@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '')
def test_weekday_locale(self):
# Test weekday directives
self.roundtrip('%A', 6)
self.roundtrip('%a', 6)

def test_julian(self):
# Test julian directives
self.helper('j', 7)
self.roundtrip('%j', 7)

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

def test_date_time(self):
# NB: Does not roundtrip on some locales like hif_FJ.
@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', 'he_IL', '')
def test_date_time_locale(self):
# Test %c directive
for position in range(6):
self.helper('c', position)
self.roundtrip('%c', slice(0, 6))
self.roundtrip('%c', slice(0, 6), (1900, 1, 1, 0, 0, 0, 0, 1, 0))

def test_date(self):
# NB: Dates before 1969 do not work on locales: C, POSIX,
# az_IR, fa_IR, sd_PK, uk_UA.
@run_with_locales('LC_TIME', 'en_US', 'fr_FR', 'de_DE', 'ja_JP')
def test_date_locale(self):
# Test %x directive
for position in range(0,3):
self.helper('x', position)
self.roundtrip('%x', slice(0, 3))
self.roundtrip('%x', slice(0, 3), (1900, 1, 1, 0, 0, 0, 0, 1, 0))

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

def test_percent(self):
# Make sure % signs are handled properly
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix :func:`time.strptime` for ``%c`` format in locales with a short March
month name, such as French or Hebrew.
Loading