diff --git a/rust/src/parsing.rs b/rust/src/parsing.rs index 374fe3cfb..07d5644e3 100644 --- a/rust/src/parsing.rs +++ b/rust/src/parsing.rs @@ -597,6 +597,7 @@ impl<'a> Parser<'a> { let mut duration: ParsedDuration = ParsedDuration::new(); let mut got_t: bool = false; + let mut has_time_component = false; let mut last_had_fraction = false; loop { @@ -683,6 +684,7 @@ impl<'a> Parser<'a> { ) } } + has_time_component = true; } else { match self.current { 'Y' => { @@ -791,6 +793,10 @@ impl<'a> Parser<'a> { } } + if got_t && !has_time_component { + return Err(self.parse_error("Missing time component in duration".to_string())); + } + parsed.duration = Some(duration); Ok(()) diff --git a/src/pendulum/parsing/iso8601.py b/src/pendulum/parsing/iso8601.py index c65d249e5..7e0592cb5 100644 --- a/src/pendulum/parsing/iso8601.py +++ b/src/pendulum/parsing/iso8601.py @@ -268,6 +268,11 @@ def _parse_iso8601_duration(text: str, **options: str) -> Duration | None: if not m or (not m.group("w") and not m.group("ymd") and not m.group("hms")): return None + if m.group("timesep") and not any( + m.group(unit) for unit in ("hours", "minutes", "seconds") + ): + return None + years = 0 months = 0 weeks = 0 diff --git a/tests/parsing/test_parse_iso8601.py b/tests/parsing/test_parse_iso8601.py index ed2d39887..4f680e0b7 100644 --- a/tests/parsing/test_parse_iso8601.py +++ b/tests/parsing/test_parse_iso8601.py @@ -191,6 +191,14 @@ def test_parse_iso8601_invalid(): ("PT1.5H", (0, 0, 0, 0, 1, 30, 0, 0)), ("PT1,5H", (0, 0, 0, 0, 1, 30, 0, 0)), ("P2Y30M4DT5H6M7S", (2, 30, 0, 4, 5, 6, 7, 0)), + ("PT0H", (0, 0, 0, 0, 0, 0, 0, 0)), + ("PT0M", (0, 0, 0, 0, 0, 0, 0, 0)), + ("PT0S", (0, 0, 0, 0, 0, 0, 0, 0)), + ("PT0.0S", (0, 0, 0, 0, 0, 0, 0, 0)), + ("P0D", (0, 0, 0, 0, 0, 0, 0, 0)), + ("P1DT0S", (0, 0, 0, 1, 0, 0, 0, 0)), + ("P1Y2MT0H", (1, 2, 0, 0, 0, 0, 0, 0)), + ("P1DT2S", (0, 0, 0, 1, 0, 0, 2, 0)), ], ) def test_parse_iso8601_duration( @@ -210,7 +218,8 @@ def test_parse_iso8601_duration( ) == expected -def test_parse_iso8601_duration_invalid(): - # Must include at least one element +@pytest.mark.parametrize("text", ["P", "PT", "P1DT", "P0DT", "P1Y2MT", "P1.5DT"]) +def test_parse_iso8601_duration_invalid(text: str) -> None: + # Durations need a component, and T must be followed by a time component. with pytest.raises(ValueError): - parse_iso8601("P") + parse_iso8601(text) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 34673c40a..0205081a6 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1,7 +1,10 @@ from __future__ import annotations +import pytest + import pendulum +from pendulum.parsing import ParserError from tests.conftest import assert_date from tests.conftest import assert_datetime from tests.conftest import assert_duration @@ -95,6 +98,31 @@ def test_parse_duration() -> None: assert_duration(duration, 0, 0, 2, 0, 0, 0, 0) +@pytest.mark.parametrize("text", ["PT", "P1DT", "P0DT", "P1Y2MT", "P1.5DT"]) +def test_parse_duration_without_time_components(text: str) -> None: + with pytest.raises(ParserError): + pendulum.parse(text, strict=True) + + +@pytest.mark.parametrize( + ["text", "days", "seconds"], + [ + ("PT0H", 0, 0), + ("PT0M", 0, 0), + ("PT0S", 0, 0), + ("PT0.0S", 0, 0), + ("P0D", 0, 0), + ("P1DT0S", 1, 0), + ("P1DT2S", 1, 2), + ], +) +def test_parse_duration_time_components(text: str, days: int, seconds: int) -> None: + duration = pendulum.parse(text, strict=True) + + assert isinstance(duration, pendulum.Duration) + assert_duration(duration, 0, 0, 0, days, 0, 0, seconds) + + def test_parse_interval() -> None: text = "2008-05-11T15:30:00Z/P1Y2M10DT2H30M"