diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index e54cf2e8bf6f9a6..9ac1260fac95672 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -910,6 +910,16 @@ zlib
Optimizations
=============
+datetime and time
+-----------------
+
+* :meth:`datetime.datetime.strptime`, :meth:`datetime.date.strptime`,
+ :meth:`datetime.time.strptime`, and :func:`time.strptime` are faster
+ for common numeric formats such as ``%Y-%m-%d`` and
+ ``%Y-%m-%d %H:%M:%S``.
+ (Contributed by Paul Ganssle in :gh:`158252`, based on work by
+ Stan Ulbrych.)
+
re
--
diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h
index b671225ca6ea44d..12940cdce8abbde 100644
--- a/Include/internal/pycore_time.h
+++ b/Include/internal/pycore_time.h
@@ -317,6 +317,45 @@ 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
+// 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/Lib/test/datetimetester.py b/Lib/test/datetimetester.py
index 716c662ad453f4a..536b6bfd4809c02 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.
@@ -1178,6 +1271,23 @@ def test_strptime(self):
self.assertEqual(expected, got)
self.assertIs(type(got), date)
+ def test_strptime_whitespace(self):
+ whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f')
+ for input_space in whitespace:
+ for format_space in whitespace:
+ with self.subTest(input_space=input_space,
+ format_space=format_space):
+ text = input_space.join(('2024', '02', '29'))
+ fmt = format_space.join(('%Y', '%m', '%d'))
+ self.assertEqual(date.strptime(text, fmt), date(2024, 2, 29))
+
+ def test_strptime_whitespace_before_adjacent_fields(self):
+ for separator in ('\x1c', '\x1d', '\x1e', '\x1f'):
+ with self.subTest(separator=separator):
+ text = f'2024{separator} 112'
+ fmt = f'%Y{separator}%d%m'
+ self.assertEqual(date.strptime(text, fmt), date(2024, 2, 11))
+
def test_strptime_single_digit(self):
# bpo-34903: Check that single digit dates are allowed.
strptime = date.strptime
@@ -3088,6 +3198,70 @@ def test_strptime(self):
with self.assertRaises(ValueError): strptime("-000", "%z")
with self.assertRaises(ValueError): strptime("z", "%z")
+ def test_strptime_whitespace(self):
+ whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f')
+ for input_space in whitespace:
+ for format_space in whitespace:
+ with self.subTest(input_space=input_space,
+ format_space=format_space):
+ text = input_space.join(('2024-02-29', '12:34:56'))
+ fmt = format_space.join(('%Y-%m-%d', '%H:%M:%S'))
+ self.assertEqual(self.theclass.strptime(text, fmt),
+ self.theclass(2024, 2, 29, 12, 34, 56))
+
+ def test_strptime_whitespace_before_adjacent_fields(self):
+ for separator in ('\x1c', '\x1d', '\x1e', '\x1f'):
+ with self.subTest(separator=separator):
+ text = f'2024{separator} 112 34{separator} 123'
+ fmt = f'%Y{separator}%d%m %M{separator}%H%S'
+ self.assertEqual(self.theclass.strptime(text, fmt),
+ self.theclass(2024, 2, 11, 12, 34, 3))
+
+ def test_strptime_numeric_fallback(self):
+ cases = (
+ ('2024\0-02-29', '%Y\0-%m-%d', (2024, 2, 29)),
+ ('٢٠٢٤-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):
@@ -4265,6 +4439,25 @@ def test_strptime(self):
self.assertEqual(expected, got)
self.assertIs(type(got), self.theclass)
+ def test_strptime_whitespace(self):
+ whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f')
+ for input_space in whitespace:
+ for format_space in whitespace:
+ with self.subTest(input_space=input_space,
+ format_space=format_space):
+ text = input_space.join(('12', '34', '56'))
+ fmt = format_space.join(('%H', '%M', '%S'))
+ self.assertEqual(self.theclass.strptime(text, fmt),
+ self.theclass(12, 34, 56))
+
+ def test_strptime_whitespace_before_adjacent_fields(self):
+ for separator in ('\x1c', '\x1d', '\x1e', '\x1f'):
+ with self.subTest(separator=separator):
+ text = f'34{separator} 123'
+ fmt = f'%M{separator}%H%S'
+ self.assertEqual(self.theclass.strptime(text, fmt),
+ self.theclass(12, 34, 3))
+
def test_strptime_tz(self):
strptime = self.theclass.strptime
self.assertEqual(strptime("+0002", "%z").utcoffset(), 2 * MINUTE)
diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py
index 985984b55123ce6..dac4dd9060ccdd1 100644
--- a/Lib/test/test_time.py
+++ b/Lib/test/test_time.py
@@ -371,6 +371,64 @@ def test_strptime(self):
self.fail("conversion specifier %r failed with '%s' input." %
(format, strf_output))
+ def test_strptime_whitespace(self):
+ whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f')
+ for input_space in whitespace:
+ for format_space in whitespace:
+ with self.subTest(input_space=input_space,
+ format_space=format_space):
+ text = input_space.join(('2024-02-29', '12:34:56'))
+ fmt = format_space.join(('%Y-%m-%d', '%H:%M:%S'))
+ self.assertEqual(time.strptime(text, fmt),
+ (2024, 2, 29, 12, 34, 56, 3, 60, -1))
+
+ def test_strptime_whitespace_before_adjacent_fields(self):
+ for separator in ('\x1c', '\x1d', '\x1e', '\x1f'):
+ with self.subTest(separator=separator):
+ text = f'2024{separator} 112 34{separator} 123'
+ fmt = f'%Y{separator}%d%m %M{separator}%H%S'
+ self.assertEqual(time.strptime(text, fmt),
+ (2024, 2, 11, 12, 34, 3, 6, 42, -1))
+
+ def test_strptime_numeric_calendar(self):
+ cases = (
+ ('0001-01-01', 1, 1, 1, 0, 1),
+ ('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/Makefile.pre.in b/Makefile.pre.in
index c49a16f1edc3bef..5a9f3545d9e3641 100644
--- a/Makefile.pre.in
+++ b/Makefile.pre.in
@@ -434,6 +434,7 @@ PARSER_HEADERS= \
PYTHON_OBJS= \
Python/_contextvars.o \
+ Python/_strptime.o \
Python/_warnings.o \
Python/Python-ast.o \
Python/Python-tokenize.o \
diff --git a/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
new file mode 100644
index 000000000000000..9d3eeb245023a9d
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-09-17-10-30-00.gh-issue-158252.strptime.rst
@@ -0,0 +1,4 @@
+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 dbd14f1d7070772..aaf713682f501f6 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
@@ -3516,6 +3502,11 @@ datetime_date_strptime_impl(PyTypeObject *type, PyObject *string,
PyObject *format)
/*[clinic end generated code: output=454d473bee2d5161 input=2db8f0b2b5242deb]*/
{
+ _PyTime_StrptimeFields fields;
+ if (type == DATE_TYPE(NO_STATE) &&
+ _PyTime_Strptime(string, format, &fields)) {
+ return new_date_ex(fields.year, fields.month, fields.day, type);
+ }
PyObject *result;
PyObject *module = PyImport_Import(&_Py_ID(_strptime));
@@ -4788,6 +4779,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));
@@ -5868,6 +5873,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. */
+ _PyTime_StrptimeFields fields;
+ if (type == DATETIME_TYPE(NO_STATE) &&
+ _PyTime_Strptime(string, format, &fields)) {
+ PyObject *tzinfo = tzinfo_from_isoformat_results(
+ fields.gmtoff != INT_MIN, fields.gmtoff, 0);
+ if (tzinfo == NULL) {
+ return NULL;
+ }
+ PyObject *result = new_datetime_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));
@@ -7689,19 +7710,19 @@ _datetime_exec(PyObject *module)
* pasting together 4 single years.
*/
static_assert(DI4Y == 4 * 365 + 1, "DI4Y");
- assert(DI4Y == days_before_year(4+1));
+ assert(DI4Y == _PyTime_DaysBeforeYear(4+1));
/* Similarly, a 400-year cycle has an extra leap day over what we'd
* get from pasting together 4 100-year cycles.
*/
static_assert(DI400Y == 4 * DI100Y + 1, "DI400Y");
- assert(DI400Y == days_before_year(400+1));
+ assert(DI400Y == _PyTime_DaysBeforeYear(400+1));
/* OTOH, a 100-year cycle has one fewer leap day than we'd get from
* pasting together 25 4-year cycles.
*/
static_assert(DI100Y == 25 * DI4Y - 1, "DI100Y");
- assert(DI100Y == days_before_year(100+1));
+ assert(DI100Y == _PyTime_DaysBeforeYear(100+1));
if (set_current_module(interp, module) < 0) {
goto error;
diff --git a/Modules/timemodule.c b/Modules/timemodule.c
index e5162fd3b678557..f3bfef333b383bb 100644
--- a/Modules/timemodule.c
+++ b/Modules/timemodule.c
@@ -1007,6 +1007,40 @@ 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;
+ }
+ const int values[] = {
+ fields.year, fields.month, fields.day,
+ fields.hour, fields.minute, fields.second,
+ _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]);
+ 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");
diff --git a/PCbuild/_freeze_module.vcxproj b/PCbuild/_freeze_module.vcxproj
index 36a1d768486a3f7..b00f2c1b486f21e 100644
--- a/PCbuild/_freeze_module.vcxproj
+++ b/PCbuild/_freeze_module.vcxproj
@@ -194,6 +194,7 @@
+
diff --git a/PCbuild/_freeze_module.vcxproj.filters b/PCbuild/_freeze_module.vcxproj.filters
index b358e09950a4439..e236cb907ae5642 100644
--- a/PCbuild/_freeze_module.vcxproj.filters
+++ b/PCbuild/_freeze_module.vcxproj.filters
@@ -19,6 +19,9 @@
Source Files
+
+ Source Files
+
Source Files
diff --git a/PCbuild/pythoncore.vcxproj b/PCbuild/pythoncore.vcxproj
index c2d8f64a72b1191..e603a5013b9cf6b 100644
--- a/PCbuild/pythoncore.vcxproj
+++ b/PCbuild/pythoncore.vcxproj
@@ -614,6 +614,7 @@
+
diff --git a/PCbuild/pythoncore.vcxproj.filters b/PCbuild/pythoncore.vcxproj.filters
index 2536fc971700deb..36b6446f845b167 100644
--- a/PCbuild/pythoncore.vcxproj.filters
+++ b/PCbuild/pythoncore.vcxproj.filters
@@ -1394,6 +1394,9 @@
Python
+
+ Python
+
Python
diff --git a/Python/_strptime.c b/Python/_strptime.c
new file mode 100644
index 000000000000000..42f546f9714f863
--- /dev/null
+++ b/Python/_strptime.c
@@ -0,0 +1,249 @@
+#include "Python.h"
+#include "pycore_time.h"
+
+
+/* Implement common parts of strptime in C to improve performance. Callers
+ * fall back to Lib/_strptime.py for a full parse when this doesn't match.
+ *
+ * Supported inputs are exact str objects containing only ASCII, with:
+ * - numeric %Y, %y, %m, %d, %H, %M, %S, and %f fields;
+ * - a terminal %z: empty, Z, or +/-HHMM or +/-HH:MM with hours below 24;
+ * - literal characters, %%, and ASCII whitespace.
+ * Duplicate directives, mixed %Y/%y, and day-of-month without a year use the
+ * fallback. So do other directives (including locale-dependent names and
+ * week/day-of-year calculations), and offsets with seconds or fractions.
+ */
+
+static int
+strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos,
+ int minimum, int maximum, int *value)
+{
+ int count = 0;
+ *value = 0;
+ while (count < maximum && pos + count < length) {
+ unsigned char c = data[pos + count];
+ if (c < '0' || c > '9') {
+ break;
+ }
+ *value = *value * 10 + c - '0';
+ count++;
+ }
+ return count >= minimum ? count : 0;
+}
+
+static int
+strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos,
+ _PyTime_StrptimeFields *fields)
+{
+ Py_ssize_t remaining = length - pos;
+ if (remaining == 0) {
+ return 1;
+ }
+ if (remaining == 1 && data[pos] == 'Z') {
+ fields->gmtoff = 0;
+ return 1;
+ }
+ /* Offsets containing seconds or fractions fall back to _strptime.py. */
+ if ((remaining != 5 && remaining != 6) ||
+ (data[pos] != '+' && data[pos] != '-')) {
+ return 0;
+ }
+ int hours, minutes;
+ int colon = remaining == 6;
+ if ((colon && data[pos + 3] != ':') ||
+ strptime_digits(data, length, pos + 1, 2, 2, &hours) != 2 ||
+ strptime_digits(data, length, pos + 3 + colon, 2, 2, &minutes) != 2 ||
+ hours > 23 || minutes > 59) {
+ return 0;
+ }
+ fields->gmtoff = (hours * 3600 + minutes * 60) *
+ (data[pos] == '-' ? -1 : 1);
+ return 1;
+}
+
+/* Return 1 for a complete numeric match, or 0 if fallback is required.
+ *
+ * Parsing doesn't allocate or set exceptions. Some mismatches require regex
+ * backtracking, but even inputs known to be invalid use the Python parser:
+ * it owns the exception messages and error precedence. For example, month 13
+ * and empty input can't be repaired by backtracking, but diagnosing them here
+ * would duplicate the Python parser's error handling. A return value of 0
+ * therefore doesn't distinguish invalid input from an unsupported format.
+ */
+int
+_PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fields)
+{
+ enum {
+ YEAR = 1U << 0,
+ SHORT_YEAR = 1U << 1,
+ MONTH = 1U << 2,
+ DAY = 1U << 3,
+ HOUR = 1U << 4,
+ MINUTE = 1U << 5,
+ SECOND = 1U << 6,
+ FRACTION = 1U << 7,
+ OFFSET = 1U << 8,
+ ANY_YEAR = YEAR | SHORT_YEAR,
+ };
+ static const unsigned short directive_bits[128] = {
+ ['Y'] = YEAR,
+ ['y'] = SHORT_YEAR,
+ ['m'] = MONTH,
+ ['d'] = DAY,
+ ['H'] = HOUR,
+ ['M'] = MINUTE,
+ ['S'] = SECOND,
+ ['f'] = FRACTION,
+ ['z'] = OFFSET,
+ };
+ /* String subclasses and non-ASCII input retain Python's behavior. */
+ if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) ||
+ !PyUnicode_IS_ASCII(string) || !PyUnicode_IS_ASCII(format)) {
+ return 0;
+ }
+ const unsigned char *data = PyUnicode_1BYTE_DATA(string);
+ const unsigned char *fmt = PyUnicode_1BYTE_DATA(format);
+ Py_ssize_t length = PyUnicode_GET_LENGTH(string);
+ Py_ssize_t fmt_length = PyUnicode_GET_LENGTH(format);
+ *fields = (_PyTime_StrptimeFields){.year = 1900, .month = 1, .day = 1, .gmtoff = INT_MIN};
+ unsigned int seen = 0;
+ Py_ssize_t pos = 0;
+
+ for (Py_ssize_t i = 0; i < fmt_length; i++) {
+ unsigned char c = fmt[i];
+ /* Match regex \s, including ASCII separators U+001C through U+001F. */
+ if (Py_UNICODE_ISSPACE(c)) {
+ if (pos == length || !Py_UNICODE_ISSPACE(data[pos])) {
+ return 0;
+ }
+ while (i + 1 < fmt_length && Py_UNICODE_ISSPACE(fmt[i + 1])) {
+ i++;
+ }
+ do {
+ pos++;
+ } while (pos < length && Py_UNICODE_ISSPACE(data[pos]));
+ continue;
+ }
+ if (c != '%') {
+ if (pos == length || data[pos++] != c) {
+ return 0;
+ }
+ continue;
+ }
+ if (++i == fmt_length) {
+ return 0;
+ }
+ c = fmt[i];
+ if (c == '%') {
+ if (pos == length || data[pos++] != '%') {
+ return 0;
+ }
+ continue;
+ }
+
+ /* Reject duplicate groups, aliases, and locale-dependent directives. */
+ unsigned int bit = directive_bits[c];
+ if (bit == 0) {
+ return 0;
+ }
+ if (seen & bit) {
+ return 0;
+ }
+ seen |= bit;
+
+ if (c == 'z') {
+ if (i != fmt_length - 1 ||
+ !strptime_offset(data, length, pos, fields)) {
+ return 0;
+ }
+ pos = length;
+ continue;
+ }
+
+ int minimum = 1;
+ int maximum = 2;
+ if (c == 'Y') {
+ minimum = maximum = 4;
+ }
+ else if (c == 'y') {
+ minimum = maximum = 2;
+ }
+ else if (c == 'f') {
+ maximum = 6;
+ }
+ else if ((c == 'd' || c == 'H') && pos < length && data[pos] == ' ') {
+ pos++;
+ maximum = 1;
+ }
+ int value;
+ int count = strptime_digits(data, length, pos, minimum, maximum, &value);
+ if (count == 0) {
+ return 0;
+ }
+ pos += count;
+ switch (c) {
+ case 'Y':
+ fields->year = value;
+ break;
+ case 'y':
+ fields->year = value + (value <= 68 ? 2000 : 1900);
+ break;
+ case 'm':
+ if (value < 1 || value > 12) {
+ return 0;
+ }
+ fields->month = value;
+ break;
+ case 'd':
+ if (value < 1 || value > 31) {
+ return 0;
+ }
+ fields->day = value;
+ break;
+ case 'H':
+ if (value > 23) {
+ return 0;
+ }
+ fields->hour = value;
+ break;
+ case 'M':
+ if (value > 59) {
+ return 0;
+ }
+ fields->minute = value;
+ break;
+ case 'S':
+ if (value > 61) {
+ return 0;
+ }
+ fields->second = value;
+ break;
+ case 'f':
+ for (; count < 6; count++) {
+ value *= 10;
+ }
+ fields->fraction = value;
+ break;
+ }
+ }
+ static const int days_before_month[] = {
+ 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365
+ };
+ int leap = fields->year % 4 == 0 &&
+ (fields->year % 100 != 0 || fields->year % 400 == 0);
+ int month_days = days_before_month[fields->month + 1] -
+ days_before_month[fields->month];
+ if (fields->month == 2) {
+ month_days += leap;
+ }
+ /* Mixed %Y/%y and day-without-year diagnostics belong to Python. */
+ if (pos != length || (seen & ANY_YEAR) == ANY_YEAR ||
+ ((seen & DAY) && !(seen & ANY_YEAR)) ||
+ fields->year < 1 ||
+ fields->day > month_days) {
+ return 0;
+ }
+ fields->yday = days_before_month[fields->month] + fields->day +
+ (fields->month > 2 && leap);
+ return 1;
+}