From b4c27d0f955602bd8049830a8e8857a31590e36a Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 20 Oct 2026 16:46:08 -0400 Subject: [PATCH 1/9] 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. --- Include/internal/pycore_time.h | 1 + Lib/test/test_strptime.py | 64 +++++++++++++++++++++ Modules/timemodule.c | 2 +- Python/_strptime.c | 102 +++++++++++++++++++++++++++++++-- 4 files changed, 164 insertions(+), 5 deletions(-) diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h index 12940cdce8abbde..71bc01eb62a1fc4 100644 --- a/Include/internal/pycore_time.h +++ b/Include/internal/pycore_time.h @@ -349,6 +349,7 @@ typedef struct { int hour, minute, second, fraction; int gmtoff; // INT_MIN means no offset was supplied. int yday; // January 1 is day 1. + int weekday; // Monday is 0; a supplied weekday need not agree with the date. } _PyTime_StrptimeFields; // Export for the '_datetime' shared extension. diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index d70837ea63f5474..b89ff6232dffca5 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -1,6 +1,7 @@ """PyUnit testing against strptime""" import unittest +import datetime import time import locale import re @@ -13,6 +14,69 @@ import _strptime + +class PublicStrptimeTests(unittest.TestCase): + @run_with_locales('LC_TIME', 'C', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '') + def test_locale_names(self): + for month in range(1, 13): + value = datetime.datetime(2024, month, 19, 13, 24, 35) + for fmt in ('%b %d %Y', '%B %d %Y', '%a %Y-%m-%d', '%A %Y-%m-%d'): + self.assert_matches_python(value.strftime(fmt), fmt) + + def assert_matches_python(self, text, fmt): + methods = ( + (time.strptime, _strptime._strptime_time), + (datetime.date.strptime, + lambda s, f: _strptime._strptime_datetime_date(datetime.date, s, f)), + (datetime.time.strptime, + lambda s, f: _strptime._strptime_datetime_time(datetime.time, s, f)), + (datetime.datetime.strptime, + lambda s, f: _strptime._strptime_datetime_datetime( + datetime.datetime, s, f)), + ) + for parse, reference in methods: + with self.subTest(method=parse, text=text, format=fmt): + try: + expected = reference(text, fmt) + except (ValueError, re.PatternError) as error: + with self.assertRaises(type(error)) as caught: + parse(text, fmt) + self.assertEqual(str(caught.exception), str(error)) + else: + actual = parse(text, fmt) + self.assertEqual(actual, expected) + if isinstance(actual, time.struct_time): + self.assertEqual(actual.tm_zone, expected.tm_zone) + self.assertEqual(actual.tm_gmtoff, expected.tm_gmtoff) + elif isinstance(actual, (datetime.time, datetime.datetime)): + self.assertEqual(actual.tzname(), expected.tzname()) + + @support.run_with_locale('LC_TIME', 'C') + def test_names_and_weekdays(self): + for month in range(1, 13): + value = datetime.datetime(2024, month, 19, 13, 24, 35) + for fmt in ('%b %d, %Y', '%d %B %Y', + '%a, %d %b %Y %H:%M:%S GMT', '%A %Y-%m-%d'): + text = value.strftime(fmt) + for spelling in (text, text.lower(), text.upper()): + self.assert_matches_python(spelling, fmt) + for directive in ('a', 'A', 'w', 'u'): + for day in range(1, 8): + # A supplied weekday is preserved even when it disagrees. + text = datetime.date(2024, 1, day).strftime('%' + directive) + self.assert_matches_python(text + ' 2024-02-29', + '%' + directive + ' %Y-%m-%d') + for text, fmt in ( + ('2024 January 02 03', '%Y %B %m %d'), + ('2024 Jan January 03', '%Y %b %B %d'), + ('2024 Monday Tue', '%Y %A %a'), + ('2024 Jax 03', '%Y %b %d'), + ('2024 Feb 30', '%Y %b %d'), + ('Monday Monday', '%A %A'), + ('8', '%u'), ('7', '%w'), + ): + self.assert_matches_python(text, fmt) + libc_ver = platform.libc_ver() if libc_ver[0] == 'glibc': glibc_ver = tuple(map(int, libc_ver[1].split('.'))) diff --git a/Modules/timemodule.c b/Modules/timemodule.c index f3bfef333b383bb..19da7dc8be76d96 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1019,7 +1019,7 @@ time_strptime(PyObject *self, PyObject *args) const int values[] = { fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, - _PyTime_Weekday(fields.year, fields.yday), fields.yday, -1 + fields.weekday, fields.yday, -1 }; for (size_t i = 0; i < Py_ARRAY_LENGTH(values); i++) { PyObject *value = PyLong_FromLong(values[i]); diff --git a/Python/_strptime.c b/Python/_strptime.c index 42f546f9714f863..707f5d23e32a31f 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -1,16 +1,19 @@ #include "Python.h" #include "pycore_time.h" +#include + /* Implement common parts of strptime in C to improve performance. Callers * fall back to Lib/_strptime.py for a full parse when this doesn't match. * * Supported inputs are exact str objects containing only ASCII, with: * - numeric %Y, %y, %m, %d, %H, %M, %S, and %f fields; + * - ASCII locale month/weekday names and numeric %w/%u weekdays; * - a terminal %z: empty, Z, or +/-HHMM or +/-HH:MM with hours below 24; * - literal characters, %%, and ASCII whitespace. * Duplicate directives, mixed %Y/%y, and day-of-month without a year use the - * fallback. So do other directives (including locale-dependent names and + * fallback. So do other directives (including non-ASCII locale names and * week/day-of-year calculations), and offsets with seconds or fractions. */ @@ -31,6 +34,53 @@ strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, return count >= minimum ? count : 0; } +static int +strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + unsigned char directive, int *value) +{ +#ifdef HAVE_STRFTIME + /* Read the current locale without keeping a cache across setlocale(). + * Non-ASCII names retain Python's decoding and Unicode case handling. + * As in TimeRE, prefer the longest match, then the first equal name. + */ + int month = directive == 'b' || directive == 'B'; + int count = month ? 12 : 7; + char format[] = {'%', directive, '\0'}; + int matched = 0; + for (int i = 0; i < count; i++) { + struct tm tm = {.tm_year = 101, .tm_mday = 1, + .tm_mon = month ? i : 0, + .tm_wday = month ? 0 : (i + 1) % 7}; + char name[256]; + size_t size = strftime(name, sizeof(name), format, &tm); + if (size == 0) { + return 0; + } + for (size_t j = 0; j < size; j++) { + if ((unsigned char)name[j] >= 128) { + return 0; + } + } + if (size <= (size_t)matched || size > (size_t)(length - pos)) { + continue; + } + size_t j; + for (j = 0; j < size; j++) { + if (Py_TOLOWER(data[pos + j]) != Py_TOLOWER(name[j])) { + break; + } + } + if (j == size) { + matched = (int)size; + *value = month ? i + 1 : i; + } + } + return matched; +#else + return 0; +#endif +} + static int strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, _PyTime_StrptimeFields *fields) @@ -83,6 +133,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie SECOND = 1U << 6, FRACTION = 1U << 7, OFFSET = 1U << 8, + WEEKDAY = 1U << 9, ANY_YEAR = YEAR | SHORT_YEAR, }; static const unsigned short directive_bits[128] = { @@ -95,6 +146,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie ['S'] = SECOND, ['f'] = FRACTION, ['z'] = OFFSET, + ['w'] = WEEKDAY, }; /* String subclasses and non-ASCII input retain Python's behavior. */ if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || @@ -105,7 +157,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie const unsigned char *fmt = PyUnicode_1BYTE_DATA(format); Py_ssize_t length = PyUnicode_GET_LENGTH(string); Py_ssize_t fmt_length = PyUnicode_GET_LENGTH(format); - *fields = (_PyTime_StrptimeFields){.year = 1900, .month = 1, .day = 1, .gmtoff = INT_MIN}; + *fields = (_PyTime_StrptimeFields){.year = 1900, .month = 1, .day = 1, + .gmtoff = INT_MIN, .weekday = -1}; unsigned int seen = 0; Py_ssize_t pos = 0; @@ -141,8 +194,15 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie continue; } - /* Reject duplicate groups, aliases, and locale-dependent directives. */ - unsigned int bit = directive_bits[c]; + /* Mixed spellings of the same field retain Python's precedence. */ + unsigned char field = c; + if (c == 'b' || c == 'B') { + field = 'm'; + } + else if (c == 'a' || c == 'A' || c == 'u') { + field = 'w'; + } + unsigned int bit = directive_bits[field]; if (bit == 0) { return 0; } @@ -151,6 +211,22 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } seen |= bit; + if (c == 'b' || c == 'B' || c == 'a' || c == 'A') { + int value; + int count = strptime_name(data, length, pos, c, &value); + if (count == 0) { + return 0; + } + pos += count; + if (field == 'm') { + fields->month = value; + } + else { + fields->weekday = value; + } + continue; + } + if (c == 'z') { if (i != fmt_length - 1 || !strptime_offset(data, length, pos, fields)) { @@ -171,6 +247,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie else if (c == 'f') { maximum = 6; } + else if (field == 'w') { + maximum = 1; + } else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') { pos++; maximum = 1; @@ -182,6 +261,18 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } pos += count; switch (c) { + case 'w': + if (value > 6) { + return 0; + } + fields->weekday = (value + 6) % 7; + break; + case 'u': + if (value < 1 || value > 7) { + return 0; + } + fields->weekday = value - 1; + break; case 'Y': fields->year = value; break; @@ -245,5 +336,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } fields->yday = days_before_month[fields->month] + fields->day + (fields->month > 2 && leap); + if (fields->weekday < 0) { + fields->weekday = _PyTime_Weekday(fields->year, fields->yday); + } return 1; } From afb1cd3adcdb1a10f0bc653d967a2ba5bfcb5b91 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 20 Oct 2026 16:48:01 -0400 Subject: [PATCH 2/9] gh-158252: Parse twelve-hour clocks in C Use the current locale for AM/PM indicators and preserve the default without an indicator. Include the equivalent %k, %l and %P directives. Locales with empty or non-ASCII indicators retain the Python parser. --- Lib/test/test_strptime.py | 20 ++++++++++++ Python/_strptime.c | 68 +++++++++++++++++++++++++++++++++++---- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index b89ff6232dffca5..185652585a533c9 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -16,6 +16,26 @@ class PublicStrptimeTests(unittest.TestCase): + @run_with_locales('LC_TIME', 'C', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '') + def test_twelve_hour_clock(self): + for hour in range(24): + value = datetime.datetime(2024, 1, 1, hour, 24, 35) + for fmt in ('%Y-%m-%d %I:%M:%S %p', '%p %I:%M:%S', '%I', '%p'): + self.assert_matches_python(value.strftime(fmt), fmt) + + @support.run_with_locale('LC_TIME', 'C') + def test_hour_aliases_and_precedence(self): + for text, fmt in ( + ('12 AM', '%I %p'), ('12 PM', '%I %p'), + (' 1 pm', '%l %P'), (' 0', '%k'), + ('PM 12', '%p %I'), ('23 AM', '%H %p'), + ('12 13 PM', '%I %H %p'), ('13 12 PM', '%H %I %p'), + ('00 AM', '%I %p'), ('13 PM', '%I %p'), + ('1 pM', '%I %p'), ('1 PX', '%I %p'), + ('AM PM', '%p %P'), + ): + self.assert_matches_python(text, fmt) + @run_with_locales('LC_TIME', 'C', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '') def test_locale_names(self): for month in range(1, 13): diff --git a/Python/_strptime.c b/Python/_strptime.c index 707f5d23e32a31f..8e08aaa45b8a0a0 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -10,6 +10,7 @@ * Supported inputs are exact str objects containing only ASCII, with: * - numeric %Y, %y, %m, %d, %H, %M, %S, and %f fields; * - ASCII locale month/weekday names and numeric %w/%u weekdays; + * - %I with ASCII %p indicators, and the %k, %l, %P aliases; * - a terminal %z: empty, Z, or +/-HHMM or +/-HH:MM with hours below 24; * - literal characters, %%, and ASCII whitespace. * Duplicate directives, mixed %Y/%y, and day-of-month without a year use the @@ -44,13 +45,14 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, * As in TimeRE, prefer the longest match, then the first equal name. */ int month = directive == 'b' || directive == 'B'; - int count = month ? 12 : 7; + int count = month ? 12 : directive == 'p' ? 2 : 7; char format[] = {'%', directive, '\0'}; int matched = 0; for (int i = 0; i < count; i++) { struct tm tm = {.tm_year = 101, .tm_mday = 1, .tm_mon = month ? i : 0, - .tm_wday = month ? 0 : (i + 1) % 7}; + .tm_wday = month ? 0 : (i + 1) % 7, + .tm_hour = i == 0 ? 1 : 22}; char name[256]; size_t size = strftime(name, sizeof(name), format, &tm); if (size == 0) { @@ -61,12 +63,31 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, return 0; } } - if (size <= (size_t)matched || size > (size_t)(length - pos)) { + char *start = name; + if (directive == 'p') { + while (size && Py_ISSPACE(start[size - 1])) { + size--; + } + while (size && Py_ISSPACE(*start)) { + start++; + size--; + } + /* Empty indicators have special defaulting rules in Python. */ + if (size == 0) { + return 0; + } + } + if (data == NULL) { + continue; + } + if (size < (size_t)matched || + (size == (size_t)matched && directive != 'p') || + size > (size_t)(length - pos)) { continue; } size_t j; for (j = 0; j < size; j++) { - if (Py_TOLOWER(data[pos + j]) != Py_TOLOWER(name[j])) { + if (Py_TOLOWER(data[pos + j]) != Py_TOLOWER(start[j])) { break; } } @@ -75,7 +96,7 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, *value = month ? i + 1 : i; } } - return matched; + return data == NULL ? 1 : matched; #else return 0; #endif @@ -134,6 +155,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie FRACTION = 1U << 7, OFFSET = 1U << 8, WEEKDAY = 1U << 9, + AMPM = 1U << 10, ANY_YEAR = YEAR | SHORT_YEAR, }; static const unsigned short directive_bits[128] = { @@ -147,6 +169,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie ['f'] = FRACTION, ['z'] = OFFSET, ['w'] = WEEKDAY, + ['p'] = AMPM, }; /* String subclasses and non-ASCII input retain Python's behavior. */ if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || @@ -160,6 +183,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie *fields = (_PyTime_StrptimeFields){.year = 1900, .month = 1, .day = 1, .gmtoff = INT_MIN, .weekday = -1}; unsigned int seen = 0; + int hour12 = 0; + int ampm = -1; Py_ssize_t pos = 0; for (Py_ssize_t i = 0; i < fmt_length; i++) { @@ -187,6 +212,15 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie return 0; } c = fmt[i]; + if (c == 'k') { + c = 'H'; + } + else if (c == 'l') { + c = 'I'; + } + else if (c == 'P') { + c = 'p'; + } if (c == '%') { if (pos == length || data[pos++] != '%') { return 0; @@ -202,6 +236,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie else if (c == 'a' || c == 'A' || c == 'u') { field = 'w'; } + else if (c == 'I') { + field = 'H'; + } unsigned int bit = directive_bits[field]; if (bit == 0) { return 0; @@ -211,7 +248,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } seen |= bit; - if (c == 'b' || c == 'B' || c == 'a' || c == 'A') { + if (c == 'b' || c == 'B' || c == 'a' || c == 'A' || c == 'p') { int value; int count = strptime_name(data, length, pos, c, &value); if (count == 0) { @@ -221,6 +258,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie if (field == 'm') { fields->month = value; } + else if (c == 'p') { + ampm = value; + } else { fields->weekday = value; } @@ -250,7 +290,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie else if (field == 'w') { maximum = 1; } - else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') { + else if ((c == 'd' || c == 'H' || c == 'I') && + pos < length && data[pos] == ' ') { pos++; maximum = 1; } @@ -261,6 +302,13 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } pos += count; switch (c) { + case 'I': + if (value < 1 || value > 12) { + return 0; + } + hour12 = 1; + fields->hour = value; + break; case 'w': if (value > 6) { return 0; @@ -317,6 +365,12 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie break; } } + if (hour12) { + if (ampm < 0 && !strptime_name(NULL, 0, 0, 'p', &m)) { + return 0; + } + fields->hour = fields->hour % 12 + (ampm == 1 ? 12 : 0); + } static const int days_before_month[] = { 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 }; From d1ea3f1ca72d0ffeaa023e64df75ba439a371f66 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 20 Oct 2026 16:50:20 -0400 Subject: [PATCH 3/9] gh-158252: Extend C strptime timezone parsing Allow nonterminal offsets, offset seconds and fractions, and %:z. Preserve fractional offsets for datetime and time objects, and preserve terminal UTC/GMT names and DST metadata for time.strptime. Local timezone names and nonterminal %Z still use the Python parser. --- Include/internal/pycore_time.h | 2 + Lib/test/test_strptime.py | 15 ++++++ Modules/_datetimemodule.c | 27 ++++++++-- Modules/timemodule.c | 13 +++-- Python/_strptime.c | 97 ++++++++++++++++++++++++++-------- 5 files changed, 124 insertions(+), 30 deletions(-) diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h index 71bc01eb62a1fc4..4cd537e37a75ca5 100644 --- a/Include/internal/pycore_time.h +++ b/Include/internal/pycore_time.h @@ -348,6 +348,8 @@ typedef struct { int year, month, day; int hour, minute, second, fraction; int gmtoff; // INT_MIN means no offset was supplied. + int gmtoff_fraction; + Py_ssize_t zone_start, zone_length; // Original spelling of a %Z name. int yday; // January 1 is day 1. int weekday; // Monday is 0; a supplied weekday need not agree with the date. } _PyTime_StrptimeFields; diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 185652585a533c9..8e153fd1201985c 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -16,6 +16,21 @@ class PublicStrptimeTests(unittest.TestCase): + def test_offsets_and_zone_names(self): + offsets = ('', 'Z', '+0530', '-03:30', '+01:02:03', '-000001.000001', + '+01:02:03.456789', '+000000.5', '-00:00:00.5', + '+2400', '+0060', '+01:0203', '+0102:03', '+01:02:99', + '+01:02:03.', '+01:02:03.1234567', 'z', '+', '+000') + for offset in offsets: + for directive in ('%z', '%:z'): + for suffix, fmt_suffix in (('', ''), (' UTC', ' %Z'), + (' gMt', ' %Z'), (' 12:34', ' %H:%M')): + self.assert_matches_python(offset + suffix, + directive + fmt_suffix) + for name in ('UTC', 'GMT', 'uTc', 'gmT', 'UTCx', 'LOCAL', ''): + self.assert_matches_python(name, '%Z') + self.assert_matches_python('+0100 +0200', '%z %:z') + @run_with_locales('LC_TIME', 'C', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '') def test_twelve_hour_clock(self): for hour in range(24): diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 71057ad704d0124..a970f334a707ac9 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1684,6 +1684,27 @@ tzinfo_from_isoformat_results(int rv, int tzoffset, int tz_useconds) * String format helpers. */ +static PyObject * +tzinfo_from_strptime_results(const _PyTime_StrptimeFields *fields, + PyObject *string) +{ + if (!fields->zone_length || fields->gmtoff == INT_MIN) { + return tzinfo_from_isoformat_results(fields->gmtoff != INT_MIN, + fields->gmtoff, + fields->gmtoff_fraction); + } + PyObject *name = PyUnicode_Substring(string, fields->zone_start, + fields->zone_start + fields->zone_length); + if (name == NULL) { + return NULL; + } + PyObject *delta = new_delta(0, fields->gmtoff, fields->gmtoff_fraction, 1); + PyObject *tzinfo = delta == NULL ? NULL : new_timezone(delta, name); + Py_XDECREF(delta); + Py_DECREF(name); + return tzinfo; +} + static PyObject * format_ctime(PyObject *date, int hours, int minutes, int seconds) { @@ -4804,8 +4825,7 @@ datetime_time_strptime_impl(PyTypeObject *type, PyObject *string, _PyTime_StrptimeFields fields; if (type == TIME_TYPE(NO_STATE) && _PyTime_Strptime(string, format, &fields)) { - PyObject *tzinfo = tzinfo_from_isoformat_results( - fields.gmtoff != INT_MIN, fields.gmtoff, 0); + PyObject *tzinfo = tzinfo_from_strptime_results(&fields, string); if (tzinfo == NULL) { return NULL; } @@ -5899,8 +5919,7 @@ datetime_datetime_strptime_impl(PyTypeObject *type, PyObject *string, _PyTime_StrptimeFields fields; if (type == DATETIME_TYPE(NO_STATE) && _PyTime_Strptime(string, format, &fields)) { - PyObject *tzinfo = tzinfo_from_isoformat_results( - fields.gmtoff != INT_MIN, fields.gmtoff, 0); + PyObject *tzinfo = tzinfo_from_strptime_results(&fields, string); if (tzinfo == NULL) { return NULL; } diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 19da7dc8be76d96..1e482345f3d2284 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1019,7 +1019,7 @@ time_strptime(PyObject *self, PyObject *args) const int values[] = { fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, - fields.weekday, fields.yday, -1 + fields.weekday, fields.yday, fields.zone_length ? 0 : -1 }; for (size_t i = 0; i < Py_ARRAY_LENGTH(values); i++) { PyObject *value = PyLong_FromLong(values[i]); @@ -1029,8 +1029,15 @@ time_strptime(PyObject *self, PyObject *args) } PyStructSequence_SET_ITEM(result, i, value); } - /* Numeric offsets don't supply a timezone name or DST information. */ - PyStructSequence_SET_ITEM(result, 9, Py_NewRef(Py_None)); + PyObject *zone = fields.zone_length + ? PyUnicode_Substring(PyTuple_GET_ITEM(args, 0), fields.zone_start, + fields.zone_start + fields.zone_length) + : Py_NewRef(Py_None); + if (zone == NULL) { + Py_DECREF(result); + return NULL; + } + PyStructSequence_SET_ITEM(result, 9, zone); PyObject *offset = fields.gmtoff == INT_MIN ? Py_NewRef(Py_None) : PyLong_FromLong(fields.gmtoff); diff --git a/Python/_strptime.c b/Python/_strptime.c index 8e08aaa45b8a0a0..a1862f1dd5c9b2b 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -11,11 +11,11 @@ * - numeric %Y, %y, %m, %d, %H, %M, %S, and %f fields; * - ASCII locale month/weekday names and numeric %w/%u weekdays; * - %I with ASCII %p indicators, and the %k, %l, %P aliases; - * - a terminal %z: empty, Z, or +/-HHMM or +/-HH:MM with hours below 24; + * - %z and %:z offsets with hours below 24, and terminal UTC/GMT %Z names; * - literal characters, %%, and ASCII whitespace. * Duplicate directives, mixed %Y/%y, and day-of-month without a year use the * fallback. So do other directives (including non-ASCII locale names and - * week/day-of-year calculations), and offsets with seconds or fractions. + * week/day-of-year calculations). */ static int @@ -104,32 +104,58 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, static int strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, - _PyTime_StrptimeFields *fields) + int require_colon, _PyTime_StrptimeFields *fields) { - Py_ssize_t remaining = length - pos; - if (remaining == 0) { - return 1; + Py_ssize_t start = pos; + if (pos == length) { + return 0; } - if (remaining == 1 && data[pos] == 'Z') { + if (data[pos] == 'Z') { fields->gmtoff = 0; return 1; } - /* Offsets containing seconds or fractions fall back to _strptime.py. */ - if ((remaining != 5 && remaining != 6) || - (data[pos] != '+' && data[pos] != '-')) { + if (data[pos] != '+' && data[pos] != '-') { return 0; } - int hours, minutes; - int colon = remaining == 6; - if ((colon && data[pos + 3] != ':') || - strptime_digits(data, length, pos + 1, 2, 2, &hours) != 2 || - strptime_digits(data, length, pos + 3 + colon, 2, 2, &minutes) != 2 || - hours > 23 || minutes > 59) { - return 0; + int sign = data[pos++] == '-' ? -1 : 1; + int hours, minutes, seconds = 0, fraction = 0; + if (strptime_digits(data, length, pos, 2, 2, &hours) != 2 || hours > 23) { + return -1; } - fields->gmtoff = (hours * 3600 + minutes * 60) * - (data[pos] == '-' ? -1 : 1); - return 1; + pos += 2; + int colon = pos < length && data[pos] == ':'; + if (require_colon && !colon) { + return -1; + } + pos += colon; + if (strptime_digits(data, length, pos, 2, 2, &minutes) != 2 || minutes > 59) { + return -1; + } + pos += 2; + int seconds_colon = pos < length && data[pos] == ':'; + Py_ssize_t seconds_pos = pos + seconds_colon; + if (strptime_digits(data, length, seconds_pos, 2, 2, &seconds) == 2) { + if (seconds > 59 || seconds_colon != colon) { + return -1; + } + pos = seconds_pos + 2; + if (pos < length && data[pos] == '.') { + int count = strptime_digits(data, length, pos + 1, 1, 6, &fraction); + if (count == 0) { + return -1; + } + pos += count + 1; + for (; count < 6; count++) { + fraction *= 10; + } + } + } + else { + seconds = 0; + } + fields->gmtoff = sign * (hours * 3600 + minutes * 60 + seconds); + fields->gmtoff_fraction = sign * fraction; + return (int)(pos - start); } /* Return 1 for a complete numeric match, or 0 if fallback is required. @@ -156,6 +182,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie OFFSET = 1U << 8, WEEKDAY = 1U << 9, AMPM = 1U << 10, + ZONE = 1U << 11, ANY_YEAR = YEAR | SHORT_YEAR, }; static const unsigned short directive_bits[128] = { @@ -170,6 +197,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie ['z'] = OFFSET, ['w'] = WEEKDAY, ['p'] = AMPM, + ['Z'] = ZONE, }; /* String subclasses and non-ASCII input retain Python's behavior. */ if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || @@ -212,6 +240,11 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie return 0; } c = fmt[i]; + int require_colon = 0; + if (c == ':' && i + 1 < fmt_length && fmt[i + 1] == 'z') { + c = fmt[++i]; + require_colon = 1; + } if (c == 'k') { c = 'H'; } @@ -267,14 +300,32 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie continue; } - if (c == 'z') { - if (i != fmt_length - 1 || - !strptime_offset(data, length, pos, fields)) { + if (c == 'Z') { + /* Local timezone names and nonterminal names retain Python's + * tzset state, DST rules and regex backtracking. + */ + if (i != fmt_length - 1 || length - pos != 3 || + !((Py_TOLOWER(data[pos]) == 'u' && + Py_TOLOWER(data[pos + 1]) == 't' && + Py_TOLOWER(data[pos + 2]) == 'c') || + (Py_TOLOWER(data[pos]) == 'g' && + Py_TOLOWER(data[pos + 1]) == 'm' && + Py_TOLOWER(data[pos + 2]) == 't'))) { return 0; } + fields->zone_start = pos; + fields->zone_length = 3; pos = length; continue; } + if (c == 'z') { + int count = strptime_offset(data, length, pos, require_colon, fields); + if (count < 0) { + return 0; + } + pos += count; + continue; + } int minimum = 1; int maximum = 2; From 903b89c035c0210c42819fa76b816dd8ea1c763d Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 20 Oct 2026 16:51:08 -0400 Subject: [PATCH 4/9] gh-158252: Parse numeric strptime aliases in C Expand %F, %D, %T and %R through the same field parser without allocating a temporary format string. Include %e and the optional whitespace directives %n and %t. --- Lib/test/test_strptime.py | 15 +++++++++++++ Python/_strptime.c | 45 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 8e153fd1201985c..cdd3a86c5c408b2 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -16,6 +16,21 @@ class PublicStrptimeTests(unittest.TestCase): + def test_numeric_aliases(self): + for text, fmt in ( + ('2024-02-29', '%F'), ('02/29/24', '%D'), + ('2024-02-29 12:34:56', '%F %T'), + ('2024-02-29T12:34:56Z', '%FT%TZ'), + ('12:34', '%R'), ('12:34:56', '%R:%S'), + ('2024 9', '%Y %e'), ('2024\t02\n29', '%Y%t%m%n%d'), + ('20240229', '%Y%t%m%n%d'), + ('2024-02-29 2024', '%F %Y'), + ('12:34:56 56', '%T %S'), + ('2023-02-29', '%F'), ('12:60', '%R'), + ('2024-02-29 2024-02-29', '%F %F'), + ): + self.assert_matches_python(text, fmt) + def test_offsets_and_zone_names(self): offsets = ('', 'Z', '+0530', '-03:30', '+01:02:03', '-000001.000001', '+01:02:03.456789', '+000000.5', '-00:00:00.5', diff --git a/Python/_strptime.c b/Python/_strptime.c index a1862f1dd5c9b2b..a5dbf8a808d6009 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -12,6 +12,7 @@ * - ASCII locale month/weekday names and numeric %w/%u weekdays; * - %I with ASCII %p indicators, and the %k, %l, %P aliases; * - %z and %:z offsets with hours below 24, and terminal UTC/GMT %Z names; + * - numeric aliases %F, %D, %T, %R, %e and whitespace %n/%t; * - literal characters, %%, and ASCII whitespace. * Duplicate directives, mixed %Y/%y, and day-of-month without a year use the * fallback. So do other directives (including non-ASCII locale names and @@ -206,6 +207,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } const unsigned char *data = PyUnicode_1BYTE_DATA(string); const unsigned char *fmt = PyUnicode_1BYTE_DATA(format); + const unsigned char *outer_fmt = NULL; + Py_ssize_t outer_length = 0, outer_pos = 0; Py_ssize_t length = PyUnicode_GET_LENGTH(string); Py_ssize_t fmt_length = PyUnicode_GET_LENGTH(format); *fields = (_PyTime_StrptimeFields){.year = 1900, .month = 1, .day = 1, @@ -215,7 +218,19 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie int ampm = -1; Py_ssize_t pos = 0; - for (Py_ssize_t i = 0; i < fmt_length; i++) { + for (Py_ssize_t i = 0; ; i++) { + if (i == fmt_length) { + if (outer_fmt == NULL) { + break; + } + fmt = outer_fmt; + fmt_length = outer_length; + i = outer_pos; + outer_fmt = NULL; + if (i == fmt_length) { + break; + } + } unsigned char c = fmt[i]; /* Match regex \s, including ASCII separators U+001C through U+001F. */ if (Py_UNICODE_ISSPACE(c)) { @@ -240,6 +255,34 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie return 0; } c = fmt[i]; + const char *alias = NULL; + switch (c) { + case 'F': alias = "%Y-%m-%d"; break; + case 'D': alias = "%m/%d/%y"; break; + case 'T': alias = "%H:%M:%S"; break; + case 'R': alias = "%H:%M"; break; + case 'e': c = 'd'; break; + } + if (alias != NULL) { + /* These fixed expansions contain no further aliases. Parsing + * them in place keeps duplicate-field checks and avoids copying + * an arbitrarily long user format into a temporary buffer. + */ + assert(outer_fmt == NULL); + outer_fmt = fmt; + outer_length = fmt_length; + outer_pos = i + 1; + fmt = (const unsigned char *)alias; + fmt_length = strlen(alias); + i = -1; + continue; + } + if (c == 'n' || c == 't') { + while (pos < length && Py_ISSPACE(data[pos])) { + pos++; + } + continue; + } int require_colon = 0; if (c == ':' && i + 1 < fmt_length && fmt[i + 1] == 'z') { c = fmt[++i]; From 7106044a6a1e31ba44b2d0824a70697f9cdee09e Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Tue, 20 Oct 2026 16:53:00 -0400 Subject: [PATCH 5/9] gh-158252: Parse ordinal and week dates in C Calculate dates for %j, %U/%W and %G/%V using the shared Gregorian calendar helpers. Preserve explicit weekdays and time.strptime yday values when a week or ordinal date crosses a year boundary. --- Include/internal/pycore_time.h | 2 +- Lib/test/test_strptime.py | 23 +++ ...6-10-20-16-00-00.gh-issue-158252.weeks.rst | 4 + Python/_strptime.c | 150 ++++++++++++++++-- 4 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h index 4cd537e37a75ca5..d2eb81be101af1e 100644 --- a/Include/internal/pycore_time.h +++ b/Include/internal/pycore_time.h @@ -340,7 +340,7 @@ _PyTime_Weekday(int year, int yday) } -// Locale-independent numeric strptime parsing. +// Shared strptime parsing, with fallback for unsupported inputs. // A complete match returns 1; otherwise use Lib/_strptime.py. This function // neither allocates nor sets exceptions. The calendar date is validated, but // seconds may be 60 or 61: callers must apply their own time validation. diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index cdd3a86c5c408b2..bf420844670b206 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -16,6 +16,29 @@ class PublicStrptimeTests(unittest.TestCase): + def test_week_and_ordinal_dates(self): + for year in (1, 4, 100, 400, 1900, 1999, 2000, 2004, 2020, 9999): + for day in (1, 28, 59, 60, 61, 365, 366, 367): + self.assert_matches_python(f'{year:04d} {day:03d}', '%Y %j') + for week in (0, 1, 52, 53, 54): + for weekday in range(1, 8): + for fmt in ('%Y %U %u', '%Y %W %u', '%G %V %u'): + self.assert_matches_python( + f'{year:04d} {week:02d} {weekday}', fmt) + for text, fmt in ( + ('2023-02-31 060', '%Y-%m-%d %j'), + ('2024-12-31 001', '%Y-%m-%d %j'), + ('2023 366 1', '%Y %j %u'), + ('2024 53', '%Y %U'), ('366', '%j'), + ('2024 001 01 1', '%Y %j %W %u'), + ('2024 2020 01 1', '%Y %G %V %u'), + ('2024 001 01 1', '%G %j %V %u'), + ('2024 01 1', '%Y %V %u'), ('2024 01', '%G %V'), + ('2024', '%G'), ('0000 001', '%Y %j'), + ('2024 01 02 1', '%Y %U %W %u'), + ): + self.assert_matches_python(text, fmt) + def test_numeric_aliases(self): for text, fmt in ( ('2024-02-29', '%F'), ('02/29/24', '%D'), diff --git a/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst b/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst new file mode 100644 index 000000000000000..cf597e2433f2262 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst @@ -0,0 +1,4 @@ +Speed up parsing month and weekday names, 12-hour clocks, UTC offsets, +numeric format aliases, and ordinal and week dates with :func:`time.strptime`, +:meth:`datetime.date.strptime`, :meth:`datetime.time.strptime`, and +:meth:`datetime.datetime.strptime`. diff --git a/Python/_strptime.c b/Python/_strptime.c index a5dbf8a808d6009..77fb25705b84292 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -13,10 +13,10 @@ * - %I with ASCII %p indicators, and the %k, %l, %P aliases; * - %z and %:z offsets with hours below 24, and terminal UTC/GMT %Z names; * - numeric aliases %F, %D, %T, %R, %e and whitespace %n/%t; + * - ordinal dates (%j), calendar weeks (%U/%W), and ISO weeks (%G/%V); * - literal characters, %%, and ASCII whitespace. * Duplicate directives, mixed %Y/%y, and day-of-month without a year use the - * fallback. So do other directives (including non-ASCII locale names and - * week/day-of-year calculations). + * fallback. So do non-ASCII locale names and directives not listed above. */ static int @@ -103,6 +103,48 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, #endif } +static int +strptime_date_from_ordinal(int ordinal, _PyTime_StrptimeFields *fields) +{ + if (ordinal < 1 || ordinal > _PyTime_DaysBeforeYear(10000)) { + return 0; + } + int lower = 1, upper = 10000; + while (lower + 1 < upper) { + int middle = (lower + upper) / 2; + if (_PyTime_DaysBeforeYear(middle) < ordinal) { + lower = middle; + } + else { + upper = middle; + } + } + fields->year = lower; + fields->yday = ordinal - _PyTime_DaysBeforeYear(lower); + fields->day = fields->yday; + int leap = lower % 4 == 0 && (lower % 100 != 0 || lower % 400 == 0); + static const int month_days[] = {31, 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31}; + for (fields->month = 1; fields->month < 12; fields->month++) { + int days = month_days[fields->month - 1] + (fields->month == 2 && leap); + if (fields->day <= days) { + break; + } + fields->day -= days; + } + if (fields->weekday < 0) { + fields->weekday = (ordinal - 1) % 7; + } + return 1; +} + +static int +strptime_iso_start(int year) +{ + /* ISO week 1 contains January 4. */ + return _PyTime_DaysBeforeYear(year) + 4 - _PyTime_Weekday(year, 4); +} + static int strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, int require_colon, _PyTime_StrptimeFields *fields) @@ -159,7 +201,7 @@ strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, return (int)(pos - start); } -/* Return 1 for a complete numeric match, or 0 if fallback is required. +/* Return 1 for a complete match, or 0 if fallback is required. * * Parsing doesn't allocate or set exceptions. Some mismatches require regex * backtracking, but even inputs known to be invalid use the Python parser: @@ -184,6 +226,10 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie WEEKDAY = 1U << 9, AMPM = 1U << 10, ZONE = 1U << 11, + JULIAN = 1U << 12, + WEEK = 1U << 13, + ISO_YEAR = 1U << 14, + ISO_WEEK = 1U << 15, ANY_YEAR = YEAR | SHORT_YEAR, }; static const unsigned short directive_bits[128] = { @@ -199,6 +245,10 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie ['w'] = WEEKDAY, ['p'] = AMPM, ['Z'] = ZONE, + ['j'] = JULIAN, + ['U'] = WEEK, + ['G'] = ISO_YEAR, + ['V'] = ISO_WEEK, }; /* String subclasses and non-ASCII input retain Python's behavior. */ if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || @@ -216,6 +266,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie unsigned int seen = 0; int hour12 = 0; int ampm = -1; + int julian = -1, week = -1, monday_first = 0; + int have_julian = 0; + int iso_year = -1, iso_week = -1; Py_ssize_t pos = 0; for (Py_ssize_t i = 0; ; i++) { @@ -315,6 +368,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie else if (c == 'I') { field = 'H'; } + else if (c == 'W') { + field = 'U'; + } unsigned int bit = directive_bits[field]; if (bit == 0) { return 0; @@ -372,7 +428,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie int minimum = 1; int maximum = 2; - if (c == 'Y') { + if (c == 'Y' || c == 'G') { minimum = maximum = 4; } else if (c == 'y') { @@ -381,6 +437,9 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie else if (c == 'f') { maximum = 6; } + else if (c == 'j') { + maximum = 3; + } else if (field == 'w') { maximum = 1; } @@ -396,6 +455,30 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } pos += count; switch (c) { + case 'j': + if (value < 1 || value > 366) { + return 0; + } + julian = value; + have_julian = 1; + break; + case 'U': + case 'W': + if (value > 53) { + return 0; + } + week = value; + monday_first = c == 'W'; + break; + case 'G': + iso_year = value; + break; + case 'V': + if (value < 1 || value > 53) { + return 0; + } + iso_week = value; + break; case 'I': if (value < 1 || value > 12) { return 0; @@ -465,6 +548,53 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } fields->hour = fields->hour % 12 + (ampm == 1 ? 12 : 0); } + /* Mixed fields and incomplete ISO dates retain Python's diagnostics. */ + if (pos != length || (seen & ANY_YEAR) == ANY_YEAR || + ((seen & DAY) && !(seen & ANY_YEAR) && iso_year < 0)) { + return 0; + } + if (iso_year >= 0 || iso_week >= 0) { + if (iso_year < 1 || iso_week < 1 || fields->weekday < 0 || + julian >= 0 || week >= 0) { + return 0; + } + int monday = strptime_iso_start(iso_year) + 7 * (iso_week - 1); + if (monday >= strptime_iso_start(iso_year + 1) || + !strptime_date_from_ordinal(monday + fields->weekday, fields)) { + return 0; + } + } + if (fields->year < 1) { + return 0; + } + if (julian < 0 && week >= 0 && fields->weekday >= 0) { + int first = _PyTime_Weekday(fields->year, 1); + int weekday = fields->weekday; + if (!monday_first) { + first = (first + 1) % 7; + weekday = (weekday + 1) % 7; + } + julian = week == 0 ? 1 + weekday - first + : 1 + (7 - first) % 7 + 7 * (week - 1) + weekday; + have_julian = 1; + } + if (have_julian) { + /* Week zero can fall in the previous year. A day spilling into the + * next year retains its original yday in time.strptime's result. + */ + int year = fields->year; + if (julian <= 0) { + if (year == 1) { + return 0; + } + year--; + julian += _PyTime_DaysBeforeYear(year + 1) - _PyTime_DaysBeforeYear(year); + } + if (!strptime_date_from_ordinal(_PyTime_DaysBeforeYear(year) + julian, fields)) { + return 0; + } + fields->yday = julian; + } static const int days_before_month[] = { 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 }; @@ -475,15 +605,13 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie if (fields->month == 2) { month_days += leap; } - /* Mixed %Y/%y and day-without-year diagnostics belong to Python. */ - if (pos != length || (seen & ANY_YEAR) == ANY_YEAR || - ((seen & DAY) && !(seen & ANY_YEAR)) || - fields->year < 1 || - fields->day > month_days) { + if (fields->day > month_days) { return 0; } - fields->yday = days_before_month[fields->month] + fields->day + - (fields->month > 2 && leap); + if (!have_julian) { + fields->yday = days_before_month[fields->month] + fields->day + + (fields->month > 2 && leap); + } if (fields->weekday < 0) { fields->weekday = _PyTime_Weekday(fields->year, fields->yday); } From c4c4e20240e6626b3068135a4cf45cab8f660be9 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 22 Oct 2026 17:12:01 -0400 Subject: [PATCH 6/9] gh-158252: Document additional optimized strptime formats --- Doc/whatsnew/3.16.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index f8fb9bf4d47e7b4..e497d058cd07b28 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -989,7 +989,9 @@ datetime and time * :meth:`datetime.datetime.strptime`, :meth:`datetime.date.strptime`, :meth:`datetime.time.strptime`, and :func:`time.strptime` are faster for common numeric formats such as ``%Y-%m-%d`` and - ``%Y-%m-%d %H:%M:%S``. + ``%Y-%m-%d %H:%M:%S``. The optimization also covers ASCII month and + weekday names, 12-hour clocks, UTC offsets, numeric format aliases, + and ordinal and week dates. (Contributed by Paul Ganssle in :gh:`158252`, based on work by Stan Ulbrych.) From 5cc35754800e77c19606084c07df0309315d3ae2 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 22 Oct 2026 20:09:09 -0400 Subject: [PATCH 7/9] gh-158252: Use Unicode whitespace for strptime aliases Use the same whitespace predicate as Python for %n, %t, and trimming AM/PM indicators. Cover ASCII separators before adjacent numeric fields in the alias formats. --- Lib/test/test_strptime.py | 10 ++++++++++ Python/_strptime.c | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index bf420844670b206..2b17bf569fe5a80 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -54,6 +54,16 @@ def test_numeric_aliases(self): ): self.assert_matches_python(text, fmt) + def test_whitespace_aliases_before_adjacent_fields(self): + for separator in ('\x1c', '\x1d', '\x1e', '\x1f'): + for whitespace in ('%n', '%t'): + for field in ('%d', '%e'): + self.assert_matches_python(f'2024{separator} 112', + f'%Y{whitespace}{field}%m') + for field in ('%H', '%k', '%I', '%l'): + self.assert_matches_python(f'34{separator} 123', + f'%M{whitespace}{field}%S') + def test_offsets_and_zone_names(self): offsets = ('', 'Z', '+0530', '-03:30', '+01:02:03', '-000001.000001', '+01:02:03.456789', '+000000.5', '-00:00:00.5', diff --git a/Python/_strptime.c b/Python/_strptime.c index 77fb25705b84292..5c002e676b9cc8d 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -66,10 +66,10 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, } char *start = name; if (directive == 'p') { - while (size && Py_ISSPACE(start[size - 1])) { + while (size && Py_UNICODE_ISSPACE(start[size - 1])) { size--; } - while (size && Py_ISSPACE(*start)) { + while (size && Py_UNICODE_ISSPACE(*start)) { start++; size--; } @@ -331,7 +331,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie continue; } if (c == 'n' || c == 't') { - while (pos < length && Py_ISSPACE(data[pos])) { + while (pos < length && Py_UNICODE_ISSPACE(data[pos])) { pos++; } continue; From 045177de8555c261dd47acde6c376829c86d0610 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Fri, 23 Oct 2026 15:04:17 -0400 Subject: [PATCH 8/9] 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. --- Include/internal/pycore_time.h | 4 +- Lib/test/test_strptime.py | 28 ++++ Modules/timemodule.c | 6 +- Python/_strptime.c | 278 +++++++++++++++++---------------- 4 files changed, 181 insertions(+), 135 deletions(-) diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h index d2eb81be101af1e..217519cc952287b 100644 --- a/Include/internal/pycore_time.h +++ b/Include/internal/pycore_time.h @@ -351,7 +351,9 @@ typedef struct { int gmtoff_fraction; Py_ssize_t zone_start, zone_length; // Original spelling of a %Z name. int yday; // January 1 is day 1. - int weekday; // Monday is 0; a supplied weekday need not agree with the date. + // Monday is 0; a supplied weekday need not agree with the date. + // If negative, the caller must compute it from year and yday if needed. + int weekday; } _PyTime_StrptimeFields; // Export for the '_datetime' shared extension. diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 2b17bf569fe5a80..1b115f322e304c9 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -16,6 +16,34 @@ class PublicStrptimeTests(unittest.TestCase): + def test_inferred_weekday(self): + for text, fmt in ( + ('0001-01-01', '%Y-%m-%d'), + ('2024-02-29', '%Y-%m-%d'), + ('9999-12-31', '%Y-%m-%d'), + ('2023 366', '%Y %j'), + ('2024 00 0', '%Y %W %w'), + ): + self.assert_matches_python(text, fmt) + + @support.run_with_locale('LC_TIME', 'C') + def test_mixed_directive_spellings(self): + groups = ( + (('m', '01'), ('b', 'Feb'), ('B', 'March')), + (('d', '01'), ('e', ' 2')), + (('H', '13'), ('k', '14'), ('I', '03'), ('l', ' 4')), + (('w', '0'), ('u', '1'), ('a', 'Tue'), ('A', 'Wednesday')), + (('p', 'AM'), ('P', 'PM')), + (('z', '+0100'), (':z', '+02:00')), + (('U', '01'), ('W', '02')), + ) + for group in groups: + for first, first_text in group: + for second, second_text in group: + self.assert_matches_python( + f'2024 {first_text} {second_text}', + f'%Y %{first} %{second}') + def test_week_and_ordinal_dates(self): for year in (1, 4, 100, 400, 1900, 1999, 2000, 2004, 2020, 9999): for day in (1, 28, 59, 60, 61, 365, 366, 367): diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 1e482345f3d2284..fe61c375313313c 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1016,10 +1016,14 @@ time_strptime(PyObject *self, PyObject *args) if (result == NULL) { return NULL; } + int weekday = fields.weekday; + if (weekday < 0) { + weekday = _PyTime_Weekday(fields.year, fields.yday); + } const int values[] = { fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, - fields.weekday, fields.yday, fields.zone_length ? 0 : -1 + weekday, fields.yday, fields.zone_length ? 0 : -1 }; for (size_t i = 0; i < Py_ARRAY_LENGTH(values); i++) { PyObject *value = PyLong_FromLong(values[i]); diff --git a/Python/_strptime.c b/Python/_strptime.c index 5c002e676b9cc8d..f512d41154304f7 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -249,6 +249,19 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie ['U'] = WEEK, ['G'] = ISO_YEAR, ['V'] = ISO_WEEK, + /* Equivalent directives share the duplicate-field checks. */ + ['b'] = MONTH, + ['B'] = MONTH, + ['e'] = DAY, + ['k'] = HOUR, + ['I'] = HOUR, + ['l'] = HOUR, + ['a'] = WEEKDAY, + ['A'] = WEEKDAY, + ['u'] = WEEKDAY, + ['P'] = AMPM, + [':'] = OFFSET, + ['W'] = WEEK, }; /* String subclasses and non-ASCII input retain Python's behavior. */ if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || @@ -308,145 +321,144 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie return 0; } c = fmt[i]; - const char *alias = NULL; - switch (c) { - case 'F': alias = "%Y-%m-%d"; break; - case 'D': alias = "%m/%d/%y"; break; - case 'T': alias = "%H:%M:%S"; break; - case 'R': alias = "%H:%M"; break; - case 'e': c = 'd'; break; - } - if (alias != NULL) { - /* These fixed expansions contain no further aliases. Parsing - * them in place keeps duplicate-field checks and avoids copying - * an arbitrarily long user format into a temporary buffer. - */ - assert(outer_fmt == NULL); - outer_fmt = fmt; - outer_length = fmt_length; - outer_pos = i + 1; - fmt = (const unsigned char *)alias; - fmt_length = strlen(alias); - i = -1; - continue; - } - if (c == 'n' || c == 't') { - while (pos < length && Py_UNICODE_ISSPACE(data[pos])) { - pos++; - } - continue; - } - int require_colon = 0; - if (c == ':' && i + 1 < fmt_length && fmt[i + 1] == 'z') { - c = fmt[++i]; - require_colon = 1; - } - if (c == 'k') { - c = 'H'; - } - else if (c == 'l') { - c = 'I'; - } - else if (c == 'P') { - c = 'p'; - } - if (c == '%') { - if (pos == length || data[pos++] != '%') { - return 0; - } - continue; - } - - /* Mixed spellings of the same field retain Python's precedence. */ - unsigned char field = c; - if (c == 'b' || c == 'B') { - field = 'm'; - } - else if (c == 'a' || c == 'A' || c == 'u') { - field = 'w'; - } - else if (c == 'I') { - field = 'H'; - } - else if (c == 'W') { - field = 'U'; - } - unsigned int bit = directive_bits[field]; - if (bit == 0) { - return 0; - } + unsigned int bit = directive_bits[c]; if (seen & bit) { return 0; } seen |= bit; - if (c == 'b' || c == 'B' || c == 'a' || c == 'A' || c == 'p') { - int value; - int count = strptime_name(data, length, pos, c, &value); - if (count == 0) { - return 0; - } - pos += count; - if (field == 'm') { - fields->month = value; - } - else if (c == 'p') { - ampm = value; + /* Dispatch once: numeric fields don't need to test for names, + * aliases or timezone spellings before parsing their digits. + */ + int minimum = 1; + int maximum = 2; + switch (c) { + case 'Y': + case 'G': + minimum = maximum = 4; + break; + case 'y': + minimum = maximum = 2; + break; + case 'f': + maximum = 6; + break; + case 'j': + maximum = 3; + break; + case 'w': + case 'u': + maximum = 1; + break; + case 'd': + case 'e': + case 'H': + case 'k': + case 'I': + case 'l': + if (pos < length && data[pos] == ' ') { + pos++; + maximum = 1; + } + break; + case 'm': + case 'M': + case 'S': + case 'U': + case 'W': + case 'V': + break; + case 'b': + case 'B': + case 'a': + case 'A': + case 'p': + case 'P': { + int value; + int count = strptime_name(data, length, pos, + c == 'P' ? 'p' : c, &value); + if (count == 0) { + return 0; + } + pos += count; + if (bit == MONTH) { + fields->month = value; + } + else if (bit == AMPM) { + ampm = value; + } + else { + fields->weekday = value; + } + continue; } - else { - fields->weekday = value; + case 'Z': + /* Local timezone names and nonterminal names retain Python's + * tzset state, DST rules and regex backtracking. + */ + if (i != fmt_length - 1 || length - pos != 3 || + !((Py_TOLOWER(data[pos]) == 'u' && + Py_TOLOWER(data[pos + 1]) == 't' && + Py_TOLOWER(data[pos + 2]) == 'c') || + (Py_TOLOWER(data[pos]) == 'g' && + Py_TOLOWER(data[pos + 1]) == 'm' && + Py_TOLOWER(data[pos + 2]) == 't'))) { + return 0; + } + fields->zone_start = pos; + fields->zone_length = 3; + pos = length; + continue; + case ':': + if (++i == fmt_length || fmt[i] != 'z') { + return 0; + } + _Py_FALLTHROUGH; + case 'z': { + int count = strptime_offset(data, length, pos, c == ':', fields); + if (count < 0) { + return 0; + } + pos += count; + continue; } - continue; - } - - if (c == 'Z') { - /* Local timezone names and nonterminal names retain Python's - * tzset state, DST rules and regex backtracking. - */ - if (i != fmt_length - 1 || length - pos != 3 || - !((Py_TOLOWER(data[pos]) == 'u' && - Py_TOLOWER(data[pos + 1]) == 't' && - Py_TOLOWER(data[pos + 2]) == 'c') || - (Py_TOLOWER(data[pos]) == 'g' && - Py_TOLOWER(data[pos + 1]) == 'm' && - Py_TOLOWER(data[pos + 2]) == 't'))) { - return 0; + case 'F': + case 'D': + case 'T': + case 'R': { + const char *alias; + switch (c) { + case 'F': alias = "%Y-%m-%d"; break; + case 'D': alias = "%m/%d/%y"; break; + case 'T': alias = "%H:%M:%S"; break; + default: alias = "%H:%M"; break; + } + /* These fixed expansions contain no further aliases. Parsing + * them in place keeps duplicate-field checks and avoids copying + * an arbitrarily long user format into a temporary buffer. + */ + assert(outer_fmt == NULL); + outer_fmt = fmt; + outer_length = fmt_length; + outer_pos = i + 1; + fmt = (const unsigned char *)alias; + fmt_length = strlen(alias); + i = -1; + continue; } - fields->zone_start = pos; - fields->zone_length = 3; - pos = length; - continue; - } - if (c == 'z') { - int count = strptime_offset(data, length, pos, require_colon, fields); - if (count < 0) { + case 'n': + case 't': + while (pos < length && Py_UNICODE_ISSPACE(data[pos])) { + pos++; + } + continue; + case '%': + if (pos == length || data[pos++] != '%') { + return 0; + } + continue; + default: return 0; - } - pos += count; - continue; - } - - int minimum = 1; - int maximum = 2; - if (c == 'Y' || c == 'G') { - minimum = maximum = 4; - } - else if (c == 'y') { - minimum = maximum = 2; - } - else if (c == 'f') { - maximum = 6; - } - else if (c == 'j') { - maximum = 3; - } - else if (field == 'w') { - maximum = 1; - } - else if ((c == 'd' || c == 'H' || c == 'I') && - pos < length && data[pos] == ' ') { - pos++; - maximum = 1; } int value; int count = strptime_digits(data, length, pos, minimum, maximum, &value); @@ -480,6 +492,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie iso_week = value; break; case 'I': + case 'l': if (value < 1 || value > 12) { return 0; } @@ -511,12 +524,14 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie fields->month = value; break; case 'd': + case 'e': if (value < 1 || value > 31) { return 0; } fields->day = value; break; case 'H': + case 'k': if (value > 23) { return 0; } @@ -612,8 +627,5 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie fields->yday = days_before_month[fields->month] + fields->day + (fields->month > 2 && leap); } - if (fields->weekday < 0) { - fields->weekday = _PyTime_Weekday(fields->year, fields->yday); - } return 1; } From 251db3cbddbbfe6818229406d1d227ddcdcc8ec8 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Fri, 23 Oct 2026 16:18:22 -0400 Subject: [PATCH 9/9] gh-158252: Clarify strptime helpers and extend parser tests Separate locale name normalization from matching and AM/PM support checks. Share the month table and leap-year calculation, and document the cases where parsed metadata deliberately differs from the calendar date. Check that representative formats use the C parser through all four public entry points. Add generated comparisons with the Python parser, retaining explicit examples for runs without Hypothesis. --- Include/internal/pycore_time.h | 6 +- Lib/test/test_strptime.py | 137 ++++++++++++++---- ...6-10-20-16-00-00.gh-issue-158252.weeks.rst | 2 +- Modules/_datetimemodule.c | 3 + Modules/timemodule.c | 3 + Python/_strptime.c | 135 ++++++++++------- 6 files changed, 201 insertions(+), 85 deletions(-) diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h index 217519cc952287b..0ef6362831f8e6e 100644 --- a/Include/internal/pycore_time.h +++ b/Include/internal/pycore_time.h @@ -348,9 +348,11 @@ typedef struct { int year, month, day; int hour, minute, second, fraction; int gmtoff; // INT_MIN means no offset was supplied. - int gmtoff_fraction; + int gmtoff_fraction; // Microseconds, with the same sign as gmtoff. Py_ssize_t zone_start, zone_length; // Original spelling of a %Z name. - int yday; // January 1 is day 1. + // January 1 is day 1. A %j spill retains the original day of year, + // which need not agree with the resulting calendar date. + int yday; // Monday is 0; a supplied weekday need not agree with the date. // If negative, the caller must compute it from year and yday if needed. int weekday; diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index 1b115f322e304c9..218c406297e0fa5 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -1,6 +1,8 @@ """PyUnit testing against strptime""" import unittest +from unittest import mock +from contextlib import nullcontext import datetime import time import locale @@ -9,13 +11,118 @@ import platform import sys from test import support -from test.support import skip_if_buggy_ucrt_strfptime, run_with_locales +from test.support import ( + skip_if_buggy_ucrt_strfptime, run_with_locales, warnings_helper, +) +from test.support.hypothesis_helper import hypothesis from datetime import date as datetime_date import _strptime +_strptime_formats = hypothesis.strategies.lists( + hypothesis.strategies.sampled_from(( + '%Y', '%y', '%m', '%d', '%H', '%M', '%S', '%f', '%b', '%B', + '%a', '%A', '%w', '%u', '%I', '%p', '%k', '%l', '%P', + '%z', '%:z', '%Z', '%F', '%D', '%T', '%R', '%e', '%n', '%t', + '%j', '%U', '%W', '%G', '%V', '%%', + '-', '/', ':', '.', ' ', '\t', '\n', '\x1c', 'T', 'Z', '年', + )), max_size=8, +).map(''.join) + + class PublicStrptimeTests(unittest.TestCase): + def assert_matches_python(self, text, fmt, *, accelerated=False): + methods = ( + (time.strptime, '_strptime_time', None), + (datetime.date.strptime, '_strptime_datetime_date', datetime.date), + (datetime.time.strptime, '_strptime_datetime_time', datetime.time), + (datetime.datetime.strptime, '_strptime_datetime_datetime', + datetime.datetime), + ) + for parse, fallback, cls in methods: + message = f'{parse.__qualname__}({text!r}, {fmt!r})' + reference = getattr(_strptime, fallback) + args = (text, fmt) if cls is None else (cls, text, fmt) + try: + expected = reference(*args) + except (ValueError, re.PatternError, OverflowError) as error: + self.assertFalse(accelerated, message) + with self.assertRaises(type(error), msg=message) as caught: + parse(text, fmt) + self.assertEqual(str(caught.exception), str(error), message) + else: + # Guard against accidentally falling back for every input. + # This mock can be removed if implementation changes make + # patching the fallback impractical; keep the comparisons. + guard = mock.patch.object( + _strptime, fallback, autospec=True, + side_effect=AssertionError('used the Python parser'), + ) if accelerated else nullcontext() + with guard: + actual = parse(text, fmt) + self.assertEqual(actual, expected, message) + if isinstance(actual, time.struct_time): + self.assertEqual(actual.tm_zone, expected.tm_zone, message) + self.assertEqual(actual.tm_gmtoff, expected.tm_gmtoff, + message) + elif isinstance(actual, (datetime.time, datetime.datetime)): + self.assertEqual(actual.tzname(), expected.tzname(), message) + + @support.cpython_only + @support.run_with_locale('LC_TIME', 'C') + def test_accelerated_formats(self): + for text, fmt in ( + ('2024-02-29 12:34:56.123456', '%Y-%m-%d %H:%M:%S.%f'), + ('Jan 02 2024', '%b %d %Y'), + ('Tuesday 2024 January 02', '%A %Y %B %d'), + ('Tue 2024-01-02', '%a %Y-%m-%d'), + ('12 PM', '%I %p'), ('12', '%I'), (' 1 pm', '%l %P'), + (' 1', '%k'), ('2024 2', '%Y %e'), + ('+01:02:03.456789', '%:z'), ('-000001.5', '%z'), + ('UTC', '%Z'), ('+0530 gMt', '%z %Z'), + ('2024-02-29 12:34:56', '%F %T'), + ('02/29/24 12:34', '%D %R'), + ('2024\t02\n29', '%Y%t%m%n%d'), + ('2023 366', '%Y %j'), + ('2024 00 0', '%Y %W %w'), + ('2024 01 1', '%Y %U %u'), + ('2020-W53-7', '%G-W%V-%u'), + ): + self.assert_matches_python(text, fmt, accelerated=True) + + # Missing-year formats deliberately exercise the deprecated behavior. + @support.run_with_locale('LC_TIME', 'C') + @warnings_helper.ignore_warnings(category=DeprecationWarning) + @hypothesis.given(value=hypothesis.strategies.datetimes(), + fmt=_strptime_formats) + @hypothesis.example(value=datetime.datetime(2024, 2, 29, 13, 24, 35), + fmt='%Y%m%d%H%M%S') + @hypothesis.example(value=datetime.datetime(2021, 1, 1), fmt='%G-W%V-%u') + @hypothesis.example(value=datetime.datetime(2024, 2, 29, 13, 24, 35), + fmt='%a %d %B %Y %I:%M:%S %p') + def test_strftime_output_matches_python(self, value, fmt): + try: + text = value.strftime(fmt) + except ValueError: + # Some strptime directives are unsupported by the platform's + # strftime. Arbitrary input tests still exercise their parsing. + hypothesis.reject() + self.assert_matches_python(text, fmt) + + @support.run_with_locale('LC_TIME', 'C') + @warnings_helper.ignore_warnings(category=DeprecationWarning) + @hypothesis.given(text=hypothesis.strategies.text(max_size=80), + fmt=_strptime_formats) + @hypothesis.example(text='2024\x1c 112', fmt='%Y\x1c%d%m') + @hypothesis.example(text='34\x1c 123', fmt='%M%n%H%S') + @hypothesis.example(text='2023 366', fmt='%Y %j') + @hypothesis.example(text='2024 01 02 1', fmt='%Y %U %W %u') + @hypothesis.example(text='+01:0203', fmt='%z') + @hypothesis.example(text='1 pM', fmt='%I %p') + def test_arbitrary_input_matches_python(self, text, fmt): + self.assert_matches_python(text, fmt) + def test_inferred_weekday(self): for text, fmt in ( ('0001-01-01', '%Y-%m-%d'), @@ -134,34 +241,6 @@ def test_locale_names(self): for fmt in ('%b %d %Y', '%B %d %Y', '%a %Y-%m-%d', '%A %Y-%m-%d'): self.assert_matches_python(value.strftime(fmt), fmt) - def assert_matches_python(self, text, fmt): - methods = ( - (time.strptime, _strptime._strptime_time), - (datetime.date.strptime, - lambda s, f: _strptime._strptime_datetime_date(datetime.date, s, f)), - (datetime.time.strptime, - lambda s, f: _strptime._strptime_datetime_time(datetime.time, s, f)), - (datetime.datetime.strptime, - lambda s, f: _strptime._strptime_datetime_datetime( - datetime.datetime, s, f)), - ) - for parse, reference in methods: - with self.subTest(method=parse, text=text, format=fmt): - try: - expected = reference(text, fmt) - except (ValueError, re.PatternError) as error: - with self.assertRaises(type(error)) as caught: - parse(text, fmt) - self.assertEqual(str(caught.exception), str(error)) - else: - actual = parse(text, fmt) - self.assertEqual(actual, expected) - if isinstance(actual, time.struct_time): - self.assertEqual(actual.tm_zone, expected.tm_zone) - self.assertEqual(actual.tm_gmtoff, expected.tm_gmtoff) - elif isinstance(actual, (datetime.time, datetime.datetime)): - self.assertEqual(actual.tzname(), expected.tzname()) - @support.run_with_locale('LC_TIME', 'C') def test_names_and_weekdays(self): for month in range(1, 13): diff --git a/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst b/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst index cf597e2433f2262..0d42e3331258d94 100644 --- a/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst +++ b/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst @@ -1,4 +1,4 @@ -Speed up parsing month and weekday names, 12-hour clocks, UTC offsets, +Speed up parsing ASCII month and weekday names, 12-hour clocks, UTC offsets, numeric format aliases, and ordinal and week dates with :func:`time.strptime`, :meth:`datetime.date.strptime`, :meth:`datetime.time.strptime`, and :meth:`datetime.datetime.strptime`. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index a970f334a707ac9..89f7d91508bc50a 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1688,6 +1688,9 @@ static PyObject * tzinfo_from_strptime_results(const _PyTime_StrptimeFields *fields, PyObject *string) { + /* As in _strptime.py, a name without a numeric offset leaves the result + * naive. Only pass the name to the timezone constructor when %z is present. + */ if (!fields->zone_length || fields->gmtoff == INT_MIN) { return tzinfo_from_isoformat_results(fields->gmtoff != INT_MIN, fields->gmtoff, diff --git a/Modules/timemodule.c b/Modules/timemodule.c index fe61c375313313c..d49fc7ad9f62ba8 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1020,6 +1020,9 @@ time_strptime(PyObject *self, PyObject *args) if (weekday < 0) { weekday = _PyTime_Weekday(fields.year, fields.yday); } + /* Only UTC and GMT names are parsed here; neither observes DST. + * Numeric offsets don't supply DST information. + */ const int values[] = { fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, diff --git a/Python/_strptime.c b/Python/_strptime.c index f512d41154304f7..5ae5f90aef1477e 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -36,51 +36,74 @@ strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, return count >= minimum ? count : 0; } -static int -strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, - unsigned char directive, int *value) +/* Return the normalized ASCII name's length, or 0 if fallback is needed. */ +static inline size_t +strptime_locale_name(unsigned char directive, int index, + char *name, size_t capacity) { #ifdef HAVE_STRFTIME /* Read the current locale without keeping a cache across setlocale(). * Non-ASCII names retain Python's decoding and Unicode case handling. - * As in TimeRE, prefer the longest match, then the first equal name. */ int month = directive == 'b' || directive == 'B'; - int count = month ? 12 : directive == 'p' ? 2 : 7; char format[] = {'%', directive, '\0'}; + /* Match calendar's localized names and LocaleTime.__calc_am_pm: + * 2001 starts on a Monday, and the AM/PM sample hours are 1 and 22. + */ + struct tm tm = {.tm_year = 101, .tm_mday = 1, + .tm_mon = month ? index : 0, + .tm_wday = month ? 0 : (index + 1) % 7, + .tm_hour = index == 0 ? 1 : 22}; + size_t size = strftime(name, capacity, format, &tm); + for (size_t j = 0; j < size; j++) { + if ((unsigned char)name[j] >= 128) { + return 0; + } + } + if (directive == 'p') { + while (size && Py_UNICODE_ISSPACE(name[size - 1])) { + size--; + } + size_t start = 0; + while (start < size && Py_UNICODE_ISSPACE(name[start])) { + start++; + } + size -= start; + if (start) { + memmove(name, name + start, size); + } + /* Empty indicators have special defaulting rules in Python. */ + } + return size; +#else + return 0; +#endif +} + +static int +strptime_ampm_supported(void) +{ + char name[256]; + return strptime_locale_name('p', 0, name, sizeof(name)) != 0 && + strptime_locale_name('p', 1, name, sizeof(name)) != 0; +} + +static int +strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + unsigned char directive, int *value) +{ + /* Prefer the longest match, then the first equal name, as in TimeRE. + * For %p, the last equal name wins: _strptime checks for PM first. + */ + int month = directive == 'b' || directive == 'B'; + int count = month ? 12 : directive == 'p' ? 2 : 7; int matched = 0; for (int i = 0; i < count; i++) { - struct tm tm = {.tm_year = 101, .tm_mday = 1, - .tm_mon = month ? i : 0, - .tm_wday = month ? 0 : (i + 1) % 7, - .tm_hour = i == 0 ? 1 : 22}; char name[256]; - size_t size = strftime(name, sizeof(name), format, &tm); + size_t size = strptime_locale_name(directive, i, name, sizeof(name)); if (size == 0) { return 0; } - for (size_t j = 0; j < size; j++) { - if ((unsigned char)name[j] >= 128) { - return 0; - } - } - char *start = name; - if (directive == 'p') { - while (size && Py_UNICODE_ISSPACE(start[size - 1])) { - size--; - } - while (size && Py_UNICODE_ISSPACE(*start)) { - start++; - size--; - } - /* Empty indicators have special defaulting rules in Python. */ - if (size == 0) { - return 0; - } - } - if (data == NULL) { - continue; - } if (size < (size_t)matched || (size == (size_t)matched && directive != 'p') || size > (size_t)(length - pos)) { @@ -88,7 +111,7 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, } size_t j; for (j = 0; j < size; j++) { - if (Py_TOLOWER(data[pos + j]) != Py_TOLOWER(start[j])) { + if (Py_TOLOWER(data[pos + j]) != Py_TOLOWER(name[j])) { break; } } @@ -97,10 +120,17 @@ strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, *value = month ? i + 1 : i; } } - return data == NULL ? 1 : matched; -#else - return 0; -#endif + return matched; +} + +static const int days_before_month[] = { + 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 +}; + +static int +strptime_is_leap(int year) +{ + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); } static int @@ -122,17 +152,20 @@ strptime_date_from_ordinal(int ordinal, _PyTime_StrptimeFields *fields) fields->year = lower; fields->yday = ordinal - _PyTime_DaysBeforeYear(lower); fields->day = fields->yday; - int leap = lower % 4 == 0 && (lower % 100 != 0 || lower % 400 == 0); - static const int month_days[] = {31, 28, 31, 30, 31, 30, - 31, 31, 30, 31, 30, 31}; + int leap = strptime_is_leap(lower); for (fields->month = 1; fields->month < 12; fields->month++) { - int days = month_days[fields->month - 1] + (fields->month == 2 && leap); + int days = days_before_month[fields->month + 1] - + days_before_month[fields->month] + + (fields->month == 2 && leap); if (fields->day <= days) { break; } fields->day -= days; } if (fields->weekday < 0) { + /* A %j spill into the next year later restores the original yday. + * Compute the weekday now, while the ordinal still identifies it. + */ fields->weekday = (ordinal - 1) % 7; } return 1; @@ -145,6 +178,9 @@ strptime_iso_start(int year) return _PyTime_DaysBeforeYear(year) + 4 - _PyTime_Weekday(year, 4); } +/* Return the number of characters consumed (0 for an empty offset), + * or -1 if the Python parser is needed. + */ static int strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, int require_colon, _PyTime_StrptimeFields *fields) @@ -194,6 +230,7 @@ strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, } } else { + /* strptime_digits may have stored one digit before failing. */ seconds = 0; } fields->gmtoff = sign * (hours * 3600 + minutes * 60 + seconds); @@ -397,12 +434,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie * tzset state, DST rules and regex backtracking. */ if (i != fmt_length - 1 || length - pos != 3 || - !((Py_TOLOWER(data[pos]) == 'u' && - Py_TOLOWER(data[pos + 1]) == 't' && - Py_TOLOWER(data[pos + 2]) == 'c') || - (Py_TOLOWER(data[pos]) == 'g' && - Py_TOLOWER(data[pos + 1]) == 'm' && - Py_TOLOWER(data[pos + 2]) == 't'))) { + (PyOS_strnicmp((const char *)data + pos, "utc", 3) != 0 && + PyOS_strnicmp((const char *)data + pos, "gmt", 3) != 0)) { return 0; } fields->zone_start = pos; @@ -558,7 +591,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } } if (hour12) { - if (ampm < 0 && !strptime_name(NULL, 0, 0, 'p', &m)) { + if (ampm < 0 && !strptime_ampm_supported()) { return 0; } fields->hour = fields->hour % 12 + (ampm == 1 ? 12 : 0); @@ -610,11 +643,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } fields->yday = julian; } - static const int days_before_month[] = { - 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 - }; - int leap = fields->year % 4 == 0 && - (fields->year % 100 != 0 || fields->year % 400 == 0); + int leap = strptime_is_leap(fields->year); int month_days = days_before_month[fields->month + 1] - days_before_month[fields->month]; if (fields->month == 2) {