Skip to content

gh-158252: Extend C strptime parsing to more formats - #160357

Draft
pganssle wants to merge 8 commits into
python:mainfrom
pganssle:c-strptime-more-formats
Draft

pganssle wants to merge 8 commits into
python:mainfrom
pganssle:c-strptime-more-formats

Conversation

@pganssle

@pganssle pganssle commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Follows #158253, now merged.

This extends the shared C parser used by datetime.datetime.strptime, datetime.date.strptime, datetime.time.strptime, and time.strptime.

The format groups are separate commits, ordered by the usage survey:

  1. Month and weekday names: %b, %B, %a, %A, plus numeric weekdays %w and %u.
  2. 12-hour clocks: %I and %p, plus %k, %l, and %P.
  3. Timezones: nonterminal %z, offset seconds and fractions, %:z, and terminal UTC/GMT names with %Z.
  4. Numeric aliases: %F, %D, %T, %R, %e, %n, and %t.
  5. Ordinal and week dates: %j, %U, %W, %G, and %V.

Locale names are read on each call, so there is no cache to invalidate after setlocale(). Explicit weekdays and timezone names are preserved in time.strptime results.

Non-ASCII input and name sets, empty AM/PM indicators, locale composites such as %c, local timezone names, and nonterminal %Z retain the Python parser. Parser mismatches, mixed spellings of a field, and matches requiring backtracking also fall back. The one-argument form of time.strptime is unchanged.

After rebasing onto upstream main, test_strptime, test_time, and test_datetime pass, along with 3,840 benchmark correctness checks. Before this rebase, 96,000 comparisons against the Python parser across C, French, German, and Japanese locales passed, as did the documentation build. Free-threaded debug tests and repeated leak checks passed before the earlier rebase.

The benchmarks below were collected before the rebase, comparing 510c6226ec6 with 071bc0bd031. They have not been rerun against the current base and head. Both builds used GCC 16.2.1 and CFLAGS="-O3 -g -fno-omit-frame-pointer", without PGO or LTO. Each case uses the same 32 inputs for all four APIs. Two runs per revision alternate before/after/after/before, with three worker processes and three measured values per run. The numeric-date and Unicode-fallback rows use longer repeats with six workers, five values, and a 50 ms minimum sample time, after the initial measurements showed substantial variation. Most rows are still short runs; small differences need more measurement.

These earlier measurements show speedups for the added formats and overhead for some existing numeric formats. The table includes two of those slower cases; the complete results are below. — means the difference was not statistically significant, and ‡ marks cases that still use the Python parser.

Selected datetime.datetime.strptime results:

Format Before After Speedup
%Y-%m-%d 75.4 ns 84.0 ns 0.90×
%Y-%m-%d %H:%M:%S 81.3 ns 95.1 ns 0.85×
%b %d, %Y 2.40 µs 354.6 ns 6.78×
%d %B %Y %H:%M:%S 2.68 µs 277.4 ns 9.65×
%a, %d %b %Y %H:%M:%S GMT 2.92 µs 490.9 ns 5.96×
%Y-%m-%d %I:%M:%S %p 2.93 µs 144.4 ns 20.26×
%Y-%m-%d %H:%M:%S %Z (UTC) 2.97 µs 100.6 ns 29.52×
%Y-%m-%dT%H:%M:%S%z (+053012.345678) 3.77 µs 124.7 ns 30.22×
%Y-%m-%d %z %H:%M:%S (+0530) 3.81 µs 124.2 ns 30.65×
%F 2.02 µs 90.7 ns 22.28×
%F %T 2.53 µs 104.7 ns 24.12×
%Y-%j 2.09 µs 104.4 ns 20.03×
%G-W%V-%u 2.36 µs 111.6 ns 21.11×
%Y-%W-%w 2.46 µs 108.6 ns 22.66×
Complete benchmarks for all four entry points

datetime.datetime.strptime

Format Before After Speedup
%Y-%m-%d 75.4 ns 84.0 ns 0.90×
%Y%m%d 68.5 ns 79.4 ns 0.86×
%Y/%m/%d 72.7 ns 84.5 ns 0.86×
%m/%d/%Y 76.7 ns 81.1 ns —
%d/%m/%Y 70.8 ns 81.6 ns 0.87×
%Y-%m-%d %H:%M:%S 81.3 ns 95.1 ns 0.85×
%Y-%m-%d %H:%M:%S.%f 87.3 ns 101.1 ns 0.86×
%Y%m%d%H%M%S 77.6 ns 89.4 ns 0.87×
%Y-%m-%dT%H:%M:%S%z (UTC) 85.4 ns 101.7 ns 0.84×
%Y-%m-%dT%H:%M:%SZ 80.9 ns 104.0 ns 0.78×
%Y-%m-%dT%H:%M:%S.%fZ 84.3 ns 99.6 ns 0.85×
%Y-%m-%dT%H:%M:%S.%f%z (+0530) 112.1 ns 127.4 ns 0.88×
%H:%M:%S 70.1 ns 78.0 ns 0.90×
%H:%M:%S.%f%z (+0530) 96.1 ns 111.9 ns 0.86×
%d %B %Y %H:%M:%S 2.68 µs 277.4 ns 9.65×
%b %d, %Y 2.40 µs 354.6 ns 6.78×
%a, %d %b %Y %H:%M:%S GMT 2.92 µs 490.9 ns 5.96×
%Y-%m-%d %I:%M:%S %p 2.93 µs 144.4 ns 20.26×
%Y-%m-%dT%H:%M:%S%z (+053012.345678) 3.77 µs 124.7 ns 30.22×
%Y-%m-%d %z %H:%M:%S (+0530) 3.81 µs 124.2 ns 30.65×
%Y-%m-%d %H:%M:%S %Z (UTC) 2.97 µs 100.6 ns 29.52×
%F 2.02 µs 90.7 ns 22.28×
%F %T 2.53 µs 104.7 ns 24.12×
%Y-%j 2.09 µs 104.4 ns 20.03×
%G-W%V-%u 2.36 µs 111.6 ns 21.11×
%Y-%W-%w 2.46 µs 108.6 ns 22.66×
%Y-%m-%d %H:%M:%S + rotating suffix 83.4 ns 100.3 ns 0.83×
%d %B %Y %H:%M:%S + rotating suffix 6.86 µs 280.9 ns 24.43×
%c ‡ 3.02 µs 3.13 µs —
%Y年%m月%d日 ‡ 2.11 µs 2.12 µs —

datetime.date.strptime

Format Before After Speedup
%Y-%m-%d 70.3 ns 83.9 ns 0.84×
%Y%m%d 74.7 ns 80.0 ns —
%Y/%m/%d 68.9 ns 77.8 ns 0.89×
%m/%d/%Y 78.0 ns 77.6 ns —
%d/%m/%Y 68.2 ns 84.5 ns 0.81×
%Y-%m-%d %H:%M:%S 84.3 ns 92.8 ns 0.91×
%Y-%m-%d %H:%M:%S.%f 87.2 ns 99.3 ns 0.88×
%Y%m%d%H%M%S 80.5 ns 86.9 ns 0.93×
%Y-%m-%dT%H:%M:%S%z (UTC) 88.7 ns 101.6 ns 0.87×
%Y-%m-%dT%H:%M:%SZ 85.3 ns 95.2 ns 0.90×
%Y-%m-%dT%H:%M:%S.%fZ 94.3 ns 100.1 ns —
%Y-%m-%dT%H:%M:%S.%f%z (+0530) 103.1 ns 101.8 ns —
%H:%M:%S 70.2 ns 79.8 ns 0.88×
%H:%M:%S.%f%z (+0530) 74.0 ns 86.8 ns 0.85×
%d %B %Y %H:%M:%S 2.60 µs 279.2 ns 9.32×
%b %d, %Y 2.15 µs 361.0 ns 5.95×
%a, %d %b %Y %H:%M:%S GMT 3.07 µs 470.0 ns 6.52×
%Y-%m-%d %I:%M:%S %p 2.85 µs 166.1 ns 17.16×
%Y-%m-%dT%H:%M:%S%z (+053012.345678) 3.21 µs 111.0 ns 28.96×
%Y-%m-%d %z %H:%M:%S (+0530) 3.12 µs 105.6 ns 29.57×
%Y-%m-%d %H:%M:%S %Z (UTC) 2.92 µs 96.9 ns 30.16×
%F 1.95 µs 82.1 ns 23.74×
%F %T 2.55 µs 102.1 ns 25.00×
%Y-%j 2.13 µs 105.0 ns 20.28×
%G-W%V-%u 2.28 µs 111.1 ns 20.51×
%Y-%W-%w 2.48 µs 112.2 ns 22.08×
%Y-%m-%d %H:%M:%S + rotating suffix 84.8 ns 100.1 ns 0.85×
%d %B %Y %H:%M:%S + rotating suffix 6.26 µs 313.0 ns 20.01×
%c ‡ 2.86 µs 3.02 µs 0.95×
%Y年%m月%d日 ‡ 2.16 µs 2.07 µs 1.04×

datetime.time.strptime

Format Before After Speedup
%Y-%m-%d 70.7 ns 82.0 ns 0.86×
%Y%m%d 67.4 ns 105.8 ns 0.64×
%Y/%m/%d 67.2 ns 89.9 ns 0.75×
%m/%d/%Y 70.9 ns 87.9 ns 0.81×
%d/%m/%Y 74.8 ns 101.3 ns 0.74×
%Y-%m-%d %H:%M:%S 89.5 ns 118.9 ns 0.75×
%Y-%m-%d %H:%M:%S.%f 97.7 ns 123.9 ns 0.79×
%Y%m%d%H%M%S 92.6 ns 99.4 ns —
%Y-%m-%dT%H:%M:%S%z (UTC) 97.8 ns 102.4 ns —
%Y-%m-%dT%H:%M:%SZ 85.3 ns 94.8 ns 0.90×
%Y-%m-%dT%H:%M:%S.%fZ 93.9 ns 97.1 ns —
%Y-%m-%dT%H:%M:%S.%f%z (+0530) 109.4 ns 123.7 ns 0.88×
%H:%M:%S 65.4 ns 80.3 ns 0.82×
%H:%M:%S.%f%z (+0530) 99.5 ns 108.2 ns 0.92×
%d %B %Y %H:%M:%S 2.68 µs 254.1 ns 10.56×
%b %d, %Y 2.20 µs 356.8 ns 6.17×
%a, %d %b %Y %H:%M:%S GMT 3.47 µs 460.3 ns 7.55×
%Y-%m-%d %I:%M:%S %p 2.91 µs 150.5 ns 19.30×
%Y-%m-%dT%H:%M:%S%z (+053012.345678) 3.79 µs 123.3 ns 30.78×
%Y-%m-%d %z %H:%M:%S (+0530) 3.71 µs 125.2 ns 29.64×
%Y-%m-%d %H:%M:%S %Z (UTC) 3.33 µs 99.1 ns 33.60×
%F 2.13 µs 82.1 ns 25.89×
%F %T 2.53 µs 100.9 ns 25.07×
%Y-%j 2.13 µs 103.4 ns 20.61×
%G-W%V-%u 2.37 µs 111.4 ns 21.24×
%Y-%W-%w 2.47 µs 106.1 ns 23.31×
%Y-%m-%d %H:%M:%S + rotating suffix 86.2 ns 97.5 ns 0.88×
%d %B %Y %H:%M:%S + rotating suffix 6.53 µs 264.1 ns 24.71×
%c ‡ 2.95 µs 2.92 µs —
%Y年%m月%d日 ‡ 2.13 µs 2.21 µs 0.96×

time.strptime

Format Before After Speedup
%Y-%m-%d 105.3 ns 112.6 ns 0.94×
%Y%m%d 105.5 ns 100.7 ns 1.05×
%Y/%m/%d 110.1 ns 105.8 ns 1.04×
%m/%d/%Y 117.0 ns 125.0 ns —
%d/%m/%Y 113.5 ns 106.7 ns —
%Y-%m-%d %H:%M:%S 120.1 ns 119.1 ns —
%Y-%m-%d %H:%M:%S.%f 126.3 ns 126.8 ns —
%Y%m%d%H%M%S 107.8 ns 114.9 ns 0.94×
%Y-%m-%dT%H:%M:%S%z (UTC) 114.7 ns 143.1 ns 0.80×
%Y-%m-%dT%H:%M:%SZ 109.7 ns 122.8 ns 0.89×
%Y-%m-%dT%H:%M:%S.%fZ 118.1 ns 131.1 ns 0.90×
%Y-%m-%dT%H:%M:%S.%f%z (+0530) 122.1 ns 136.3 ns 0.90×
%H:%M:%S 89.3 ns 115.5 ns 0.77×
%H:%M:%S.%f%z (+0530) 105.7 ns 118.5 ns 0.89×
%d %B %Y %H:%M:%S 2.88 µs 267.7 ns 10.76×
%b %d, %Y 2.26 µs 346.9 ns 6.52×
%a, %d %b %Y %H:%M:%S GMT 3.02 µs 436.2 ns 6.93×
%Y-%m-%d %I:%M:%S %p 3.12 µs 159.4 ns 19.59×
%Y-%m-%dT%H:%M:%S%z (+053012.345678) 3.68 µs 133.8 ns 27.54×
%Y-%m-%d %z %H:%M:%S (+0530) 3.30 µs 128.9 ns 25.59×
%Y-%m-%d %H:%M:%S %Z (UTC) 3.10 µs 133.8 ns 23.18×
%F 2.31 µs 110.0 ns 20.99×
%F %T 2.64 µs 128.6 ns 20.52×
%Y-%j 2.20 µs 130.5 ns 16.83×
%G-W%V-%u 2.45 µs 138.4 ns 17.68×
%Y-%W-%w 2.48 µs 136.1 ns 18.20×
%Y-%m-%d %H:%M:%S + rotating suffix 115.9 ns 127.7 ns 0.91×
%d %B %Y %H:%M:%S + rotating suffix 7.45 µs 302.2 ns 24.66×
%c ‡ 3.03 µs 3.05 µs —
%Y年%m月%d日 ‡ 2.21 µs 2.19 µs —
Benchmark reproducer

Build both revisions with the configure options above and --without-ensurepip, install pyperf==2.9.0 into a shared dependency directory, and save the script below as benchmark.py. For each interpreter:

PYTHONPATH=/path/to/deps /path/to/python benchmark.py --smoke
PYTHONPATH=/path/to/deps /path/to/python benchmark.py \
    --inherit-environ=PYTHONPATH --affinity=2 \
    -p 3 -n 3 -w 1 --min-time=0.01 -o run.json

Choose an available CPU for --affinity, and use a different output file for each run. The comparison uses two runs per revision in before/after/after/before order.

For the longer repeats, add --case numeric_date or --case unicode_date and --method datetime, date, datetime_time, or time, and replace the timing options with -p 6 -n 5 -w 2 --min-time=0.05.

# /// script
# requires-python = ">=3.13"
# dependencies = ["pyperf==2.9.0"]
# ///
"""Identical strptime inputs and formats across all four public entry points."""

import argparse
import dataclasses
import datetime
import locale
import sys
import time
from collections.abc import Callable

import pyperf


@dataclasses.dataclass(frozen=True)
class Case:
    name: str
    inputs: tuple[tuple[str, str, datetime.datetime], ...]


