Skip to content

Commit 045177d

Browse files
committed
gh-158252: Dispatch strptime directives directly
Common numeric directives were passing through alias and locale-name checks before parsing their digits. Look up equivalent fields directly in the directive table and dispatch once to select the parsing rules. Compute missing weekdays in time.strptime, so the datetime methods avoid work they don't use. Keep weekdays supplied by the input or calculated from ordinal dates intact. Add coverage for mixed directive spellings and inferred weekdays.
1 parent 5cc3575 commit 045177d

4 files changed

Lines changed: 181 additions & 135 deletions

File tree

‎Include/internal/pycore_time.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,9 @@ typedef struct {
351351
int gmtoff_fraction;
352352
Py_ssize_t zone_start, zone_length; // Original spelling of a %Z name.
353353
int yday; // January 1 is day 1.
354-
int weekday; // Monday is 0; a supplied weekday need not agree with the date.
354+
// Monday is 0; a supplied weekday need not agree with the date.
355+
// If negative, the caller must compute it from year and yday if needed.
356+
int weekday;
355357
} _PyTime_StrptimeFields;
356358

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

‎Lib/test/test_strptime.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,34 @@
1616

1717

1818
class PublicStrptimeTests(unittest.TestCase):
19+
def test_inferred_weekday(self):
20+
for text, fmt in (
21+
('0001-01-01', '%Y-%m-%d'),
22+
('2024-02-29', '%Y-%m-%d'),
23+
('9999-12-31', '%Y-%m-%d'),
24+
('2023 366', '%Y %j'),
25+
('2024 00 0', '%Y %W %w'),
26+
):
27+
self.assert_matches_python(text, fmt)
28+
29+
@support.run_with_locale('LC_TIME', 'C')
30+
def test_mixed_directive_spellings(self):
31+
groups = (
32+
(('m', '01'), ('b', 'Feb'), ('B', 'March')),
33+
(('d', '01'), ('e', ' 2')),
34+
(('H', '13'), ('k', '14'), ('I', '03'), ('l', ' 4')),
35+
(('w', '0'), ('u', '1'), ('a', 'Tue'), ('A', 'Wednesday')),
36+
(('p', 'AM'), ('P', 'PM')),
37+
(('z', '+0100'), (':z', '+02:00')),
38+
(('U', '01'), ('W', '02')),
39+
)
40+
for group in groups:
41+
for first, first_text in group:
42+
for second, second_text in group:
43+
self.assert_matches_python(
44+
f'2024 {first_text} {second_text}',
45+
f'%Y %{first} %{second}')
46+
1947
def test_week_and_ordinal_dates(self):
2048
for year in (1, 4, 100, 400, 1900, 1999, 2000, 2004, 2020, 9999):
2149
for day in (1, 28, 59, 60, 61, 365, 366, 367):

‎Modules/timemodule.c‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1016,10 +1016,14 @@ time_strptime(PyObject *self, PyObject *args)
10161016
if (result == NULL) {
10171017
return NULL;
10181018
}
1019+
int weekday = fields.weekday;
1020+
if (weekday < 0) {
1021+
weekday = _PyTime_Weekday(fields.year, fields.yday);
1022+
}
10191023
const int values[] = {
10201024
fields.year, fields.month, fields.day,
10211025
fields.hour, fields.minute, fields.second,
1022-
fields.weekday, fields.yday, fields.zone_length ? 0 : -1
1026+
weekday, fields.yday, fields.zone_length ? 0 : -1
10231027
};
10241028
for (size_t i = 0; i < Py_ARRAY_LENGTH(values); i++) {
10251029
PyObject *value = PyLong_FromLong(values[i]);

‎Python/_strptime.c‎

Lines changed: 145 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,19 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
249249
['U'] = WEEK,
250250
['G'] = ISO_YEAR,
251251
['V'] = ISO_WEEK,
252+
/* Equivalent directives share the duplicate-field checks. */
253+
['b'] = MONTH,
254+
['B'] = MONTH,
255+
['e'] = DAY,
256+
['k'] = HOUR,
257+
['I'] = HOUR,
258+
['l'] = HOUR,
259+
['a'] = WEEKDAY,
260+
['A'] = WEEKDAY,
261+
['u'] = WEEKDAY,
262+
['P'] = AMPM,
263+
[':'] = OFFSET,
264+
['W'] = WEEK,
252265
};
253266
/* String subclasses and non-ASCII input retain Python's behavior. */
254267
if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) ||
@@ -308,145 +321,144 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
308321
return 0;
309322
}
310323
c = fmt[i];
311-
const char *alias = NULL;
312-
switch (c) {
313-
case 'F': alias = "%Y-%m-%d"; break;
314-
case 'D': alias = "%m/%d/%y"; break;
315-
case 'T': alias = "%H:%M:%S"; break;
316-
case 'R': alias = "%H:%M"; break;
317-
case 'e': c = 'd'; break;
318-
}
319-
if (alias != NULL) {
320-
/* These fixed expansions contain no further aliases. Parsing
321-
* them in place keeps duplicate-field checks and avoids copying
322-
* an arbitrarily long user format into a temporary buffer.
323-
*/
324-
assert(outer_fmt == NULL);
325-
outer_fmt = fmt;
326-
outer_length = fmt_length;
327-
outer_pos = i + 1;
328-
fmt = (const unsigned char *)alias;
329-
fmt_length = strlen(alias);
330-
i = -1;
331-
continue;
332-
}
333-
if (c == 'n' || c == 't') {
334-
while (pos < length && Py_UNICODE_ISSPACE(data[pos])) {
335-
pos++;
336-
}
337-
continue;
338-
}
339-
int require_colon = 0;
340-
if (c == ':' && i + 1 < fmt_length && fmt[i + 1] == 'z') {
341-
c = fmt[++i];
342-
require_colon = 1;
343-
}
344-
if (c == 'k') {
345-
c = 'H';
346-
}
347-
else if (c == 'l') {
348-
c = 'I';
349-
}
350-
else if (c == 'P') {
351-
c = 'p';
352-
}
353-
if (c == '%') {
354-
if (pos == length || data[pos++] != '%') {
355-
return 0;
356-
}
357-
continue;
358-
}
359-
360-
/* Mixed spellings of the same field retain Python's precedence. */
361-
unsigned char field = c;
362-
if (c == 'b' || c == 'B') {
363-
field = 'm';
364-
}
365-
else if (c == 'a' || c == 'A' || c == 'u') {
366-
field = 'w';
367-
}
368-
else if (c == 'I') {
369-
field = 'H';
370-
}
371-
else if (c == 'W') {
372-
field = 'U';
373-
}
374-
unsigned int bit = directive_bits[field];
375-
if (bit == 0) {
376-
return 0;
377-
}
324+
unsigned int bit = directive_bits[c];
378325
if (seen & bit) {
379326
return 0;
380327
}
381328
seen |= bit;
382329

383-
if (c == 'b' || c == 'B' || c == 'a' || c == 'A' || c == 'p') {
384-
int value;
385-
int count = strptime_name(data, length, pos, c, &value);
386-
if (count == 0) {
387-
return 0;
388-
}
389-
pos += count;
390-
if (field == 'm') {
391-
fields->month = value;
392-
}
393-
else if (c == 'p') {
394-
ampm = value;
330+
/* Dispatch once: numeric fields don't need to test for names,
331+
* aliases or timezone spellings before parsing their digits.
332+
*/
333+
int minimum = 1;
334+
int maximum = 2;
335+
switch (c) {
336+
case 'Y':
337+
case 'G':
338+
minimum = maximum = 4;
339+
break;
340+
case 'y':
341+
minimum = maximum = 2;
342+
break;
343+
case 'f':
344+
maximum = 6;
345+
break;
346+
case 'j':
347+
maximum = 3;
348+
break;
349+
case 'w':
350+
case 'u':
351+
maximum = 1;
352+
break;
353+
case 'd':
354+
case 'e':
355+
case 'H':
356+
case 'k':
357+
case 'I':
358+
case 'l':
359+
if (pos < length && data[pos] == ' ') {
360+
pos++;
361+
maximum = 1;
362+
}
363+
break;
364+
case 'm':
365+
case 'M':
366+
case 'S':
367+
case 'U':
368+
case 'W':
369+
case 'V':
370+
break;
371+
case 'b':
372+
case 'B':
373+
case 'a':
374+
case 'A':
375+
case 'p':
376+
case 'P': {
377+
int value;
378+
int count = strptime_name(data, length, pos,
379+
c == 'P' ? 'p' : c, &value);
380+
if (count == 0) {
381+
return 0;
382+
}
383+
pos += count;
384+
if (bit == MONTH) {
385+
fields->month = value;
386+
}
387+
else if (bit == AMPM) {
388+
ampm = value;
389+
}
390+
else {
391+
fields->weekday = value;
392+
}
393+
continue;
395394
}
396-
else {
397-
fields->weekday = value;
395+
case 'Z':
396+
/* Local timezone names and nonterminal names retain Python's
397+
* tzset state, DST rules and regex backtracking.
398+
*/
399+
if (i != fmt_length - 1 || length - pos != 3 ||
400+
!((Py_TOLOWER(data[pos]) == 'u' &&
401+
Py_TOLOWER(data[pos + 1]) == 't' &&
402+
Py_TOLOWER(data[pos + 2]) == 'c') ||
403+
(Py_TOLOWER(data[pos]) == 'g' &&
404+
Py_TOLOWER(data[pos + 1]) == 'm' &&
405+
Py_TOLOWER(data[pos + 2]) == 't'))) {
406+
return 0;
407+
}
408+
fields->zone_start = pos;
409+
fields->zone_length = 3;
410+
pos = length;
411+
continue;
412+
case ':':
413+
if (++i == fmt_length || fmt[i] != 'z') {
414+
return 0;
415+
}
416+
_Py_FALLTHROUGH;
417+
case 'z': {
418+
int count = strptime_offset(data, length, pos, c == ':', fields);
419+
if (count < 0) {
420+
return 0;
421+
}
422+
pos += count;
423+
continue;
398424
}
399-
continue;
400-
}
401-
402-
if (c == 'Z') {
403-
/* Local timezone names and nonterminal names retain Python's
404-
* tzset state, DST rules and regex backtracking.
405-
*/
406-
if (i != fmt_length - 1 || length - pos != 3 ||
407-
!((Py_TOLOWER(data[pos]) == 'u' &&
408-
Py_TOLOWER(data[pos + 1]) == 't' &&
409-
Py_TOLOWER(data[pos + 2]) == 'c') ||
410-
(Py_TOLOWER(data[pos]) == 'g' &&
411-
Py_TOLOWER(data[pos + 1]) == 'm' &&
412-
Py_TOLOWER(data[pos + 2]) == 't'))) {
413-
return 0;
425+
case 'F':
426+
case 'D':
427+
case 'T':
428+
case 'R': {
429+
const char *alias;
430+
switch (c) {
431+
case 'F': alias = "%Y-%m-%d"; break;
432+
case 'D': alias = "%m/%d/%y"; break;
433+
case 'T': alias = "%H:%M:%S"; break;
434+
default: alias = "%H:%M"; break;
435+
}
436+
/* These fixed expansions contain no further aliases. Parsing
437+
* them in place keeps duplicate-field checks and avoids copying
438+
* an arbitrarily long user format into a temporary buffer.
439+
*/
440+
assert(outer_fmt == NULL);
441+
outer_fmt = fmt;
442+
outer_length = fmt_length;
443+
outer_pos = i + 1;
444+
fmt = (const unsigned char *)alias;
445+
fmt_length = strlen(alias);
446+
i = -1;
447+
continue;
414448
}
415-
fields->zone_start = pos;
416-
fields->zone_length = 3;
417-
pos = length;
418-
continue;
419-
}
420-
if (c == 'z') {
421-
int count = strptime_offset(data, length, pos, require_colon, fields);
422-
if (count < 0) {
449+
case 'n':
450+
case 't':
451+
while (pos < length && Py_UNICODE_ISSPACE(data[pos])) {
452+
pos++;
453+
}
454+
continue;
455+
case '%':
456+
if (pos == length || data[pos++] != '%') {
457+
return 0;
458+
}
459+
continue;
460+
default:
423461
return 0;
424-
}
425-
pos += count;
426-
continue;
427-
}
428-
429-
int minimum = 1;
430-
int maximum = 2;
431-
if (c == 'Y' || c == 'G') {
432-
minimum = maximum = 4;
433-
}
434-
else if (c == 'y') {
435-
minimum = maximum = 2;
436-
}
437-
else if (c == 'f') {
438-
maximum = 6;
439-
}
440-
else if (c == 'j') {
441-
maximum = 3;
442-
}
443-
else if (field == 'w') {
444-
maximum = 1;
445-
}
446-
else if ((c == 'd' || c == 'H' || c == 'I') &&
447-
pos < length && data[pos] == ' ') {
448-
pos++;
449-
maximum = 1;
450462
}
451463
int value;
452464
int count = strptime_digits(data, length, pos, minimum, maximum, &value);
@@ -480,6 +492,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
480492
iso_week = value;
481493
break;
482494
case 'I':
495+
case 'l':
483496
if (value < 1 || value > 12) {
484497
return 0;
485498
}
@@ -511,12 +524,14 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
511524
fields->month = value;
512525
break;
513526
case 'd':
527+
case 'e':
514528
if (value < 1 || value > 31) {
515529
return 0;
516530
}
517531
fields->day = value;
518532
break;
519533
case 'H':
534+
case 'k':
520535
if (value > 23) {
521536
return 0;
522537
}
@@ -612,8 +627,5 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie
612627
fields->yday = days_before_month[fields->month] + fields->day +
613628
(fields->month > 2 && leap);
614629
}
615-
if (fields->weekday < 0) {
616-
fields->weekday = _PyTime_Weekday(fields->year, fields->yday);
617-
}
618630
return 1;
619631
}

0 commit comments

Comments
 (0)