From a60b8fff258d587d95ccce392f5fcd2f1eac79c6 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 14 Feb 2026 20:01:28 +0000 Subject: [PATCH 01/12] Initial implementation --- Lib/_strptime.py | 11 + Modules/Setup.stdlib.in | 1 + Modules/_strptimemodule.c | 1159 ++++++++++++++++++++++++++++ Modules/clinic/_strptimemodule.c.h | 48 ++ PC/config.c | 2 + configure | 28 + configure.ac | 1 + 7 files changed, 1250 insertions(+) create mode 100644 Modules/_strptimemodule.c create mode 100644 Modules/clinic/_strptimemodule.c.h diff --git a/Lib/_strptime.py b/Lib/_strptime.py index 3311ec888714696..45ea5eb4d2d19fe 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -24,6 +24,11 @@ timezone as datetime_timezone) from _thread import allocate_lock as _thread_allocate_lock +try: + from _strptime_impl import _strptime_parse as _c_strptime_parse +except ImportError: + _c_strptime_parse = None + __all__ = [] def _getlang(): @@ -553,6 +558,12 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"): locale_time = _TimeRE_cache.locale_time if len(_regex_cache) > _CACHE_MAX_SIZE: _regex_cache.clear() + if _c_strptime_parse is not None: + result = _c_strptime_parse(data_string, format) + if result is not None: + if format not in _regex_cache: + _regex_cache[format] = None + return result format_regex = _regex_cache.get(format) if not format_regex: try: diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in index d8cd8de10dfe084..24e45f60201e9d9 100644 --- a/Modules/Setup.stdlib.in +++ b/Modules/Setup.stdlib.in @@ -40,6 +40,7 @@ @MODULE__MATH_INTEGER_TRUE@_math_integer mathintegermodule.c @MODULE__PICKLE_TRUE@_pickle _pickle.c @MODULE__QUEUE_TRUE@_queue _queuemodule.c +@MODULE__STRPTIME_IMPL_TRUE@_strptime_impl _strptimemodule.c @MODULE__RANDOM_TRUE@_random _randommodule.c @MODULE__REMOTE_DEBUGGING_TRUE@_remote_debugging _remote_debugging/module.c _remote_debugging/gc_stats.c _remote_debugging/object_reading.c _remote_debugging/code_objects.c _remote_debugging/frames.c _remote_debugging/frame_cache.c _remote_debugging/threads.c _remote_debugging/asyncio.c _remote_debugging/binary_io_writer.c _remote_debugging/binary_io_reader.c _remote_debugging/subprocess.c _remote_debugging/interpreters.c @MODULE__STRUCT_TRUE@_struct _struct.c diff --git a/Modules/_strptimemodule.c b/Modules/_strptimemodule.c new file mode 100644 index 000000000000000..0522c02d29d5b92 --- /dev/null +++ b/Modules/_strptimemodule.c @@ -0,0 +1,1159 @@ +/* _strptime_impl accelerator C extension module. */ + +#ifndef Py_BUILD_CORE_BUILTIN +# define Py_BUILD_CORE_MODULE 1 +#endif + +#include "Python.h" + +#include +#include +#include + +/*[clinic input] +module _strptime_impl +[clinic start generated code]*/ +/*[clinic end generated code: output=da39a3ee5e6b4b0d input=f7541041c3424c24]*/ + +#include "clinic/_strptimemodule.c.h" + +/* ========================== helpers ========================== */ + +/* Parsed fields accumulated while walking the format string. */ +typedef struct { + int year; /* -1 = not set */ + int month; /* 1-12, default 1 */ + int day; /* 1-31, default 1 */ + int hour; /* 0-23, default 0 */ + int minute; /* 0-59, default 0 */ + int second; /* 0-61, default 0 */ + int weekday; /* 0-6 (Mon=0), -1 = not set */ + int julian; /* 1-366, -1 = not set */ + int tz; /* -1 = not set, 0 = no DST, 1 = DST */ + int fraction; /* microseconds, 0 */ + int gmtoff; /* seconds east of UTC, INT_MIN = not set */ + int gmtoff_fraction; /* microseconds part of gmtoff, 0 */ + int iso_year; /* -1 = not set */ + int iso_week; /* -1 = not set */ + int week_of_year; /* -1 = not set */ + int week_of_year_start; /* 0 = Mon, 6 = Sun, -1 = not set */ + int century; /* -1 = not set */ + int has_year; /* whether %Y was seen */ + int has_short_year;/* whether %y was seen */ + int day_of_month_in_format; /* whether %d was seen */ + int year_in_format; /* whether %Y/%y/%G was seen */ + int colon_z_in_format; /* whether %:z was seen */ +} ParsedTime; + +static void +parsed_time_init(ParsedTime *pt) +{ + pt->year = -1; + pt->month = 1; + pt->day = 1; + pt->hour = 0; + pt->minute = 0; + pt->second = 0; + pt->weekday = -1; + pt->julian = INT_MIN; + pt->tz = -1; + pt->fraction = 0; + pt->gmtoff = INT_MIN; + pt->gmtoff_fraction = 0; + pt->iso_year = -1; + pt->iso_week = -1; + pt->week_of_year = -1; + pt->week_of_year_start = -1; + pt->century = -1; + pt->has_year = 0; + pt->has_short_year = 0; + pt->day_of_month_in_format = 0; + pt->year_in_format = 0; + pt->colon_z_in_format = 0; +} + +/* Parse up to max_digits decimal digits from s at position *pos. + Stores the integer value in *out. Returns number of digits consumed, + or 0 on failure. Does NOT advance *pos. */ +static int +parse_digits(const char *s, Py_ssize_t len, Py_ssize_t pos, + int min_digits, int max_digits, int *out) +{ + int val = 0; + int count = 0; + while (count < max_digits && pos + count < len) { + char c = s[pos + count]; + if (c < '0' || c > '9') { + break; + } + val = val * 10 + (c - '0'); + count++; + } + if (count < min_digits) { + return 0; + } + *out = val; + return count; +} + +/* Check if character at pos is ASCII whitespace */ +static int +is_ascii_space(char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || + c == '\f' || c == '\v'; +} + +/* ========================== date math ========================== */ + +static int +is_leap_year(int year) +{ + return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)); +} + +/* Day of year (1-based) for a given y/m/d */ +static int +day_of_year(int year, int month, int day) +{ + static const int cum[12] = {0,31,59,90,120,151,181,212,243,273,304,334}; + int doy = cum[month - 1] + day; + if (month > 2 && is_leap_year(year)) { + doy++; + } + return doy; +} + +/* Compute weekday (0=Mon, 6=Sun) from y/m/d using Tomohiko Sakamoto's algo */ +static int +weekday_from_date(int y, int m, int d) +{ + static const int t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; + if (m < 3) { + y--; + } + int w = (y + y/4 - y/100 + y/400 + t[m-1] + d) % 7; + /* Sakamoto gives 0=Sun, we want 0=Mon */ + return (w + 6) % 7; +} + +/* Compute ordinal (days since 0001-01-01, where 0001-01-01 = ordinal 1). + This matches datetime.date(year, month, day).toordinal(). */ +static long +date_to_ordinal(int year, int month, int day) +{ + /* Days before year: 365*y + leaps */ + long y = (long)year - 1; + long days_before_year = y * 365 + y / 4 - y / 100 + y / 400; + + /* Days before month in this year */ + static const int days_before_month[13] = { + 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 + }; + long dbm = days_before_month[month]; + if (month > 2 && is_leap_year(year)) { + dbm++; + } + + return days_before_year + dbm + day; +} + +/* Convert ordinal back to (year, month, day). + Inverse of date_to_ordinal(). */ +static void +ordinal_to_date(long ordinal, int *year, int *month, int *day) +{ + /* Algorithm from the CPython datetime module (Lib/datetime.py) */ + long n = ordinal - 1; /* 0-based day count */ + long n400 = n / 146097; + n = n % 146097; + long n100 = n / 36524; + n = n % 36524; + long n4 = n / 1461; + n = n % 1461; + long n1 = n / 365; + n = n % 365; + + *year = (int)(n400 * 400 + n100 * 100 + n4 * 4 + n1 + 1); + + /* If n1 == 4 or n100 == 4, then the ordinal is the last day of a + leap year (Dec 31), and the year must be backed up by one. */ + if (n1 == 4 || n100 == 4) { + *year -= 1; + n = 365; /* Dec 31 */ + } + + /* n is now the 0-based day within the year */ + static const int days_before_month[13] = { + 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 + }; + int leap = is_leap_year(*year); + + /* Find month */ + int m; + for (m = 12; m >= 1; m--) { + int dbm = days_before_month[m]; + if (m > 2 && leap) { + dbm++; + } + if (n >= dbm) { + *month = m; + *day = (int)(n - dbm + 1); + return; + } + } + /* Should never reach here */ + *month = 1; + *day = (int)(n + 1); +} + +/* Return the number of ISO weeks in a given ISO year (52 or 53). */ +static int +iso_weeks_in_year(int iso_year) +{ + /* A year has 53 weeks iff Jan 1 is Thursday or Dec 31 is Thursday. */ + int jan1_wd = weekday_from_date(iso_year, 1, 1); /* 0=Mon */ + int dec31_wd = weekday_from_date(iso_year, 12, 31); + if (jan1_wd == 3 || dec31_wd == 3) { /* Thursday = 3 */ + return 53; + } + return 52; +} + +/* Convert ISO calendar (iso_year, iso_week, weekday_1based) to (year, month, day). + weekday is 1=Monday through 7=Sunday. + Returns 1 on success, 0 on error (exception set). */ +static int +isocalendar_to_date(int iso_year, int iso_week, int iso_weekday, + int *year, int *month, int *day) +{ + /* Validate week number */ + if (iso_week > iso_weeks_in_year(iso_year)) { + PyErr_Format(PyExc_ValueError, + "Invalid week: %d", iso_week); + return 0; + } + + /* ISO year starts on the Monday of the week containing Jan 4. + Day 1 of ISO week 1 is the Monday on or before Jan 4. */ + long jan4_ord = date_to_ordinal(iso_year, 1, 4); + /* Weekday of Jan 4: 0=Mon, 6=Sun */ + int jan4_weekday = (int)((jan4_ord - 1) % 7); /* ordinal 1 = Mon = 0 */ + /* Monday of ISO week 1 */ + long week1_monday = jan4_ord - jan4_weekday; + /* Target ordinal */ + long target = week1_monday + (iso_week - 1) * 7 + (iso_weekday - 1); + ordinal_to_date(target, year, month, day); + return 1; +} + +/* Julian day from U or W week number */ +static int +calc_julian_from_U_or_W(int year, int week_of_year, int day_of_week, + int week_starts_Mon) +{ + int first_weekday = weekday_from_date(year, 1, 1); + if (!week_starts_Mon) { + first_weekday = (first_weekday + 1) % 7; + day_of_week = (day_of_week + 1) % 7; + } + int week_0_length = (7 - first_weekday) % 7; + if (week_of_year == 0) { + return 1 + day_of_week - first_weekday; + } + else { + int days_to_week = week_0_length + (7 * (week_of_year - 1)); + return 1 + days_to_week + day_of_week; + } +} + +/* ========================== format parsing ========================== */ + +/* Parse the timezone offset component of %z / %:z. + s points to the full data string, *ppos is current position. + data_len is total length of data string. + On success, advances *ppos and sets pt->gmtoff, pt->gmtoff_fraction. + colon_z: 1 if %:z directive, 0 if %z directive. + Returns 1 on success, 0 on parse failure, -1 to signal fallback. */ +static int +parse_tz_offset(const char *s, Py_ssize_t len, Py_ssize_t *ppos, + ParsedTime *pt, int colon_z) +{ + Py_ssize_t pos = *ppos; + Py_ssize_t tz_start = pos; /* remember start for error messages */ + + /* Check for 'Z' */ + if (pos < len && s[pos] == 'Z') { + pt->gmtoff = 0; + pt->gmtoff_fraction = 0; + *ppos = pos + 1; + return 1; + } + + /* %z is optional - no sign means no tz offset */ + if (pos >= len) { + return 1; + } + + int sign; + if (s[pos] == '+') { + sign = 1; + } else if (s[pos] == '-') { + sign = -1; + } else { + return 1; + } + pos++; + + int hours, minutes = 0, seconds = 0; + int n; + + /* Hours: exactly 2 digits */ + n = parse_digits(s, len, pos, 2, 2, &hours); + if (n != 2) { + PyErr_SetString(PyExc_ValueError, + "time data does not match format"); + return 0; + } + pos += 2; + + /* Check for colon after hours */ + int has_colon = 0; + if (pos < len && s[pos] == ':') { + has_colon = 1; + pos++; + } + else if (colon_z) { + /* %:z requires colons; without one, just match +HH */ + goto done; + } + + /* Minutes: exactly 2 digits */ + n = parse_digits(s, len, pos, 2, 2, &minutes); + if (n != 2) { + /* No minutes - just hours matched */ + goto done; + } + if (minutes > 59) { + PyErr_SetString(PyExc_ValueError, + "time data does not match format"); + return 0; + } + pos += 2; + + /* Seconds: check for separator */ + if (pos < len) { + int sec_sep_colon = (s[pos] == ':'); + + if (sec_sep_colon) { + /* Colon before seconds */ + if (!has_colon) { + /* No colon after hours, but colon before seconds = inconsistent. + E.g. -0130:30 */ + Py_ssize_t tz_end = pos; + while (tz_end < len && !is_ascii_space(s[tz_end])) { + tz_end++; + } + PyErr_Format(PyExc_ValueError, + "Inconsistent use of : in %.*s", + (int)(tz_end - tz_start), + s + tz_start); + return 0; + } + pos++; /* consume colon */ + } + else if (has_colon) { + /* Colon after hours but no colon before seconds. + For %:z, just stop — don't consume what follows. + For %z, if there are digits, it's an inconsistency. */ + if (!colon_z && pos < len && s[pos] >= '0' && s[pos] <= '5') { + /* Check if this really looks like seconds (2 digits) */ + int tmp; + int tmp_n = parse_digits(s, len, pos, 2, 2, &tmp); + if (tmp_n == 2) { + /* Has colon after hours, no colon before seconds = inconsistent. + E.g. -01:3030 */ + Py_ssize_t tz_end = pos + tmp_n; + /* Include any trailing fraction */ + while (tz_end < len && !is_ascii_space(s[tz_end])) { + tz_end++; + } + PyErr_Format(PyExc_ValueError, + "Inconsistent use of : in %.*s", + (int)(tz_end - tz_start), + s + tz_start); + return 0; + } + } + goto done; + } + else { + /* No colons anywhere - check for seconds without separator */ + /* Only proceed if it looks like 2 digits for seconds */ + } + + /* Try to parse seconds digits */ + int sec_n = parse_digits(s, len, pos, 2, 2, &seconds); + if (sec_n == 2 && seconds <= 59) { + pos += 2; + + /* Fractional seconds */ + if (pos < len && s[pos] == '.') { + int frac; + int frac_n = parse_digits(s, len, pos + 1, 1, 6, &frac); + if (frac_n == 0) { + /* Decimal point not followed by digits */ + PyErr_SetString(PyExc_ValueError, + "time data does not match format"); + return 0; + } + /* Check for too many digits */ + if (pos + 1 + frac_n < len && + s[pos + 1 + frac_n] >= '0' && + s[pos + 1 + frac_n] <= '9') { + PyErr_SetString(PyExc_ValueError, + "time data does not match format"); + return 0; + } + pos += 1 + frac_n; + for (int i = frac_n; i < 6; i++) { + frac *= 10; + } + pt->gmtoff_fraction = sign * frac; + } + /* Check for colon used as decimal separator */ + else if (pos < len && s[pos] == ':' && + pos + 1 < len && + s[pos + 1] >= '0' && s[pos + 1] <= '9') { + PyErr_SetString(PyExc_ValueError, + "time data does not match format"); + return 0; + } + } + } + +done: + pt->gmtoff = sign * (hours * 3600 + minutes * 60 + seconds); + *ppos = pos; + return 1; +} + +/* Parse a single directive. + Returns 1 on success (consumed chars stored in *consumed_out), + 0 on parse error, -1 to signal "fallback to Python". */ +static int +parse_directive(const char *data, Py_ssize_t data_len, Py_ssize_t data_pos, + const char *fmt, Py_ssize_t fmt_len, Py_ssize_t *fmt_pos, + ParsedTime *pt, Py_ssize_t *consumed_out) +{ + Py_ssize_t fpos = *fmt_pos; + /* fpos points to char after '%' */ + if (fpos >= fmt_len) { + /* stray % at end of format - fall back to Python for proper error */ + return -1; + } + + /* Skip modifier flags: -, _, 0, ^, # and width digits */ + while (fpos < fmt_len && + (fmt[fpos] == '-' || fmt[fpos] == '_' || fmt[fpos] == '0' || + fmt[fpos] == '^' || fmt[fpos] == '#')) { + fpos++; + } + /* Skip width digits */ + while (fpos < fmt_len && fmt[fpos] >= '0' && fmt[fpos] <= '9') { + fpos++; + } + + if (fpos >= fmt_len) { + /* stray % with only flags - fall back to Python for proper error */ + return -1; + } + + char directive = fmt[fpos]; + fpos++; + + int val; + int n; + Py_ssize_t consumed = 0; + + switch (directive) { + case 'Y': /* 4-digit year */ + n = parse_digits(data, data_len, data_pos, 4, 4, &val); + if (n != 4) { + goto match_fail; + } + pt->year = val; + pt->has_year = 1; + pt->year_in_format = 1; + consumed = 4; + break; + + case 'y': /* 2-digit year */ + n = parse_digits(data, data_len, data_pos, 2, 2, &val); + if (n != 2) { + goto match_fail; + } + pt->year = val; + pt->has_short_year = 1; + pt->year_in_format = 1; + consumed = 2; + break; + + case 'C': /* century, 2 digits */ + n = parse_digits(data, data_len, data_pos, 2, 2, &val); + if (n != 2) { + goto match_fail; + } + pt->century = val; + consumed = 2; + break; + + case 'm': /* month 01-12 or 1-12 */ + n = parse_digits(data, data_len, data_pos, 1, 2, &val); + if (n == 0 || val < 1 || val > 12) { + goto match_fail; + } + pt->month = val; + consumed = n; + break; + + case 'd': /* day 01-31 or 1-31 or space-padded */ + case 'e': + pt->day_of_month_in_format = 1; + /* Handle space-padded day */ + if (data_pos < data_len && data[data_pos] == ' ') { + data_pos++; + consumed = 1; + } + n = parse_digits(data, data_len, data_pos, 1, 2, &val); + if (n == 0 || val < 1 || val > 31) { + goto match_fail; + } + pt->day = val; + consumed += n; + break; + + case 'H': /* hour 00-23, 0-23, or space-padded */ + case 'k': + if (data_pos < data_len && data[data_pos] == ' ') { + data_pos++; + consumed = 1; + } + n = parse_digits(data, data_len, data_pos, 1, 2, &val); + if (n == 0 || val > 23) { + goto match_fail; + } + pt->hour = val; + consumed += n; + break; + + case 'I': /* 12-hour: 01-12 or 1-12 or space-padded */ + case 'l': + /* Fall back to Python - needs %p for AM/PM resolution */ + return -1; + + case 'M': /* minute 00-59 */ + n = parse_digits(data, data_len, data_pos, 1, 2, &val); + if (n == 0 || val > 59) { + goto match_fail; + } + pt->minute = val; + consumed = n; + break; + + case 'S': /* second 00-61 */ + n = parse_digits(data, data_len, data_pos, 1, 2, &val); + if (n == 0 || val > 61) { + goto match_fail; + } + pt->second = val; + consumed = n; + break; + + case 'f': /* microseconds, 1-6 digits */ + { + int frac; + n = parse_digits(data, data_len, data_pos, 1, 6, &frac); + if (n == 0) { + goto match_fail; + } + /* Pad to 6 digits */ + for (int i = n; i < 6; i++) { + frac *= 10; + } + pt->fraction = frac; + consumed = n; + break; + } + + case 'j': /* day of year 001-366 */ + n = parse_digits(data, data_len, data_pos, 1, 3, &val); + if (n == 0 || val < 1 || val > 366) { + goto match_fail; + } + pt->julian = val; + consumed = n; + break; + + case 'w': /* weekday 0=Sun, 6=Sat */ + n = parse_digits(data, data_len, data_pos, 1, 1, &val); + if (n == 0 || val > 6) { + goto match_fail; + } + /* Convert: Python uses 0=Mon, %w has 0=Sun */ + if (val == 0) { + pt->weekday = 6; + } else { + pt->weekday = val - 1; + } + consumed = 1; + break; + + case 'u': /* weekday 1=Mon, 7=Sun (ISO) */ + n = parse_digits(data, data_len, data_pos, 1, 1, &val); + if (n == 0 || val < 1 || val > 7) { + goto match_fail; + } + pt->weekday = val - 1; /* 0=Mon */ + consumed = 1; + break; + + case 'G': /* ISO year, 4 digits */ + n = parse_digits(data, data_len, data_pos, 4, 4, &val); + if (n != 4) { + goto match_fail; + } + pt->iso_year = val; + pt->year_in_format = 1; + consumed = 4; + break; + + case 'V': /* ISO week 01-53 */ + n = parse_digits(data, data_len, data_pos, 1, 2, &val); + if (n == 0 || val < 1 || val > 53) { + goto match_fail; + } + pt->iso_week = val; + consumed = n; + break; + + case 'U': /* week number (Sunday start) 00-53 */ + n = parse_digits(data, data_len, data_pos, 1, 2, &val); + if (n == 0 || val > 53) { + goto match_fail; + } + pt->week_of_year = val; + pt->week_of_year_start = 6; /* Sunday */ + consumed = n; + break; + + case 'W': /* week number (Monday start) 00-53 */ + n = parse_digits(data, data_len, data_pos, 1, 2, &val); + if (n == 0 || val > 53) { + goto match_fail; + } + pt->week_of_year = val; + pt->week_of_year_start = 0; /* Monday */ + consumed = n; + break; + + case 'z': /* timezone offset */ + { + Py_ssize_t zpos = data_pos; + int rc = parse_tz_offset(data, data_len, &zpos, pt, 0); + if (rc == 0) return 0; + if (rc == -1) return -1; + consumed = zpos - data_pos; + break; + } + + case ':': /* %:z */ + if (fpos < fmt_len && fmt[fpos] == 'z') { + fpos++; + pt->colon_z_in_format = 1; + Py_ssize_t zpos = data_pos; + int rc = parse_tz_offset(data, data_len, &zpos, pt, 1); + if (rc == 0) return 0; + if (rc == -1) return -1; + consumed = zpos - data_pos; + } else { + /* Unknown directive %:X - fall back */ + return -1; + } + break; + + case '%': /* literal % */ + if (data_pos >= data_len || data[data_pos] != '%') { + goto match_fail; + } + consumed = 1; + break; + + case 'n': /* newline */ + if (data_pos >= data_len || data[data_pos] != '\n') { + goto match_fail; + } + consumed = 1; + break; + + case 't': /* tab */ + if (data_pos >= data_len || data[data_pos] != '\t') { + goto match_fail; + } + consumed = 1; + break; + + /* Compound directives that expand to other directives */ + case 'F': /* %Y-%m-%d */ + case 'T': /* %H:%M:%S */ + case 'R': /* %H:%M */ + { + /* We handle these by setting up a sub-format and parsing it inline */ + const char *sub_fmt; + Py_ssize_t sub_fmt_len; + if (directive == 'F') { + sub_fmt = "%Y-%m-%d"; + sub_fmt_len = 8; + } else if (directive == 'T') { + sub_fmt = "%H:%M:%S"; + sub_fmt_len = 8; + } else { + sub_fmt = "%H:%M"; + sub_fmt_len = 5; + } + + Py_ssize_t sub_fpos = 0; + Py_ssize_t sub_dpos = data_pos; + while (sub_fpos < sub_fmt_len) { + if (sub_fmt[sub_fpos] == '%') { + sub_fpos++; /* skip % */ + Py_ssize_t sub_consumed; + int ret = parse_directive(data, data_len, sub_dpos, + sub_fmt, sub_fmt_len, + &sub_fpos, pt, &sub_consumed); + if (ret <= 0) { + if (ret == 0 && !PyErr_Occurred()) goto match_fail; + return ret; + } + sub_dpos += sub_consumed; + } else { + /* literal */ + if (sub_dpos >= data_len || data[sub_dpos] != sub_fmt[sub_fpos]) { + goto match_fail; + } + sub_dpos++; + sub_fpos++; + } + } + consumed = sub_dpos - data_pos; + break; + } + + /* Locale-dependent directives: fall back to Python */ + case 'b': case 'B': case 'a': case 'A': + case 'p': case 'P': + case 'c': case 'x': case 'X': case 'r': + case 'Z': + return -1; + + case 'O': case 'E': + /* %O* and %E* modifiers: fall back to Python */ + return -1; + + default: + /* Unknown directive */ + return -1; + } + + *fmt_pos = fpos; + *consumed_out = consumed; + return 1; + +match_fail: + /* Don't set error here - parse_format will set the proper message + with data_string and format included */ + return 0; +} + + +/* Main parsing function: walks the format string, dispatching directives. + data_obj and fmt_obj are the original Python string objects, used + for error messages. */ +static int +parse_format(const char *data, Py_ssize_t data_len, + const char *fmt, Py_ssize_t fmt_len, + ParsedTime *pt, + PyObject *data_obj, PyObject *fmt_obj) +{ + Py_ssize_t dpos = 0; + Py_ssize_t fpos = 0; + +/* Macro to set the standard mismatch error with data and format repr */ +#define SET_MISMATCH_ERROR() do { \ + PyObject *data_repr = PyObject_Repr(data_obj); \ + PyObject *fmt_repr = PyObject_Repr(fmt_obj); \ + if (data_repr && fmt_repr) { \ + PyErr_Format(PyExc_ValueError, \ + "time data %U does not match format %U", \ + data_repr, fmt_repr); \ + } \ + Py_XDECREF(data_repr); \ + Py_XDECREF(fmt_repr); \ +} while (0) + + while (fpos < fmt_len) { + char fc = fmt[fpos]; + + if (fc == '%') { + fpos++; /* skip '%' */ + Py_ssize_t directive_consumed; + int ret = parse_directive(data, data_len, dpos, + fmt, fmt_len, &fpos, pt, + &directive_consumed); + if (ret < 0) { + /* Signal fallback: return -1, no error set */ + PyErr_Clear(); + return -1; + } + if (ret == 0) { + /* Error may or may not be set by parse_directive. + If not set (match_fail), set the standard mismatch error. */ + if (!PyErr_Occurred()) { + SET_MISMATCH_ERROR(); + } + return 0; + } + dpos += directive_consumed; + } + else if (is_ascii_space(fc)) { + /* Whitespace in format matches 1+ whitespace in data */ + if (dpos >= data_len || !is_ascii_space(data[dpos])) { + SET_MISMATCH_ERROR(); + return 0; + } + /* Skip all whitespace in format */ + while (fpos < fmt_len && is_ascii_space(fmt[fpos])) { + fpos++; + } + /* Skip all whitespace in data */ + while (dpos < data_len && is_ascii_space(data[dpos])) { + dpos++; + } + } + else if (fc == '\'') { + /* Apostrophe matches ' or \u02bc - but \u02bc is multi-byte UTF-8. + For ASCII-only fast path, just match '. */ + if (dpos < data_len && data[dpos] == '\'') { + dpos++; + fpos++; + } + /* Check for \u02bc (UTF-8: 0xCA 0xBC) */ + else if (dpos + 1 < data_len && + (unsigned char)data[dpos] == 0xCA && + (unsigned char)data[dpos + 1] == 0xBC) { + dpos += 2; + fpos++; + } + else { + SET_MISMATCH_ERROR(); + return 0; + } + } + else { + /* Literal character match */ + if (dpos >= data_len || data[dpos] != fc) { + SET_MISMATCH_ERROR(); + return 0; + } + dpos++; + fpos++; + } + } + +#undef SET_MISMATCH_ERROR + + /* Check for unconverted data */ + if (dpos != data_len) { + const char *rest = data + dpos; + /* Specific check for %:z directive */ + if (pt->colon_z_in_format && pt->gmtoff != INT_MIN && + rest[0] != ':') { + PyErr_Format(PyExc_ValueError, + "Missing colon in %%:z before '%s', got '%s'", + rest, data); + return 0; + } + PyErr_Format(PyExc_ValueError, + "unconverted data remains: %s", + rest); + return 0; + } + + return 1; /* success */ +} + + +/* ========================== post-processing ========================== */ + +/* Resolve parsed fields into final values, replicating _strptime.py logic. + Returns 1 on success, 0 on error (exception set), -1 for fallback. */ +static int +resolve_date_fields(ParsedTime *pt) +{ + /* Handle %y with %C (century) */ + if (pt->has_short_year) { + if (pt->century >= 0) { + pt->year += pt->century * 100; + } else { + if (pt->year <= 68) { + pt->year += 2000; + } else { + pt->year += 1900; + } + } + } + + /* ISO year validation */ + if (pt->iso_year >= 0) { + if (pt->julian != INT_MIN) { + PyErr_SetString(PyExc_ValueError, + "Day of the year directive '%j' is not " + "compatible with ISO year directive '%G'. " + "Use '%Y' instead."); + return 0; + } + if (pt->iso_week < 0 || pt->weekday < 0) { + PyErr_SetString(PyExc_ValueError, + "ISO year directive '%G' must be used with " + "the ISO week directive '%V' and a weekday " + "directive ('%A', '%a', '%w', or '%u')."); + return 0; + } + } + else if (pt->iso_week >= 0) { + if (pt->year < 0 || pt->weekday < 0) { + PyErr_SetString(PyExc_ValueError, + "ISO week directive '%V' must be used with " + "the ISO year directive '%G' and a weekday " + "directive ('%A', '%a', '%w', or '%u')."); + return 0; + } + else { + PyErr_SetString(PyExc_ValueError, + "ISO week directive '%V' is incompatible with " + "the year directive '%Y'. Use the ISO year '%G' " + "instead."); + return 0; + } + } + + /* Default year handling */ + int leap_year_fix = 0; + if (pt->year < 0) { + if (pt->month == 2 && pt->day == 29) { + pt->year = 1904; + leap_year_fix = 1; + } else { + pt->year = 1900; + } + } + + /* Julian / weekday calculation */ + if (pt->julian == INT_MIN && pt->weekday >= 0) { + if (pt->week_of_year >= 0) { + int week_starts_Mon = (pt->week_of_year_start == 0) ? 1 : 0; + pt->julian = calc_julian_from_U_or_W(pt->year, pt->week_of_year, + pt->weekday, week_starts_Mon); + } + else if (pt->iso_year >= 0 && pt->iso_week >= 0) { + if (!isocalendar_to_date(pt->iso_year, pt->iso_week, + pt->weekday + 1, + &pt->year, &pt->month, &pt->day)) { + return 0; + } + } + + if (pt->julian != INT_MIN && pt->julian <= 0) { + pt->year -= 1; + int yday = is_leap_year(pt->year) ? 366 : 365; + pt->julian += yday; + } + } + + if (pt->julian == INT_MIN) { + /* Compute julian from year/month/day */ + pt->julian = day_of_year(pt->year, pt->month, pt->day); + } else { + /* Compute year/month/day from julian */ + long jan1_ord = date_to_ordinal(pt->year, 1, 1); + long target_ord = jan1_ord + pt->julian - 1; + ordinal_to_date(target_ord, &pt->year, &pt->month, &pt->day); + } + + if (pt->weekday < 0) { + pt->weekday = weekday_from_date(pt->year, pt->month, pt->day); + } + + if (leap_year_fix) { + pt->year = 1900; + } + + return 1; +} + + +/* Build the return 3-tuple matching Python's _strptime() return value: + ((y, m, d, H, M, S, wd, jd, tz, tzname, gmtoff), fraction, gmtoff_fraction) +*/ +static PyObject * +build_result(ParsedTime *pt) +{ + /* tzname: None if gmtoff not set, else string representation or None */ + PyObject *tzname_obj; + PyObject *gmtoff_obj; + + if (pt->gmtoff == INT_MIN) { + tzname_obj = Py_NewRef(Py_None); + gmtoff_obj = Py_NewRef(Py_None); + } else { + /* For numeric timezone offsets parsed from %z, we don't have a name */ + tzname_obj = Py_NewRef(Py_None); + gmtoff_obj = PyLong_FromLong(pt->gmtoff); + if (!gmtoff_obj) { + Py_DECREF(tzname_obj); + return NULL; + } + } + + PyObject *inner = Py_BuildValue( + "(iiiiiiiiiOO)", + pt->year, pt->month, pt->day, + pt->hour, pt->minute, pt->second, + pt->weekday, pt->julian, pt->tz, + tzname_obj, gmtoff_obj + ); + + Py_DECREF(tzname_obj); + Py_DECREF(gmtoff_obj); + + if (!inner) { + return NULL; + } + + PyObject *result = Py_BuildValue("(Oii)", inner, pt->fraction, + pt->gmtoff_fraction); + Py_DECREF(inner); + return result; +} + + +/* ========================== module function ========================== */ + +/*[clinic input] +_strptime_impl._strptime_parse + + data_string: unicode + format: unicode + / + +Parse a time string according to a format. + +Returns a 3-tuple on success, or None if the format contains +directives that require the Python fallback path. + +[clinic start generated code]*/ + +static PyObject * +_strptime_impl__strptime_parse_impl(PyObject *module, PyObject *data_string, + PyObject *format) +/*[clinic end generated code: output=c3c1f836ef1972ae input=9a670dbb56f2ad26]*/ +{ + const char *data_str = PyUnicode_AsUTF8(data_string); + if (data_str == NULL) { + /* Strings with surrogates can't be encoded to UTF-8. + Fall back to the Python path which handles them. */ + PyErr_Clear(); + Py_RETURN_NONE; + } + const char *fmt_str = PyUnicode_AsUTF8(format); + if (fmt_str == NULL) { + PyErr_Clear(); + Py_RETURN_NONE; + } + + Py_ssize_t data_len = (Py_ssize_t)strlen(data_str); + Py_ssize_t fmt_len = (Py_ssize_t)strlen(fmt_str); + + ParsedTime pt; + parsed_time_init(&pt); + + int rc = parse_format(data_str, data_len, fmt_str, fmt_len, &pt, + data_string, format); + if (rc == -1) { + /* Fallback signal: return None */ + Py_RETURN_NONE; + } + if (rc == 0) { + /* Error already set */ + return NULL; + } + + /* DeprecationWarning for %d without year */ + if (pt.day_of_month_in_format && !pt.year_in_format) { + if (PyErr_WarnEx(PyExc_DeprecationWarning, + "Parsing dates involving a day of month without a year specified " + "is ambiguous and fails to parse leap day. The default behavior " + "will change in Python 3.15 to either always raise an exception " + "or to use a different default year (TBD). To avoid trouble, add " + "a specific year to the input & format. " + "See https://github.com/python/cpython/issues/70647.", 2) < 0) { + return NULL; + } + } + + /* Post-processing */ + rc = resolve_date_fields(&pt); + if (rc == -1) { + Py_RETURN_NONE; + } + if (rc == 0) { + return NULL; + } + + return build_result(&pt); +} + + +/* ========================== module definition ========================== */ + +static PyMethodDef strptime_methods[] = { + _STRPTIME_IMPL__STRPTIME_PARSE_METHODDEF + {NULL, NULL, 0, NULL} +}; + +PyDoc_STRVAR(strptime_doc, +"C accelerator for _strptime time parsing.\n"); + +static struct PyModuleDef_Slot _strptimemodule_slots[] = { + {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED}, + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, + {0, NULL} +}; + +static struct PyModuleDef _strptimemodule = { + PyModuleDef_HEAD_INIT, + "_strptime_impl", + strptime_doc, + 0, + strptime_methods, + _strptimemodule_slots, + NULL, + NULL, + NULL +}; + +PyMODINIT_FUNC +PyInit__strptime_impl(void) +{ + return PyModuleDef_Init(&_strptimemodule); +} diff --git a/Modules/clinic/_strptimemodule.c.h b/Modules/clinic/_strptimemodule.c.h new file mode 100644 index 000000000000000..fa536264f4ecf36 --- /dev/null +++ b/Modules/clinic/_strptimemodule.c.h @@ -0,0 +1,48 @@ +/*[clinic input] +preserve +[clinic start generated code]*/ + +#include "pycore_modsupport.h" // _PyArg_CheckPositional() + +PyDoc_STRVAR(_strptime_impl__strptime_parse__doc__, +"_strptime_parse($module, data_string, format, /)\n" +"--\n" +"\n" +"Parse a time string according to a format.\n" +"\n" +"Returns a 3-tuple on success, or None if the format contains\n" +"directives that require the Python fallback path."); + +#define _STRPTIME_IMPL__STRPTIME_PARSE_METHODDEF \ + {"_strptime_parse", _PyCFunction_CAST(_strptime_impl__strptime_parse), METH_FASTCALL, _strptime_impl__strptime_parse__doc__}, + +static PyObject * +_strptime_impl__strptime_parse_impl(PyObject *module, PyObject *data_string, + PyObject *format); + +static PyObject * +_strptime_impl__strptime_parse(PyObject *module, PyObject *const *args, Py_ssize_t nargs) +{ + PyObject *return_value = NULL; + PyObject *data_string; + PyObject *format; + + if (!_PyArg_CheckPositional("_strptime_parse", nargs, 2, 2)) { + goto exit; + } + if (!PyUnicode_Check(args[0])) { + _PyArg_BadArgument("_strptime_parse", "argument 1", "str", args[0]); + goto exit; + } + data_string = args[0]; + if (!PyUnicode_Check(args[1])) { + _PyArg_BadArgument("_strptime_parse", "argument 2", "str", args[1]); + goto exit; + } + format = args[1]; + return_value = _strptime_impl__strptime_parse_impl(module, data_string, format); + +exit: + return return_value; +} +/*[clinic end generated code: output=e22b619e9547671c input=a9049054013a1b77]*/ diff --git a/PC/config.c b/PC/config.c index dcccc6a3a6d0167..831843cd0626c8a 100644 --- a/PC/config.c +++ b/PC/config.c @@ -19,6 +19,7 @@ extern PyObject* PyInit_nt(void); extern PyObject* PyInit__operator(void); extern PyObject* PyInit__signal(void); extern PyObject* PyInit__statistics(void); +extern PyObject* PyInit__strptime_impl(void); extern PyObject* PyInit__sysconfig(void); extern PyObject* PyInit__types(void); extern PyObject* PyInit__typing(void); @@ -113,6 +114,7 @@ struct _inittab _PyImport_Inittab[] = { {"_types", PyInit__types}, {"_typing", PyInit__typing}, {"_statistics", PyInit__statistics}, + {"_strptime_impl", PyInit__strptime_impl}, /* cryptographic hash functions */ {"_blake2", PyInit__blake2}, diff --git a/configure b/configure index 607f43db23360df..bbc26a0e81affd3 100755 --- a/configure +++ b/configure @@ -798,6 +798,8 @@ MODULE_SELECT_FALSE MODULE_SELECT_TRUE MODULE__REMOTE_DEBUGGING_FALSE MODULE__REMOTE_DEBUGGING_TRUE +MODULE__STRPTIME_IMPL_FALSE +MODULE__STRPTIME_IMPL_TRUE MODULE__RANDOM_FALSE MODULE__RANDOM_TRUE MODULE__QUEUE_FALSE @@ -36360,6 +36362,28 @@ then : +fi + + + if test "$py_cv_module__strptime_impl" != "n/a" +then : + py_cv_module__strptime_impl=yes +fi + if test "$py_cv_module__strptime_impl" = yes; then + MODULE__STRPTIME_IMPL_TRUE= + MODULE__STRPTIME_IMPL_FALSE='#' +else + MODULE__STRPTIME_IMPL_TRUE='#' + MODULE__STRPTIME_IMPL_FALSE= +fi + + as_fn_append MODULE_BLOCK "MODULE__STRPTIME_IMPL_STATE=$py_cv_module__strptime_impl$as_nl" + if test "x$py_cv_module__strptime_impl" = xyes +then : + + + + fi @@ -39315,6 +39339,10 @@ if test -z "${MODULE__RANDOM_TRUE}" && test -z "${MODULE__RANDOM_FALSE}"; then as_fn_error $? "conditional \"MODULE__RANDOM\" was never defined. Usually this means the macro was only invoked conditionally." "$LINENO" 5 fi +if test -z "${MODULE__STRPTIME_IMPL_TRUE}" && test -z "${MODULE__STRPTIME_IMPL_FALSE}"; then + as_fn_error $? "conditional \"MODULE__STRPTIME_IMPL\" was never defined. +Usually this means the macro was only invoked conditionally." "$LINENO" 5 +fi if test -z "${MODULE__REMOTE_DEBUGGING_TRUE}" && test -z "${MODULE__REMOTE_DEBUGGING_FALSE}"; then as_fn_error $? "conditional \"MODULE__REMOTE_DEBUGGING\" was never defined. Usually this means the macro was only invoked conditionally." "$LINENO" 5 diff --git a/configure.ac b/configure.ac index 775b8b4e6038773..9d2004c8ecd6a9b 100644 --- a/configure.ac +++ b/configure.ac @@ -8564,6 +8564,7 @@ PY_STDLIB_MOD_SIMPLE([_pickle]) PY_STDLIB_MOD_SIMPLE([_posixsubprocess]) PY_STDLIB_MOD_SIMPLE([_queue]) PY_STDLIB_MOD_SIMPLE([_random]) +PY_STDLIB_MOD_SIMPLE([_strptime_impl]) PY_STDLIB_MOD_SIMPLE([_remote_debugging], [$REMOTE_DEBUGGING_CFLAGS], [$REMOTE_DEBUGGING_LIBS]) PY_STDLIB_MOD_SIMPLE([select]) PY_STDLIB_MOD_SIMPLE([_struct]) From daa5e815c8f5db83d0894f467f2ff69f7fff34bd Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 17 Sep 2026 10:19:49 -0400 Subject: [PATCH 02/12] Preserve Python strptime semantics in the C accelerator Restrict the fast path to independent ASCII numeric fields and defer unusual formats and mismatches to the Python parser. This preserves regex backtracking, duplicate-directive errors, and current diagnostics for dates without a year. Use string lengths to preserve embedded NULs, validate calendar dates before returning time tuples, and preserve allocation failures. --- Lib/test/datetimetester.py | 33 +++++ Modules/_strptimemodule.c | 243 +++++++++++-------------------------- 2 files changed, 102 insertions(+), 174 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 716c662ad453f4a..aeb42481e2997ce 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1178,6 +1178,39 @@ def test_strptime(self): self.assertEqual(expected, got) self.assertIs(type(got), date) + def test_strptime_numeric_fallback(self): + cases = ( + ('2024\0-02-29', '%Y\0-%m-%d', (2024, 2, 29)), + ('٢٠٢٤-02-29', '%Y-%m-%d', (2024, 2, 29)), + ('2024\u200302\u200329', '%Y %m %d', (2024, 2, 29)), + ('2024t02t29', '%YT%mt%d', (2024, 2, 29)), + ('2024111', '%Y%m%d', (2024, 11, 1)), + ('2024131', '%Y%m%d', (2024, 1, 31)), + ('24 2025-02-01', '%y %Y-%m-%d', (2025, 2, 1)), + ('2025 24-02-01', '%Y %y-%m-%d', (2024, 2, 1)), + ) + for text, fmt, expected in cases: + with self.subTest(text=text, fmt=fmt): + self.assertEqual(date.strptime(text, fmt), + date(*expected)) + + def test_strptime_numeric_invalid(self): + cases = ( + ('2024\0ignored', '%Y'), + ('2024', '%Y\0ignored'), + ('2024', '%4Y'), + ('2024-02-30', '%Y-%m-%d'), + ('1900-02-29', '%Y-%m-%d'), + ('0000-01-01', '%Y-%m-%d'), + ('2024-01- 12', '%Y-%m-%d'), + ) + for text, fmt in cases: + with self.subTest(text=text, fmt=fmt): + with self.assertRaises(ValueError): + date.strptime(text, fmt) + with self.assertRaises(re.PatternError): + date.strptime('2024 2025', '%Y %Y') + def test_strptime_single_digit(self): # bpo-34903: Check that single digit dates are allowed. strptime = date.strptime diff --git a/Modules/_strptimemodule.c b/Modules/_strptimemodule.c index 0522c02d29d5b92..dc44b6aa6b34dd5 100644 --- a/Modules/_strptimemodule.c +++ b/Modules/_strptimemodule.c @@ -280,164 +280,34 @@ parse_tz_offset(const char *s, Py_ssize_t len, Py_ssize_t *ppos, ParsedTime *pt, int colon_z) { Py_ssize_t pos = *ppos; - Py_ssize_t tz_start = pos; /* remember start for error messages */ - - /* Check for 'Z' */ - if (pos < len && s[pos] == 'Z') { - pt->gmtoff = 0; - pt->gmtoff_fraction = 0; - *ppos = pos + 1; - return 1; - } - - /* %z is optional - no sign means no tz offset */ - if (pos >= len) { + Py_ssize_t remaining = len - pos; + if (remaining == 0) { return 1; } - - int sign; - if (s[pos] == '+') { - sign = 1; - } else if (s[pos] == '-') { - sign = -1; - } else { + if (remaining == 1 && s[pos] == 'Z') { + pt->gmtoff = 0; + *ppos = len; return 1; } - pos++; - - int hours, minutes = 0, seconds = 0; - int n; - - /* Hours: exactly 2 digits */ - n = parse_digits(s, len, pos, 2, 2, &hours); - if (n != 2) { - PyErr_SetString(PyExc_ValueError, - "time data does not match format"); - return 0; - } - pos += 2; - - /* Check for colon after hours */ - int has_colon = 0; - if (pos < len && s[pos] == ':') { - has_colon = 1; - pos++; - } - else if (colon_z) { - /* %:z requires colons; without one, just match +HH */ - goto done; - } - - /* Minutes: exactly 2 digits */ - n = parse_digits(s, len, pos, 2, 2, &minutes); - if (n != 2) { - /* No minutes - just hours matched */ - goto done; - } - if (minutes > 59) { - PyErr_SetString(PyExc_ValueError, - "time data does not match format"); - return 0; + /* More involved offsets use the Python parser, including its errors. */ + if ((remaining != 5 && remaining != 6) || + (s[pos] != '+' && s[pos] != '-')) { + return -1; } - pos += 2; - - /* Seconds: check for separator */ - if (pos < len) { - int sec_sep_colon = (s[pos] == ':'); - - if (sec_sep_colon) { - /* Colon before seconds */ - if (!has_colon) { - /* No colon after hours, but colon before seconds = inconsistent. - E.g. -0130:30 */ - Py_ssize_t tz_end = pos; - while (tz_end < len && !is_ascii_space(s[tz_end])) { - tz_end++; - } - PyErr_Format(PyExc_ValueError, - "Inconsistent use of : in %.*s", - (int)(tz_end - tz_start), - s + tz_start); - return 0; - } - pos++; /* consume colon */ - } - else if (has_colon) { - /* Colon after hours but no colon before seconds. - For %:z, just stop — don't consume what follows. - For %z, if there are digits, it's an inconsistency. */ - if (!colon_z && pos < len && s[pos] >= '0' && s[pos] <= '5') { - /* Check if this really looks like seconds (2 digits) */ - int tmp; - int tmp_n = parse_digits(s, len, pos, 2, 2, &tmp); - if (tmp_n == 2) { - /* Has colon after hours, no colon before seconds = inconsistent. - E.g. -01:3030 */ - Py_ssize_t tz_end = pos + tmp_n; - /* Include any trailing fraction */ - while (tz_end < len && !is_ascii_space(s[tz_end])) { - tz_end++; - } - PyErr_Format(PyExc_ValueError, - "Inconsistent use of : in %.*s", - (int)(tz_end - tz_start), - s + tz_start); - return 0; - } - } - goto done; - } - else { - /* No colons anywhere - check for seconds without separator */ - /* Only proceed if it looks like 2 digits for seconds */ - } - - /* Try to parse seconds digits */ - int sec_n = parse_digits(s, len, pos, 2, 2, &seconds); - if (sec_n == 2 && seconds <= 59) { - pos += 2; - - /* Fractional seconds */ - if (pos < len && s[pos] == '.') { - int frac; - int frac_n = parse_digits(s, len, pos + 1, 1, 6, &frac); - if (frac_n == 0) { - /* Decimal point not followed by digits */ - PyErr_SetString(PyExc_ValueError, - "time data does not match format"); - return 0; - } - /* Check for too many digits */ - if (pos + 1 + frac_n < len && - s[pos + 1 + frac_n] >= '0' && - s[pos + 1 + frac_n] <= '9') { - PyErr_SetString(PyExc_ValueError, - "time data does not match format"); - return 0; - } - pos += 1 + frac_n; - for (int i = frac_n; i < 6; i++) { - frac *= 10; - } - pt->gmtoff_fraction = sign * frac; - } - /* Check for colon used as decimal separator */ - else if (pos < len && s[pos] == ':' && - pos + 1 < len && - s[pos + 1] >= '0' && s[pos + 1] <= '9') { - PyErr_SetString(PyExc_ValueError, - "time data does not match format"); - return 0; - } - } + int hours, minutes; + int colon = remaining == 6; + if ((colon && s[pos + 3] != ':') || (colon_z && !colon) || + parse_digits(s, len, pos + 1, 2, 2, &hours) != 2 || + parse_digits(s, len, pos + 3 + colon, 2, 2, &minutes) != 2 || + hours > 23 || minutes > 59) { + return -1; } - -done: - pt->gmtoff = sign * (hours * 3600 + minutes * 60 + seconds); - *ppos = pos; + pt->gmtoff = (hours * 3600 + minutes * 60) * (s[pos] == '-' ? -1 : 1); + *ppos = len; return 1; } + /* Parse a single directive. Returns 1 on success (consumed chars stored in *consumed_out), 0 on parse error, -1 to signal "fallback to Python". */ @@ -525,7 +395,7 @@ parse_directive(const char *data, Py_ssize_t data_len, Py_ssize_t data_pos, data_pos++; consumed = 1; } - n = parse_digits(data, data_len, data_pos, 1, 2, &val); + n = parse_digits(data, data_len, data_pos, 1, consumed ? 1 : 2, &val); if (n == 0 || val < 1 || val > 31) { goto match_fail; } @@ -539,7 +409,7 @@ parse_directive(const char *data, Py_ssize_t data_len, Py_ssize_t data_pos, data_pos++; consumed = 1; } - n = parse_digits(data, data_len, data_pos, 1, 2, &val); + n = parse_digits(data, data_len, data_pos, 1, consumed ? 1 : 2, &val); if (n == 0 || val > 23) { goto match_fail; } @@ -1068,22 +938,47 @@ _strptime_impl__strptime_parse_impl(PyObject *module, PyObject *data_string, PyObject *format) /*[clinic end generated code: output=c3c1f836ef1972ae input=9a670dbb56f2ad26]*/ { - const char *data_str = PyUnicode_AsUTF8(data_string); - if (data_str == NULL) { - /* Strings with surrogates can't be encoded to UTF-8. - Fall back to the Python path which handles them. */ - PyErr_Clear(); + /* Avoid encoding allocations and preserve string subclass behavior. */ + if (!PyUnicode_CheckExact(data_string) || !PyUnicode_CheckExact(format) || + !PyUnicode_IS_ASCII(data_string) || !PyUnicode_IS_ASCII(format)) { Py_RETURN_NONE; } + const char *data_str = PyUnicode_AsUTF8(data_string); const char *fmt_str = PyUnicode_AsUTF8(format); - if (fmt_str == NULL) { - PyErr_Clear(); + Py_ssize_t data_len = PyUnicode_GET_LENGTH(data_string); + Py_ssize_t fmt_len = PyUnicode_GET_LENGTH(format); + + /* Only accelerate independent numeric fields. Python handles aliases, + duplicate groups, locale directives, and day-without-year diagnostics. */ + unsigned int seen = 0; + const char *directives = "YymdHMSfz"; + for (Py_ssize_t i = 0; i < fmt_len; i++) { + if (fmt_str[i] != '%') { + continue; + } + if (++i == fmt_len) { + Py_RETURN_NONE; + } + if (fmt_str[i] == '%') { + continue; + } + const char *directive = strchr(directives, fmt_str[i]); + if (directive == NULL || *directive == '\0') { + Py_RETURN_NONE; + } + unsigned int bit = 1U << (directive - directives); + if (seen & bit) { + Py_RETURN_NONE; + } + seen |= bit; + if (fmt_str[i] == 'z' && i != fmt_len - 1) { + Py_RETURN_NONE; + } + } + if ((seen & 3) == 3 || ((seen & (1U << 3)) && !(seen & 3))) { Py_RETURN_NONE; } - Py_ssize_t data_len = (Py_ssize_t)strlen(data_str); - Py_ssize_t fmt_len = (Py_ssize_t)strlen(fmt_str); - ParsedTime pt; parsed_time_init(&pt); @@ -1094,21 +989,21 @@ _strptime_impl__strptime_parse_impl(PyObject *module, PyObject *data_string, Py_RETURN_NONE; } if (rc == 0) { - /* Error already set */ - return NULL; - } - - /* DeprecationWarning for %d without year */ - if (pt.day_of_month_in_format && !pt.year_in_format) { - if (PyErr_WarnEx(PyExc_DeprecationWarning, - "Parsing dates involving a day of month without a year specified " - "is ambiguous and fails to parse leap day. The default behavior " - "will change in Python 3.15 to either always raise an exception " - "or to use a different default year (TBD). To avoid trouble, add " - "a specific year to the input & format. " - "See https://github.com/python/cpython/issues/70647.", 2) < 0) { + /* Mismatches may need regex backtracking. Preserve allocation errors. */ + if (!PyErr_ExceptionMatches(PyExc_ValueError)) { return NULL; } + PyErr_Clear(); + Py_RETURN_NONE; + } + int year = pt.year < 0 ? 1900 : pt.year; + if (pt.has_short_year) { + year += year <= 68 ? 2000 : 1900; + } + static const int month_days[] = {31,28,31,30,31,30,31,31,30,31,30,31}; + if (year < 1 || pt.day > month_days[pt.month - 1] + + (pt.month == 2 && is_leap_year(year))) { + Py_RETURN_NONE; } /* Post-processing */ From 3a580534660e9e43890eb1383e8b152240087b0a Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 17 Sep 2026 10:29:53 -0400 Subject: [PATCH 03/12] Construct numeric strptime datetimes directly in C The compatible accelerator spends more time building and unpacking Python objects and traversing wrappers than parsing fields. Keep those fields in C and use the existing datetime and timezone construction machinery. Move the numeric parser into _datetime and remove the separate extension. Exact datetime instances can avoid importing _strptime and acquiring its locale/cache lock. Subclasses, pure-Python datetime, and other entry points retain the Python path, including constructor arguments and diagnostics. Drop unused directive and date-normalization code. Parsing itself neither allocates nor sets exceptions; failure requests Python fallback. Errors from result construction propagate normally. --- Lib/_strptime.py | 11 - Lib/test/datetimetester.py | 78 +- .../Library/2026-09-17-10-30-00.strptime.rst | 3 + Modules/Setup.stdlib.in | 1 - Modules/_datetimemodule.c | 228 ++++ Modules/_strptimemodule.c | 1054 ----------------- Modules/clinic/_strptimemodule.c.h | 48 - PC/config.c | 2 - configure | 28 - configure.ac | 1 - 10 files changed, 276 insertions(+), 1178 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-17-10-30-00.strptime.rst delete mode 100644 Modules/_strptimemodule.c delete mode 100644 Modules/clinic/_strptimemodule.c.h diff --git a/Lib/_strptime.py b/Lib/_strptime.py index 45ea5eb4d2d19fe..3311ec888714696 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -24,11 +24,6 @@ timezone as datetime_timezone) from _thread import allocate_lock as _thread_allocate_lock -try: - from _strptime_impl import _strptime_parse as _c_strptime_parse -except ImportError: - _c_strptime_parse = None - __all__ = [] def _getlang(): @@ -558,12 +553,6 @@ def _strptime(data_string, format="%a %b %d %H:%M:%S %Y"): locale_time = _TimeRE_cache.locale_time if len(_regex_cache) > _CACHE_MAX_SIZE: _regex_cache.clear() - if _c_strptime_parse is not None: - result = _c_strptime_parse(data_string, format) - if result is not None: - if format not in _regex_cache: - _regex_cache[format] = None - return result format_regex = _regex_cache.get(format) if not format_regex: try: diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index aeb42481e2997ce..92bbce1c4133de3 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1178,39 +1178,6 @@ def test_strptime(self): self.assertEqual(expected, got) self.assertIs(type(got), date) - def test_strptime_numeric_fallback(self): - cases = ( - ('2024\0-02-29', '%Y\0-%m-%d', (2024, 2, 29)), - ('٢٠٢٤-02-29', '%Y-%m-%d', (2024, 2, 29)), - ('2024\u200302\u200329', '%Y %m %d', (2024, 2, 29)), - ('2024t02t29', '%YT%mt%d', (2024, 2, 29)), - ('2024111', '%Y%m%d', (2024, 11, 1)), - ('2024131', '%Y%m%d', (2024, 1, 31)), - ('24 2025-02-01', '%y %Y-%m-%d', (2025, 2, 1)), - ('2025 24-02-01', '%Y %y-%m-%d', (2024, 2, 1)), - ) - for text, fmt, expected in cases: - with self.subTest(text=text, fmt=fmt): - self.assertEqual(date.strptime(text, fmt), - date(*expected)) - - def test_strptime_numeric_invalid(self): - cases = ( - ('2024\0ignored', '%Y'), - ('2024', '%Y\0ignored'), - ('2024', '%4Y'), - ('2024-02-30', '%Y-%m-%d'), - ('1900-02-29', '%Y-%m-%d'), - ('0000-01-01', '%Y-%m-%d'), - ('2024-01- 12', '%Y-%m-%d'), - ) - for text, fmt in cases: - with self.subTest(text=text, fmt=fmt): - with self.assertRaises(ValueError): - date.strptime(text, fmt) - with self.assertRaises(re.PatternError): - date.strptime('2024 2025', '%Y %Y') - def test_strptime_single_digit(self): # bpo-34903: Check that single digit dates are allowed. strptime = date.strptime @@ -3121,6 +3088,51 @@ def test_strptime(self): with self.assertRaises(ValueError): strptime("-000", "%z") with self.assertRaises(ValueError): strptime("z", "%z") + def test_strptime_numeric_fallback(self): + cases = ( + ('2024\0-02-29', '%Y\0-%m-%d', (2024, 2, 29)), + ('٢٠٢٤-02-29', '%Y-%m-%d', (2024, 2, 29)), + ('2024\u200302\u200329', '%Y %m %d', (2024, 2, 29)), + ('2024t02t29', '%YT%mt%d', (2024, 2, 29)), + ('2024111', '%Y%m%d', (2024, 11, 1)), + ('2024131', '%Y%m%d', (2024, 1, 31)), + ('24 2025-02-01', '%y %Y-%m-%d', (2025, 2, 1)), + ('2025 24-02-01', '%Y %y-%m-%d', (2024, 2, 1)), + ) + for text, fmt, expected in cases: + with self.subTest(text=text, fmt=fmt): + self.assertEqual(self.theclass.strptime(text, fmt), + self.theclass(*expected)) + + def test_strptime_numeric_invalid(self): + cases = ( + ('2024\0ignored', '%Y'), + ('2024', '%Y\0ignored'), + ('2024', '%4Y'), + ('2024-02-30', '%Y-%m-%d'), + ('1900-02-29', '%Y-%m-%d'), + ('0000-01-01', '%Y-%m-%d'), + ('2024-01- 12', '%Y-%m-%d'), + ) + for text, fmt in cases: + with self.subTest(text=text, fmt=fmt): + with self.assertRaises(ValueError): + self.theclass.strptime(text, fmt) + with self.assertRaises(re.PatternError): + self.theclass.strptime('2024 2025', '%Y %Y') + + def test_strptime_subclass_constructor(self): + class Capture(self.theclass): + def __new__(cls, *args, **kwargs): + return args, kwargs + + args = (2024, 2, 29, 12, 34, 56, 123000) + fmt = '%Y-%m-%d %H:%M:%S.%f' + text = '2024-02-29 12:34:56.123' + self.assertEqual(Capture.strptime(text, fmt), (args, {})) + self.assertEqual(Capture.strptime(text + '+0530', fmt + '%z'), + (args + (timezone(timedelta(hours=5, minutes=30)),), {})) + def test_strptime_ampm(self): dt = datetime(1999, 3, 17, 0, 44, 55, 2) for hour in range(0, 24): diff --git a/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.strptime.rst b/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.strptime.rst new file mode 100644 index 000000000000000..b4b4a90ebb35f6a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.strptime.rst @@ -0,0 +1,3 @@ +Speed up :meth:`datetime.datetime.strptime` for common numeric formats by +parsing fields and constructing the result in C. Other inputs continue to use +the Python parser. diff --git a/Modules/Setup.stdlib.in b/Modules/Setup.stdlib.in index 24e45f60201e9d9..d8cd8de10dfe084 100644 --- a/Modules/Setup.stdlib.in +++ b/Modules/Setup.stdlib.in @@ -40,7 +40,6 @@ @MODULE__MATH_INTEGER_TRUE@_math_integer mathintegermodule.c @MODULE__PICKLE_TRUE@_pickle _pickle.c @MODULE__QUEUE_TRUE@_queue _queuemodule.c -@MODULE__STRPTIME_IMPL_TRUE@_strptime_impl _strptimemodule.c @MODULE__RANDOM_TRUE@_random _randommodule.c @MODULE__REMOTE_DEBUGGING_TRUE@_remote_debugging _remote_debugging/module.c _remote_debugging/gc_stats.c _remote_debugging/object_reading.c _remote_debugging/code_objects.c _remote_debugging/frames.c _remote_debugging/frame_cache.c _remote_debugging/threads.c _remote_debugging/asyncio.c _remote_debugging/binary_io_writer.c _remote_debugging/binary_io_reader.c _remote_debugging/subprocess.c _remote_debugging/interpreters.c @MODULE__STRUCT_TRUE@_struct _struct.c diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index dbd14f1d7070772..8690835e8a690b4 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -5848,6 +5848,218 @@ datetime_datetime_utcfromtimestamp_impl(PyTypeObject *type, return datetime_from_timestamp(type, _PyTime_gmtime, timestamp, Py_None); } +/* Locale-independent strptime parsing for _datetime. + * + * Return 1 for a complete numeric match, or 0 to use Lib/_strptime.py. + * Parsing doesn't allocate or set exceptions. In particular, mismatches may + * require regex backtracking, so their diagnostics belong to the fallback. + */ + +typedef struct { + int year, month, day; + int hour, minute, second, fraction; + int gmtoff; /* INT_MIN means no offset was supplied. */ +} StrptimeFields; + +static int +strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + int minimum, int maximum, int *value) +{ + int count = 0; + *value = 0; + while (count < maximum && pos + count < length) { + unsigned char c = data[pos + count]; + if (c < '0' || c > '9') { + break; + } + *value = *value * 10 + c - '0'; + count++; + } + return count >= minimum ? count : 0; +} + +static int +strptime_space(unsigned char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || + c == '\f' || c == '\v'; +} + +static int +strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + StrptimeFields *fields) +{ + Py_ssize_t remaining = length - pos; + if (remaining == 0) { + return 1; + } + if (remaining == 1 && data[pos] == 'Z') { + fields->gmtoff = 0; + return 1; + } + /* Offsets containing seconds or fractions use the Python parser. */ + if ((remaining != 5 && remaining != 6) || + (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; + } + fields->gmtoff = (hours * 3600 + minutes * 60) * + (data[pos] == '-' ? -1 : 1); + return 1; +} + +static int +strptime_fields(PyObject *string, PyObject *format, StrptimeFields *fields) +{ + /* String subclasses and non-ASCII input retain Python's behavior. */ + if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || + !PyUnicode_IS_ASCII(string) || !PyUnicode_IS_ASCII(format)) { + return 0; + } + const unsigned char *data = PyUnicode_1BYTE_DATA(string); + 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 = (StrptimeFields){1900, 1, 1, 0, 0, 0, 0, INT_MIN}; + unsigned int seen = 0; + Py_ssize_t pos = 0; + + for (Py_ssize_t i = 0; i < fmt_length; i++) { + unsigned char c = fmt[i]; + if (strptime_space(c)) { + if (pos == length || !strptime_space(data[pos])) { + return 0; + } + while (i + 1 < fmt_length && strptime_space(fmt[i + 1])) { + i++; + } + do { + pos++; + } while (pos < length && strptime_space(data[pos])); + continue; + } + if (c != '%') { + if (pos == length || data[pos++] != c) { + return 0; + } + continue; + } + if (++i == fmt_length) { + return 0; + } + c = fmt[i]; + if (c == '%') { + if (pos == length || data[pos++] != '%') { + return 0; + } + continue; + } + + /* Reject duplicate groups, aliases, and locale-dependent directives. */ + const char *directives = "YymdHMSfz"; + const char *directive = strchr(directives, c); + if (directive == NULL || c == '\0') { + return 0; + } + unsigned int bit = 1U << (directive - directives); + if (seen & bit) { + return 0; + } + seen |= bit; + + if (c == 'z') { + if (i != fmt_length - 1 || + !strptime_offset(data, length, pos, fields)) { + return 0; + } + pos = length; + continue; + } + + int minimum = 1; + int maximum = 2; + if (c == 'Y') { + minimum = maximum = 4; + } + else if (c == 'y') { + minimum = maximum = 2; + } + else if (c == 'f') { + maximum = 6; + } + else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') { + pos++; + maximum = 1; + } + int value; + int count = strptime_digits(data, length, pos, minimum, maximum, &value); + if (count == 0) { + return 0; + } + pos += count; + switch (c) { + case 'Y': + fields->year = value; + break; + case 'y': + fields->year = value + (value <= 68 ? 2000 : 1900); + break; + case 'm': + if (value < 1 || value > 12) { + return 0; + } + fields->month = value; + break; + case 'd': + if (value < 1 || value > 31) { + return 0; + } + fields->day = value; + break; + case 'H': + if (value > 23) { + return 0; + } + fields->hour = value; + break; + case 'M': + if (value > 59) { + return 0; + } + fields->minute = value; + break; + case 'S': + if (value > 61) { + return 0; + } + fields->second = value; + break; + case 'f': + while (count++ < 6) { + value *= 10; + } + fields->fraction = value; + break; + } + } + /* Mixed %Y/%y and day-without-year diagnostics belong to Python. */ + if (pos != length || (seen & 3) == 3 || + ((seen & (1U << 3)) && !(seen & 3)) || + fields->year < 1 || + fields->day > days_in_month(fields->year, fields->month)) { + return 0; + } + return 1; +} + + /*[clinic input] @permit_long_summary @classmethod @@ -5868,6 +6080,22 @@ datetime_datetime_strptime_impl(PyTypeObject *type, PyObject *string, PyObject *format) /*[clinic end generated code: output=af2c2d024f3203f5 input=ef7807589f1d50e7]*/ { + /* Subclasses retain the Python parser's constructor arguments. */ + StrptimeFields fields; + if (type == DATETIME_TYPE(NO_STATE) && + strptime_fields(string, format, &fields)) { + PyObject *tzinfo = tzinfo_from_isoformat_results( + fields.gmtoff != INT_MIN, fields.gmtoff, 0); + if (tzinfo == NULL) { + return NULL; + } + PyObject *result = new_datetime_subclass_ex( + fields.year, fields.month, fields.day, + fields.hour, fields.minute, fields.second, fields.fraction, + tzinfo, type); + Py_DECREF(tzinfo); + return result; + } PyObject *result; PyObject *module = PyImport_Import(&_Py_ID(_strptime)); diff --git a/Modules/_strptimemodule.c b/Modules/_strptimemodule.c deleted file mode 100644 index dc44b6aa6b34dd5..000000000000000 --- a/Modules/_strptimemodule.c +++ /dev/null @@ -1,1054 +0,0 @@ -/* _strptime_impl accelerator C extension module. */ - -#ifndef Py_BUILD_CORE_BUILTIN -# define Py_BUILD_CORE_MODULE 1 -#endif - -#include "Python.h" - -#include -#include -#include - -/*[clinic input] -module _strptime_impl -[clinic start generated code]*/ -/*[clinic end generated code: output=da39a3ee5e6b4b0d input=f7541041c3424c24]*/ - -#include "clinic/_strptimemodule.c.h" - -/* ========================== helpers ========================== */ - -/* Parsed fields accumulated while walking the format string. */ -typedef struct { - int year; /* -1 = not set */ - int month; /* 1-12, default 1 */ - int day; /* 1-31, default 1 */ - int hour; /* 0-23, default 0 */ - int minute; /* 0-59, default 0 */ - int second; /* 0-61, default 0 */ - int weekday; /* 0-6 (Mon=0), -1 = not set */ - int julian; /* 1-366, -1 = not set */ - int tz; /* -1 = not set, 0 = no DST, 1 = DST */ - int fraction; /* microseconds, 0 */ - int gmtoff; /* seconds east of UTC, INT_MIN = not set */ - int gmtoff_fraction; /* microseconds part of gmtoff, 0 */ - int iso_year; /* -1 = not set */ - int iso_week; /* -1 = not set */ - int week_of_year; /* -1 = not set */ - int week_of_year_start; /* 0 = Mon, 6 = Sun, -1 = not set */ - int century; /* -1 = not set */ - int has_year; /* whether %Y was seen */ - int has_short_year;/* whether %y was seen */ - int day_of_month_in_format; /* whether %d was seen */ - int year_in_format; /* whether %Y/%y/%G was seen */ - int colon_z_in_format; /* whether %:z was seen */ -} ParsedTime; - -static void -parsed_time_init(ParsedTime *pt) -{ - pt->year = -1; - pt->month = 1; - pt->day = 1; - pt->hour = 0; - pt->minute = 0; - pt->second = 0; - pt->weekday = -1; - pt->julian = INT_MIN; - pt->tz = -1; - pt->fraction = 0; - pt->gmtoff = INT_MIN; - pt->gmtoff_fraction = 0; - pt->iso_year = -1; - pt->iso_week = -1; - pt->week_of_year = -1; - pt->week_of_year_start = -1; - pt->century = -1; - pt->has_year = 0; - pt->has_short_year = 0; - pt->day_of_month_in_format = 0; - pt->year_in_format = 0; - pt->colon_z_in_format = 0; -} - -/* Parse up to max_digits decimal digits from s at position *pos. - Stores the integer value in *out. Returns number of digits consumed, - or 0 on failure. Does NOT advance *pos. */ -static int -parse_digits(const char *s, Py_ssize_t len, Py_ssize_t pos, - int min_digits, int max_digits, int *out) -{ - int val = 0; - int count = 0; - while (count < max_digits && pos + count < len) { - char c = s[pos + count]; - if (c < '0' || c > '9') { - break; - } - val = val * 10 + (c - '0'); - count++; - } - if (count < min_digits) { - return 0; - } - *out = val; - return count; -} - -/* Check if character at pos is ASCII whitespace */ -static int -is_ascii_space(char c) -{ - return c == ' ' || c == '\t' || c == '\n' || c == '\r' || - c == '\f' || c == '\v'; -} - -/* ========================== date math ========================== */ - -static int -is_leap_year(int year) -{ - return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)); -} - -/* Day of year (1-based) for a given y/m/d */ -static int -day_of_year(int year, int month, int day) -{ - static const int cum[12] = {0,31,59,90,120,151,181,212,243,273,304,334}; - int doy = cum[month - 1] + day; - if (month > 2 && is_leap_year(year)) { - doy++; - } - return doy; -} - -/* Compute weekday (0=Mon, 6=Sun) from y/m/d using Tomohiko Sakamoto's algo */ -static int -weekday_from_date(int y, int m, int d) -{ - static const int t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; - if (m < 3) { - y--; - } - int w = (y + y/4 - y/100 + y/400 + t[m-1] + d) % 7; - /* Sakamoto gives 0=Sun, we want 0=Mon */ - return (w + 6) % 7; -} - -/* Compute ordinal (days since 0001-01-01, where 0001-01-01 = ordinal 1). - This matches datetime.date(year, month, day).toordinal(). */ -static long -date_to_ordinal(int year, int month, int day) -{ - /* Days before year: 365*y + leaps */ - long y = (long)year - 1; - long days_before_year = y * 365 + y / 4 - y / 100 + y / 400; - - /* Days before month in this year */ - static const int days_before_month[13] = { - 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 - }; - long dbm = days_before_month[month]; - if (month > 2 && is_leap_year(year)) { - dbm++; - } - - return days_before_year + dbm + day; -} - -/* Convert ordinal back to (year, month, day). - Inverse of date_to_ordinal(). */ -static void -ordinal_to_date(long ordinal, int *year, int *month, int *day) -{ - /* Algorithm from the CPython datetime module (Lib/datetime.py) */ - long n = ordinal - 1; /* 0-based day count */ - long n400 = n / 146097; - n = n % 146097; - long n100 = n / 36524; - n = n % 36524; - long n4 = n / 1461; - n = n % 1461; - long n1 = n / 365; - n = n % 365; - - *year = (int)(n400 * 400 + n100 * 100 + n4 * 4 + n1 + 1); - - /* If n1 == 4 or n100 == 4, then the ordinal is the last day of a - leap year (Dec 31), and the year must be backed up by one. */ - if (n1 == 4 || n100 == 4) { - *year -= 1; - n = 365; /* Dec 31 */ - } - - /* n is now the 0-based day within the year */ - static const int days_before_month[13] = { - 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 - }; - int leap = is_leap_year(*year); - - /* Find month */ - int m; - for (m = 12; m >= 1; m--) { - int dbm = days_before_month[m]; - if (m > 2 && leap) { - dbm++; - } - if (n >= dbm) { - *month = m; - *day = (int)(n - dbm + 1); - return; - } - } - /* Should never reach here */ - *month = 1; - *day = (int)(n + 1); -} - -/* Return the number of ISO weeks in a given ISO year (52 or 53). */ -static int -iso_weeks_in_year(int iso_year) -{ - /* A year has 53 weeks iff Jan 1 is Thursday or Dec 31 is Thursday. */ - int jan1_wd = weekday_from_date(iso_year, 1, 1); /* 0=Mon */ - int dec31_wd = weekday_from_date(iso_year, 12, 31); - if (jan1_wd == 3 || dec31_wd == 3) { /* Thursday = 3 */ - return 53; - } - return 52; -} - -/* Convert ISO calendar (iso_year, iso_week, weekday_1based) to (year, month, day). - weekday is 1=Monday through 7=Sunday. - Returns 1 on success, 0 on error (exception set). */ -static int -isocalendar_to_date(int iso_year, int iso_week, int iso_weekday, - int *year, int *month, int *day) -{ - /* Validate week number */ - if (iso_week > iso_weeks_in_year(iso_year)) { - PyErr_Format(PyExc_ValueError, - "Invalid week: %d", iso_week); - return 0; - } - - /* ISO year starts on the Monday of the week containing Jan 4. - Day 1 of ISO week 1 is the Monday on or before Jan 4. */ - long jan4_ord = date_to_ordinal(iso_year, 1, 4); - /* Weekday of Jan 4: 0=Mon, 6=Sun */ - int jan4_weekday = (int)((jan4_ord - 1) % 7); /* ordinal 1 = Mon = 0 */ - /* Monday of ISO week 1 */ - long week1_monday = jan4_ord - jan4_weekday; - /* Target ordinal */ - long target = week1_monday + (iso_week - 1) * 7 + (iso_weekday - 1); - ordinal_to_date(target, year, month, day); - return 1; -} - -/* Julian day from U or W week number */ -static int -calc_julian_from_U_or_W(int year, int week_of_year, int day_of_week, - int week_starts_Mon) -{ - int first_weekday = weekday_from_date(year, 1, 1); - if (!week_starts_Mon) { - first_weekday = (first_weekday + 1) % 7; - day_of_week = (day_of_week + 1) % 7; - } - int week_0_length = (7 - first_weekday) % 7; - if (week_of_year == 0) { - return 1 + day_of_week - first_weekday; - } - else { - int days_to_week = week_0_length + (7 * (week_of_year - 1)); - return 1 + days_to_week + day_of_week; - } -} - -/* ========================== format parsing ========================== */ - -/* Parse the timezone offset component of %z / %:z. - s points to the full data string, *ppos is current position. - data_len is total length of data string. - On success, advances *ppos and sets pt->gmtoff, pt->gmtoff_fraction. - colon_z: 1 if %:z directive, 0 if %z directive. - Returns 1 on success, 0 on parse failure, -1 to signal fallback. */ -static int -parse_tz_offset(const char *s, Py_ssize_t len, Py_ssize_t *ppos, - ParsedTime *pt, int colon_z) -{ - Py_ssize_t pos = *ppos; - Py_ssize_t remaining = len - pos; - if (remaining == 0) { - return 1; - } - if (remaining == 1 && s[pos] == 'Z') { - pt->gmtoff = 0; - *ppos = len; - return 1; - } - /* More involved offsets use the Python parser, including its errors. */ - if ((remaining != 5 && remaining != 6) || - (s[pos] != '+' && s[pos] != '-')) { - return -1; - } - int hours, minutes; - int colon = remaining == 6; - if ((colon && s[pos + 3] != ':') || (colon_z && !colon) || - parse_digits(s, len, pos + 1, 2, 2, &hours) != 2 || - parse_digits(s, len, pos + 3 + colon, 2, 2, &minutes) != 2 || - hours > 23 || minutes > 59) { - return -1; - } - pt->gmtoff = (hours * 3600 + minutes * 60) * (s[pos] == '-' ? -1 : 1); - *ppos = len; - return 1; -} - - -/* Parse a single directive. - Returns 1 on success (consumed chars stored in *consumed_out), - 0 on parse error, -1 to signal "fallback to Python". */ -static int -parse_directive(const char *data, Py_ssize_t data_len, Py_ssize_t data_pos, - const char *fmt, Py_ssize_t fmt_len, Py_ssize_t *fmt_pos, - ParsedTime *pt, Py_ssize_t *consumed_out) -{ - Py_ssize_t fpos = *fmt_pos; - /* fpos points to char after '%' */ - if (fpos >= fmt_len) { - /* stray % at end of format - fall back to Python for proper error */ - return -1; - } - - /* Skip modifier flags: -, _, 0, ^, # and width digits */ - while (fpos < fmt_len && - (fmt[fpos] == '-' || fmt[fpos] == '_' || fmt[fpos] == '0' || - fmt[fpos] == '^' || fmt[fpos] == '#')) { - fpos++; - } - /* Skip width digits */ - while (fpos < fmt_len && fmt[fpos] >= '0' && fmt[fpos] <= '9') { - fpos++; - } - - if (fpos >= fmt_len) { - /* stray % with only flags - fall back to Python for proper error */ - return -1; - } - - char directive = fmt[fpos]; - fpos++; - - int val; - int n; - Py_ssize_t consumed = 0; - - switch (directive) { - case 'Y': /* 4-digit year */ - n = parse_digits(data, data_len, data_pos, 4, 4, &val); - if (n != 4) { - goto match_fail; - } - pt->year = val; - pt->has_year = 1; - pt->year_in_format = 1; - consumed = 4; - break; - - case 'y': /* 2-digit year */ - n = parse_digits(data, data_len, data_pos, 2, 2, &val); - if (n != 2) { - goto match_fail; - } - pt->year = val; - pt->has_short_year = 1; - pt->year_in_format = 1; - consumed = 2; - break; - - case 'C': /* century, 2 digits */ - n = parse_digits(data, data_len, data_pos, 2, 2, &val); - if (n != 2) { - goto match_fail; - } - pt->century = val; - consumed = 2; - break; - - case 'm': /* month 01-12 or 1-12 */ - n = parse_digits(data, data_len, data_pos, 1, 2, &val); - if (n == 0 || val < 1 || val > 12) { - goto match_fail; - } - pt->month = val; - consumed = n; - break; - - case 'd': /* day 01-31 or 1-31 or space-padded */ - case 'e': - pt->day_of_month_in_format = 1; - /* Handle space-padded day */ - if (data_pos < data_len && data[data_pos] == ' ') { - data_pos++; - consumed = 1; - } - n = parse_digits(data, data_len, data_pos, 1, consumed ? 1 : 2, &val); - if (n == 0 || val < 1 || val > 31) { - goto match_fail; - } - pt->day = val; - consumed += n; - break; - - case 'H': /* hour 00-23, 0-23, or space-padded */ - case 'k': - if (data_pos < data_len && data[data_pos] == ' ') { - data_pos++; - consumed = 1; - } - n = parse_digits(data, data_len, data_pos, 1, consumed ? 1 : 2, &val); - if (n == 0 || val > 23) { - goto match_fail; - } - pt->hour = val; - consumed += n; - break; - - case 'I': /* 12-hour: 01-12 or 1-12 or space-padded */ - case 'l': - /* Fall back to Python - needs %p for AM/PM resolution */ - return -1; - - case 'M': /* minute 00-59 */ - n = parse_digits(data, data_len, data_pos, 1, 2, &val); - if (n == 0 || val > 59) { - goto match_fail; - } - pt->minute = val; - consumed = n; - break; - - case 'S': /* second 00-61 */ - n = parse_digits(data, data_len, data_pos, 1, 2, &val); - if (n == 0 || val > 61) { - goto match_fail; - } - pt->second = val; - consumed = n; - break; - - case 'f': /* microseconds, 1-6 digits */ - { - int frac; - n = parse_digits(data, data_len, data_pos, 1, 6, &frac); - if (n == 0) { - goto match_fail; - } - /* Pad to 6 digits */ - for (int i = n; i < 6; i++) { - frac *= 10; - } - pt->fraction = frac; - consumed = n; - break; - } - - case 'j': /* day of year 001-366 */ - n = parse_digits(data, data_len, data_pos, 1, 3, &val); - if (n == 0 || val < 1 || val > 366) { - goto match_fail; - } - pt->julian = val; - consumed = n; - break; - - case 'w': /* weekday 0=Sun, 6=Sat */ - n = parse_digits(data, data_len, data_pos, 1, 1, &val); - if (n == 0 || val > 6) { - goto match_fail; - } - /* Convert: Python uses 0=Mon, %w has 0=Sun */ - if (val == 0) { - pt->weekday = 6; - } else { - pt->weekday = val - 1; - } - consumed = 1; - break; - - case 'u': /* weekday 1=Mon, 7=Sun (ISO) */ - n = parse_digits(data, data_len, data_pos, 1, 1, &val); - if (n == 0 || val < 1 || val > 7) { - goto match_fail; - } - pt->weekday = val - 1; /* 0=Mon */ - consumed = 1; - break; - - case 'G': /* ISO year, 4 digits */ - n = parse_digits(data, data_len, data_pos, 4, 4, &val); - if (n != 4) { - goto match_fail; - } - pt->iso_year = val; - pt->year_in_format = 1; - consumed = 4; - break; - - case 'V': /* ISO week 01-53 */ - n = parse_digits(data, data_len, data_pos, 1, 2, &val); - if (n == 0 || val < 1 || val > 53) { - goto match_fail; - } - pt->iso_week = val; - consumed = n; - break; - - case 'U': /* week number (Sunday start) 00-53 */ - n = parse_digits(data, data_len, data_pos, 1, 2, &val); - if (n == 0 || val > 53) { - goto match_fail; - } - pt->week_of_year = val; - pt->week_of_year_start = 6; /* Sunday */ - consumed = n; - break; - - case 'W': /* week number (Monday start) 00-53 */ - n = parse_digits(data, data_len, data_pos, 1, 2, &val); - if (n == 0 || val > 53) { - goto match_fail; - } - pt->week_of_year = val; - pt->week_of_year_start = 0; /* Monday */ - consumed = n; - break; - - case 'z': /* timezone offset */ - { - Py_ssize_t zpos = data_pos; - int rc = parse_tz_offset(data, data_len, &zpos, pt, 0); - if (rc == 0) return 0; - if (rc == -1) return -1; - consumed = zpos - data_pos; - break; - } - - case ':': /* %:z */ - if (fpos < fmt_len && fmt[fpos] == 'z') { - fpos++; - pt->colon_z_in_format = 1; - Py_ssize_t zpos = data_pos; - int rc = parse_tz_offset(data, data_len, &zpos, pt, 1); - if (rc == 0) return 0; - if (rc == -1) return -1; - consumed = zpos - data_pos; - } else { - /* Unknown directive %:X - fall back */ - return -1; - } - break; - - case '%': /* literal % */ - if (data_pos >= data_len || data[data_pos] != '%') { - goto match_fail; - } - consumed = 1; - break; - - case 'n': /* newline */ - if (data_pos >= data_len || data[data_pos] != '\n') { - goto match_fail; - } - consumed = 1; - break; - - case 't': /* tab */ - if (data_pos >= data_len || data[data_pos] != '\t') { - goto match_fail; - } - consumed = 1; - break; - - /* Compound directives that expand to other directives */ - case 'F': /* %Y-%m-%d */ - case 'T': /* %H:%M:%S */ - case 'R': /* %H:%M */ - { - /* We handle these by setting up a sub-format and parsing it inline */ - const char *sub_fmt; - Py_ssize_t sub_fmt_len; - if (directive == 'F') { - sub_fmt = "%Y-%m-%d"; - sub_fmt_len = 8; - } else if (directive == 'T') { - sub_fmt = "%H:%M:%S"; - sub_fmt_len = 8; - } else { - sub_fmt = "%H:%M"; - sub_fmt_len = 5; - } - - Py_ssize_t sub_fpos = 0; - Py_ssize_t sub_dpos = data_pos; - while (sub_fpos < sub_fmt_len) { - if (sub_fmt[sub_fpos] == '%') { - sub_fpos++; /* skip % */ - Py_ssize_t sub_consumed; - int ret = parse_directive(data, data_len, sub_dpos, - sub_fmt, sub_fmt_len, - &sub_fpos, pt, &sub_consumed); - if (ret <= 0) { - if (ret == 0 && !PyErr_Occurred()) goto match_fail; - return ret; - } - sub_dpos += sub_consumed; - } else { - /* literal */ - if (sub_dpos >= data_len || data[sub_dpos] != sub_fmt[sub_fpos]) { - goto match_fail; - } - sub_dpos++; - sub_fpos++; - } - } - consumed = sub_dpos - data_pos; - break; - } - - /* Locale-dependent directives: fall back to Python */ - case 'b': case 'B': case 'a': case 'A': - case 'p': case 'P': - case 'c': case 'x': case 'X': case 'r': - case 'Z': - return -1; - - case 'O': case 'E': - /* %O* and %E* modifiers: fall back to Python */ - return -1; - - default: - /* Unknown directive */ - return -1; - } - - *fmt_pos = fpos; - *consumed_out = consumed; - return 1; - -match_fail: - /* Don't set error here - parse_format will set the proper message - with data_string and format included */ - return 0; -} - - -/* Main parsing function: walks the format string, dispatching directives. - data_obj and fmt_obj are the original Python string objects, used - for error messages. */ -static int -parse_format(const char *data, Py_ssize_t data_len, - const char *fmt, Py_ssize_t fmt_len, - ParsedTime *pt, - PyObject *data_obj, PyObject *fmt_obj) -{ - Py_ssize_t dpos = 0; - Py_ssize_t fpos = 0; - -/* Macro to set the standard mismatch error with data and format repr */ -#define SET_MISMATCH_ERROR() do { \ - PyObject *data_repr = PyObject_Repr(data_obj); \ - PyObject *fmt_repr = PyObject_Repr(fmt_obj); \ - if (data_repr && fmt_repr) { \ - PyErr_Format(PyExc_ValueError, \ - "time data %U does not match format %U", \ - data_repr, fmt_repr); \ - } \ - Py_XDECREF(data_repr); \ - Py_XDECREF(fmt_repr); \ -} while (0) - - while (fpos < fmt_len) { - char fc = fmt[fpos]; - - if (fc == '%') { - fpos++; /* skip '%' */ - Py_ssize_t directive_consumed; - int ret = parse_directive(data, data_len, dpos, - fmt, fmt_len, &fpos, pt, - &directive_consumed); - if (ret < 0) { - /* Signal fallback: return -1, no error set */ - PyErr_Clear(); - return -1; - } - if (ret == 0) { - /* Error may or may not be set by parse_directive. - If not set (match_fail), set the standard mismatch error. */ - if (!PyErr_Occurred()) { - SET_MISMATCH_ERROR(); - } - return 0; - } - dpos += directive_consumed; - } - else if (is_ascii_space(fc)) { - /* Whitespace in format matches 1+ whitespace in data */ - if (dpos >= data_len || !is_ascii_space(data[dpos])) { - SET_MISMATCH_ERROR(); - return 0; - } - /* Skip all whitespace in format */ - while (fpos < fmt_len && is_ascii_space(fmt[fpos])) { - fpos++; - } - /* Skip all whitespace in data */ - while (dpos < data_len && is_ascii_space(data[dpos])) { - dpos++; - } - } - else if (fc == '\'') { - /* Apostrophe matches ' or \u02bc - but \u02bc is multi-byte UTF-8. - For ASCII-only fast path, just match '. */ - if (dpos < data_len && data[dpos] == '\'') { - dpos++; - fpos++; - } - /* Check for \u02bc (UTF-8: 0xCA 0xBC) */ - else if (dpos + 1 < data_len && - (unsigned char)data[dpos] == 0xCA && - (unsigned char)data[dpos + 1] == 0xBC) { - dpos += 2; - fpos++; - } - else { - SET_MISMATCH_ERROR(); - return 0; - } - } - else { - /* Literal character match */ - if (dpos >= data_len || data[dpos] != fc) { - SET_MISMATCH_ERROR(); - return 0; - } - dpos++; - fpos++; - } - } - -#undef SET_MISMATCH_ERROR - - /* Check for unconverted data */ - if (dpos != data_len) { - const char *rest = data + dpos; - /* Specific check for %:z directive */ - if (pt->colon_z_in_format && pt->gmtoff != INT_MIN && - rest[0] != ':') { - PyErr_Format(PyExc_ValueError, - "Missing colon in %%:z before '%s', got '%s'", - rest, data); - return 0; - } - PyErr_Format(PyExc_ValueError, - "unconverted data remains: %s", - rest); - return 0; - } - - return 1; /* success */ -} - - -/* ========================== post-processing ========================== */ - -/* Resolve parsed fields into final values, replicating _strptime.py logic. - Returns 1 on success, 0 on error (exception set), -1 for fallback. */ -static int -resolve_date_fields(ParsedTime *pt) -{ - /* Handle %y with %C (century) */ - if (pt->has_short_year) { - if (pt->century >= 0) { - pt->year += pt->century * 100; - } else { - if (pt->year <= 68) { - pt->year += 2000; - } else { - pt->year += 1900; - } - } - } - - /* ISO year validation */ - if (pt->iso_year >= 0) { - if (pt->julian != INT_MIN) { - PyErr_SetString(PyExc_ValueError, - "Day of the year directive '%j' is not " - "compatible with ISO year directive '%G'. " - "Use '%Y' instead."); - return 0; - } - if (pt->iso_week < 0 || pt->weekday < 0) { - PyErr_SetString(PyExc_ValueError, - "ISO year directive '%G' must be used with " - "the ISO week directive '%V' and a weekday " - "directive ('%A', '%a', '%w', or '%u')."); - return 0; - } - } - else if (pt->iso_week >= 0) { - if (pt->year < 0 || pt->weekday < 0) { - PyErr_SetString(PyExc_ValueError, - "ISO week directive '%V' must be used with " - "the ISO year directive '%G' and a weekday " - "directive ('%A', '%a', '%w', or '%u')."); - return 0; - } - else { - PyErr_SetString(PyExc_ValueError, - "ISO week directive '%V' is incompatible with " - "the year directive '%Y'. Use the ISO year '%G' " - "instead."); - return 0; - } - } - - /* Default year handling */ - int leap_year_fix = 0; - if (pt->year < 0) { - if (pt->month == 2 && pt->day == 29) { - pt->year = 1904; - leap_year_fix = 1; - } else { - pt->year = 1900; - } - } - - /* Julian / weekday calculation */ - if (pt->julian == INT_MIN && pt->weekday >= 0) { - if (pt->week_of_year >= 0) { - int week_starts_Mon = (pt->week_of_year_start == 0) ? 1 : 0; - pt->julian = calc_julian_from_U_or_W(pt->year, pt->week_of_year, - pt->weekday, week_starts_Mon); - } - else if (pt->iso_year >= 0 && pt->iso_week >= 0) { - if (!isocalendar_to_date(pt->iso_year, pt->iso_week, - pt->weekday + 1, - &pt->year, &pt->month, &pt->day)) { - return 0; - } - } - - if (pt->julian != INT_MIN && pt->julian <= 0) { - pt->year -= 1; - int yday = is_leap_year(pt->year) ? 366 : 365; - pt->julian += yday; - } - } - - if (pt->julian == INT_MIN) { - /* Compute julian from year/month/day */ - pt->julian = day_of_year(pt->year, pt->month, pt->day); - } else { - /* Compute year/month/day from julian */ - long jan1_ord = date_to_ordinal(pt->year, 1, 1); - long target_ord = jan1_ord + pt->julian - 1; - ordinal_to_date(target_ord, &pt->year, &pt->month, &pt->day); - } - - if (pt->weekday < 0) { - pt->weekday = weekday_from_date(pt->year, pt->month, pt->day); - } - - if (leap_year_fix) { - pt->year = 1900; - } - - return 1; -} - - -/* Build the return 3-tuple matching Python's _strptime() return value: - ((y, m, d, H, M, S, wd, jd, tz, tzname, gmtoff), fraction, gmtoff_fraction) -*/ -static PyObject * -build_result(ParsedTime *pt) -{ - /* tzname: None if gmtoff not set, else string representation or None */ - PyObject *tzname_obj; - PyObject *gmtoff_obj; - - if (pt->gmtoff == INT_MIN) { - tzname_obj = Py_NewRef(Py_None); - gmtoff_obj = Py_NewRef(Py_None); - } else { - /* For numeric timezone offsets parsed from %z, we don't have a name */ - tzname_obj = Py_NewRef(Py_None); - gmtoff_obj = PyLong_FromLong(pt->gmtoff); - if (!gmtoff_obj) { - Py_DECREF(tzname_obj); - return NULL; - } - } - - PyObject *inner = Py_BuildValue( - "(iiiiiiiiiOO)", - pt->year, pt->month, pt->day, - pt->hour, pt->minute, pt->second, - pt->weekday, pt->julian, pt->tz, - tzname_obj, gmtoff_obj - ); - - Py_DECREF(tzname_obj); - Py_DECREF(gmtoff_obj); - - if (!inner) { - return NULL; - } - - PyObject *result = Py_BuildValue("(Oii)", inner, pt->fraction, - pt->gmtoff_fraction); - Py_DECREF(inner); - return result; -} - - -/* ========================== module function ========================== */ - -/*[clinic input] -_strptime_impl._strptime_parse - - data_string: unicode - format: unicode - / - -Parse a time string according to a format. - -Returns a 3-tuple on success, or None if the format contains -directives that require the Python fallback path. - -[clinic start generated code]*/ - -static PyObject * -_strptime_impl__strptime_parse_impl(PyObject *module, PyObject *data_string, - PyObject *format) -/*[clinic end generated code: output=c3c1f836ef1972ae input=9a670dbb56f2ad26]*/ -{ - /* Avoid encoding allocations and preserve string subclass behavior. */ - if (!PyUnicode_CheckExact(data_string) || !PyUnicode_CheckExact(format) || - !PyUnicode_IS_ASCII(data_string) || !PyUnicode_IS_ASCII(format)) { - Py_RETURN_NONE; - } - const char *data_str = PyUnicode_AsUTF8(data_string); - const char *fmt_str = PyUnicode_AsUTF8(format); - Py_ssize_t data_len = PyUnicode_GET_LENGTH(data_string); - Py_ssize_t fmt_len = PyUnicode_GET_LENGTH(format); - - /* Only accelerate independent numeric fields. Python handles aliases, - duplicate groups, locale directives, and day-without-year diagnostics. */ - unsigned int seen = 0; - const char *directives = "YymdHMSfz"; - for (Py_ssize_t i = 0; i < fmt_len; i++) { - if (fmt_str[i] != '%') { - continue; - } - if (++i == fmt_len) { - Py_RETURN_NONE; - } - if (fmt_str[i] == '%') { - continue; - } - const char *directive = strchr(directives, fmt_str[i]); - if (directive == NULL || *directive == '\0') { - Py_RETURN_NONE; - } - unsigned int bit = 1U << (directive - directives); - if (seen & bit) { - Py_RETURN_NONE; - } - seen |= bit; - if (fmt_str[i] == 'z' && i != fmt_len - 1) { - Py_RETURN_NONE; - } - } - if ((seen & 3) == 3 || ((seen & (1U << 3)) && !(seen & 3))) { - Py_RETURN_NONE; - } - - ParsedTime pt; - parsed_time_init(&pt); - - int rc = parse_format(data_str, data_len, fmt_str, fmt_len, &pt, - data_string, format); - if (rc == -1) { - /* Fallback signal: return None */ - Py_RETURN_NONE; - } - if (rc == 0) { - /* Mismatches may need regex backtracking. Preserve allocation errors. */ - if (!PyErr_ExceptionMatches(PyExc_ValueError)) { - return NULL; - } - PyErr_Clear(); - Py_RETURN_NONE; - } - int year = pt.year < 0 ? 1900 : pt.year; - if (pt.has_short_year) { - year += year <= 68 ? 2000 : 1900; - } - static const int month_days[] = {31,28,31,30,31,30,31,31,30,31,30,31}; - if (year < 1 || pt.day > month_days[pt.month - 1] + - (pt.month == 2 && is_leap_year(year))) { - Py_RETURN_NONE; - } - - /* Post-processing */ - rc = resolve_date_fields(&pt); - if (rc == -1) { - Py_RETURN_NONE; - } - if (rc == 0) { - return NULL; - } - - return build_result(&pt); -} - - -/* ========================== module definition ========================== */ - -static PyMethodDef strptime_methods[] = { - _STRPTIME_IMPL__STRPTIME_PARSE_METHODDEF - {NULL, NULL, 0, NULL} -}; - -PyDoc_STRVAR(strptime_doc, -"C accelerator for _strptime time parsing.\n"); - -static struct PyModuleDef_Slot _strptimemodule_slots[] = { - {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED}, - {Py_mod_gil, Py_MOD_GIL_NOT_USED}, - {0, NULL} -}; - -static struct PyModuleDef _strptimemodule = { - PyModuleDef_HEAD_INIT, - "_strptime_impl", - strptime_doc, - 0, - strptime_methods, - _strptimemodule_slots, - NULL, - NULL, - NULL -}; - -PyMODINIT_FUNC -PyInit__strptime_impl(void) -{ - return PyModuleDef_Init(&_strptimemodule); -} diff --git a/Modules/clinic/_strptimemodule.c.h b/Modules/clinic/_strptimemodule.c.h deleted file mode 100644 index fa536264f4ecf36..000000000000000 --- a/Modules/clinic/_strptimemodule.c.h +++ /dev/null @@ -1,48 +0,0 @@ -/*[clinic input] -preserve -[clinic start generated code]*/ - -#include "pycore_modsupport.h" // _PyArg_CheckPositional() - -PyDoc_STRVAR(_strptime_impl__strptime_parse__doc__, -"_strptime_parse($module, data_string, format, /)\n" -"--\n" -"\n" -"Parse a time string according to a format.\n" -"\n" -"Returns a 3-tuple on success, or None if the format contains\n" -"directives that require the Python fallback path."); - -#define _STRPTIME_IMPL__STRPTIME_PARSE_METHODDEF \ - {"_strptime_parse", _PyCFunction_CAST(_strptime_impl__strptime_parse), METH_FASTCALL, _strptime_impl__strptime_parse__doc__}, - -static PyObject * -_strptime_impl__strptime_parse_impl(PyObject *module, PyObject *data_string, - PyObject *format); - -static PyObject * -_strptime_impl__strptime_parse(PyObject *module, PyObject *const *args, Py_ssize_t nargs) -{ - PyObject *return_value = NULL; - PyObject *data_string; - PyObject *format; - - if (!_PyArg_CheckPositional("_strptime_parse", nargs, 2, 2)) { - goto exit; - } - if (!PyUnicode_Check(args[0])) { - _PyArg_BadArgument("_strptime_parse", "argument 1", "str", args[0]); - goto exit; - } - data_string = args[0]; - if (!PyUnicode_Check(args[1])) { - _PyArg_BadArgument("_strptime_parse", "argument 2", "str", args[1]); - goto exit; - } - format = args[1]; - return_value = _strptime_impl__strptime_parse_impl(module, data_string, format); - -exit: - return return_value; -} -/*[clinic end generated code: output=e22b619e9547671c input=a9049054013a1b77]*/ diff --git a/PC/config.c b/PC/config.c index 831843cd0626c8a..dcccc6a3a6d0167 100644 --- a/PC/config.c +++ b/PC/config.c @@ -19,7 +19,6 @@ extern PyObject* PyInit_nt(void); extern PyObject* PyInit__operator(void); extern PyObject* PyInit__signal(void); extern PyObject* PyInit__statistics(void); -extern PyObject* PyInit__strptime_impl(void); extern PyObject* PyInit__sysconfig(void); extern PyObject* PyInit__types(void); extern PyObject* PyInit__typing(void); @@ -114,7 +113,6 @@ struct _inittab _PyImport_Inittab[] = { {"_types", PyInit__types}, {"_typing", PyInit__typing}, {"_statistics", PyInit__statistics}, - {"_strptime_impl", PyInit__strptime_impl}, /* cryptographic hash functions */ {"_blake2", PyInit__blake2}, diff --git a/configure b/configure index bbc26a0e81affd3..607f43db23360df 100755 --- a/configure +++ b/configure @@ -798,8 +798,6 @@ MODULE_SELECT_FALSE MODULE_SELECT_TRUE MODULE__REMOTE_DEBUGGING_FALSE MODULE__REMOTE_DEBUGGING_TRUE -MODULE__STRPTIME_IMPL_FALSE -MODULE__STRPTIME_IMPL_TRUE MODULE__RANDOM_FALSE MODULE__RANDOM_TRUE MODULE__QUEUE_FALSE @@ -36362,28 +36360,6 @@ then : -fi - - - if test "$py_cv_module__strptime_impl" != "n/a" -then : - py_cv_module__strptime_impl=yes -fi - if test "$py_cv_module__strptime_impl" = yes; then - MODULE__STRPTIME_IMPL_TRUE= - MODULE__STRPTIME_IMPL_FALSE='#' -else - MODULE__STRPTIME_IMPL_TRUE='#' - MODULE__STRPTIME_IMPL_FALSE= -fi - - as_fn_append MODULE_BLOCK "MODULE__STRPTIME_IMPL_STATE=$py_cv_module__strptime_impl$as_nl" - if test "x$py_cv_module__strptime_impl" = xyes -then : - - - - fi @@ -39339,10 +39315,6 @@ if test -z "${MODULE__RANDOM_TRUE}" && test -z "${MODULE__RANDOM_FALSE}"; then as_fn_error $? "conditional \"MODULE__RANDOM\" was never defined. Usually this means the macro was only invoked conditionally." "$LINENO" 5 fi -if test -z "${MODULE__STRPTIME_IMPL_TRUE}" && test -z "${MODULE__STRPTIME_IMPL_FALSE}"; then - as_fn_error $? "conditional \"MODULE__STRPTIME_IMPL\" was never defined. -Usually this means the macro was only invoked conditionally." "$LINENO" 5 -fi if test -z "${MODULE__REMOTE_DEBUGGING_TRUE}" && test -z "${MODULE__REMOTE_DEBUGGING_FALSE}"; then as_fn_error $? "conditional \"MODULE__REMOTE_DEBUGGING\" was never defined. Usually this means the macro was only invoked conditionally." "$LINENO" 5 diff --git a/configure.ac b/configure.ac index 9d2004c8ecd6a9b..775b8b4e6038773 100644 --- a/configure.ac +++ b/configure.ac @@ -8564,7 +8564,6 @@ PY_STDLIB_MOD_SIMPLE([_pickle]) PY_STDLIB_MOD_SIMPLE([_posixsubprocess]) PY_STDLIB_MOD_SIMPLE([_queue]) PY_STDLIB_MOD_SIMPLE([_random]) -PY_STDLIB_MOD_SIMPLE([_strptime_impl]) PY_STDLIB_MOD_SIMPLE([_remote_debugging], [$REMOTE_DEBUGGING_CFLAGS], [$REMOTE_DEBUGGING_LIBS]) PY_STDLIB_MOD_SIMPLE([select]) PY_STDLIB_MOD_SIMPLE([_struct]) From fd00b8229b71dfd41ef3cfc327dc4dcc3ffb5faa Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 26 Sep 2026 15:49:29 -0400 Subject: [PATCH 04/12] gh-158252: Fix strptime NEWS filename --- ...ptime.rst => 2026-09-17-10-30-00.gh-issue-158252.strptime.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Misc/NEWS.d/next/Library/{2026-09-17-10-30-00.strptime.rst => 2026-09-17-10-30-00.gh-issue-158252.strptime.rst} (100%) diff --git a/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.strptime.rst b/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.gh-issue-158252.strptime.rst similarity index 100% rename from Misc/NEWS.d/next/Library/2026-09-17-10-30-00.strptime.rst rename to Misc/NEWS.d/next/Library/2026-09-17-10-30-00.gh-issue-158252.strptime.rst From b0df9516a72975be7081926cc3c7c22763a49c1b Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 26 Sep 2026 15:58:22 -0400 Subject: [PATCH 05/12] gh-158252: Share the numeric strptime parser Move numeric field parsing into pytime so the time and datetime modules can use it without importing each other. Keep result construction in the caller and expose the day of year for struct_time construction. --- Include/internal/pycore_time.h | 16 +++ Modules/_datetimemodule.c | 216 +------------------------------- Python/pytime.c | 220 +++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 214 deletions(-) diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h index b671225ca6ea44d..8ca0345edc5d0d8 100644 --- a/Include/internal/pycore_time.h +++ b/Include/internal/pycore_time.h @@ -317,6 +317,22 @@ extern PyTime_t _PyTimeFraction_Mul( extern double _PyTimeFraction_Resolution( const _PyTimeFraction *frac); +// Locale-independent numeric strptime parsing. +// 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. +typedef struct { + int year, month, day; + int hour, minute, second, fraction; + int gmtoff; // INT_MIN means no offset was supplied. + int yday; // January 1 is day 1. +} _PyTime_StrptimeFields; + +// Export for the '_datetime' shared extension. +PyAPI_FUNC(int) _PyTime_Strptime( + PyObject *string, PyObject *format, _PyTime_StrptimeFields *fields); + + extern PyStatus _PyTime_Init(struct _Py_time_runtime_state *state); #ifdef __cplusplus diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 8690835e8a690b4..3a50cc7fcfa39bc 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -5848,218 +5848,6 @@ datetime_datetime_utcfromtimestamp_impl(PyTypeObject *type, return datetime_from_timestamp(type, _PyTime_gmtime, timestamp, Py_None); } -/* Locale-independent strptime parsing for _datetime. - * - * Return 1 for a complete numeric match, or 0 to use Lib/_strptime.py. - * Parsing doesn't allocate or set exceptions. In particular, mismatches may - * require regex backtracking, so their diagnostics belong to the fallback. - */ - -typedef struct { - int year, month, day; - int hour, minute, second, fraction; - int gmtoff; /* INT_MIN means no offset was supplied. */ -} StrptimeFields; - -static int -strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, - int minimum, int maximum, int *value) -{ - int count = 0; - *value = 0; - while (count < maximum && pos + count < length) { - unsigned char c = data[pos + count]; - if (c < '0' || c > '9') { - break; - } - *value = *value * 10 + c - '0'; - count++; - } - return count >= minimum ? count : 0; -} - -static int -strptime_space(unsigned char c) -{ - return c == ' ' || c == '\t' || c == '\n' || c == '\r' || - c == '\f' || c == '\v'; -} - -static int -strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, - StrptimeFields *fields) -{ - Py_ssize_t remaining = length - pos; - if (remaining == 0) { - return 1; - } - if (remaining == 1 && data[pos] == 'Z') { - fields->gmtoff = 0; - return 1; - } - /* Offsets containing seconds or fractions use the Python parser. */ - if ((remaining != 5 && remaining != 6) || - (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; - } - fields->gmtoff = (hours * 3600 + minutes * 60) * - (data[pos] == '-' ? -1 : 1); - return 1; -} - -static int -strptime_fields(PyObject *string, PyObject *format, StrptimeFields *fields) -{ - /* String subclasses and non-ASCII input retain Python's behavior. */ - if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || - !PyUnicode_IS_ASCII(string) || !PyUnicode_IS_ASCII(format)) { - return 0; - } - const unsigned char *data = PyUnicode_1BYTE_DATA(string); - 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 = (StrptimeFields){1900, 1, 1, 0, 0, 0, 0, INT_MIN}; - unsigned int seen = 0; - Py_ssize_t pos = 0; - - for (Py_ssize_t i = 0; i < fmt_length; i++) { - unsigned char c = fmt[i]; - if (strptime_space(c)) { - if (pos == length || !strptime_space(data[pos])) { - return 0; - } - while (i + 1 < fmt_length && strptime_space(fmt[i + 1])) { - i++; - } - do { - pos++; - } while (pos < length && strptime_space(data[pos])); - continue; - } - if (c != '%') { - if (pos == length || data[pos++] != c) { - return 0; - } - continue; - } - if (++i == fmt_length) { - return 0; - } - c = fmt[i]; - if (c == '%') { - if (pos == length || data[pos++] != '%') { - return 0; - } - continue; - } - - /* Reject duplicate groups, aliases, and locale-dependent directives. */ - const char *directives = "YymdHMSfz"; - const char *directive = strchr(directives, c); - if (directive == NULL || c == '\0') { - return 0; - } - unsigned int bit = 1U << (directive - directives); - if (seen & bit) { - return 0; - } - seen |= bit; - - if (c == 'z') { - if (i != fmt_length - 1 || - !strptime_offset(data, length, pos, fields)) { - return 0; - } - pos = length; - continue; - } - - int minimum = 1; - int maximum = 2; - if (c == 'Y') { - minimum = maximum = 4; - } - else if (c == 'y') { - minimum = maximum = 2; - } - else if (c == 'f') { - maximum = 6; - } - else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') { - pos++; - maximum = 1; - } - int value; - int count = strptime_digits(data, length, pos, minimum, maximum, &value); - if (count == 0) { - return 0; - } - pos += count; - switch (c) { - case 'Y': - fields->year = value; - break; - case 'y': - fields->year = value + (value <= 68 ? 2000 : 1900); - break; - case 'm': - if (value < 1 || value > 12) { - return 0; - } - fields->month = value; - break; - case 'd': - if (value < 1 || value > 31) { - return 0; - } - fields->day = value; - break; - case 'H': - if (value > 23) { - return 0; - } - fields->hour = value; - break; - case 'M': - if (value > 59) { - return 0; - } - fields->minute = value; - break; - case 'S': - if (value > 61) { - return 0; - } - fields->second = value; - break; - case 'f': - while (count++ < 6) { - value *= 10; - } - fields->fraction = value; - break; - } - } - /* Mixed %Y/%y and day-without-year diagnostics belong to Python. */ - if (pos != length || (seen & 3) == 3 || - ((seen & (1U << 3)) && !(seen & 3)) || - fields->year < 1 || - fields->day > days_in_month(fields->year, fields->month)) { - return 0; - } - return 1; -} - - /*[clinic input] @permit_long_summary @classmethod @@ -6081,9 +5869,9 @@ datetime_datetime_strptime_impl(PyTypeObject *type, PyObject *string, /*[clinic end generated code: output=af2c2d024f3203f5 input=ef7807589f1d50e7]*/ { /* Subclasses retain the Python parser's constructor arguments. */ - StrptimeFields fields; + _PyTime_StrptimeFields fields; if (type == DATETIME_TYPE(NO_STATE) && - strptime_fields(string, format, &fields)) { + _PyTime_Strptime(string, format, &fields)) { PyObject *tzinfo = tzinfo_from_isoformat_results( fields.gmtoff != INT_MIN, fields.gmtoff, 0); if (tzinfo == NULL) { diff --git a/Python/pytime.c b/Python/pytime.c index 53c82736137a16b..d7fe8f73c6f34d8 100644 --- a/Python/pytime.c +++ b/Python/pytime.c @@ -1416,3 +1416,223 @@ _PyDeadline_Get(PyTime_t deadline) (void)PyTime_MonotonicRaw(&now); return deadline - now; } + + +/* Locale-independent numeric strptime parsing. + * + * Return 1 for a complete numeric match, or 0 to use Lib/_strptime.py. + * Parsing doesn't allocate or set exceptions. In particular, mismatches may + * require regex backtracking, so their diagnostics belong to the fallback. + */ + + + +static int +strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + int minimum, int maximum, int *value) +{ + int count = 0; + *value = 0; + while (count < maximum && pos + count < length) { + unsigned char c = data[pos + count]; + if (c < '0' || c > '9') { + break; + } + *value = *value * 10 + c - '0'; + count++; + } + return count >= minimum ? count : 0; +} + +static int +strptime_space(unsigned char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || + c == '\f' || c == '\v'; +} + +static int +strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + _PyTime_StrptimeFields *fields) +{ + Py_ssize_t remaining = length - pos; + if (remaining == 0) { + return 1; + } + if (remaining == 1 && data[pos] == 'Z') { + fields->gmtoff = 0; + return 1; + } + /* Offsets containing seconds or fractions use the Python parser. */ + if ((remaining != 5 && remaining != 6) || + (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; + } + fields->gmtoff = (hours * 3600 + minutes * 60) * + (data[pos] == '-' ? -1 : 1); + return 1; +} + +int +_PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fields) +{ + /* String subclasses and non-ASCII input retain Python's behavior. */ + if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || + !PyUnicode_IS_ASCII(string) || !PyUnicode_IS_ASCII(format)) { + return 0; + } + const unsigned char *data = PyUnicode_1BYTE_DATA(string); + 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}; + unsigned int seen = 0; + Py_ssize_t pos = 0; + + for (Py_ssize_t i = 0; i < fmt_length; i++) { + unsigned char c = fmt[i]; + if (strptime_space(c)) { + if (pos == length || !strptime_space(data[pos])) { + return 0; + } + while (i + 1 < fmt_length && strptime_space(fmt[i + 1])) { + i++; + } + do { + pos++; + } while (pos < length && strptime_space(data[pos])); + continue; + } + if (c != '%') { + if (pos == length || data[pos++] != c) { + return 0; + } + continue; + } + if (++i == fmt_length) { + return 0; + } + c = fmt[i]; + if (c == '%') { + if (pos == length || data[pos++] != '%') { + return 0; + } + continue; + } + + /* Reject duplicate groups, aliases, and locale-dependent directives. */ + const char *directives = "YymdHMSfz"; + const char *directive = strchr(directives, c); + if (directive == NULL || c == '\0') { + return 0; + } + unsigned int bit = 1U << (directive - directives); + if (seen & bit) { + return 0; + } + seen |= bit; + + if (c == 'z') { + if (i != fmt_length - 1 || + !strptime_offset(data, length, pos, fields)) { + return 0; + } + pos = length; + continue; + } + + int minimum = 1; + int maximum = 2; + if (c == 'Y') { + minimum = maximum = 4; + } + else if (c == 'y') { + minimum = maximum = 2; + } + else if (c == 'f') { + maximum = 6; + } + else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') { + pos++; + maximum = 1; + } + int value; + int count = strptime_digits(data, length, pos, minimum, maximum, &value); + if (count == 0) { + return 0; + } + pos += count; + switch (c) { + case 'Y': + fields->year = value; + break; + case 'y': + fields->year = value + (value <= 68 ? 2000 : 1900); + break; + case 'm': + if (value < 1 || value > 12) { + return 0; + } + fields->month = value; + break; + case 'd': + if (value < 1 || value > 31) { + return 0; + } + fields->day = value; + break; + case 'H': + if (value > 23) { + return 0; + } + fields->hour = value; + break; + case 'M': + if (value > 59) { + return 0; + } + fields->minute = value; + break; + case 'S': + if (value > 61) { + return 0; + } + fields->second = value; + break; + case 'f': + while (count++ < 6) { + value *= 10; + } + fields->fraction = value; + break; + } + } + 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 month_days = days_before_month[fields->month + 1] - + days_before_month[fields->month]; + if (fields->month == 2) { + month_days += leap; + } + /* Mixed %Y/%y and day-without-year diagnostics belong to Python. */ + if (pos != length || (seen & 3) == 3 || + ((seen & (1U << 3)) && !(seen & 3)) || + fields->year < 1 || + fields->day > month_days) { + return 0; + } + fields->yday = days_before_month[fields->month] + fields->day + + (fields->month > 2 && leap); + return 1; +} From 9602414988fb5572c0cace894a6bfbf7c1127006 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 26 Sep 2026 16:02:46 -0400 Subject: [PATCH 06/12] gh-158252: Use the C parser for all strptime entry points Construct date, time, and struct_time results from the shared numeric fields. Preserve subclass constructors and Python fallback behavior, including leap seconds, discarded fields, and timezone metadata. --- Lib/test/datetimetester.py | 93 +++++++++++++++++++ Lib/test/test_time.py | 39 ++++++++ ...9-17-10-30-00.gh-issue-158252.strptime.rst | 7 +- Modules/_datetimemodule.c | 19 ++++ Modules/timemodule.c | 38 ++++++++ 5 files changed, 193 insertions(+), 3 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 92bbce1c4133de3..bd7e0e8f5eea18b 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1104,6 +1104,99 @@ def __divmod__(self, other): ############################################################################# # date tests +class TestStrptime(unittest.TestCase): + def test_numeric_fields(self): + cases = ( + ('2024-02-29 12:34:56.123', '%Y-%m-%d %H:%M:%S.%f', + datetime(2024, 2, 29, 12, 34, 56, 123000)), + ('2024-02-29T12:34:56.123+0530', '%Y-%m-%dT%H:%M:%S.%f%z', + datetime(2024, 2, 29, 12, 34, 56, 123000, + timezone(timedelta(hours=5, minutes=30)))), + ('12:34:56Z', '%H:%M:%S%z', datetime(1900, 1, 1, 12, 34, 56, tzinfo=UTC)), + ('12:34:56-03:30', '%H:%M:%S%z', + datetime(1900, 1, 1, 12, 34, 56, + tzinfo=timezone(-timedelta(hours=3, minutes=30)))), + ('12:34:56', '%H:%M:%S%z', datetime(1900, 1, 1, 12, 34, 56)), + ('', '', datetime(1900, 1, 1)), + ('2024111', '%Y%m%d', datetime(2024, 11, 1)), + ('2024131', '%Y%m%d', datetime(2024, 1, 31)), + ('2024\0-02-29', '%Y\0-%m-%d', datetime(2024, 2, 29)), + ('٢٠٢٤-02-29', '%Y-%m-%d', datetime(2024, 2, 29)), + ('2024\u200302\u200329', '%Y %m %d', datetime(2024, 2, 29)), + ('2024t02t29', '%YT%mt%d', datetime(2024, 2, 29)), + ('24 2025-02-01', '%y %Y-%m-%d', datetime(2025, 2, 1)), + ) + for text, fmt, expected in cases: + for cls, result in ((datetime, expected), (date, expected.date()), + (time, expected.timetz())): + with self.subTest(text=text, fmt=fmt, cls=cls): + actual = cls.strptime(text, fmt) + self.assertEqual(actual, result) + self.assertIs(type(actual), cls) + with self.subTest(text=text, fmt=fmt, cls=_time.struct_time): + actual = _time.strptime(text, fmt) + self.assertEqual(actual, expected.timetuple()) + self.assertIsNone(actual.tm_zone) + offset = expected.utcoffset() + self.assertEqual(actual.tm_gmtoff, + None if offset is None else offset.total_seconds()) + + def test_invalid_fields(self): + cases = ( + ('2024-02-30', '%Y-%m-%d'), + ('1900-02-29', '%Y-%m-%d'), + ('0000-01-01', '%Y-%m-%d'), + ('2024-01- 12', '%Y-%m-%d'), + ('2024\0ignored', '%Y'), + ('24:00:00', '%H:%M:%S'), + ('23:60:00', '%H:%M:%S'), + ('23:59:62', '%H:%M:%S'), + ) + for text, fmt in cases: + for parse in (datetime.strptime, date.strptime, time.strptime, + _time.strptime): + with self.subTest(text=text, fmt=fmt, parse=parse): + with self.assertRaises(ValueError): + parse(text, fmt) + + def test_leap_seconds(self): + for second in (60, 61): + text = f'2024-02-29 23:59:{second}' + fmt = '%Y-%m-%d %H:%M:%S' + with self.subTest(second=second): + self.assertEqual(date.strptime(text, fmt), date(2024, 2, 29)) + self.assertEqual(_time.strptime(text, fmt), + (2024, 2, 29, 23, 59, second, 3, 60, -1)) + for cls in (datetime, time): + with self.assertRaises(ValueError): + cls.strptime(text, fmt) + + def test_offset_outside_datetime_range(self): + # date discards the offset; struct_time doesn't construct a timezone. + self.assertEqual(date.strptime('+2400', '%z'), date(1900, 1, 1)) + self.assertEqual(_time.strptime('+2400', '%z').tm_gmtoff, 86400) + for cls in (datetime, time): + with self.subTest(cls=cls): + with self.assertRaises(ValueError): + cls.strptime('+2400', '%z') + + def test_subclass_constructor(self): + for cls, args in ((date, (2024, 2, 29)), + (time, (12, 34, 56, 123000)), + (datetime, (2024, 2, 29, 12, 34, 56, 123000))): + class Capture(cls): + def __new__(cls, *args, **kwargs): + return args, kwargs + + text = '2024-02-29 12:34:56.123' + fmt = '%Y-%m-%d %H:%M:%S.%f' + with self.subTest(cls=cls): + self.assertEqual(Capture.strptime(text, fmt), (args, {})) + aware_args = args if cls is date else args + (UTC,) + self.assertEqual(Capture.strptime(text + 'Z', fmt + '%z'), + (aware_args, {})) + + class TestDateOnly(unittest.TestCase): # Tests here won't pass if also run on datetime objects, so don't # subclass this to test datetimes too. diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index 985984b55123ce6..719d60f96cb4868 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -371,6 +371,45 @@ def test_strptime(self): self.fail("conversion specifier %r failed with '%s' input." % (format, strf_output)) + def test_strptime_numeric_calendar(self): + cases = ( + ('0001-01-01', 1, 1, 1, 0, 1), + ('1900-03-01', 1900, 3, 1, 3, 60), + ('2000-02-29', 2000, 2, 29, 1, 60), + ('2000-03-01', 2000, 3, 1, 2, 61), + ('2024-12-31', 2024, 12, 31, 1, 366), + ('2100-03-01', 2100, 3, 1, 0, 60), + ('9999-12-31', 9999, 12, 31, 4, 365), + ) + for text, year, month, day, weekday, yday in cases: + with self.subTest(text=text): + result = time.strptime(text, '%Y-%m-%d') + self.assertIs(type(result), time.struct_time) + self.assertEqual(result, + (year, month, day, 0, 0, 0, weekday, yday, -1)) + self.assertIsNone(result.tm_zone) + self.assertIsNone(result.tm_gmtoff) + + def test_strptime_numeric_offset(self): + for text, offset in (('', None), ('Z', 0), ('+0000', 0), + ('-00:00', 0), ('+0530', 19800), + ('-03:30', -12600), ('+2359', 86340), + ('+01:02:03.456', 3723)): + with self.subTest(text=text): + result = time.strptime('12:34:56.123' + text, '%H:%M:%S.%f%z') + self.assertEqual(result, (1900, 1, 1, 12, 34, 56, 0, 1, -1)) + self.assertIsNone(result.tm_zone) + self.assertEqual(result.tm_gmtoff, offset) + + @support.run_with_locale('LC_TIME', 'C') + def test_strptime_default_format(self): + self.assertEqual(time.strptime('Thu Feb 29 12:34:56 2024'), + (2024, 2, 29, 12, 34, 56, 3, 60, -1)) + for args in ((), ('2024', '%Y', 'extra'), (None, '%Y')): + with self.subTest(args=args): + with self.assertRaises(TypeError): + time.strptime(*args) + def test_strptime_bytes(self): # Make sure only strings are accepted as arguments to strptime. self.assertRaises(TypeError, time.strptime, b'2009', "%Y") diff --git a/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.gh-issue-158252.strptime.rst b/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.gh-issue-158252.strptime.rst index b4b4a90ebb35f6a..9d3eeb245023a9d 100644 --- a/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.gh-issue-158252.strptime.rst +++ b/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.gh-issue-158252.strptime.rst @@ -1,3 +1,4 @@ -Speed up :meth:`datetime.datetime.strptime` for common numeric formats by -parsing fields and constructing the result in C. Other inputs continue to use -the Python parser. +Speed up :func:`time.strptime`, :meth:`datetime.date.strptime`, +:meth:`datetime.time.strptime`, and :meth:`datetime.datetime.strptime` for +common numeric formats by parsing fields and constructing the result in C. +Other inputs continue to use the Python parser. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 3a50cc7fcfa39bc..89cdfd2977446bb 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3516,6 +3516,11 @@ datetime_date_strptime_impl(PyTypeObject *type, PyObject *string, PyObject *format) /*[clinic end generated code: output=454d473bee2d5161 input=2db8f0b2b5242deb]*/ { + _PyTime_StrptimeFields fields; + if (type == DATE_TYPE(NO_STATE) && + _PyTime_Strptime(string, format, &fields)) { + return new_date_ex(fields.year, fields.month, fields.day, type); + } PyObject *result; PyObject *module = PyImport_Import(&_Py_ID(_strptime)); @@ -4788,6 +4793,20 @@ datetime_time_strptime_impl(PyTypeObject *type, PyObject *string, PyObject *format) /*[clinic end generated code: output=ae05a9bc0241d3bf input=f01d0b9eb5383da5]*/ { + _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); + if (tzinfo == NULL) { + return NULL; + } + PyObject *result = new_time_ex( + fields.hour, fields.minute, fields.second, fields.fraction, + tzinfo, type); + Py_DECREF(tzinfo); + return result; + } PyObject *result; PyObject *module = PyImport_Import(&_Py_ID(_strptime)); diff --git a/Modules/timemodule.c b/Modules/timemodule.c index e5162fd3b678557..4b79f9c68d62e97 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1007,6 +1007,44 @@ is not present, current time as returned by localtime() is used.\n\ static PyObject * time_strptime(PyObject *self, PyObject *args) { + _PyTime_StrptimeFields fields; + if (PyTuple_GET_SIZE(args) == 2 && + _PyTime_Strptime(PyTuple_GET_ITEM(args, 0), + PyTuple_GET_ITEM(args, 1), &fields)) { + time_module_state *state = get_time_state(self); + PyObject *result = PyStructSequence_New(state->struct_time_type); + if (result == NULL) { + return NULL; + } + /* January 1 of year 1 was a Monday. */ + int year = fields.year - 1; + int weekday = (365 * year + year / 4 - year / 100 + year / 400 + + fields.yday - 1) % 7; + const int values[] = { + fields.year, fields.month, fields.day, + fields.hour, fields.minute, fields.second, + weekday, fields.yday, -1 + }; + for (size_t i = 0; i < Py_ARRAY_LENGTH(values); i++) { + PyObject *value = PyLong_FromLong(values[i]); + if (value == NULL) { + Py_DECREF(result); + return NULL; + } + 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 *offset = fields.gmtoff == INT_MIN + ? Py_NewRef(Py_None) + : PyLong_FromLong(fields.gmtoff); + if (offset == NULL) { + Py_DECREF(result); + return NULL; + } + PyStructSequence_SET_ITEM(result, 10, offset); + return result; + } PyObject *func, *result; func = PyImport_ImportModuleAttrString("_strptime", "_strptime_time"); From 510c6226ec6b6f27cd190a5fe7e718749b28884d Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 26 Sep 2026 17:42:43 -0400 Subject: [PATCH 07/12] gh-158252: Share calendar helpers and clarify strptime fallback Reuse the datetime calendar arithmetic for struct_time construction. Document the numeric parser scope and why even known-invalid inputs use the Python parser for error messages and error precedence. --- Include/internal/pycore_time.h | 23 +++++++++++++++++++++++ Modules/_datetimemodule.c | 30 ++++++++---------------------- Modules/timemodule.c | 6 +----- Python/pytime.c | 24 ++++++++++++++++++------ 4 files changed, 50 insertions(+), 33 deletions(-) diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h index 8ca0345edc5d0d8..12940cdce8abbde 100644 --- a/Include/internal/pycore_time.h +++ b/Include/internal/pycore_time.h @@ -317,6 +317,29 @@ extern PyTime_t _PyTimeFraction_Mul( extern double _PyTimeFraction_Resolution( const _PyTimeFraction *frac); +/* year -> number of days before January 1st of year. Remember that we + * start with year 1, so days_before_year(1) == 0. + */ +static inline int +_PyTime_DaysBeforeYear(int year) +{ + int y = year - 1; + /* This is incorrect if year <= 0; we really want the floor + * here. But so long as MINYEAR is 1, the smallest year this + * can see is 1. + */ + assert(year >= 1); + return y*365 + y/4 - y/100 + y/400; +} + +// Weekday from a valid Gregorian year and 1-based day of year; Monday is 0. +static inline int +_PyTime_Weekday(int year, int yday) +{ + return (_PyTime_DaysBeforeYear(year) + yday + 6) % 7; +} + + // Locale-independent numeric strptime parsing. // A complete match returns 1; otherwise use Lib/_strptime.py. This function // neither allocates nor sets exceptions. The calendar date is validated, but diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 89cdfd2977446bb..c687b51b3b1418b 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -463,27 +463,13 @@ days_before_month(int year, int month) return days; } -/* year -> number of days before January 1st of year. Remember that we - * start with year 1, so days_before_year(1) == 0. - */ -static int -days_before_year(int year) -{ - int y = year - 1; - /* This is incorrect if year <= 0; we really want the floor - * here. But so long as MINYEAR is 1, the smallest year this - * can see is 1. - */ - assert (year >= 1); - return y*365 + y/4 - y/100 + y/400; -} /* Number of days in 4, 100, and 400 year cycles. That these have * the correct values is asserted in the module init function. */ -#define DI4Y 1461 /* days_before_year(5); days in 4 years */ -#define DI100Y 36524 /* days_before_year(101); days in 100 years */ -#define DI400Y 146097 /* days_before_year(401); days in 400 years */ +#define DI4Y 1461 /* _PyTime_DaysBeforeYear(5); days in 4 years */ +#define DI100Y 36524 /* _PyTime_DaysBeforeYear(101); days in 100 years */ +#define DI400Y 146097 /* _PyTime_DaysBeforeYear(401); days in 400 years */ /* ordinal -> year, month, day, considering 01-Jan-0001 as day 1. */ static void @@ -573,14 +559,14 @@ ord_to_ymd(int ordinal, int *year, int *month, int *day) static int ymd_to_ord(int year, int month, int day) { - return days_before_year(year) + days_before_month(year, month) + day; + return _PyTime_DaysBeforeYear(year) + days_before_month(year, month) + day; } /* Day of week, where Monday==0, ..., Sunday==6. 1/1/1 was a Monday. */ static int weekday(int year, int month, int day) { - return (ymd_to_ord(year, month, day) + 6) % 7; + return _PyTime_Weekday(year, days_before_month(year, month) + day); } /* Ordinal of the Monday starting week 1 of the ISO year. Week 1 is the @@ -7724,19 +7710,19 @@ _datetime_exec(PyObject *module) * pasting together 4 single years. */ static_assert(DI4Y == 4 * 365 + 1, "DI4Y"); - assert(DI4Y == days_before_year(4+1)); + assert(DI4Y == _PyTime_DaysBeforeYear(4+1)); /* Similarly, a 400-year cycle has an extra leap day over what we'd * get from pasting together 4 100-year cycles. */ static_assert(DI400Y == 4 * DI100Y + 1, "DI400Y"); - assert(DI400Y == days_before_year(400+1)); + assert(DI400Y == _PyTime_DaysBeforeYear(400+1)); /* OTOH, a 100-year cycle has one fewer leap day than we'd get from * pasting together 25 4-year cycles. */ static_assert(DI100Y == 25 * DI4Y - 1, "DI100Y"); - assert(DI100Y == days_before_year(100+1)); + assert(DI100Y == _PyTime_DaysBeforeYear(100+1)); if (set_current_module(interp, module) < 0) { goto error; diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 4b79f9c68d62e97..f3bfef333b383bb 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1016,14 +1016,10 @@ time_strptime(PyObject *self, PyObject *args) if (result == NULL) { return NULL; } - /* January 1 of year 1 was a Monday. */ - int year = fields.year - 1; - int weekday = (365 * year + year / 4 - year / 100 + year / 400 + - fields.yday - 1) % 7; const int values[] = { fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, - weekday, fields.yday, -1 + _PyTime_Weekday(fields.year, fields.yday), fields.yday, -1 }; for (size_t i = 0; i < Py_ARRAY_LENGTH(values); i++) { PyObject *value = PyLong_FromLong(values[i]); diff --git a/Python/pytime.c b/Python/pytime.c index d7fe8f73c6f34d8..65656c5b81ed160 100644 --- a/Python/pytime.c +++ b/Python/pytime.c @@ -1418,15 +1418,18 @@ _PyDeadline_Get(PyTime_t deadline) } -/* Locale-independent numeric strptime parsing. +/* 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. * - * Return 1 for a complete numeric match, or 0 to use Lib/_strptime.py. - * Parsing doesn't allocate or set exceptions. In particular, mismatches may - * require regex backtracking, so their diagnostics belong to the fallback. + * Supported inputs are exact str objects containing only ASCII, with: + * - numeric %Y, %y, %m, %d, %H, %M, %S, and %f fields; + * - 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 + * week/day-of-year calculations), and offsets with seconds or fractions. */ - - static int strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, int minimum, int maximum, int *value) @@ -1481,6 +1484,15 @@ strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, return 1; } +/* Return 1 for a complete numeric 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: + * it owns the exception messages and error precedence. For example, month 13 + * and empty input can't be repaired by backtracking, but diagnosing them here + * would duplicate the Python parser's error handling. A return value of 0 + * therefore doesn't distinguish invalid input from an unsupported format. + */ int _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fields) { From c7f5313cd3c27f8ad9d17bde105ca460b17d4a99 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Wed, 21 Oct 2026 14:31:10 -0400 Subject: [PATCH 08/12] gh-158252: Use explicit directive flags in strptime Replace positional flags with a lookup table and named masks, so year and day checks do not depend on the order of a directive string. Use new_datetime_ex for the exact-type fast path and Py_ISSPACE for ASCII whitespace. Add tests covering Unicode whitespace in both the input and format across all four public entry points. --- Lib/test/datetimetester.py | 32 +++++++++++++++++++++++ Lib/test/test_time.py | 11 ++++++++ Modules/_datetimemodule.c | 2 +- Python/pytime.c | 52 ++++++++++++++++++++++++-------------- 4 files changed, 77 insertions(+), 20 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index bd7e0e8f5eea18b..e411bede8774280 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1271,6 +1271,16 @@ def test_strptime(self): self.assertEqual(expected, got) self.assertIs(type(got), date) + def test_strptime_whitespace(self): + whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f') + for input_space in whitespace: + for format_space in whitespace: + with self.subTest(input_space=input_space, + format_space=format_space): + text = input_space.join(('2024', '02', '29')) + fmt = format_space.join(('%Y', '%m', '%d')) + self.assertEqual(date.strptime(text, fmt), date(2024, 2, 29)) + def test_strptime_single_digit(self): # bpo-34903: Check that single digit dates are allowed. strptime = date.strptime @@ -3181,6 +3191,17 @@ def test_strptime(self): with self.assertRaises(ValueError): strptime("-000", "%z") with self.assertRaises(ValueError): strptime("z", "%z") + def test_strptime_whitespace(self): + whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f') + for input_space in whitespace: + for format_space in whitespace: + with self.subTest(input_space=input_space, + format_space=format_space): + text = input_space.join(('2024-02-29', '12:34:56')) + fmt = format_space.join(('%Y-%m-%d', '%H:%M:%S')) + self.assertEqual(self.theclass.strptime(text, fmt), + self.theclass(2024, 2, 29, 12, 34, 56)) + def test_strptime_numeric_fallback(self): cases = ( ('2024\0-02-29', '%Y\0-%m-%d', (2024, 2, 29)), @@ -4403,6 +4424,17 @@ def test_strptime(self): self.assertEqual(expected, got) self.assertIs(type(got), self.theclass) + def test_strptime_whitespace(self): + whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f') + for input_space in whitespace: + for format_space in whitespace: + with self.subTest(input_space=input_space, + format_space=format_space): + text = input_space.join(('12', '34', '56')) + fmt = format_space.join(('%H', '%M', '%S')) + self.assertEqual(self.theclass.strptime(text, fmt), + self.theclass(12, 34, 56)) + def test_strptime_tz(self): strptime = self.theclass.strptime self.assertEqual(strptime("+0002", "%z").utcoffset(), 2 * MINUTE) diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index 719d60f96cb4868..fe7ef10b1826d38 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -371,6 +371,17 @@ def test_strptime(self): self.fail("conversion specifier %r failed with '%s' input." % (format, strf_output)) + def test_strptime_whitespace(self): + whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f') + for input_space in whitespace: + for format_space in whitespace: + with self.subTest(input_space=input_space, + format_space=format_space): + text = input_space.join(('2024-02-29', '12:34:56')) + fmt = format_space.join(('%Y-%m-%d', '%H:%M:%S')) + self.assertEqual(time.strptime(text, fmt), + (2024, 2, 29, 12, 34, 56, 3, 60, -1)) + def test_strptime_numeric_calendar(self): cases = ( ('0001-01-01', 1, 1, 1, 0, 1), diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index c687b51b3b1418b..aaf713682f501f6 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -5882,7 +5882,7 @@ datetime_datetime_strptime_impl(PyTypeObject *type, PyObject *string, if (tzinfo == NULL) { return NULL; } - PyObject *result = new_datetime_subclass_ex( + PyObject *result = new_datetime_ex( fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, fields.fraction, tzinfo, type); diff --git a/Python/pytime.c b/Python/pytime.c index 65656c5b81ed160..87266aa6e33f4f7 100644 --- a/Python/pytime.c +++ b/Python/pytime.c @@ -1447,13 +1447,6 @@ strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, return count >= minimum ? count : 0; } -static int -strptime_space(unsigned char c) -{ - return c == ' ' || c == '\t' || c == '\n' || c == '\r' || - c == '\f' || c == '\v'; -} - static int strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, _PyTime_StrptimeFields *fields) @@ -1466,7 +1459,7 @@ strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, fields->gmtoff = 0; return 1; } - /* Offsets containing seconds or fractions use the Python parser. */ + /* Offsets containing seconds or fractions fall back to _strptime.py. */ if ((remaining != 5 && remaining != 6) || (data[pos] != '+' && data[pos] != '-')) { return 0; @@ -1496,6 +1489,29 @@ strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, int _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fields) { + enum { + YEAR = 1U << 0, + SHORT_YEAR = 1U << 1, + MONTH = 1U << 2, + DAY = 1U << 3, + HOUR = 1U << 4, + MINUTE = 1U << 5, + SECOND = 1U << 6, + FRACTION = 1U << 7, + OFFSET = 1U << 8, + ANY_YEAR = YEAR | SHORT_YEAR, + }; + static const unsigned short directive_bits[128] = { + ['Y'] = YEAR, + ['y'] = SHORT_YEAR, + ['m'] = MONTH, + ['d'] = DAY, + ['H'] = HOUR, + ['M'] = MINUTE, + ['S'] = SECOND, + ['f'] = FRACTION, + ['z'] = OFFSET, + }; /* String subclasses and non-ASCII input retain Python's behavior. */ if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || !PyUnicode_IS_ASCII(string) || !PyUnicode_IS_ASCII(format)) { @@ -1511,16 +1527,16 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie for (Py_ssize_t i = 0; i < fmt_length; i++) { unsigned char c = fmt[i]; - if (strptime_space(c)) { - if (pos == length || !strptime_space(data[pos])) { + if (Py_ISSPACE(c)) { + if (pos == length || !Py_ISSPACE(data[pos])) { return 0; } - while (i + 1 < fmt_length && strptime_space(fmt[i + 1])) { + while (i + 1 < fmt_length && Py_ISSPACE(fmt[i + 1])) { i++; } do { pos++; - } while (pos < length && strptime_space(data[pos])); + } while (pos < length && Py_ISSPACE(data[pos])); continue; } if (c != '%') { @@ -1541,12 +1557,10 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } /* Reject duplicate groups, aliases, and locale-dependent directives. */ - const char *directives = "YymdHMSfz"; - const char *directive = strchr(directives, c); - if (directive == NULL || c == '\0') { + unsigned int bit = directive_bits[c]; + if (bit == 0) { return 0; } - unsigned int bit = 1U << (directive - directives); if (seen & bit) { return 0; } @@ -1620,7 +1634,7 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie fields->second = value; break; case 'f': - while (count++ < 6) { + for (; count < 6; count++) { value *= 10; } fields->fraction = value; @@ -1638,8 +1652,8 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie month_days += leap; } /* Mixed %Y/%y and day-without-year diagnostics belong to Python. */ - if (pos != length || (seen & 3) == 3 || - ((seen & (1U << 3)) && !(seen & 3)) || + if (pos != length || (seen & ANY_YEAR) == ANY_YEAR || + ((seen & DAY) && !(seen & ANY_YEAR)) || fields->year < 1 || fields->day > month_days) { return 0; From 96742aaedb04020719b96acb56aaca491bd7f708 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Wed, 21 Oct 2026 14:32:08 -0400 Subject: [PATCH 09/12] gh-158252: Move the C parser to Python/_strptime.c Keep strptime parsing separate from the clock and timestamp helpers in pytime.c. Update the Unix and Windows builds, including _freeze_module. The parser is unchanged in this commit, so the move can be reverted independently of the review fixes. --- Makefile.pre.in | 1 + PCbuild/_freeze_module.vcxproj | 1 + PCbuild/_freeze_module.vcxproj.filters | 3 + PCbuild/pythoncore.vcxproj | 1 + PCbuild/pythoncore.vcxproj.filters | 3 + Python/_strptime.c | 248 +++++++++++++++++++++++++ Python/pytime.c | 246 ------------------------ 7 files changed, 257 insertions(+), 246 deletions(-) create mode 100644 Python/_strptime.c diff --git a/Makefile.pre.in b/Makefile.pre.in index c49a16f1edc3bef..5a9f3545d9e3641 100644 --- a/Makefile.pre.in +++ b/Makefile.pre.in @@ -434,6 +434,7 @@ PARSER_HEADERS= \ PYTHON_OBJS= \ Python/_contextvars.o \ + Python/_strptime.o \ Python/_warnings.o \ Python/Python-ast.o \ Python/Python-tokenize.o \ diff --git a/PCbuild/_freeze_module.vcxproj b/PCbuild/_freeze_module.vcxproj index 36a1d768486a3f7..b00f2c1b486f21e 100644 --- a/PCbuild/_freeze_module.vcxproj +++ b/PCbuild/_freeze_module.vcxproj @@ -194,6 +194,7 @@ + diff --git a/PCbuild/_freeze_module.vcxproj.filters b/PCbuild/_freeze_module.vcxproj.filters index b358e09950a4439..e236cb907ae5642 100644 --- a/PCbuild/_freeze_module.vcxproj.filters +++ b/PCbuild/_freeze_module.vcxproj.filters @@ -19,6 +19,9 @@ Source Files + + Source Files + Source Files diff --git a/PCbuild/pythoncore.vcxproj b/PCbuild/pythoncore.vcxproj index c2d8f64a72b1191..e603a5013b9cf6b 100644 --- a/PCbuild/pythoncore.vcxproj +++ b/PCbuild/pythoncore.vcxproj @@ -614,6 +614,7 @@ + diff --git a/PCbuild/pythoncore.vcxproj.filters b/PCbuild/pythoncore.vcxproj.filters index 2536fc971700deb..36b6446f845b167 100644 --- a/PCbuild/pythoncore.vcxproj.filters +++ b/PCbuild/pythoncore.vcxproj.filters @@ -1394,6 +1394,9 @@ Python + + Python + Python diff --git a/Python/_strptime.c b/Python/_strptime.c new file mode 100644 index 000000000000000..2ad7f315cc183a0 --- /dev/null +++ b/Python/_strptime.c @@ -0,0 +1,248 @@ +#include "Python.h" +#include "pycore_time.h" + + +/* 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; + * - 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 + * week/day-of-year calculations), and offsets with seconds or fractions. + */ + +static int +strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + int minimum, int maximum, int *value) +{ + int count = 0; + *value = 0; + while (count < maximum && pos + count < length) { + unsigned char c = data[pos + count]; + if (c < '0' || c > '9') { + break; + } + *value = *value * 10 + c - '0'; + count++; + } + return count >= minimum ? count : 0; +} + +static int +strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + _PyTime_StrptimeFields *fields) +{ + Py_ssize_t remaining = length - pos; + if (remaining == 0) { + return 1; + } + if (remaining == 1 && 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] != '-')) { + 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; + } + fields->gmtoff = (hours * 3600 + minutes * 60) * + (data[pos] == '-' ? -1 : 1); + return 1; +} + +/* Return 1 for a complete numeric 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: + * it owns the exception messages and error precedence. For example, month 13 + * and empty input can't be repaired by backtracking, but diagnosing them here + * would duplicate the Python parser's error handling. A return value of 0 + * therefore doesn't distinguish invalid input from an unsupported format. + */ +int +_PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fields) +{ + enum { + YEAR = 1U << 0, + SHORT_YEAR = 1U << 1, + MONTH = 1U << 2, + DAY = 1U << 3, + HOUR = 1U << 4, + MINUTE = 1U << 5, + SECOND = 1U << 6, + FRACTION = 1U << 7, + OFFSET = 1U << 8, + ANY_YEAR = YEAR | SHORT_YEAR, + }; + static const unsigned short directive_bits[128] = { + ['Y'] = YEAR, + ['y'] = SHORT_YEAR, + ['m'] = MONTH, + ['d'] = DAY, + ['H'] = HOUR, + ['M'] = MINUTE, + ['S'] = SECOND, + ['f'] = FRACTION, + ['z'] = OFFSET, + }; + /* String subclasses and non-ASCII input retain Python's behavior. */ + if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || + !PyUnicode_IS_ASCII(string) || !PyUnicode_IS_ASCII(format)) { + return 0; + } + const unsigned char *data = PyUnicode_1BYTE_DATA(string); + 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}; + unsigned int seen = 0; + Py_ssize_t pos = 0; + + for (Py_ssize_t i = 0; i < fmt_length; i++) { + unsigned char c = fmt[i]; + if (Py_ISSPACE(c)) { + if (pos == length || !Py_ISSPACE(data[pos])) { + return 0; + } + while (i + 1 < fmt_length && Py_ISSPACE(fmt[i + 1])) { + i++; + } + do { + pos++; + } while (pos < length && Py_ISSPACE(data[pos])); + continue; + } + if (c != '%') { + if (pos == length || data[pos++] != c) { + return 0; + } + continue; + } + if (++i == fmt_length) { + return 0; + } + c = fmt[i]; + if (c == '%') { + if (pos == length || data[pos++] != '%') { + return 0; + } + continue; + } + + /* Reject duplicate groups, aliases, and locale-dependent directives. */ + unsigned int bit = directive_bits[c]; + if (bit == 0) { + return 0; + } + if (seen & bit) { + return 0; + } + seen |= bit; + + if (c == 'z') { + if (i != fmt_length - 1 || + !strptime_offset(data, length, pos, fields)) { + return 0; + } + pos = length; + continue; + } + + int minimum = 1; + int maximum = 2; + if (c == 'Y') { + minimum = maximum = 4; + } + else if (c == 'y') { + minimum = maximum = 2; + } + else if (c == 'f') { + maximum = 6; + } + else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') { + pos++; + maximum = 1; + } + int value; + int count = strptime_digits(data, length, pos, minimum, maximum, &value); + if (count == 0) { + return 0; + } + pos += count; + switch (c) { + case 'Y': + fields->year = value; + break; + case 'y': + fields->year = value + (value <= 68 ? 2000 : 1900); + break; + case 'm': + if (value < 1 || value > 12) { + return 0; + } + fields->month = value; + break; + case 'd': + if (value < 1 || value > 31) { + return 0; + } + fields->day = value; + break; + case 'H': + if (value > 23) { + return 0; + } + fields->hour = value; + break; + case 'M': + if (value > 59) { + return 0; + } + fields->minute = value; + break; + case 'S': + if (value > 61) { + return 0; + } + fields->second = value; + break; + case 'f': + for (; count < 6; count++) { + value *= 10; + } + fields->fraction = value; + break; + } + } + 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 month_days = days_before_month[fields->month + 1] - + days_before_month[fields->month]; + 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) { + return 0; + } + fields->yday = days_before_month[fields->month] + fields->day + + (fields->month > 2 && leap); + return 1; +} diff --git a/Python/pytime.c b/Python/pytime.c index 87266aa6e33f4f7..53c82736137a16b 100644 --- a/Python/pytime.c +++ b/Python/pytime.c @@ -1416,249 +1416,3 @@ _PyDeadline_Get(PyTime_t deadline) (void)PyTime_MonotonicRaw(&now); return deadline - now; } - - -/* 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; - * - 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 - * week/day-of-year calculations), and offsets with seconds or fractions. - */ - -static int -strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, - int minimum, int maximum, int *value) -{ - int count = 0; - *value = 0; - while (count < maximum && pos + count < length) { - unsigned char c = data[pos + count]; - if (c < '0' || c > '9') { - break; - } - *value = *value * 10 + c - '0'; - count++; - } - return count >= minimum ? count : 0; -} - -static int -strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, - _PyTime_StrptimeFields *fields) -{ - Py_ssize_t remaining = length - pos; - if (remaining == 0) { - return 1; - } - if (remaining == 1 && 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] != '-')) { - 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; - } - fields->gmtoff = (hours * 3600 + minutes * 60) * - (data[pos] == '-' ? -1 : 1); - return 1; -} - -/* Return 1 for a complete numeric 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: - * it owns the exception messages and error precedence. For example, month 13 - * and empty input can't be repaired by backtracking, but diagnosing them here - * would duplicate the Python parser's error handling. A return value of 0 - * therefore doesn't distinguish invalid input from an unsupported format. - */ -int -_PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fields) -{ - enum { - YEAR = 1U << 0, - SHORT_YEAR = 1U << 1, - MONTH = 1U << 2, - DAY = 1U << 3, - HOUR = 1U << 4, - MINUTE = 1U << 5, - SECOND = 1U << 6, - FRACTION = 1U << 7, - OFFSET = 1U << 8, - ANY_YEAR = YEAR | SHORT_YEAR, - }; - static const unsigned short directive_bits[128] = { - ['Y'] = YEAR, - ['y'] = SHORT_YEAR, - ['m'] = MONTH, - ['d'] = DAY, - ['H'] = HOUR, - ['M'] = MINUTE, - ['S'] = SECOND, - ['f'] = FRACTION, - ['z'] = OFFSET, - }; - /* String subclasses and non-ASCII input retain Python's behavior. */ - if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || - !PyUnicode_IS_ASCII(string) || !PyUnicode_IS_ASCII(format)) { - return 0; - } - const unsigned char *data = PyUnicode_1BYTE_DATA(string); - 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}; - unsigned int seen = 0; - Py_ssize_t pos = 0; - - for (Py_ssize_t i = 0; i < fmt_length; i++) { - unsigned char c = fmt[i]; - if (Py_ISSPACE(c)) { - if (pos == length || !Py_ISSPACE(data[pos])) { - return 0; - } - while (i + 1 < fmt_length && Py_ISSPACE(fmt[i + 1])) { - i++; - } - do { - pos++; - } while (pos < length && Py_ISSPACE(data[pos])); - continue; - } - if (c != '%') { - if (pos == length || data[pos++] != c) { - return 0; - } - continue; - } - if (++i == fmt_length) { - return 0; - } - c = fmt[i]; - if (c == '%') { - if (pos == length || data[pos++] != '%') { - return 0; - } - continue; - } - - /* Reject duplicate groups, aliases, and locale-dependent directives. */ - unsigned int bit = directive_bits[c]; - if (bit == 0) { - return 0; - } - if (seen & bit) { - return 0; - } - seen |= bit; - - if (c == 'z') { - if (i != fmt_length - 1 || - !strptime_offset(data, length, pos, fields)) { - return 0; - } - pos = length; - continue; - } - - int minimum = 1; - int maximum = 2; - if (c == 'Y') { - minimum = maximum = 4; - } - else if (c == 'y') { - minimum = maximum = 2; - } - else if (c == 'f') { - maximum = 6; - } - else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') { - pos++; - maximum = 1; - } - int value; - int count = strptime_digits(data, length, pos, minimum, maximum, &value); - if (count == 0) { - return 0; - } - pos += count; - switch (c) { - case 'Y': - fields->year = value; - break; - case 'y': - fields->year = value + (value <= 68 ? 2000 : 1900); - break; - case 'm': - if (value < 1 || value > 12) { - return 0; - } - fields->month = value; - break; - case 'd': - if (value < 1 || value > 31) { - return 0; - } - fields->day = value; - break; - case 'H': - if (value > 23) { - return 0; - } - fields->hour = value; - break; - case 'M': - if (value > 59) { - return 0; - } - fields->minute = value; - break; - case 'S': - if (value > 61) { - return 0; - } - fields->second = value; - break; - case 'f': - for (; count < 6; count++) { - value *= 10; - } - fields->fraction = value; - break; - } - } - 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 month_days = days_before_month[fields->month + 1] - - days_before_month[fields->month]; - 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) { - return 0; - } - fields->yday = days_before_month[fields->month] + fields->day + - (fields->month > 2 && leap); - return 1; -} From 79115de7afed32f9e97e68a37cda8e0e697663fc Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 22 Oct 2026 17:11:23 -0400 Subject: [PATCH 10/12] =?UTF-8?q?gh-158252:=20Add=20strptime=20optimizatio?= =?UTF-8?q?ns=20to=20What=E2=80=99s=20New?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Doc/whatsnew/3.16.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index e54cf2e8bf6f9a6..4f457a9d1e924da 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -910,6 +910,16 @@ zlib Optimizations ============= +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``. + (Contributed by Paul Ganssle in :gh:`158252`, based on work by + StanFromIreland.) + re -- From 49ff84b42bad5536694acfa30a886fc916258bf6 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 22 Oct 2026 20:07:30 -0400 Subject: [PATCH 11/12] gh-158252: Match Unicode whitespace in the C strptime parser Py_ISSPACE excludes ASCII separators U+001C through U+001F, which the Python parser treats as whitespace. Consuming one as a literal can leave a space for the next numeric field, changing how adjacent fields split. Use Py_UNICODE_ISSPACE and add regression tests for all four public entry points. These cases returned different dates or times before the fix. --- Lib/test/datetimetester.py | 23 +++++++++++++++++++++++ Lib/test/test_time.py | 8 ++++++++ Python/_strptime.c | 9 +++++---- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index e411bede8774280..536b6bfd4809c02 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1281,6 +1281,13 @@ def test_strptime_whitespace(self): fmt = format_space.join(('%Y', '%m', '%d')) self.assertEqual(date.strptime(text, fmt), date(2024, 2, 29)) + def test_strptime_whitespace_before_adjacent_fields(self): + for separator in ('\x1c', '\x1d', '\x1e', '\x1f'): + with self.subTest(separator=separator): + text = f'2024{separator} 112' + fmt = f'%Y{separator}%d%m' + self.assertEqual(date.strptime(text, fmt), date(2024, 2, 11)) + def test_strptime_single_digit(self): # bpo-34903: Check that single digit dates are allowed. strptime = date.strptime @@ -3202,6 +3209,14 @@ def test_strptime_whitespace(self): self.assertEqual(self.theclass.strptime(text, fmt), self.theclass(2024, 2, 29, 12, 34, 56)) + def test_strptime_whitespace_before_adjacent_fields(self): + for separator in ('\x1c', '\x1d', '\x1e', '\x1f'): + with self.subTest(separator=separator): + text = f'2024{separator} 112 34{separator} 123' + fmt = f'%Y{separator}%d%m %M{separator}%H%S' + self.assertEqual(self.theclass.strptime(text, fmt), + self.theclass(2024, 2, 11, 12, 34, 3)) + def test_strptime_numeric_fallback(self): cases = ( ('2024\0-02-29', '%Y\0-%m-%d', (2024, 2, 29)), @@ -4435,6 +4450,14 @@ def test_strptime_whitespace(self): self.assertEqual(self.theclass.strptime(text, fmt), self.theclass(12, 34, 56)) + def test_strptime_whitespace_before_adjacent_fields(self): + for separator in ('\x1c', '\x1d', '\x1e', '\x1f'): + with self.subTest(separator=separator): + text = f'34{separator} 123' + fmt = f'%M{separator}%H%S' + self.assertEqual(self.theclass.strptime(text, fmt), + self.theclass(12, 34, 3)) + def test_strptime_tz(self): strptime = self.theclass.strptime self.assertEqual(strptime("+0002", "%z").utcoffset(), 2 * MINUTE) diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index fe7ef10b1826d38..dac4dd9060ccdd1 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -382,6 +382,14 @@ def test_strptime_whitespace(self): self.assertEqual(time.strptime(text, fmt), (2024, 2, 29, 12, 34, 56, 3, 60, -1)) + def test_strptime_whitespace_before_adjacent_fields(self): + for separator in ('\x1c', '\x1d', '\x1e', '\x1f'): + with self.subTest(separator=separator): + text = f'2024{separator} 112 34{separator} 123' + fmt = f'%Y{separator}%d%m %M{separator}%H%S' + self.assertEqual(time.strptime(text, fmt), + (2024, 2, 11, 12, 34, 3, 6, 42, -1)) + def test_strptime_numeric_calendar(self): cases = ( ('0001-01-01', 1, 1, 1, 0, 1), diff --git a/Python/_strptime.c b/Python/_strptime.c index 2ad7f315cc183a0..42f546f9714f863 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -111,16 +111,17 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie for (Py_ssize_t i = 0; i < fmt_length; i++) { unsigned char c = fmt[i]; - if (Py_ISSPACE(c)) { - if (pos == length || !Py_ISSPACE(data[pos])) { + /* Match regex \s, including ASCII separators U+001C through U+001F. */ + if (Py_UNICODE_ISSPACE(c)) { + if (pos == length || !Py_UNICODE_ISSPACE(data[pos])) { return 0; } - while (i + 1 < fmt_length && Py_ISSPACE(fmt[i + 1])) { + while (i + 1 < fmt_length && Py_UNICODE_ISSPACE(fmt[i + 1])) { i++; } do { pos++; - } while (pos < length && Py_ISSPACE(data[pos])); + } while (pos < length && Py_UNICODE_ISSPACE(data[pos])); continue; } if (c != '%') { From 4e915793c50200be9bfb3ef206d13fa100bd70c9 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Fri, 23 Oct 2026 15:48:49 +0100 Subject: [PATCH 12/12] Use Stan's name --- Doc/whatsnew/3.16.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 4f457a9d1e924da..9ac1260fac95672 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -918,7 +918,7 @@ datetime and time for common numeric formats such as ``%Y-%m-%d`` and ``%Y-%m-%d %H:%M:%S``. (Contributed by Paul Ganssle in :gh:`158252`, based on work by - StanFromIreland.) + Stan Ulbrych.) re --