From 2f05c46f0258f613e334c5c6696485f28fa5ecd6 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 14 Feb 2026 20:01:28 +0000 Subject: [PATCH 1/7] 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 59ac96745aa15e2..3a014e37194291c 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 d10ed146db92b6b..e045a7d2f24ed42 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 9af81bf7cc67d1d..e374a085c7c6226 100755 --- a/configure +++ b/configure @@ -799,6 +799,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 @@ -35777,6 +35779,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 @@ -38732,6 +38756,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 92d7c17a6b448ba..21bfa9ef5b5a615 100644 --- a/configure.ac +++ b/configure.ac @@ -8494,6 +8494,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 9a8a3bd557f113b7d6ab4718c840ca58bbd735ab Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 17 Sep 2026 10:19:49 -0400 Subject: [PATCH 2/7] 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 45e2b8fe5f8cc26d8050c3035578ed3160615863 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Thu, 17 Sep 2026 10:29:53 -0400 Subject: [PATCH 3/7] 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 3a014e37194291c..59ac96745aa15e2 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 e045a7d2f24ed42..d10ed146db92b6b 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 bd76b3bd81cce40..bcf8c17730185cd 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -5846,6 +5846,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 @@ -5866,6 +6078,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 e374a085c7c6226..9af81bf7cc67d1d 100755 --- a/configure +++ b/configure @@ -799,8 +799,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 @@ -35779,28 +35777,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 @@ -38756,10 +38732,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 21bfa9ef5b5a615..92d7c17a6b448ba 100644 --- a/configure.ac +++ b/configure.ac @@ -8494,7 +8494,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 efe2c4b11f3a80375f403d27ceaecf1b1ca1f97a Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 26 Sep 2026 15:49:29 -0400 Subject: [PATCH 4/7] 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 ec3bb6e1aec299e913ad43b2d1e865422f0f1099 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 26 Sep 2026 15:58:22 -0400 Subject: [PATCH 5/7] 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 bcf8c17730185cd..76f469f8960a428 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -5846,218 +5846,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 @@ -6079,9 +5867,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 bf4f1dd0531ce02d650e3d0e9a2d0277fd793eef Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 26 Sep 2026 16:02:46 -0400 Subject: [PATCH 6/7] 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 76f469f8960a428..54815b9d116ffde 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3514,6 +3514,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)); @@ -4786,6 +4791,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 0005974b52499ce..9ede632b061c637 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -989,6 +989,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 207ae592412fa475e02f43ef0612ed85f5e77dde Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 26 Sep 2026 17:42:43 -0400 Subject: [PATCH 7/7] 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 54815b9d116ffde..3d229a0a8c66d9f 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 @@ -7722,19 +7708,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 9ede632b061c637..8c484c8767e897e 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -998,14 +998,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) {