Skip to content

gh-152204: Validate date fields in pure-Python date.fromisoformat - #152205

Open
tonghuaroot wants to merge 6 commits into
python:mainfrom
tonghuaroot:fix-gh-152204-fromisoformat-date-fields
Open

gh-152204: Validate date fields in pure-Python date.fromisoformat#152205
tonghuaroot wants to merge 6 commits into
python:mainfrom
tonghuaroot:fix-gh-152204-fromisoformat-date-fields

Conversation

@tonghuaroot

@tonghuaroot tonghuaroot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

_pydatetime._parse_isoformat_date reads each fixed-width field with int() on a slice, without checking that the slice is exactly N ASCII digits. int() accepts a leading +/-/whitespace and a short string, so several malformed ISO 8601 basic-format dates are silently parsed into a wrong-but-plausible date instead of raising ValueError:

>>> import _pydatetime
>>> _pydatetime.date.fromisoformat('2020+12')
datetime.date(2020, 1, 2)
>>> _pydatetime.date.fromisoformat('+020-06-15')
datetime.date(20, 6, 15)
>>> _pydatetime.date.fromisoformat('2020061')   # 7 chars: day slice reads '1'
datetime.date(2020, 6, 1)
>>> _pydatetime.date.fromisoformat('2020-W2')   # 1-digit week number
datetime.date(2020, 1, 6)

The C accelerator rejects all of these via parse_digits() (which requires the exact field width and digit-only content), so this is a C-vs-pure-Python divergence. The pure-Python path is used when the _datetime C extension is unavailable, and directly via _pydatetime.

This validates each field slice (year / month / day / weekno / weekday) to be exactly N ASCII digits before converting, mirroring the C parse_digits(), and extends datetimetester's test_fromisoformat_fails with the affected inputs (the new cases now reject on both implementations).

Fixes #152204.


Prepared with AI assistance (Claude Code) and verified by hand against a debug build, against both the C and pure-Python implementations.

The pure-Python _parse_isoformat_date read each fixed-width field with
int() on a slice, which silently accepts a leading sign or whitespace, or
a short slice that runs off the end of the string.  Malformed basic-format
inputs such as '2020+12' or '2020061' were therefore parsed into a
wrong-but-plausible date instead of raising, while the C accelerator
rejects them via parse_digits().  Validate that each field slice is exactly
N ASCII digits before converting.

@serhiy-storchaka serhiy-storchaka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change has significant impact on performance.

Would not it be simpler to use .isascii() at the beginning and .isdigit() for each fragment?

@tonghuaroot

Copy link
Copy Markdown
Contributor Author

Done in 46f6881: one dtstr.isascii() up front (also covers the datetime path, which lacked it) + s.isdigit() per fragment — ~10x faster than the per-character check, and the same rejection.

Comment thread Lib/test/datetimetester.py Outdated
Comment on lines +2115 to +2117
'2020061', # 7 chars: day slice reads a 1-character tail
'2020123', # 7 chars: day slice reads a 1-character tail
'9999121', # 7 chars: day slice reads a 1-character tail

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these cases are identical?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They duplicates can be removed.

Comment thread Lib/_pydatetime.py Outdated
year = int(dtstr[0:4])
if not dtstr.isascii():
raise ValueError(f"Invalid isoformat string: {dtstr!r}")
def _read(s, n):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this out of the hotpath to reduce overhead.

Comment thread Lib/_pydatetime.py Outdated
if not dtstr.isascii():
raise ValueError(f"Invalid isoformat string: {dtstr!r}")
def _read(s, n):
# dtstr is ASCII, so isdigit() matches only ASCII digits.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have a helper for this:

cpython/Lib/_pydatetime.py

Lines 298 to 299 in 20e6c2f

def _is_ascii_digit(c):
return c in "0123456789"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was in the the initial variant. It is slower.

Comment thread Lib/_pydatetime.py Outdated
raise ValueError("Invalid isoformat string")
year = int(dtstr[0:4])
if not dtstr.isascii():
raise ValueError(f"Invalid isoformat string: {dtstr!r}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep it the same as the other message, and these won't surface anyway I think.

Comment thread Lib/_pydatetime.py
return 8


def _parse_isoformat_date(dtstr):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the time?

>>> import _pydatetime
>>> _pydatetime.datetime.fromisoformat('20201212T0102٣٤')
datetime.datetime(2020, 12, 12, 1, 2, 34)

Comment thread Lib/test/datetimetester.py Outdated
'2020123', # 7 chars: day slice reads a 1-character tail
'9999121', # 7 chars: day slice reads a 1-character tail
'2020-W2', # 1-digit week number
'٢025-03-09' # Unicode characters

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the missing comma.

@serhiy-storchaka serhiy-storchaka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. 👍

Comment thread Lib/_pydatetime.py Outdated
if not dtstr.isascii():
raise ValueError(f"Invalid isoformat string: {dtstr!r}")
def _read(s, n):
# dtstr is ASCII, so isdigit() matches only ASCII digits.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was in the the initial variant. It is slower.

@tonghuaroot

Copy link
Copy Markdown
Contributor Author

Done in a025467: moved the reader to a module-level _read_isoformat_component, added the same isascii()+isdigit() guard to the time path (fixes the T0102٣٤ case), and fixed the missing comma. Kept isdigit() per Serhiy.

@StanFromIreland

Copy link
Copy Markdown
Member

There are conflicts now.

@tonghuaroot

Copy link
Copy Markdown
Contributor Author

Done in aed8e2d: merged main (conflicts resolved) and removed the duplicate 7-char cases.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pure-Python date.fromisoformat silently mis-parses malformed basic-format dates

3 participants