Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--

Expand Down
39 changes: 39 additions & 0 deletions Include/internal/pycore_time.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
193 changes: 193 additions & 0 deletions Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions Lib/test/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading