Skip to content

Speed up parser.parse() (ISO-8601 fast path, faster tokenizer) with no behaviour change - #1624

Open
Admiral-x wants to merge 6 commits into
dateutil:masterfrom
AdmiralOSX:parser-speedup
Open

Admiral-x wants to merge 6 commits into
dateutil:masterfrom
AdmiralOSX:parser-speedup

Conversation

@Admiral-x

Copy link
Copy Markdown

Summary of changes

Speeds up parser.parse() without changing its behaviour, and adds tests that check this against a frozen copy of the current parser.

 parse(timestr)
-  default = now()                      # every call
   _parse(timestr)
+    if ISO-8601 and stock parserinfo and not dayfirst/fuzzy:
+      return _try_parse_iso(timestr)   # regex → the same _result
-    _timelex.split(timestr)            # StringIO.read(1) per char
+    _timelex.split(timestr)            # same state machine, over the str
     ... unchanged ...
+  default = now() only if the string lacks year, month or day
   _build_naive / _build_tzaware        # unchanged
  • ISO-8601 fast path: strict YYYY-MM-DD[( |T)HH:MM[:SS[.ffffff]][Z|±HH[[:]MM]]] strings build the _result the general path would build, then go through the unchanged _build_naive / _build_tzaware. Out-of-range values are not checked separately; datetime rejects them with the same messages as before. Custom parserinfo subclasses, dayfirst, fuzzy and non-str input always take the general path.
  • Tokenizer: _timelex.split() runs the existing get_token() state machine directly over the string. _timelex subclasses and streams keep the old path.
  • Smaller costs: datetime.now() is only called when the string doesn't give a full date, and _resultbase.__len__ no longer sums a generator.

This covers the ISO fast path and tokenizer work proposed in #1519. It overlaps with #1609 and #1613 (opened 14 Oct). The difference here is how the tests check that behaviour stays the same.

Evidence

parse(), µs per call (CPython 3.12, macOS arm64):

input before after
2024-03-15T14:30:00 20.9 4.8 4.4×
2024-03-15T14:30:00+02:00 27.2 6.4 4.2×
2024-03-15T14:30:00.123456Z 26.1 5.4 4.8×
2024-03-15 12.9 3.8 3.4×
03/15/2024 12.9 8.9 1.5×
Fri, 15 Mar 2024 14:30:00 +0000 37.7 27.8 1.4×
March 15, 2024 2:30 PM 30.6 22.8 1.3×

Behaviour preservation:

  • tests/_reference_parser.py is a verbatim copy of _parser.py at 2642afa (only its two relative imports changed). It is test-only.
  • tests/test_parser_equivalence.py uses Hypothesis to check that _timelex.split tokens (str, bytes and streams) and full parse() results match the reference. It compares values, utcoffset(), exception type and args, and fuzzy_with_tokens output. Inputs are real-world strftime formats, random fragments and Unicode digits/letters. Options vary over dayfirst, yearfirst, ignoretz, fuzzy, fuzzy_with_tokens, default, tzinfos and parserinfo.
  • tests/test_parser_fastpath.py checks parse() with and without the fast path on inputs built around its grammar boundaries, including a parserinfo subclass with a different JUMP list. It also checks that common ISO strings skip the tokenizer.
  • I checked that the tests can catch bugs by injecting 24 deliberate bugs into the new code; all 24 were caught. A separate run of 100,000 cases per check found no difference from the reference.

Full suite: CPython 2.7, 3.5, 3.6, 3.7, 3.12 and PyPy 2.7 all pass. Python 2.7 needed one test-only setting: Hypothesis 4.x (the last release for Python 2) can spend minutes in its target phase, so these tests skip that phase. They don't call target(), so nothing is lost.

Merge Danger

Door: two-way. The changes are internal to _parser.py with no API change, and a revert is clean.

Blast Radius: wide. parse() is used throughout the ecosystem (pandas calls _timelex.split directly), which is why the tests compare against the old implementation rather than against hand-picked cases.

The reference copy adds about 1,600 lines to tests/. If you would rather not carry it, the equivalence tests could compare against an installed release instead.

Pull Request Checklist

  • Changes have tests
  • Authors have been added to AUTHORS.md
  • News fragment added in changelog.d. See CONTRIBUTING.md for details

🤖 Generated with Claude Code

Admiral-x and others added 6 commits October 21, 2026 23:24
Strict ``YYYY-MM-DD[( |T)HH:MM[:SS[.ffffff]][Z|+HH[[:]MM]]]`` strings, the
most common input, now skip the tokenizer: a regex builds the same
``_result`` the general path would, and the rest of parse() is unchanged.
The fast path is only taken with the stock parserinfo, without dayfirst or
fuzzy, and for str input; everything else falls back to the general path.

parse() on ISO strings goes from ~21-27 us to ~6-8 us (3.5x).

tests/test_parser_fastpath.py checks with hypothesis that parse() returns
the same result, error or fuzzy tokens with and without the fast path,
across dayfirst, yearfirst, ignoretz, fuzzy, fuzzy_with_tokens, default,
tzinfos and parserinfo (including a subclass with different JUMP tokens).
Ten deliberately injected bugs in the fast path are all caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- _timelex.split() runs the same get_token() state machine directly over
  the string, slicing tokens out, instead of one StringIO.read(1) and
  method call per character. Subclasses and streams keep the old path.
  Tokenizing is 2.5-2.9x faster.
- parse() only calls datetime.now() for the default when the string does
  not give the full date.
- _resultbase.__len__ no longer sums a generator.

parse() vs upstream: ISO 4.2-4.8x, other formats 1.3-1.5x faster.

tests/_reference_parser.py is a frozen copy of the upstream parser.
tests/test_parser_equivalence.py checks with hypothesis that tokens and
full parse() results (values, errors, fuzzy tokens) match it on real-world
strftime formats, random fragments and Unicode edge cases. Fourteen
deliberately injected bugs are all caught; a 100k-case run of each check
found no difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Apply the project's darker/black/isort formatting to the changed lines.
- Compare exception args instead of str(e): on Python 2 str() of a
  ParserError holding non-ASCII input raises UnicodeEncodeError.
- Compare tokenizer outcomes for bytes input: on Python 2 bytes.decode()
  is ASCII, and both parsers raise the same error.
- Generate strftime inputs from 1900 on (Python 2's strftime limit).
- Skip Hypothesis's target phase: on Hypothesis 4.x (the last release for
  Python 2) its Pareto optimiser ran for many minutes on these strategies.
  The tests do not call target(), so the phase added nothing.
- Add Daniel Galperson to AUTHORS.md.

Tested on CPython 2.7, 3.5, 3.6, 3.7, 3.12 and PyPy 2.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
darker checks every line a PR adds, which includes this new file. The
syntax tree is unchanged apart from merged imports and three
docstrings that lost their leading and trailing spaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant