From c81bf8bebb1ce4406c409d8cb894a30878ed0134 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:21:51 -0400 Subject: [PATCH] Add typed parse helpers for datetime, date, time and duration --- docs/docs/parsing.md | 33 ++++++++++++++++++ src/pendulum/__init__.py | 8 +++++ src/pendulum/parser.py | 63 ++++++++++++++++++++++++++++++++++ tests/test_parsing.py | 74 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+) diff --git a/docs/docs/parsing.md b/docs/docs/parsing.md index dd78fd45a..e6f417e91 100644 --- a/docs/docs/parsing.md +++ b/docs/docs/parsing.md @@ -112,3 +112,36 @@ When passing only time information the date will default to today. >>> pendulum.parse('12:04:23', exact=True) Time(12, 04, 23) ``` + +## Typed helpers + +Because `parse()` can return a `DateTime`, `Date`, `Time` or `Duration` depending on the +input, its return type is a union, which is awkward in type-checked code. If you know which +type you expect, you can use one of the typed helpers instead. Each one returns that single +type and raises a `ParserError` if the string represents something else. + +```python +>>> import pendulum + +>>> pendulum.parse_datetime('2012-05-03T12:04:23') +DateTime(2012, 5, 3, 12, 4, 23, tzinfo=Timezone('UTC')) + +>>> pendulum.parse_datetime('P2Y3M4DT5H6M7S') +Traceback (most recent call last): +... +ParserError: Text 'P2Y3M4DT5H6M7S' does not represent a datetime, got Duration +``` + +`parse_date()` and `parse_time()` expect the string to represent that exact type, so pass +`exact=True` as you would to `parse()`: + +```python +>>> pendulum.parse_date('2012-05-03', exact=True) +Date(2012, 5, 3) + +>>> pendulum.parse_time('12:04:23', exact=True) +Time(12, 4, 23) + +>>> pendulum.parse_duration('P2Y3M4DT5H6M7S') +Duration(years=2, months=3, days=4, hours=5, minutes=6, seconds=7) +``` diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index 16ae0865c..3d90ce400 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -31,6 +31,10 @@ from pendulum.helpers import week_starts_at from pendulum.interval import Interval from pendulum.parser import parse as parse +from pendulum.parser import parse_date as parse_date +from pendulum.parser import parse_datetime as parse_datetime +from pendulum.parser import parse_duration as parse_duration +from pendulum.parser import parse_time as parse_time from pendulum.time import Time from pendulum.tz import UTC from pendulum.tz import fixed_timezone @@ -423,6 +427,10 @@ def __getattr__(name: str) -> Any: "naive", "now", "parse", + "parse_date", + "parse_datetime", + "parse_duration", + "parse_time", "set_local_timezone", "set_locale", "test_local_timezone", diff --git a/src/pendulum/parser.py b/src/pendulum/parser.py index 833bae3c1..fad31a1ba 100644 --- a/src/pendulum/parser.py +++ b/src/pendulum/parser.py @@ -9,6 +9,7 @@ from pendulum.duration import Duration from pendulum.parsing import _Interval from pendulum.parsing import parse as base_parse +from pendulum.parsing.exceptions import ParserError from pendulum.tz.timezone import UTC @@ -36,6 +37,68 @@ def parse(text: str, **options: t.Any) -> Date | Time | DateTime | Duration: return _parse(text, **options) +def parse_datetime(text: str, **options: t.Any) -> DateTime: + """Parse a string and return a ``DateTime``. + + Accepts the same options as ``parse()`` but raises a ``ParserError`` if the + string represents something else (a date, a time, a duration, ...). + """ + parsed = parse(text, **options) + if not isinstance(parsed, pendulum.DateTime): + raise _wrong_type(text, "a datetime", parsed) + + return parsed + + +def parse_date(text: str, **options: t.Any) -> Date: + """Parse a string and return a ``Date``. + + Accepts the same options as ``parse()`` but raises a ``ParserError`` if the + string represents something else. Note that ``parse()`` yields a ``DateTime`` + for a date string unless ``exact=True`` is passed. + """ + parsed = parse(text, **options) + # DateTime is a subclass of Date, so a datetime must not pass as a date. + if not isinstance(parsed, pendulum.Date) or isinstance(parsed, pendulum.DateTime): + raise _wrong_type(text, "a date", parsed) + + return parsed + + +def parse_time(text: str, **options: t.Any) -> Time: + """Parse a string and return a ``Time``. + + Accepts the same options as ``parse()`` but raises a ``ParserError`` if the + string represents something else. Note that ``parse()`` yields a ``DateTime`` + for a time string unless ``exact=True`` is passed. + """ + parsed = parse(text, **options) + if not isinstance(parsed, pendulum.Time): + raise _wrong_type(text, "a time", parsed) + + return parsed + + +def parse_duration(text: str, **options: t.Any) -> Duration: + """Parse a string and return a ``Duration``. + + Accepts the same options as ``parse()`` but raises a ``ParserError`` if the + string represents something else. + """ + parsed = parse(text, **options) + # Interval is a subclass of Duration, so an interval must not pass as one. + if not isinstance(parsed, Duration) or isinstance(parsed, pendulum.Interval): + raise _wrong_type(text, "a duration", parsed) + + return parsed + + +def _wrong_type(text: str, expected: str, parsed: object) -> ParserError: + return ParserError( + f"Text '{text}' does not represent {expected}, got {type(parsed).__name__}" + ) + + def _parse( text: str, **options: t.Any ) -> Date | DateTime | Time | Duration | Interval[DateTime]: diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 34673c40a..935cc83f8 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.exceptions import ParserError from tests.conftest import assert_date from tests.conftest import assert_datetime from tests.conftest import assert_duration @@ -147,3 +150,74 @@ def test_parse_with_utc_timezone() -> None: dt = pendulum.parse("2020-02-05T20:05:37.364951Z") assert dt.to_iso8601_string() == "2020-02-05T20:05:37.364951Z" + + +def test_parse_datetime() -> None: + dt = pendulum.parse_datetime("2016-10-16T12:34:56.123456+01:30") + + assert isinstance(dt, pendulum.DateTime) + assert_datetime(dt, 2016, 10, 16, 12, 34, 56, 123456) + assert dt.offset == 5400 + + # A date string still parses to a DateTime by default. + dt = pendulum.parse_datetime("2016-10-16") + + assert isinstance(dt, pendulum.DateTime) + assert_datetime(dt, 2016, 10, 16, 0, 0, 0, 0) + + # Options are forwarded to parse(). + dt = pendulum.parse_datetime("2016-10-16T12:34:56", tz="Europe/Paris") + + assert dt.tz is not None + assert dt.tz.name == "Europe/Paris" + + +def test_parse_datetime_raises_for_other_types() -> None: + with pytest.raises(ParserError): + pendulum.parse_datetime("P2Y3M4DT5H6M7S") + + with pytest.raises(ParserError): + pendulum.parse_datetime("2016-10-16", exact=True) + + +def test_parse_date() -> None: + d = pendulum.parse_date("2016-10-16", exact=True) + + assert isinstance(d, pendulum.Date) + assert_date(d, 2016, 10, 16) + + +def test_parse_date_raises_for_other_types() -> None: + # A datetime must not pass as a date, even though DateTime subclasses Date. + with pytest.raises(ParserError): + pendulum.parse_date("2016-10-16") + + with pytest.raises(ParserError): + pendulum.parse_date("12:34:56", exact=True) + + +def test_parse_time() -> None: + t = pendulum.parse_time("12:34:56.123456", exact=True) + + assert isinstance(t, pendulum.Time) + assert_time(t, 12, 34, 56, 123456) + + +def test_parse_time_raises_for_other_types() -> None: + with pytest.raises(ParserError): + pendulum.parse_time("2016-10-16", exact=True) + + +def test_parse_duration_helper() -> None: + duration = pendulum.parse_duration("P2Y3M4DT5H6M7S") + + assert isinstance(duration, pendulum.Duration) + assert_duration(duration, 2, 3, 0, 4, 5, 6, 7) + + +def test_parse_duration_raises_for_other_types() -> None: + with pytest.raises(ParserError): + pendulum.parse_duration("2016-10-16") + + with pytest.raises(ParserError): + pendulum.parse_duration("2008-05-11T15:30:00Z/2008-05-11T16:30:00Z")