Skip to content

Commit baaf475

Browse files
committed
gh-158252: Parse month and weekday names in C
Read ASCII names from the current locale without caching them across setlocale calls. Preserve explicitly supplied weekdays in time.strptime, including numeric weekdays and weekdays that disagree with the date. Non-ASCII names and mixed spellings of a field retain the Python parser.
1 parent 79115de commit baaf475

4 files changed

Lines changed: 164 additions & 5 deletions

File tree

‎Include/internal/pycore_time.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ typedef struct {
349349
int hour, minute, second, fraction;
350350
int gmtoff; // INT_MIN means no offset was supplied.
351351
int yday; // January 1 is day 1.
352+
int weekday; // Monday is 0; a supplied weekday need not agree with the date.
352353
} _PyTime_StrptimeFields;
353354

354355
// Export for the '_datetime' shared extension.

‎Lib/test/test_strptime.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""PyUnit testing against strptime"""
22

33
import unittest
4+
import datetime
45
import time
56
import locale
67
import re
@@ -13,6 +14,69 @@
1314

1415
import _strptime
1516

17+
18+
class PublicStrptimeTests(unittest.TestCase):
19+
@run_with_locales('LC_TIME', 'C', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '')
20+
def test_locale_names(self):
21+
for month in range(1, 13):
22+
value = datetime.datetime(2024, month, 19, 13, 24, 35)
23+
for fmt in ('%b %d %Y', '%B %d %Y', '%a %Y-%m-%d', '%A %Y-%m-%d'):
24+
self.assert_matches_python(value.strftime(fmt), fmt)
25+
26+
def assert_matches_python(self, text, fmt):
27+
methods = (
28+
(time.strptime, _strptime._strptime_time),
29+
(datetime.date.strptime,
30+
lambda s, f: _strptime._strptime_datetime_date(datetime.date, s, f)),
31+
(datetime.time.strptime,
32+
lambda s, f: _strptime._strptime_datetime_time(datetime.time, s, f)),
33+
(datetime.datetime.strptime,
34+
lambda s, f: _strptime._strptime_datetime_datetime(
35+
datetime.datetime, s, f)),
36+
)
37+
for parse, reference in methods:
38+
with self.subTest(method=parse, text=text, format=fmt):
39+
try:
40+
expected = reference(text, fmt)
41+
except (ValueError, re.PatternError) as error:
42+
with self.assertRaises(type(error)) as caught:
43+
parse(text, fmt)
44+
self.assertEqual(str(caught.exception), str(error))
45+
else:
46+
actual = parse(text, fmt)
47+
self.assertEqual(actual, expected)
48+
if isinstance(actual, time.struct_time):
49+
self.assertEqual(actual.tm_zone, expected.tm_zone)
50+
self.assertEqual(actual.tm_gmtoff, expected.tm_gmtoff)
51+
elif isinstance(actual, (datetime.time, datetime.datetime)):
52+
self.assertEqual(actual.tzname(), expected.tzname())
53+
54+
@support.run_with_locale('LC_TIME', 'C')
55+
def test_names_and_weekdays(self):
56+
for month in range(1, 13):
57+
value = datetime.datetime(2024, month, 19, 13, 24, 35)
58+
for fmt in ('%b %d, %Y', '%d %B %Y',
59+
'%a, %d %b %Y %H:%M:%S GMT', '%A %Y-%m-%d'):
60+
text = value.strftime(fmt)
61+
for spelling in (text, text.lower(), text.upper()):
62+
self.assert_matches_python(spelling, fmt)
63+
for directive in ('a', 'A', 'w', 'u'):
64+
for day in range(1, 8):
65+
# A supplied weekday is preserved even when it disagrees.
66+
text = datetime.date(2024, 1, day).strftime('%' + directive)
67+
self.assert_matches_python(text + ' 2024-02-29',
68+
'%' + directive + ' %Y-%m-%d')
69+
for text, fmt in (
70+
('2024 January 02 03', '%Y %B %m %d'),
71+
('2024 Jan January 03', '%Y %b %B %d'),
72+
('2024 Monday Tue', '%Y %A %a'),
73+
('2024 Jax 03', '%Y %b %d'),
74+
('2024 Feb 30', '%Y %b %d'),
75+
('Monday Monday', '%A %A'),
76+
('8', '%u'), ('7', '%w'),
77+
):
78+
self.assert_matches_python(text, fmt)
79+
1680
libc_ver = platform.libc_ver()
1781
if libc_ver[0] == 'glibc':
1882
glibc_ver = tuple(map(int, libc_ver[1].split('.')))

‎Modules/timemodule.c‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1019,7 +1019,7 @@ time_strptime(PyObject *self, PyObject *args)
10191019
const int values[] = {
10201020
fields.year, fields.month, fields.day,
10211021
fields.hour, fields.minute, fields.second,
1022-
_PyTime_Weekday(fields.year, fields.yday), fields.yday, -1
1022+
fields.weekday, fields.yday, -1
10231023
};
10241024
for (size_t i = 0; i < Py_ARRAY_LENGTH(values); i++) {
10251025
PyObject *value = PyLong_FromLong(values[i]);

‎Python/_strptime.c‎

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
#include "Python.h"
22
#include "pycore_time.h"
33

4+
#include <time.h>
5+
46

57
/* Implement common parts of strptime in C to improve performance. Callers
68
* fall back to Lib/_strptime.py for a full parse when this doesn't match.
79
*
810
* Supported inputs are exact str objects containing only ASCII, with:
911
* - numeric %Y, %y, %m, %d, %H, %M, %S, and %f fields;
12+
* - ASCII locale month/weekday names and numeric %w/%u weekdays;
1013
* - a terminal %z: empty, Z, or +/-HHMM or +/-HH:MM with hours below 24;
1114
* - literal characters, %%, and ASCII whitespace.
1215
* Duplicate directives, mixed %Y/%y, and day-of-month without a year use the
13-
* fallback. So do other directives (including locale-dependent names and
16+
* fallback. So do other directives (including non-ASCII locale names and
1417
* week/day-of-year calculations), and offsets with seconds or fractions.
1518
*/
1619

@@ -31,6 +34,53 @@ strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos,
3134
return count >= minimum ? count : 0;
3235
}
3336

37+
static int
38+
strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos,
39+
unsigned char directive, int *value)
40+
{
41+
#ifdef HAVE_STRFTIME
42+
/* Read the current locale without keeping a cache across setlocale().
43+
* Non-ASCII names retain Python's decoding and Unicode case handling.
44+
* As in TimeRE, prefer the longest match, then the first equal name.
45+
*/
46+
int month = directive == 'b' || directive == 'B';
47+
int count = month ? 12 : 7;
48+
char format[] = {'%', directive, '\0'};
49+
int matched = 0;
50+
for (int i = 0; i < count; i++) {
51+
struct tm tm = {.tm_year = 101, .tm_mday = 1,
52+
.tm_mon = month ? i : 0,
53+
.tm_wday = month ? 0 : (i + 1) % 7};
54+
char name[256];
55+
size_t size = strftime(name, sizeof(name), format, &tm);
56+
if (size == 0) {
57+
return 0;
58+
}
59+
for (size_t j = 0; j < size; j++) {
60+
if ((unsigned char)name[j] >= 128) {
61+
return 0;
62+
}
63+
}
64+
if (size <= (size_t)matched || size > (size_t)(length - pos)) {
65+
continue;
66+
}
67+
size_t j;
68+
for (j = 0; j < size; j++) {
69+
if (Py_TOLOWER(data[pos + j]) != Py_TOLOWER(name[j])) {
70+
break;
71+
}
72+
}
73+
if (j == size) {
74+
matched = (int)size;
75+
*value = month ? i + 1 : i;
76+
}
77+
}
78+
return matched;
79+
#else
80+
return 0;
81+
#endif
82+
}
83+
3484
static int
3585
strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos,
3686
_PyTime_StrptimeFields *fields)
@@ -83,6 +133,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
83133
SECOND = 1U << 6,
84134
FRACTION = 1U << 7,
85135
OFFSET = 1U << 8,
136+
WEEKDAY = 1U << 9,
86137
ANY_YEAR = YEAR | SHORT_YEAR,
87138
};
88139
static const unsigned short directive_bits[128] = {
@@ -95,6 +146,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
95146
['S'] = SECOND,
96147
['f'] = FRACTION,
97148
['z'] = OFFSET,
149+
['w'] = WEEKDAY,
98150
};
99151
/* String subclasses and non-ASCII input retain Python's behavior. */
100152
if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) ||
@@ -105,7 +157,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
105157
const unsigned char *fmt = PyUnicode_1BYTE_DATA(format);
106158
Py_ssize_t length = PyUnicode_GET_LENGTH(string);
107159
Py_ssize_t fmt_length = PyUnicode_GET_LENGTH(format);
108-
*fields = (_PyTime_StrptimeFields){.year = 1900, .month = 1, .day = 1, .gmtoff = INT_MIN};
160+
*fields = (_PyTime_StrptimeFields){.year = 1900, .month = 1, .day = 1,
161+
.gmtoff = INT_MIN, .weekday = -1};
109162
unsigned int seen = 0;
110163
Py_ssize_t pos = 0;
111164

@@ -140,8 +193,15 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
140193
continue;
141194
}
142195

143-
/* Reject duplicate groups, aliases, and locale-dependent directives. */
144-
unsigned int bit = directive_bits[c];
196+
/* Mixed spellings of the same field retain Python's precedence. */
197+
unsigned char field = c;
198+
if (c == 'b' || c == 'B') {
199+
field = 'm';
200+
}
201+
else if (c == 'a' || c == 'A' || c == 'u') {
202+
field = 'w';
203+
}
204+
unsigned int bit = directive_bits[field];
145205
if (bit == 0) {
146206
return 0;
147207
}
@@ -150,6 +210,22 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
150210
}
151211
seen |= bit;
152212

213+
if (c == 'b' || c == 'B' || c == 'a' || c == 'A') {
214+
int value;
215+
int count = strptime_name(data, length, pos, c, &value);
216+
if (count == 0) {
217+
return 0;
218+
}
219+
pos += count;
220+
if (field == 'm') {
221+
fields->month = value;
222+
}
223+
else {
224+
fields->weekday = value;
225+
}
226+
continue;
227+
}
228+
153229
if (c == 'z') {
154230
if (i != fmt_length - 1 ||
155231
!strptime_offset(data, length, pos, fields)) {
@@ -170,6 +246,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
170246
else if (c == 'f') {
171247
maximum = 6;
172248
}
249+
else if (field == 'w') {
250+
maximum = 1;
251+
}
173252
else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') {
174253
pos++;
175254
maximum = 1;
@@ -181,6 +260,18 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
181260
}
182261
pos += count;
183262
switch (c) {
263+
case 'w':
264+
if (value > 6) {
265+
return 0;
266+
}
267+
fields->weekday = (value + 6) % 7;
268+
break;
269+
case 'u':
270+
if (value < 1 || value > 7) {
271+
return 0;
272+
}
273+
fields->weekday = value - 1;
274+
break;
184275
case 'Y':
185276
fields->year = value;
186277
break;
@@ -244,5 +335,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
244335
}
245336
fields->yday = days_before_month[fields->month] + fields->day +
246337
(fields->month > 2 && leap);
338+
if (fields->weekday < 0) {
339+
fields->weekday = _PyTime_Weekday(fields->year, fields->yday);
340+
}
247341
return 1;
248342
}

0 commit comments

Comments
 (0)