Skip to content

Commit 9e80c8b

Browse files
committed
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.
1 parent cbab60c commit 9e80c8b

2 files changed

Lines changed: 34 additions & 4 deletions

File tree

src/pendulum/parser.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ def parse(text: str, **options: t.Any) -> Date | Time | DateTime | Duration:
3636
return _parse(text, **options)
3737

3838

39+
def _as_datetime(value: datetime.date) -> datetime.datetime:
40+
# An interval endpoint parsed from a date-only ISO 8601 value (e.g. the
41+
# "2021-01-01" in "2021-01-01/P1D") is a datetime.date. Promote it to a
42+
# midnight datetime so the interval has DateTime endpoints and so
43+
# DateTime.add/subtract accepts the duration's time components below
44+
# (Date.add/subtract does not take hours/minutes/seconds/microseconds).
45+
if isinstance(value, datetime.datetime):
46+
return value
47+
48+
return datetime.datetime(value.year, value.month, value.day)
49+
50+
3951
def _parse(
4052
text: str, **options: t.Any
4153
) -> Date | DateTime | Time | Duration | Interval[DateTime]:
@@ -75,7 +87,9 @@ def _parse(
7587
duration = parsed.duration
7688

7789
if parsed.start is not None:
78-
dt = pendulum.instance(parsed.start, tz=options.get("tz", UTC))
90+
dt = pendulum.instance(
91+
_as_datetime(parsed.start), tz=options.get("tz", UTC)
92+
)
7993

8094
return pendulum.interval(
8195
dt,
@@ -91,9 +105,7 @@ def _parse(
91105
),
92106
)
93107

94-
dt = pendulum.instance(
95-
t.cast("datetime.datetime", parsed.end), tz=options.get("tz", UTC)
96-
)
108+
dt = pendulum.instance(_as_datetime(parsed.end), tz=options.get("tz", UTC))
97109

98110
return pendulum.interval(
99111
dt.subtract(

tests/test_parsing.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,24 @@ def test_parse_interval() -> None:
127127
assert interval.end.offset == 0
128128

129129

130+
def test_parse_interval_with_date_and_duration() -> None:
131+
# The interval endpoint is a date-only value, so it parses to a
132+
# datetime.date rather than a datetime.datetime. See
133+
# https://github.com/python-pendulum/pendulum/issues/881
134+
interval = pendulum.parse("2021-01-01/P1DT1H")
135+
136+
assert isinstance(interval, pendulum.Interval)
137+
assert_datetime(interval.start, 2021, 1, 1, 0, 0, 0, 0)
138+
assert_datetime(interval.end, 2021, 1, 2, 1, 0, 0, 0)
139+
140+
# The reverse form (duration then a date endpoint) goes through subtract().
141+
interval = pendulum.parse("P1Y/2021-01-01")
142+
143+
assert isinstance(interval, pendulum.Interval)
144+
assert_datetime(interval.start, 2020, 1, 1, 0, 0, 0, 0)
145+
assert_datetime(interval.end, 2021, 1, 1, 0, 0, 0, 0)
146+
147+
130148
def test_parse_now() -> None:
131149
assert pendulum.parse("now").timezone_name == "UTC"
132150
assert (

0 commit comments

Comments
 (0)