diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index f8fb9bf4d47e7b..e497d058cd07b2 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -989,7 +989,9 @@ 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``. + ``%Y-%m-%d %H:%M:%S``. The optimization also covers ASCII month and + weekday names, 12-hour clocks, UTC offsets, numeric format aliases, + and ordinal and week dates. (Contributed by Paul Ganssle in :gh:`158252`, based on work by Stan Ulbrych.) diff --git a/Include/internal/pycore_time.h b/Include/internal/pycore_time.h index 12940cdce8abbd..0ef6362831f8e6 100644 --- a/Include/internal/pycore_time.h +++ b/Include/internal/pycore_time.h @@ -340,7 +340,7 @@ _PyTime_Weekday(int year, int yday) } -// Locale-independent numeric strptime parsing. +// Shared strptime parsing, with fallback for unsupported inputs. // 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. @@ -348,7 +348,14 @@ 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. + int gmtoff_fraction; // Microseconds, with the same sign as gmtoff. + Py_ssize_t zone_start, zone_length; // Original spelling of a %Z name. + // January 1 is day 1. A %j spill retains the original day of year, + // which need not agree with the resulting calendar date. + int yday; + // Monday is 0; a supplied weekday need not agree with the date. + // If negative, the caller must compute it from year and yday if needed. + int weekday; } _PyTime_StrptimeFields; // Export for the '_datetime' shared extension. diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index d70837ea63f547..218c406297e0fa 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -1,6 +1,9 @@ """PyUnit testing against strptime""" import unittest +from unittest import mock +from contextlib import nullcontext +import datetime import time import locale import re @@ -8,11 +11,262 @@ import platform import sys from test import support -from test.support import skip_if_buggy_ucrt_strfptime, run_with_locales +from test.support import ( + skip_if_buggy_ucrt_strfptime, run_with_locales, warnings_helper, +) +from test.support.hypothesis_helper import hypothesis from datetime import date as datetime_date import _strptime + +_strptime_formats = hypothesis.strategies.lists( + hypothesis.strategies.sampled_from(( + '%Y', '%y', '%m', '%d', '%H', '%M', '%S', '%f', '%b', '%B', + '%a', '%A', '%w', '%u', '%I', '%p', '%k', '%l', '%P', + '%z', '%:z', '%Z', '%F', '%D', '%T', '%R', '%e', '%n', '%t', + '%j', '%U', '%W', '%G', '%V', '%%', + '-', '/', ':', '.', ' ', '\t', '\n', '\x1c', 'T', 'Z', '年', + )), max_size=8, +).map(''.join) + + +class PublicStrptimeTests(unittest.TestCase): + def assert_matches_python(self, text, fmt, *, accelerated=False): + methods = ( + (time.strptime, '_strptime_time', None), + (datetime.date.strptime, '_strptime_datetime_date', datetime.date), + (datetime.time.strptime, '_strptime_datetime_time', datetime.time), + (datetime.datetime.strptime, '_strptime_datetime_datetime', + datetime.datetime), + ) + for parse, fallback, cls in methods: + message = f'{parse.__qualname__}({text!r}, {fmt!r})' + reference = getattr(_strptime, fallback) + args = (text, fmt) if cls is None else (cls, text, fmt) + try: + expected = reference(*args) + except (ValueError, re.PatternError, OverflowError) as error: + self.assertFalse(accelerated, message) + with self.assertRaises(type(error), msg=message) as caught: + parse(text, fmt) + self.assertEqual(str(caught.exception), str(error), message) + else: + # Guard against accidentally falling back for every input. + # This mock can be removed if implementation changes make + # patching the fallback impractical; keep the comparisons. + guard = mock.patch.object( + _strptime, fallback, autospec=True, + side_effect=AssertionError('used the Python parser'), + ) if accelerated else nullcontext() + with guard: + actual = parse(text, fmt) + self.assertEqual(actual, expected, message) + if isinstance(actual, time.struct_time): + self.assertEqual(actual.tm_zone, expected.tm_zone, message) + self.assertEqual(actual.tm_gmtoff, expected.tm_gmtoff, + message) + elif isinstance(actual, (datetime.time, datetime.datetime)): + self.assertEqual(actual.tzname(), expected.tzname(), message) + + @support.cpython_only + @support.run_with_locale('LC_TIME', 'C') + def test_accelerated_formats(self): + for text, fmt in ( + ('2024-02-29 12:34:56.123456', '%Y-%m-%d %H:%M:%S.%f'), + ('Jan 02 2024', '%b %d %Y'), + ('Tuesday 2024 January 02', '%A %Y %B %d'), + ('Tue 2024-01-02', '%a %Y-%m-%d'), + ('12 PM', '%I %p'), ('12', '%I'), (' 1 pm', '%l %P'), + (' 1', '%k'), ('2024 2', '%Y %e'), + ('+01:02:03.456789', '%:z'), ('-000001.5', '%z'), + ('UTC', '%Z'), ('+0530 gMt', '%z %Z'), + ('2024-02-29 12:34:56', '%F %T'), + ('02/29/24 12:34', '%D %R'), + ('2024\t02\n29', '%Y%t%m%n%d'), + ('2023 366', '%Y %j'), + ('2024 00 0', '%Y %W %w'), + ('2024 01 1', '%Y %U %u'), + ('2020-W53-7', '%G-W%V-%u'), + ): + self.assert_matches_python(text, fmt, accelerated=True) + + # Missing-year formats deliberately exercise the deprecated behavior. + @support.run_with_locale('LC_TIME', 'C') + @warnings_helper.ignore_warnings(category=DeprecationWarning) + @hypothesis.given(value=hypothesis.strategies.datetimes(), + fmt=_strptime_formats) + @hypothesis.example(value=datetime.datetime(2024, 2, 29, 13, 24, 35), + fmt='%Y%m%d%H%M%S') + @hypothesis.example(value=datetime.datetime(2021, 1, 1), fmt='%G-W%V-%u') + @hypothesis.example(value=datetime.datetime(2024, 2, 29, 13, 24, 35), + fmt='%a %d %B %Y %I:%M:%S %p') + def test_strftime_output_matches_python(self, value, fmt): + try: + text = value.strftime(fmt) + except ValueError: + # Some strptime directives are unsupported by the platform's + # strftime. Arbitrary input tests still exercise their parsing. + hypothesis.reject() + self.assert_matches_python(text, fmt) + + @support.run_with_locale('LC_TIME', 'C') + @warnings_helper.ignore_warnings(category=DeprecationWarning) + @hypothesis.given(text=hypothesis.strategies.text(max_size=80), + fmt=_strptime_formats) + @hypothesis.example(text='2024\x1c 112', fmt='%Y\x1c%d%m') + @hypothesis.example(text='34\x1c 123', fmt='%M%n%H%S') + @hypothesis.example(text='2023 366', fmt='%Y %j') + @hypothesis.example(text='2024 01 02 1', fmt='%Y %U %W %u') + @hypothesis.example(text='+01:0203', fmt='%z') + @hypothesis.example(text='1 pM', fmt='%I %p') + def test_arbitrary_input_matches_python(self, text, fmt): + self.assert_matches_python(text, fmt) + + def test_inferred_weekday(self): + for text, fmt in ( + ('0001-01-01', '%Y-%m-%d'), + ('2024-02-29', '%Y-%m-%d'), + ('9999-12-31', '%Y-%m-%d'), + ('2023 366', '%Y %j'), + ('2024 00 0', '%Y %W %w'), + ): + self.assert_matches_python(text, fmt) + + @support.run_with_locale('LC_TIME', 'C') + def test_mixed_directive_spellings(self): + groups = ( + (('m', '01'), ('b', 'Feb'), ('B', 'March')), + (('d', '01'), ('e', ' 2')), + (('H', '13'), ('k', '14'), ('I', '03'), ('l', ' 4')), + (('w', '0'), ('u', '1'), ('a', 'Tue'), ('A', 'Wednesday')), + (('p', 'AM'), ('P', 'PM')), + (('z', '+0100'), (':z', '+02:00')), + (('U', '01'), ('W', '02')), + ) + for group in groups: + for first, first_text in group: + for second, second_text in group: + self.assert_matches_python( + f'2024 {first_text} {second_text}', + f'%Y %{first} %{second}') + + def test_week_and_ordinal_dates(self): + for year in (1, 4, 100, 400, 1900, 1999, 2000, 2004, 2020, 9999): + for day in (1, 28, 59, 60, 61, 365, 366, 367): + self.assert_matches_python(f'{year:04d} {day:03d}', '%Y %j') + for week in (0, 1, 52, 53, 54): + for weekday in range(1, 8): + for fmt in ('%Y %U %u', '%Y %W %u', '%G %V %u'): + self.assert_matches_python( + f'{year:04d} {week:02d} {weekday}', fmt) + for text, fmt in ( + ('2023-02-31 060', '%Y-%m-%d %j'), + ('2024-12-31 001', '%Y-%m-%d %j'), + ('2023 366 1', '%Y %j %u'), + ('2024 53', '%Y %U'), ('366', '%j'), + ('2024 001 01 1', '%Y %j %W %u'), + ('2024 2020 01 1', '%Y %G %V %u'), + ('2024 001 01 1', '%G %j %V %u'), + ('2024 01 1', '%Y %V %u'), ('2024 01', '%G %V'), + ('2024', '%G'), ('0000 001', '%Y %j'), + ('2024 01 02 1', '%Y %U %W %u'), + ): + self.assert_matches_python(text, fmt) + + def test_numeric_aliases(self): + for text, fmt in ( + ('2024-02-29', '%F'), ('02/29/24', '%D'), + ('2024-02-29 12:34:56', '%F %T'), + ('2024-02-29T12:34:56Z', '%FT%TZ'), + ('12:34', '%R'), ('12:34:56', '%R:%S'), + ('2024 9', '%Y %e'), ('2024\t02\n29', '%Y%t%m%n%d'), + ('20240229', '%Y%t%m%n%d'), + ('2024-02-29 2024', '%F %Y'), + ('12:34:56 56', '%T %S'), + ('2023-02-29', '%F'), ('12:60', '%R'), + ('2024-02-29 2024-02-29', '%F %F'), + ): + self.assert_matches_python(text, fmt) + + def test_whitespace_aliases_before_adjacent_fields(self): + for separator in ('\x1c', '\x1d', '\x1e', '\x1f'): + for whitespace in ('%n', '%t'): + for field in ('%d', '%e'): + self.assert_matches_python(f'2024{separator} 112', + f'%Y{whitespace}{field}%m') + for field in ('%H', '%k', '%I', '%l'): + self.assert_matches_python(f'34{separator} 123', + f'%M{whitespace}{field}%S') + + def test_offsets_and_zone_names(self): + offsets = ('', 'Z', '+0530', '-03:30', '+01:02:03', '-000001.000001', + '+01:02:03.456789', '+000000.5', '-00:00:00.5', + '+2400', '+0060', '+01:0203', '+0102:03', '+01:02:99', + '+01:02:03.', '+01:02:03.1234567', 'z', '+', '+000') + for offset in offsets: + for directive in ('%z', '%:z'): + for suffix, fmt_suffix in (('', ''), (' UTC', ' %Z'), + (' gMt', ' %Z'), (' 12:34', ' %H:%M')): + self.assert_matches_python(offset + suffix, + directive + fmt_suffix) + for name in ('UTC', 'GMT', 'uTc', 'gmT', 'UTCx', 'LOCAL', ''): + self.assert_matches_python(name, '%Z') + self.assert_matches_python('+0100 +0200', '%z %:z') + + @run_with_locales('LC_TIME', 'C', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '') + def test_twelve_hour_clock(self): + for hour in range(24): + value = datetime.datetime(2024, 1, 1, hour, 24, 35) + for fmt in ('%Y-%m-%d %I:%M:%S %p', '%p %I:%M:%S', '%I', '%p'): + self.assert_matches_python(value.strftime(fmt), fmt) + + @support.run_with_locale('LC_TIME', 'C') + def test_hour_aliases_and_precedence(self): + for text, fmt in ( + ('12 AM', '%I %p'), ('12 PM', '%I %p'), + (' 1 pm', '%l %P'), (' 0', '%k'), + ('PM 12', '%p %I'), ('23 AM', '%H %p'), + ('12 13 PM', '%I %H %p'), ('13 12 PM', '%H %I %p'), + ('00 AM', '%I %p'), ('13 PM', '%I %p'), + ('1 pM', '%I %p'), ('1 PX', '%I %p'), + ('AM PM', '%p %P'), + ): + self.assert_matches_python(text, fmt) + + @run_with_locales('LC_TIME', 'C', 'en_US', 'fr_FR', 'de_DE', 'ja_JP', '') + def test_locale_names(self): + for month in range(1, 13): + value = datetime.datetime(2024, month, 19, 13, 24, 35) + for fmt in ('%b %d %Y', '%B %d %Y', '%a %Y-%m-%d', '%A %Y-%m-%d'): + self.assert_matches_python(value.strftime(fmt), fmt) + + @support.run_with_locale('LC_TIME', 'C') + def test_names_and_weekdays(self): + for month in range(1, 13): + value = datetime.datetime(2024, month, 19, 13, 24, 35) + for fmt in ('%b %d, %Y', '%d %B %Y', + '%a, %d %b %Y %H:%M:%S GMT', '%A %Y-%m-%d'): + text = value.strftime(fmt) + for spelling in (text, text.lower(), text.upper()): + self.assert_matches_python(spelling, fmt) + for directive in ('a', 'A', 'w', 'u'): + for day in range(1, 8): + # A supplied weekday is preserved even when it disagrees. + text = datetime.date(2024, 1, day).strftime('%' + directive) + self.assert_matches_python(text + ' 2024-02-29', + '%' + directive + ' %Y-%m-%d') + for text, fmt in ( + ('2024 January 02 03', '%Y %B %m %d'), + ('2024 Jan January 03', '%Y %b %B %d'), + ('2024 Monday Tue', '%Y %A %a'), + ('2024 Jax 03', '%Y %b %d'), + ('2024 Feb 30', '%Y %b %d'), + ('Monday Monday', '%A %A'), + ('8', '%u'), ('7', '%w'), + ): + self.assert_matches_python(text, fmt) + libc_ver = platform.libc_ver() if libc_ver[0] == 'glibc': glibc_ver = tuple(map(int, libc_ver[1].split('.'))) diff --git a/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst b/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst new file mode 100644 index 00000000000000..0d42e3331258d9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-20-16-00-00.gh-issue-158252.weeks.rst @@ -0,0 +1,4 @@ +Speed up parsing ASCII month and weekday names, 12-hour clocks, UTC offsets, +numeric format aliases, and ordinal and week dates with :func:`time.strptime`, +:meth:`datetime.date.strptime`, :meth:`datetime.time.strptime`, and +:meth:`datetime.datetime.strptime`. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 71057ad704d012..89f7d91508bc50 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1684,6 +1684,30 @@ tzinfo_from_isoformat_results(int rv, int tzoffset, int tz_useconds) * String format helpers. */ +static PyObject * +tzinfo_from_strptime_results(const _PyTime_StrptimeFields *fields, + PyObject *string) +{ + /* As in _strptime.py, a name without a numeric offset leaves the result + * naive. Only pass the name to the timezone constructor when %z is present. + */ + if (!fields->zone_length || fields->gmtoff == INT_MIN) { + return tzinfo_from_isoformat_results(fields->gmtoff != INT_MIN, + fields->gmtoff, + fields->gmtoff_fraction); + } + PyObject *name = PyUnicode_Substring(string, fields->zone_start, + fields->zone_start + fields->zone_length); + if (name == NULL) { + return NULL; + } + PyObject *delta = new_delta(0, fields->gmtoff, fields->gmtoff_fraction, 1); + PyObject *tzinfo = delta == NULL ? NULL : new_timezone(delta, name); + Py_XDECREF(delta); + Py_DECREF(name); + return tzinfo; +} + static PyObject * format_ctime(PyObject *date, int hours, int minutes, int seconds) { @@ -4804,8 +4828,7 @@ datetime_time_strptime_impl(PyTypeObject *type, PyObject *string, _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); + PyObject *tzinfo = tzinfo_from_strptime_results(&fields, string); if (tzinfo == NULL) { return NULL; } @@ -5899,8 +5922,7 @@ datetime_datetime_strptime_impl(PyTypeObject *type, PyObject *string, _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); + PyObject *tzinfo = tzinfo_from_strptime_results(&fields, string); if (tzinfo == NULL) { return NULL; } diff --git a/Modules/timemodule.c b/Modules/timemodule.c index f3bfef333b383b..d49fc7ad9f62ba 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1016,10 +1016,17 @@ time_strptime(PyObject *self, PyObject *args) if (result == NULL) { return NULL; } + int weekday = fields.weekday; + if (weekday < 0) { + weekday = _PyTime_Weekday(fields.year, fields.yday); + } + /* Only UTC and GMT names are parsed here; neither observes DST. + * Numeric offsets don't supply DST information. + */ const int values[] = { fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, - _PyTime_Weekday(fields.year, fields.yday), fields.yday, -1 + weekday, fields.yday, fields.zone_length ? 0 : -1 }; for (size_t i = 0; i < Py_ARRAY_LENGTH(values); i++) { PyObject *value = PyLong_FromLong(values[i]); @@ -1029,8 +1036,15 @@ time_strptime(PyObject *self, PyObject *args) } 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 *zone = fields.zone_length + ? PyUnicode_Substring(PyTuple_GET_ITEM(args, 0), fields.zone_start, + fields.zone_start + fields.zone_length) + : Py_NewRef(Py_None); + if (zone == NULL) { + Py_DECREF(result); + return NULL; + } + PyStructSequence_SET_ITEM(result, 9, zone); PyObject *offset = fields.gmtoff == INT_MIN ? Py_NewRef(Py_None) : PyLong_FromLong(fields.gmtoff); diff --git a/Python/_strptime.c b/Python/_strptime.c index 42f546f9714f86..5ae5f90aef1477 100644 --- a/Python/_strptime.c +++ b/Python/_strptime.c @@ -1,17 +1,22 @@ #include "Python.h" #include "pycore_time.h" +#include + /* 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; + * - ASCII locale month/weekday names and numeric %w/%u weekdays; + * - %I with ASCII %p indicators, and the %k, %l, %P aliases; + * - %z and %:z offsets with hours below 24, and terminal UTC/GMT %Z names; + * - numeric aliases %F, %D, %T, %R, %e and whitespace %n/%t; + * - ordinal dates (%j), calendar weeks (%U/%W), and ISO weeks (%G/%V); * - 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. + * fallback. So do non-ASCII locale names and directives not listed above. */ static int @@ -31,37 +36,209 @@ strptime_digits(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, return count >= minimum ? count : 0; } +/* Return the normalized ASCII name's length, or 0 if fallback is needed. */ +static inline size_t +strptime_locale_name(unsigned char directive, int index, + char *name, size_t capacity) +{ +#ifdef HAVE_STRFTIME + /* Read the current locale without keeping a cache across setlocale(). + * Non-ASCII names retain Python's decoding and Unicode case handling. + */ + int month = directive == 'b' || directive == 'B'; + char format[] = {'%', directive, '\0'}; + /* Match calendar's localized names and LocaleTime.__calc_am_pm: + * 2001 starts on a Monday, and the AM/PM sample hours are 1 and 22. + */ + struct tm tm = {.tm_year = 101, .tm_mday = 1, + .tm_mon = month ? index : 0, + .tm_wday = month ? 0 : (index + 1) % 7, + .tm_hour = index == 0 ? 1 : 22}; + size_t size = strftime(name, capacity, format, &tm); + for (size_t j = 0; j < size; j++) { + if ((unsigned char)name[j] >= 128) { + return 0; + } + } + if (directive == 'p') { + while (size && Py_UNICODE_ISSPACE(name[size - 1])) { + size--; + } + size_t start = 0; + while (start < size && Py_UNICODE_ISSPACE(name[start])) { + start++; + } + size -= start; + if (start) { + memmove(name, name + start, size); + } + /* Empty indicators have special defaulting rules in Python. */ + } + return size; +#else + return 0; +#endif +} + +static int +strptime_ampm_supported(void) +{ + char name[256]; + return strptime_locale_name('p', 0, name, sizeof(name)) != 0 && + strptime_locale_name('p', 1, name, sizeof(name)) != 0; +} + +static int +strptime_name(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, + unsigned char directive, int *value) +{ + /* Prefer the longest match, then the first equal name, as in TimeRE. + * For %p, the last equal name wins: _strptime checks for PM first. + */ + int month = directive == 'b' || directive == 'B'; + int count = month ? 12 : directive == 'p' ? 2 : 7; + int matched = 0; + for (int i = 0; i < count; i++) { + char name[256]; + size_t size = strptime_locale_name(directive, i, name, sizeof(name)); + if (size == 0) { + return 0; + } + if (size < (size_t)matched || + (size == (size_t)matched && directive != 'p') || + size > (size_t)(length - pos)) { + continue; + } + size_t j; + for (j = 0; j < size; j++) { + if (Py_TOLOWER(data[pos + j]) != Py_TOLOWER(name[j])) { + break; + } + } + if (j == size) { + matched = (int)size; + *value = month ? i + 1 : i; + } + } + return matched; +} + +static const int days_before_month[] = { + 0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 +}; + +static int +strptime_is_leap(int year) +{ + return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); +} + +static int +strptime_date_from_ordinal(int ordinal, _PyTime_StrptimeFields *fields) +{ + if (ordinal < 1 || ordinal > _PyTime_DaysBeforeYear(10000)) { + return 0; + } + int lower = 1, upper = 10000; + while (lower + 1 < upper) { + int middle = (lower + upper) / 2; + if (_PyTime_DaysBeforeYear(middle) < ordinal) { + lower = middle; + } + else { + upper = middle; + } + } + fields->year = lower; + fields->yday = ordinal - _PyTime_DaysBeforeYear(lower); + fields->day = fields->yday; + int leap = strptime_is_leap(lower); + for (fields->month = 1; fields->month < 12; fields->month++) { + int days = days_before_month[fields->month + 1] - + days_before_month[fields->month] + + (fields->month == 2 && leap); + if (fields->day <= days) { + break; + } + fields->day -= days; + } + if (fields->weekday < 0) { + /* A %j spill into the next year later restores the original yday. + * Compute the weekday now, while the ordinal still identifies it. + */ + fields->weekday = (ordinal - 1) % 7; + } + return 1; +} + +static int +strptime_iso_start(int year) +{ + /* ISO week 1 contains January 4. */ + return _PyTime_DaysBeforeYear(year) + 4 - _PyTime_Weekday(year, 4); +} + +/* Return the number of characters consumed (0 for an empty offset), + * or -1 if the Python parser is needed. + */ static int strptime_offset(const unsigned char *data, Py_ssize_t length, Py_ssize_t pos, - _PyTime_StrptimeFields *fields) + int require_colon, _PyTime_StrptimeFields *fields) { - Py_ssize_t remaining = length - pos; - if (remaining == 0) { - return 1; + Py_ssize_t start = pos; + if (pos == length) { + return 0; } - if (remaining == 1 && data[pos] == 'Z') { + if (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] != '-')) { + if (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; + int sign = data[pos++] == '-' ? -1 : 1; + int hours, minutes, seconds = 0, fraction = 0; + if (strptime_digits(data, length, pos, 2, 2, &hours) != 2 || hours > 23) { + return -1; } - fields->gmtoff = (hours * 3600 + minutes * 60) * - (data[pos] == '-' ? -1 : 1); - return 1; + pos += 2; + int colon = pos < length && data[pos] == ':'; + if (require_colon && !colon) { + return -1; + } + pos += colon; + if (strptime_digits(data, length, pos, 2, 2, &minutes) != 2 || minutes > 59) { + return -1; + } + pos += 2; + int seconds_colon = pos < length && data[pos] == ':'; + Py_ssize_t seconds_pos = pos + seconds_colon; + if (strptime_digits(data, length, seconds_pos, 2, 2, &seconds) == 2) { + if (seconds > 59 || seconds_colon != colon) { + return -1; + } + pos = seconds_pos + 2; + if (pos < length && data[pos] == '.') { + int count = strptime_digits(data, length, pos + 1, 1, 6, &fraction); + if (count == 0) { + return -1; + } + pos += count + 1; + for (; count < 6; count++) { + fraction *= 10; + } + } + } + else { + /* strptime_digits may have stored one digit before failing. */ + seconds = 0; + } + fields->gmtoff = sign * (hours * 3600 + minutes * 60 + seconds); + fields->gmtoff_fraction = sign * fraction; + return (int)(pos - start); } -/* Return 1 for a complete numeric match, or 0 if fallback is required. +/* Return 1 for a complete 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: @@ -83,6 +260,13 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie SECOND = 1U << 6, FRACTION = 1U << 7, OFFSET = 1U << 8, + WEEKDAY = 1U << 9, + AMPM = 1U << 10, + ZONE = 1U << 11, + JULIAN = 1U << 12, + WEEK = 1U << 13, + ISO_YEAR = 1U << 14, + ISO_WEEK = 1U << 15, ANY_YEAR = YEAR | SHORT_YEAR, }; static const unsigned short directive_bits[128] = { @@ -95,6 +279,26 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie ['S'] = SECOND, ['f'] = FRACTION, ['z'] = OFFSET, + ['w'] = WEEKDAY, + ['p'] = AMPM, + ['Z'] = ZONE, + ['j'] = JULIAN, + ['U'] = WEEK, + ['G'] = ISO_YEAR, + ['V'] = ISO_WEEK, + /* Equivalent directives share the duplicate-field checks. */ + ['b'] = MONTH, + ['B'] = MONTH, + ['e'] = DAY, + ['k'] = HOUR, + ['I'] = HOUR, + ['l'] = HOUR, + ['a'] = WEEKDAY, + ['A'] = WEEKDAY, + ['u'] = WEEKDAY, + ['P'] = AMPM, + [':'] = OFFSET, + ['W'] = WEEK, }; /* String subclasses and non-ASCII input retain Python's behavior. */ if (!PyUnicode_CheckExact(string) || !PyUnicode_CheckExact(format) || @@ -103,13 +307,33 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } const unsigned char *data = PyUnicode_1BYTE_DATA(string); const unsigned char *fmt = PyUnicode_1BYTE_DATA(format); + const unsigned char *outer_fmt = NULL; + Py_ssize_t outer_length = 0, outer_pos = 0; 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}; + *fields = (_PyTime_StrptimeFields){.year = 1900, .month = 1, .day = 1, + .gmtoff = INT_MIN, .weekday = -1}; unsigned int seen = 0; + int hour12 = 0; + int ampm = -1; + int julian = -1, week = -1, monday_first = 0; + int have_julian = 0; + int iso_year = -1, iso_week = -1; Py_ssize_t pos = 0; - for (Py_ssize_t i = 0; i < fmt_length; i++) { + for (Py_ssize_t i = 0; ; i++) { + if (i == fmt_length) { + if (outer_fmt == NULL) { + break; + } + fmt = outer_fmt; + fmt_length = outer_length; + i = outer_pos; + outer_fmt = NULL; + if (i == fmt_length) { + break; + } + } unsigned char c = fmt[i]; /* Match regex \s, including ASCII separators U+001C through U+001F. */ if (Py_UNICODE_ISSPACE(c)) { @@ -134,46 +358,140 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie 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; - } - + /* Dispatch once: numeric fields don't need to test for names, + * aliases or timezone spellings before parsing their digits. + */ 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; + switch (c) { + case 'Y': + case 'G': + minimum = maximum = 4; + break; + case 'y': + minimum = maximum = 2; + break; + case 'f': + maximum = 6; + break; + case 'j': + maximum = 3; + break; + case 'w': + case 'u': + maximum = 1; + break; + case 'd': + case 'e': + case 'H': + case 'k': + case 'I': + case 'l': + if (pos < length && data[pos] == ' ') { + pos++; + maximum = 1; + } + break; + case 'm': + case 'M': + case 'S': + case 'U': + case 'W': + case 'V': + break; + case 'b': + case 'B': + case 'a': + case 'A': + case 'p': + case 'P': { + int value; + int count = strptime_name(data, length, pos, + c == 'P' ? 'p' : c, &value); + if (count == 0) { + return 0; + } + pos += count; + if (bit == MONTH) { + fields->month = value; + } + else if (bit == AMPM) { + ampm = value; + } + else { + fields->weekday = value; + } + continue; + } + case 'Z': + /* Local timezone names and nonterminal names retain Python's + * tzset state, DST rules and regex backtracking. + */ + if (i != fmt_length - 1 || length - pos != 3 || + (PyOS_strnicmp((const char *)data + pos, "utc", 3) != 0 && + PyOS_strnicmp((const char *)data + pos, "gmt", 3) != 0)) { + return 0; + } + fields->zone_start = pos; + fields->zone_length = 3; + pos = length; + continue; + case ':': + if (++i == fmt_length || fmt[i] != 'z') { + return 0; + } + _Py_FALLTHROUGH; + case 'z': { + int count = strptime_offset(data, length, pos, c == ':', fields); + if (count < 0) { + return 0; + } + pos += count; + continue; + } + case 'F': + case 'D': + case 'T': + case 'R': { + const char *alias; + switch (c) { + case 'F': alias = "%Y-%m-%d"; break; + case 'D': alias = "%m/%d/%y"; break; + case 'T': alias = "%H:%M:%S"; break; + default: alias = "%H:%M"; break; + } + /* These fixed expansions contain no further aliases. Parsing + * them in place keeps duplicate-field checks and avoids copying + * an arbitrarily long user format into a temporary buffer. + */ + assert(outer_fmt == NULL); + outer_fmt = fmt; + outer_length = fmt_length; + outer_pos = i + 1; + fmt = (const unsigned char *)alias; + fmt_length = strlen(alias); + i = -1; + continue; + } + case 'n': + case 't': + while (pos < length && Py_UNICODE_ISSPACE(data[pos])) { + pos++; + } + continue; + case '%': + if (pos == length || data[pos++] != '%') { + return 0; + } + continue; + default: + return 0; } int value; int count = strptime_digits(data, length, pos, minimum, maximum, &value); @@ -182,6 +500,50 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie } pos += count; switch (c) { + case 'j': + if (value < 1 || value > 366) { + return 0; + } + julian = value; + have_julian = 1; + break; + case 'U': + case 'W': + if (value > 53) { + return 0; + } + week = value; + monday_first = c == 'W'; + break; + case 'G': + iso_year = value; + break; + case 'V': + if (value < 1 || value > 53) { + return 0; + } + iso_week = value; + break; + case 'I': + case 'l': + if (value < 1 || value > 12) { + return 0; + } + hour12 = 1; + fields->hour = value; + break; + case 'w': + if (value > 6) { + return 0; + } + fields->weekday = (value + 6) % 7; + break; + case 'u': + if (value < 1 || value > 7) { + return 0; + } + fields->weekday = value - 1; + break; case 'Y': fields->year = value; break; @@ -195,12 +557,14 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie fields->month = value; break; case 'd': + case 'e': if (value < 1 || value > 31) { return 0; } fields->day = value; break; case 'H': + case 'k': if (value > 23) { return 0; } @@ -226,24 +590,71 @@ _PyTime_Strptime(PyObject *string, PyObject *format, _PyTime_StrptimeFields *fie 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); + if (hour12) { + if (ampm < 0 && !strptime_ampm_supported()) { + return 0; + } + fields->hour = fields->hour % 12 + (ampm == 1 ? 12 : 0); + } + /* Mixed fields and incomplete ISO dates retain Python's diagnostics. */ + if (pos != length || (seen & ANY_YEAR) == ANY_YEAR || + ((seen & DAY) && !(seen & ANY_YEAR) && iso_year < 0)) { + return 0; + } + if (iso_year >= 0 || iso_week >= 0) { + if (iso_year < 1 || iso_week < 1 || fields->weekday < 0 || + julian >= 0 || week >= 0) { + return 0; + } + int monday = strptime_iso_start(iso_year) + 7 * (iso_week - 1); + if (monday >= strptime_iso_start(iso_year + 1) || + !strptime_date_from_ordinal(monday + fields->weekday, fields)) { + return 0; + } + } + if (fields->year < 1) { + return 0; + } + if (julian < 0 && week >= 0 && fields->weekday >= 0) { + int first = _PyTime_Weekday(fields->year, 1); + int weekday = fields->weekday; + if (!monday_first) { + first = (first + 1) % 7; + weekday = (weekday + 1) % 7; + } + julian = week == 0 ? 1 + weekday - first + : 1 + (7 - first) % 7 + 7 * (week - 1) + weekday; + have_julian = 1; + } + if (have_julian) { + /* Week zero can fall in the previous year. A day spilling into the + * next year retains its original yday in time.strptime's result. + */ + int year = fields->year; + if (julian <= 0) { + if (year == 1) { + return 0; + } + year--; + julian += _PyTime_DaysBeforeYear(year + 1) - _PyTime_DaysBeforeYear(year); + } + if (!strptime_date_from_ordinal(_PyTime_DaysBeforeYear(year) + julian, fields)) { + return 0; + } + fields->yday = julian; + } + int leap = strptime_is_leap(fields->year); 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) { + if (fields->day > month_days) { return 0; } - fields->yday = days_before_month[fields->month] + fields->day + - (fields->month > 2 && leap); + if (!have_julian) { + fields->yday = days_before_month[fields->month] + fields->day + + (fields->month > 2 && leap); + } return 1; }