@dataclasses.dataclass(frozen=True)
class Method:
    name: str
    expression: str
    parse: Callable[[str, str], object]
    expected: Callable[[datetime.datetime], object]


def methods() -> tuple[Method, ...]:
    return (
        Method("datetime", "datetime.datetime", datetime.datetime.strptime,
               lambda value: value),
        Method("date", "datetime.date", datetime.date.strptime,
               lambda value: value.date()),
        Method("datetime_time", "datetime.time", datetime.time.strptime,
               lambda value: value.timetz()),
        Method("time", "time", time.strptime, lambda value: value.timetuple()),
    )


def cases() -> tuple[Case, ...]:
    formats = (
        ("numeric_date", "%Y-%m-%d"),
        ("compact_date", "%Y%m%d"),
        ("slash_date", "%Y/%m/%d"),
        ("us_date", "%m/%d/%Y"),
        ("eu_date", "%d/%m/%Y"),
        ("timestamp", "%Y-%m-%d %H:%M:%S"),
        ("fraction", "%Y-%m-%d %H:%M:%S.%f"),
        ("compact", "%Y%m%d%H%M%S"),
        ("utc", "%Y-%m-%dT%H:%M:%S%z"),
        ("literal_z", "%Y-%m-%dT%H:%M:%SZ"),
        ("literal_z_fraction", "%Y-%m-%dT%H:%M:%S.%fZ"),
        ("offset", "%Y-%m-%dT%H:%M:%S.%f%z"),
        ("time_only", "%H:%M:%S"),
        ("time_offset", "%H:%M:%S.%f%z"),
        ("month_name", "%d %B %Y %H:%M:%S"),
        ("month_abbr", "%b %d, %Y"),
        ("http_date", "%a, %d %b %Y %H:%M:%S GMT"),
        ("ampm", "%Y-%m-%d %I:%M:%S %p"),
        ("offset_seconds", "%Y-%m-%dT%H:%M:%S%z"),
        ("offset_before_time", "%Y-%m-%d %z %H:%M:%S"),
        ("zone_name", "%Y-%m-%d %H:%M:%S %Z"),
        ("date_alias", "%F"),
        ("timestamp_alias", "%F %T"),
        ("ordinal_date", "%Y-%j"),
        ("iso_week", "%G-W%V-%u"),
        ("calendar_week", "%Y-%W-%w"),
        ("locale_datetime", "%c"),
        ("unicode_date", "%Y年%m月%d日"),
    )
    result = []
    for name, fmt in formats:
        inputs = []
        for i in range(32):
            value = datetime.datetime(2000 + i % 25, 1 + i % 12,
                                      1 + i % 28, i % 24, i * 7 % 60,
                                      i * 11 % 60)
            if name in {"time_only", "time_offset"}:
                value = value.replace(year=1900, month=1, day=1)
            if "%f" in fmt:
                value = value.replace(microsecond=i * 31249)
            if name in {"numeric_date", "compact_date", "slash_date",
                        "us_date", "eu_date", "month_abbr", "date_alias",
                        "ordinal_date", "iso_week", "calendar_week", "unicode_date"}:
                value = value.replace(hour=0, minute=0, second=0)
            if "%z" in fmt:
                offset = datetime.timedelta(minutes=0 if name == "utc" else 330)
                if name == "offset_seconds":
                    offset += datetime.timedelta(seconds=12, microseconds=345678)
                value = value.replace(tzinfo=datetime.timezone(offset))
            text = value.strftime(fmt)
            if name == "zone_name":
                text += "UTC"
            inputs.append((text, fmt, value))
        result.append(Case(name, tuple(inputs)))
    for name, source in (("rotating_numeric", "timestamp"),
                         ("rotating_names", "month_name")):
        case = next(case for case in result if case.name == source)
        inputs = tuple((text + f"/{i}", fmt + f"/{i}", expected)
                       for i, (text, fmt, expected) in enumerate(case.inputs))
        result.append(Case(name, inputs))
    return tuple(result)


def check(method: Method, case: Case) -> None:
    for text, fmt, value in case.inputs:
        actual = method.parse(text, fmt)
        expected = method.expected(value)
        if isinstance(expected, time.struct_time) and "%Z" in fmt:
            expected = time.struct_time((*expected[:8], 0))
        assert type(actual) is type(expected)
        assert actual == expected, (method.name, case.name, text, actual, expected)
        if isinstance(actual, time.struct_time):
            offset = value.utcoffset()
            assert actual.tm_zone == ("UTC" if "%Z" in fmt else None)
            assert actual.tm_gmtoff == (None if offset is None else int(offset.total_seconds()))


def worker_args(command: list[str], args: argparse.Namespace) -> None:
    for option in ("method", "case"):
        if value := getattr(args, option):
            command.extend((f"--{option}", value))


def main() -> None:
    locale.setlocale(locale.LC_TIME, "C")
    suite = cases()
    entrypoints = methods()
    for method in entrypoints:
        for case in suite:
            check(method, case)
    if "--smoke" in sys.argv:
        print(f"Checked {len(entrypoints) * sum(len(case.inputs) for case in suite)} results")
        return
    runner = pyperf.Runner(add_cmdline_args=worker_args)
    runner.argparser.add_argument("--method", choices=tuple(method.name for method in entrypoints))
    runner.argparser.add_argument("--case", choices=tuple(case.name for case in suite))
    args = runner.parse_args()
    runner.metadata["description"] = "Same 32 inputs/formats for all four public strptime methods"
    runner.metadata["LC_TIME"] = "C"
    for method in entrypoints:
        if args.method and args.method != method.name:
            continue
        for case in suite:
            if args.case and args.case != case.name:
                continue
            runner.timeit(
                f"{method.name}.{case.name}",
                f"for text, fmt, _ in inputs:\n    {method.expression}.strptime(text, fmt)",
                setup="import datetime; import time; import _strptime",
                globals={"inputs": case.inputs},
                inner_loops=len(case.inputs),
            )


if __name__ == "__main__":
    main()

@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Oct 22, 2026
@read-the-docs-community

read-the-docs-community Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Read ASCII names from the current locale without caching them across
setlocale calls. Preserve explicitly supplied weekdays in time.strptime,
including numeric weekdays and weekdays that disagree with the date.

Non-ASCII names and mixed spellings of a field retain the Python parser.
Use the current locale for AM/PM indicators and preserve the default
without an indicator. Include the equivalent %k, %l and %P directives.
Locales with empty or non-ASCII indicators retain the Python parser.
Allow nonterminal offsets, offset seconds and fractions, and %:z.
Preserve fractional offsets for datetime and time objects, and preserve
terminal UTC/GMT names and DST metadata for time.strptime.

Local timezone names and nonterminal %Z still use the Python parser.
Expand %F, %D, %T and %R through the same field parser without allocating
a temporary format string. Include %e and the optional whitespace
directives %n and %t.
Calculate dates for %j, %U/%W and %G/%V using the shared Gregorian
calendar helpers. Preserve explicit weekdays and time.strptime yday
values when a week or ordinal date crosses a year boundary.
Use the same whitespace predicate as Python for %n, %t, and trimming
AM/PM indicators. Cover ASCII separators before adjacent numeric fields
in the alias formats.
@pganssle
pganssle force-pushed the c-strptime-more-formats branch from da4922b to 5cc3575 Compare October 23, 2026 17:11
Common numeric directives were passing through alias and locale-name
checks before parsing their digits. Look up equivalent fields directly
in the directive table and dispatch once to select the parsing rules.

Compute missing weekdays in time.strptime, so the datetime methods avoid
work they don't use. Keep weekdays supplied by the input or calculated
from ordinal dates intact.

Add coverage for mixed directive spellings and inferred weekdays.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant