diff --git a/README.md b/README.md index c72a53c..5d865cd 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,21 @@ python3 -m pip install -e . 'an hour ago' ``` +#### Also accepts iso8601 text strings + +```pycon +>>> import humanize +>>> humanize.naturaldate('2007-06-05') +'Jun 05 2007' +>>> humanize.naturaldate('2007-06-05T22:00:10') +'Jun 05 2007' +>>> humanize.naturaldate('2007-06-05T22:00:10+02:00') +'Jun 05 2007' + +Relies on the native `fromisoformat` function that exists on date, datetime and time objects. +``` + + ### Precise time delta ```pycon diff --git a/src/humanize/time.py b/src/humanize/time.py index 1c65d8e..0b5ee60 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -47,6 +47,41 @@ def _now() -> dt.datetime: return dt.datetime.now() +T = typing.TypeVar("T") + + +def _parse_iso( + value: T, +) -> T | dt.date | dt.datetime: + """If string attempts to parse as iso8601 date or datetime. + + Args: + value (str or any object): String to be parsed. + + Returns: + str (str or any): If string, it will first attempt parsing with + date.fromisoformat. If that fails it will attempt parsing with + datetime.isoformat. + + If `value` is not a string or both attempts at parsing fail, + `value` is returned as is. + """ + if isinstance(value, str): + try: + # catches eg '2023-04-01' + return dt.date.fromisoformat(value) + except ValueError: + pass + + try: + # catches eg '2023-04-01T12:00:00.123456+02:00', '2023-04-20 12:00:00' + return dt.datetime.fromisoformat(value) + except ValueError: + pass + + return value + + def _abs_timedelta(delta: dt.timedelta) -> dt.timedelta: """Return an "absolute" value for a timedelta, always representing a time distance. @@ -220,7 +255,7 @@ def naturaldelta( def naturaltime( - value: dt.datetime | dt.timedelta | float, + value: dt.datetime | dt.timedelta | float | str, future: bool = False, months: bool = True, minimum_unit: str = "seconds", @@ -246,6 +281,10 @@ def naturaltime( str: A natural representation of the input in a resolution that makes sense. """ now = when or _now() + _value = _parse_iso(value) + if type(_value) != dt.date: + # naturaltime is not built to handle dt.date + value = typing.cast(typing.Union[dt.datetime, dt.timedelta, float, str], _value) if isinstance(value, dt.datetime) and value.tzinfo is not None: value = dt.datetime.fromtimestamp(value.timestamp()) @@ -274,6 +313,8 @@ def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: formatted according to `format`. """ + value = _parse_iso(value) + try: value = dt.date(value.year, value.month, value.day) except AttributeError: @@ -298,6 +339,8 @@ def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: def naturaldate(value: dt.date | dt.datetime) -> str: """Like `naturalday`, but append a year for dates more than ~five months away.""" + value = _parse_iso(value) + try: value = dt.date(value.year, value.month, value.day) except AttributeError: diff --git a/tests/test_time.py b/tests/test_time.py index a4d40f9..e88a15d 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -56,6 +56,53 @@ def assert_equal_timedelta(td1: dt.timedelta, td2: dt.timedelta) -> None: # These are not considered "public" interfaces, but require tests anyway. +@pytest.mark.parametrize( + "test_input, expected", + [ + # values that can not be parsed are returned as is + ("invalid iso 8601 string", "invalid iso 8601 string"), + (42, 42), + (-273.15, -273.15), + (None, None), + # returns date + ("2023-04-01", dt.date(2023, 4, 1)), + ("2020-02-02", dt.date(2020, 2, 2)), + # returns datetime + ("2023-04-01T13:15", dt.datetime(2023, 4, 1, 13, 15)), + ("2023-04-01T13:15:30", dt.datetime(2023, 4, 1, 13, 15, 30)), + ("2023-04-01T13:15:30.123456", dt.datetime(2023, 4, 1, 13, 15, 30, 123456)), + # returns datetime with timezone + ( + "2023-04-01T13:15+02:00", + dt.datetime( + 2023, 4, 1, 13, 15, tzinfo=dt.timezone(dt.timedelta(seconds=7200)) + ), + ), + ( + "2023-04-01T13:15:30+02:00", + dt.datetime( + 2023, 4, 1, 13, 15, 30, tzinfo=dt.timezone(dt.timedelta(seconds=7200)) + ), + ), + ( + "2023-04-01T13:15:30.123456+02:00", + dt.datetime( + 2023, + 4, + 1, + 13, + 15, + 30, + 123456, + tzinfo=dt.timezone(dt.timedelta(seconds=7200)), + ), + ), + ], +) +def test_parse_iso(test_input: object, expected: object) -> None: + assert time._parse_iso(test_input) == expected + + def test_date_and_delta() -> None: now = dt.datetime.now() td = dt.timedelta @@ -163,6 +210,11 @@ def test_naturaldelta(test_input: int | dt.timedelta, expected: str) -> None: (NOW - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW - dt.timedelta(days=365 + 4), "1 year, 4 days ago"), ("NaN", "NaN"), + # iso 8601 + ( + (NOW - dt.timedelta(days=365 + 4)).isoformat(timespec="hours"), + "1 year, 4 days ago", + ), ], ) def test_naturaltime(test_input: dt.datetime, expected: str) -> None: @@ -224,6 +276,8 @@ def test_naturaltime_nomonths(test_input: dt.datetime, expected: str) -> None: (["Not a date at all."], "Not a date at all."), ([VALUE_ERROR_TEST], str(VALUE_ERROR_TEST)), ([OVERFLOW_ERROR_TEST], str(OVERFLOW_ERROR_TEST)), + # iso 8601 + ([TODAY.isoformat()], "today"), ], ) def test_naturalday(test_args: list[typing.Any], expected: str) -> None: @@ -268,6 +322,10 @@ def test_naturalday(test_args: list[typing.Any], expected: str) -> None: (dt.date(2020, 12, 2), "Dec 02 2020"), (dt.date(2021, 1, 2), "Jan 02 2021"), (dt.date(2021, 2, 2), "Feb 02 2021"), + # iso 8601 + ("2019-09-02", "Sep 02 2019"), + ("2020-04-02", "Apr 02"), + ("2021-02-02", "Feb 02 2021"), ], ) def test_naturaldate(test_input: dt.date, expected: str) -> None: @@ -438,6 +496,11 @@ def test_naturaltime_minimum_unit_explicit( (NOW_UTC - dt.timedelta(days=365 + 35), "1 year, 1 month ago"), (NOW_UTC - dt.timedelta(days=365 * 2 + 65), "2 years ago"), (NOW_UTC - dt.timedelta(days=365 + 4), "1 year, 4 days ago"), + # iso 8601 + ( + (NOW_UTC - dt.timedelta(days=365 + 4)).isoformat(timespec="hours"), + "1 year, 4 days ago", + ), ], ) def test_naturaltime_timezone(test_input: dt.datetime, expected: str) -> None: