Skip to content

Commit 6854e79

Browse files
gh-158252: Add a C implementation for strptime (#158253)
Co-authored-by: Stan Ulbrych <stan@python.org>
1 parent ae5c457 commit 6854e79

13 files changed

Lines changed: 639 additions & 22 deletions

‎Doc/whatsnew/3.16.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,16 @@ zlib
983983
Optimizations
984984
=============
985985

986+
datetime and time
987+
-----------------
988+
989+
* :meth:`datetime.datetime.strptime`, :meth:`datetime.date.strptime`,
990+
:meth:`datetime.time.strptime`, and :func:`time.strptime` are faster
991+
for common numeric formats such as ``%Y-%m-%d`` and
992+
``%Y-%m-%d %H:%M:%S``.
993+
(Contributed by Paul Ganssle in :gh:`158252`, based on work by
994+
Stan Ulbrych.)
995+
986996
re
987997
--
988998

‎Include/internal/pycore_time.h‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,45 @@ extern PyTime_t _PyTimeFraction_Mul(
317317
extern double _PyTimeFraction_Resolution(
318318
const _PyTimeFraction *frac);
319319

320+
/* year -> number of days before January 1st of year. Remember that we
321+
* start with year 1, so days_before_year(1) == 0.
322+
*/
323+
static inline int
324+
_PyTime_DaysBeforeYear(int year)
325+
{
326+
int y = year - 1;
327+
/* This is incorrect if year <= 0; we really want the floor
328+
* here. But so long as MINYEAR is 1, the smallest year this
329+
* can see is 1.
330+
*/
331+
assert(year >= 1);
332+
return y*365 + y/4 - y/100 + y/400;
333+
}
334+
335+
// Weekday from a valid Gregorian year and 1-based day of year; Monday is 0.
336+
static inline int
337+
_PyTime_Weekday(int year, int yday)
338+
{
339+
return (_PyTime_DaysBeforeYear(year) + yday + 6) % 7;
340+
}
341+
342+
343+
// Locale-independent numeric strptime parsing.
344+
// A complete match returns 1; otherwise use Lib/_strptime.py. This function
345+
// neither allocates nor sets exceptions. The calendar date is validated, but
346+
// seconds may be 60 or 61: callers must apply their own time validation.
347+
typedef struct {
348+
int year, month, day;
349+
int hour, minute, second, fraction;
350+
int gmtoff; // INT_MIN means no offset was supplied.
351+
int yday; // January 1 is day 1.
352+
} _PyTime_StrptimeFields;
353+
354+
// Export for the '_datetime' shared extension.
355+
PyAPI_FUNC(int) _PyTime_Strptime(
356+
PyObject *string, PyObject *format, _PyTime_StrptimeFields *fields);
357+
358+
320359
extern PyStatus _PyTime_Init(struct _Py_time_runtime_state *state);
321360

322361
#ifdef __cplusplus

‎Lib/test/datetimetester.py‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,99 @@ def __divmod__(self, other):
11041104
#############################################################################
11051105
# date tests
11061106

1107+
class TestStrptime(unittest.TestCase):
1108+
def test_numeric_fields(self):
1109+
cases = (
1110+
('2024-02-29 12:34:56.123', '%Y-%m-%d %H:%M:%S.%f',
1111+
datetime(2024, 2, 29, 12, 34, 56, 123000)),
1112+
('2024-02-29T12:34:56.123+0530', '%Y-%m-%dT%H:%M:%S.%f%z',
1113+
datetime(2024, 2, 29, 12, 34, 56, 123000,
1114+
timezone(timedelta(hours=5, minutes=30)))),
1115+
('12:34:56Z', '%H:%M:%S%z', datetime(1900, 1, 1, 12, 34, 56, tzinfo=UTC)),
1116+
('12:34:56-03:30', '%H:%M:%S%z',
1117+
datetime(1900, 1, 1, 12, 34, 56,
1118+
tzinfo=timezone(-timedelta(hours=3, minutes=30)))),
1119+
('12:34:56', '%H:%M:%S%z', datetime(1900, 1, 1, 12, 34, 56)),
1120+
('', '', datetime(1900, 1, 1)),
1121+
('2024111', '%Y%m%d', datetime(2024, 11, 1)),
1122+
('2024131', '%Y%m%d', datetime(2024, 1, 31)),
1123+
('2024\0-02-29', '%Y\0-%m-%d', datetime(2024, 2, 29)),
1124+
('٢٠٢٤-02-29', '%Y-%m-%d', datetime(2024, 2, 29)),
1125+
('2024\u200302\u200329', '%Y %m %d', datetime(2024, 2, 29)),
1126+
('2024t02t29', '%YT%mt%d', datetime(2024, 2, 29)),
1127+
('24 2025-02-01', '%y %Y-%m-%d', datetime(2025, 2, 1)),
1128+
)
1129+
for text, fmt, expected in cases:
1130+
for cls, result in ((datetime, expected), (date, expected.date()),
1131+
(time, expected.timetz())):
1132+
with self.subTest(text=text, fmt=fmt, cls=cls):
1133+
actual = cls.strptime(text, fmt)
1134+
self.assertEqual(actual, result)
1135+
self.assertIs(type(actual), cls)
1136+
with self.subTest(text=text, fmt=fmt, cls=_time.struct_time):
1137+
actual = _time.strptime(text, fmt)
1138+
self.assertEqual(actual, expected.timetuple())
1139+
self.assertIsNone(actual.tm_zone)
1140+
offset = expected.utcoffset()
1141+
self.assertEqual(actual.tm_gmtoff,
1142+
None if offset is None else offset.total_seconds())
1143+
1144+
def test_invalid_fields(self):
1145+
cases = (
1146+
('2024-02-30', '%Y-%m-%d'),
1147+
('1900-02-29', '%Y-%m-%d'),
1148+
('0000-01-01', '%Y-%m-%d'),
1149+
('2024-01- 12', '%Y-%m-%d'),
1150+
('2024\0ignored', '%Y'),
1151+
('24:00:00', '%H:%M:%S'),
1152+
('23:60:00', '%H:%M:%S'),
1153+
('23:59:62', '%H:%M:%S'),
1154+
)
1155+
for text, fmt in cases:
1156+
for parse in (datetime.strptime, date.strptime, time.strptime,
1157+
_time.strptime):
1158+
with self.subTest(text=text, fmt=fmt, parse=parse):
1159+
with self.assertRaises(ValueError):
1160+
parse(text, fmt)
1161+
1162+
def test_leap_seconds(self):
1163+
for second in (60, 61):
1164+
text = f'2024-02-29 23:59:{second}'
1165+
fmt = '%Y-%m-%d %H:%M:%S'
1166+
with self.subTest(second=second):
1167+
self.assertEqual(date.strptime(text, fmt), date(2024, 2, 29))
1168+
self.assertEqual(_time.strptime(text, fmt),
1169+
(2024, 2, 29, 23, 59, second, 3, 60, -1))
1170+
for cls in (datetime, time):
1171+
with self.assertRaises(ValueError):
1172+
cls.strptime(text, fmt)
1173+
1174+
def test_offset_outside_datetime_range(self):
1175+
# date discards the offset; struct_time doesn't construct a timezone.
1176+
self.assertEqual(date.strptime('+2400', '%z'), date(1900, 1, 1))
1177+
self.assertEqual(_time.strptime('+2400', '%z').tm_gmtoff, 86400)
1178+
for cls in (datetime, time):
1179+
with self.subTest(cls=cls):
1180+
with self.assertRaises(ValueError):
1181+
cls.strptime('+2400', '%z')
1182+
1183+
def test_subclass_constructor(self):
1184+
for cls, args in ((date, (2024, 2, 29)),
1185+
(time, (12, 34, 56, 123000)),
1186+
(datetime, (2024, 2, 29, 12, 34, 56, 123000))):
1187+
class Capture(cls):
1188+
def __new__(cls, *args, **kwargs):
1189+
return args, kwargs
1190+
1191+
text = '2024-02-29 12:34:56.123'
1192+
fmt = '%Y-%m-%d %H:%M:%S.%f'
1193+
with self.subTest(cls=cls):
1194+
self.assertEqual(Capture.strptime(text, fmt), (args, {}))
1195+
aware_args = args if cls is date else args + (UTC,)
1196+
self.assertEqual(Capture.strptime(text + 'Z', fmt + '%z'),
1197+
(aware_args, {}))
1198+
1199+
11071200
class TestDateOnly(unittest.TestCase):
11081201
# Tests here won't pass if also run on datetime objects, so don't
11091202
# subclass this to test datetimes too.
@@ -1178,6 +1271,23 @@ def test_strptime(self):
11781271
self.assertEqual(expected, got)
11791272
self.assertIs(type(got), date)
11801273

1274+
def test_strptime_whitespace(self):
1275+
whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f')
1276+
for input_space in whitespace:
1277+
for format_space in whitespace:
1278+
with self.subTest(input_space=input_space,
1279+
format_space=format_space):
1280+
text = input_space.join(('2024', '02', '29'))
1281+
fmt = format_space.join(('%Y', '%m', '%d'))
1282+
self.assertEqual(date.strptime(text, fmt), date(2024, 2, 29))
1283+
1284+
def test_strptime_whitespace_before_adjacent_fields(self):
1285+
for separator in ('\x1c', '\x1d', '\x1e', '\x1f'):
1286+
with self.subTest(separator=separator):
1287+
text = f'2024{separator} 112'
1288+
fmt = f'%Y{separator}%d%m'
1289+
self.assertEqual(date.strptime(text, fmt), date(2024, 2, 11))
1290+
11811291
def test_strptime_single_digit(self):
11821292
# bpo-34903: Check that single digit dates are allowed.
11831293
strptime = date.strptime
@@ -3089,6 +3199,70 @@ def test_strptime(self):
30893199
with self.assertRaises(ValueError): strptime("-000", "%z")
30903200
with self.assertRaises(ValueError): strptime("z", "%z")
30913201

3202+
def test_strptime_whitespace(self):
3203+
whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f')
3204+
for input_space in whitespace:
3205+
for format_space in whitespace:
3206+
with self.subTest(input_space=input_space,
3207+
format_space=format_space):
3208+
text = input_space.join(('2024-02-29', '12:34:56'))
3209+
fmt = format_space.join(('%Y-%m-%d', '%H:%M:%S'))
3210+
self.assertEqual(self.theclass.strptime(text, fmt),
3211+
self.theclass(2024, 2, 29, 12, 34, 56))
3212+
3213+
def test_strptime_whitespace_before_adjacent_fields(self):
3214+
for separator in ('\x1c', '\x1d', '\x1e', '\x1f'):
3215+
with self.subTest(separator=separator):
3216+
text = f'2024{separator} 112 34{separator} 123'
3217+
fmt = f'%Y{separator}%d%m %M{separator}%H%S'
3218+
self.assertEqual(self.theclass.strptime(text, fmt),
3219+
self.theclass(2024, 2, 11, 12, 34, 3))
3220+
3221+
def test_strptime_numeric_fallback(self):
3222+
cases = (
3223+
('2024\0-02-29', '%Y\0-%m-%d', (2024, 2, 29)),
3224+
('٢٠٢٤-02-29', '%Y-%m-%d', (2024, 2, 29)),
3225+
('2024\u200302\u200329', '%Y %m %d', (2024, 2, 29)),
3226+
('2024t02t29', '%YT%mt%d', (2024, 2, 29)),
3227+
('2024111', '%Y%m%d', (2024, 11, 1)),
3228+
('2024131', '%Y%m%d', (2024, 1, 31)),
3229+
('24 2025-02-01', '%y %Y-%m-%d', (2025, 2, 1)),
3230+
('2025 24-02-01', '%Y %y-%m-%d', (2024, 2, 1)),
3231+
)
3232+
for text, fmt, expected in cases:
3233+
with self.subTest(text=text, fmt=fmt):
3234+
self.assertEqual(self.theclass.strptime(text, fmt),
3235+
self.theclass(*expected))
3236+
3237+
def test_strptime_numeric_invalid(self):
3238+
cases = (
3239+
('2024\0ignored', '%Y'),
3240+
('2024', '%Y\0ignored'),
3241+
('2024', '%4Y'),
3242+
('2024-02-30', '%Y-%m-%d'),
3243+
('1900-02-29', '%Y-%m-%d'),
3244+
('0000-01-01', '%Y-%m-%d'),
3245+
('2024-01- 12', '%Y-%m-%d'),
3246+
)
3247+
for text, fmt in cases:
3248+
with self.subTest(text=text, fmt=fmt):
3249+
with self.assertRaises(ValueError):
3250+
self.theclass.strptime(text, fmt)
3251+
with self.assertRaises(re.PatternError):
3252+
self.theclass.strptime('2024 2025', '%Y %Y')
3253+
3254+
def test_strptime_subclass_constructor(self):
3255+
class Capture(self.theclass):
3256+
def __new__(cls, *args, **kwargs):
3257+
return args, kwargs
3258+
3259+
args = (2024, 2, 29, 12, 34, 56, 123000)
3260+
fmt = '%Y-%m-%d %H:%M:%S.%f'
3261+
text = '2024-02-29 12:34:56.123'
3262+
self.assertEqual(Capture.strptime(text, fmt), (args, {}))
3263+
self.assertEqual(Capture.strptime(text + '+0530', fmt + '%z'),
3264+
(args + (timezone(timedelta(hours=5, minutes=30)),), {}))
3265+
30923266
def test_strptime_ampm(self):
30933267
dt = datetime(1999, 3, 17, 0, 44, 55, 2)
30943268
for hour in range(0, 24):
@@ -4286,6 +4460,25 @@ def test_strptime(self):
42864460
with self.assertRaises(ValueError):
42874461
self.theclass.strptime('13:02:47.1975', '%H:%M:%S.%3f')
42884462

4463+
def test_strptime_whitespace(self):
4464+
whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f')
4465+
for input_space in whitespace:
4466+
for format_space in whitespace:
4467+
with self.subTest(input_space=input_space,
4468+
format_space=format_space):
4469+
text = input_space.join(('12', '34', '56'))
4470+
fmt = format_space.join(('%H', '%M', '%S'))
4471+
self.assertEqual(self.theclass.strptime(text, fmt),
4472+
self.theclass(12, 34, 56))
4473+
4474+
def test_strptime_whitespace_before_adjacent_fields(self):
4475+
for separator in ('\x1c', '\x1d', '\x1e', '\x1f'):
4476+
with self.subTest(separator=separator):
4477+
text = f'34{separator} 123'
4478+
fmt = f'%M{separator}%H%S'
4479+
self.assertEqual(self.theclass.strptime(text, fmt),
4480+
self.theclass(12, 34, 3))
4481+
42894482
def test_strptime_tz(self):
42904483
strptime = self.theclass.strptime
42914484
self.assertEqual(strptime("+0002", "%z").utcoffset(), 2 * MINUTE)

‎Lib/test/test_time.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,64 @@ def test_strptime(self):
371371
self.fail("conversion specifier %r failed with '%s' input." %
372372
(format, strf_output))
373373

374+
def test_strptime_whitespace(self):
375+
whitespace = (' ', '\t\n\r\v\f', '\x1c', '\xa0', '\u2003', '\u202f')
376+
for input_space in whitespace:
377+
for format_space in whitespace:
378+
with self.subTest(input_space=input_space,
379+
format_space=format_space):
380+
text = input_space.join(('2024-02-29', '12:34:56'))
381+
fmt = format_space.join(('%Y-%m-%d', '%H:%M:%S'))
382+
self.assertEqual(time.strptime(text, fmt),
383+
(2024, 2, 29, 12, 34, 56, 3, 60, -1))
384+
385+
def test_strptime_whitespace_before_adjacent_fields(self):
386+
for separator in ('\x1c', '\x1d', '\x1e', '\x1f'):
387+
with self.subTest(separator=separator):
388+
text = f'2024{separator} 112 34{separator} 123'
389+
fmt = f'%Y{separator}%d%m %M{separator}%H%S'
390+
self.assertEqual(time.strptime(text, fmt),
391+
(2024, 2, 11, 12, 34, 3, 6, 42, -1))
392+
393+
def test_strptime_numeric_calendar(self):
394+
cases = (
395+
('0001-01-01', 1, 1, 1, 0, 1),
396+
('1900-03-01', 1900, 3, 1, 3, 60),
397+
('2000-02-29', 2000, 2, 29, 1, 60),
398+
('2000-03-01', 2000, 3, 1, 2, 61),
399+
('2024-12-31', 2024, 12, 31, 1, 366),
400+
('2100-03-01', 2100, 3, 1, 0, 60),
401+
('9999-12-31', 9999, 12, 31, 4, 365),
402+
)
403+
for text, year, month, day, weekday, yday in cases:
404+
with self.subTest(text=text):
405+
result = time.strptime(text, '%Y-%m-%d')
406+
self.assertIs(type(result), time.struct_time)
407+
self.assertEqual(result,
408+
(year, month, day, 0, 0, 0, weekday, yday, -1))
409+
self.assertIsNone(result.tm_zone)
410+
self.assertIsNone(result.tm_gmtoff)
411+
412+
def test_strptime_numeric_offset(self):
413+
for text, offset in (('', None), ('Z', 0), ('+0000', 0),
414+
('-00:00', 0), ('+0530', 19800),
415+
('-03:30', -12600), ('+2359', 86340),
416+
('+01:02:03.456', 3723)):
417+
with self.subTest(text=text):
418+
result = time.strptime('12:34:56.123' + text, '%H:%M:%S.%f%z')
419+
self.assertEqual(result, (1900, 1, 1, 12, 34, 56, 0, 1, -1))
420+
self.assertIsNone(result.tm_zone)
421+
self.assertEqual(result.tm_gmtoff, offset)
422+
423+
@support.run_with_locale('LC_TIME', 'C')
424+
def test_strptime_default_format(self):
425+
self.assertEqual(time.strptime('Thu Feb 29 12:34:56 2024'),
426+
(2024, 2, 29, 12, 34, 56, 3, 60, -1))
427+
for args in ((), ('2024', '%Y', 'extra'), (None, '%Y')):
428+
with self.subTest(args=args):
429+
with self.assertRaises(TypeError):
430+
time.strptime(*args)
431+
374432
def test_strptime_bytes(self):
375433
# Make sure only strings are accepted as arguments to strptime.
376434
self.assertRaises(TypeError, time.strptime, b'2009', "%Y")

‎Makefile.pre.in‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,7 @@ PARSER_HEADERS= \
435435

436436
PYTHON_OBJS= \
437437
Python/_contextvars.o \
438+
Python/_strptime.o \
438439
Python/_warnings.o \
439440
Python/Python-ast.o \
440441
Python/Python-tokenize.o \
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Speed up :func:`time.strptime`, :meth:`datetime.date.strptime`,
2+
:meth:`datetime.time.strptime`, and :meth:`datetime.datetime.strptime` for
3+
common numeric formats by parsing fields and constructing the result in C.
4+
Other inputs continue to use the Python parser.

0 commit comments

Comments
 (0)