From 9e80c8b9f34209ffeaed7e4730458dd6728d93e6 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Date: Tue, 7 Jul 2026 11:53:01 -0700 Subject: [PATCH] Fix parse() crash on an ISO 8601 date + duration interval When parse() is given an interval made of a duration and a date-only endpoint (e.g. "2021-01-01/P1DT1H" or "P1Y/2021-01-01"), base_parse returns a datetime.date for the endpoint, so pendulum.instance() returns a Date. _parse then calls Date.add()/Date.subtract() with the duration's hours/minutes/seconds/microseconds, which Date does not accept, raising 'TypeError: add() got an unexpected keyword argument 'hours''. Endpoints that carry a time component return a DateTime and worked fine. Promote a date-only endpoint to a midnight datetime before instance(), so the interval has DateTime endpoints (matching the time-bearing case and the function's Interval[DateTime] return type) and add()/subtract() accept the time components. Fixes #881. --- src/pendulum/parser.py | 20 ++++++++++++++++---- tests/test_parsing.py | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/pendulum/parser.py b/src/pendulum/parser.py index 833bae3c..08e0fd0b 100644 --- a/src/pendulum/parser.py +++ b/src/pendulum/parser.py @@ -36,6 +36,18 @@ def parse(text: str, **options: t.Any) -> Date | Time | DateTime | Duration: return _parse(text, **options) +def _as_datetime(value: datetime.date) -> datetime.datetime: + # An interval endpoint parsed from a date-only ISO 8601 value (e.g. the + # "2021-01-01" in "2021-01-01/P1D") is a datetime.date. Promote it to a + # midnight datetime so the interval has DateTime endpoints and so + # DateTime.add/subtract accepts the duration's time components below + # (Date.add/subtract does not take hours/minutes/seconds/microseconds). + if isinstance(value, datetime.datetime): + return value + + return datetime.datetime(value.year, value.month, value.day) + + def _parse( text: str, **options: t.Any ) -> Date | DateTime | Time | Duration | Interval[DateTime]: @@ -75,7 +87,9 @@ def _parse( duration = parsed.duration if parsed.start is not None: - dt = pendulum.instance(parsed.start, tz=options.get("tz", UTC)) + dt = pendulum.instance( + _as_datetime(parsed.start), tz=options.get("tz", UTC) + ) return pendulum.interval( dt, @@ -91,9 +105,7 @@ def _parse( ), ) - dt = pendulum.instance( - t.cast("datetime.datetime", parsed.end), tz=options.get("tz", UTC) - ) + dt = pendulum.instance(_as_datetime(parsed.end), tz=options.get("tz", UTC)) return pendulum.interval( dt.subtract( diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 34673c40..48d9a039 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -127,6 +127,24 @@ def test_parse_interval() -> None: assert interval.end.offset == 0 +def test_parse_interval_with_date_and_duration() -> None: + # The interval endpoint is a date-only value, so it parses to a + # datetime.date rather than a datetime.datetime. See + # https://github.com/python-pendulum/pendulum/issues/881 + interval = pendulum.parse("2021-01-01/P1DT1H") + + assert isinstance(interval, pendulum.Interval) + assert_datetime(interval.start, 2021, 1, 1, 0, 0, 0, 0) + assert_datetime(interval.end, 2021, 1, 2, 1, 0, 0, 0) + + # The reverse form (duration then a date endpoint) goes through subtract(). + interval = pendulum.parse("P1Y/2021-01-01") + + assert isinstance(interval, pendulum.Interval) + assert_datetime(interval.start, 2020, 1, 1, 0, 0, 0, 0) + assert_datetime(interval.end, 2021, 1, 1, 0, 0, 0, 0) + + def test_parse_now() -> None: assert pendulum.parse("now").timezone_name == "UTC" assert (