From 762b90668352d9e401fb1b26e17251fb29fd7734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Janu=C3=A1rio?= Date: Mon, 28 Sep 2026 21:11:50 +0100 Subject: [PATCH] Fix ParserError on subsecond precision beyond nanoseconds The pure-Python datetime parsers (used as a fallback when the compiled Rust extension is unavailable, e.g. PENDULUM_EXTENSIONS=0, and for the "common" format that has no Rust equivalent) capped the fractional seconds group at 9 digits (\\d{1,9}). A 10th digit made the whole string fail to match, raising ParserError instead of parsing it. The value is already truncated to 6 digits (microseconds) a few lines below, so the cap served no purpose beyond rejecting otherwise valid input. Python's datetime.fromisoformat() has no such limit and just truncates, which is the behavior restored here by widening both regexes to \\d+. Fixes ParserError on inputs like: pendulum.parse("2001-01-01T12:34:56.1234567890Z") pendulum.parse("2016/10/06 12:34:56.1234567890") --- src/pendulum/parsing/__init__.py | 2 +- src/pendulum/parsing/iso8601.py | 2 +- tests/parsing/test_parse_iso8601.py | 15 +++++++++++++ tests/parsing/test_parsing.py | 35 +++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/pendulum/parsing/__init__.py b/src/pendulum/parsing/__init__.py index d88c089a6..1301f452a 100644 --- a/src/pendulum/parsing/__init__.py +++ b/src/pendulum/parsing/__init__.py @@ -49,7 +49,7 @@ # Subsecond part (optional) " (?P" " (?:[.|,])" # Subsecond separator (optional) - r" (?P\d{1,9})" # Subsecond + r" (?P\d+)" # Subsecond (any number of digits; truncated to microseconds below) " )?" ")?" "$", diff --git a/src/pendulum/parsing/iso8601.py b/src/pendulum/parsing/iso8601.py index c65d249e5..e3e980814 100644 --- a/src/pendulum/parsing/iso8601.py +++ b/src/pendulum/parsing/iso8601.py @@ -49,7 +49,7 @@ # Subsecond part (optional) " (?P" " (?:[.,])" # Subsecond separator (optional) - r" (?P\d{1,9})" # Subsecond + r" (?P\d+)" # Subsecond (any number of digits; truncated to microseconds below) " )?" # Timezone offset " (?P" diff --git a/tests/parsing/test_parse_iso8601.py b/tests/parsing/test_parse_iso8601.py index ed2d39887..3f70c39e7 100644 --- a/tests/parsing/test_parse_iso8601.py +++ b/tests/parsing/test_parse_iso8601.py @@ -214,3 +214,18 @@ def test_parse_iso8601_duration_invalid(): # Must include at least one element with pytest.raises(ValueError): parse_iso8601("P") + + +def test_parse_iso8601_subsecond_beyond_nanoseconds_pure_python(): + # Regression test for the pure-Python ISO 8601 parser (used as a fallback + # when the compiled extension is unavailable): a subsecond part longer + # than 9 digits used to raise a ParserError instead of being truncated to + # microsecond precision like `datetime.fromisoformat` does. Imported + # directly so the check runs regardless of whether the Rust extension is + # built, since `pendulum.parsing.parse_iso8601` prefers the extension + # when it's available. + from pendulum.parsing.iso8601 import parse_iso8601 as py_parse_iso8601 + + parsed = py_parse_iso8601("2016-10-06T12:34:56.1234567890123+05:30") + + assert parsed == datetime(2016, 10, 6, 12, 34, 56, 123456, FixedTimezone(19800)) diff --git a/tests/parsing/test_parsing.py b/tests/parsing/test_parsing.py index 8151ce12f..33af076af 100644 --- a/tests/parsing/test_parsing.py +++ b/tests/parsing/test_parsing.py @@ -156,6 +156,41 @@ def test_rfc_3339_extended_nanoseconds(): assert parsed.utcoffset().total_seconds() == 19800 +def test_rfc_3339_extended_beyond_nanoseconds(): + # A subsecond part longer than 9 digits (i.e. finer than nanoseconds) is + # unusual but not invalid ISO 8601: it should still be accepted and + # truncated to microsecond precision, the same as `datetime.fromisoformat` + # does, instead of raising a ParserError. + text = "2016-10-06T12:34:56.1234567890123+05:30" + + parsed = parse(text) + + assert parsed.year == 2016 + assert parsed.month == 10 + assert parsed.day == 6 + assert parsed.hour == 12 + assert parsed.minute == 34 + assert parsed.second == 56 + assert parsed.microsecond == 123456 + assert parsed.utcoffset().total_seconds() == 19800 + + +def test_common_format_extended_beyond_nanoseconds(): + # Same as `test_rfc_3339_extended_beyond_nanoseconds()` but for the + # "common" datetime format (handled separately from the ISO 8601 parser). + text = "2016/10/06 12:34:56.1234567890123" + + parsed = parse(text) + + assert parsed.year == 2016 + assert parsed.month == 10 + assert parsed.day == 6 + assert parsed.hour == 12 + assert parsed.minute == 34 + assert parsed.second == 56 + assert parsed.microsecond == 123456 + + def test_iso_8601_date(): text = "2012"