From 67a8a6b277af26e840eef1075ebdb9e2cb833df3 Mon Sep 17 00:00:00 2001 From: thomas Date: Sat, 8 Jul 2023 22:45:45 +0200 Subject: [PATCH 1/7] * time.py, added new function _parseiso(value) * relevant functions now call value = _parseiso(value) * wrote inline documentation, and short subparagraph to README.md * have not tested code in any way except the existing pytests which all succeeded * tox gives mypy errors. I was able to remove some, but not all. * tox gives lint errors. Have not figured out how to see what causes the errors. * tox gives griffe error, ast.BoolOp, but did so even before I edited code * not sure if Im using tox correctly. I simply run 'tox' --- README.md | 17 ++++++++++++++++ src/humanize/time.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/README.md b/README.md index c72a53c..3303174 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,23 @@ 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' +>>> humanize.naturaltime('22:00:10') # run at 22:00:00 +'10 seconds from now' + +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..6aa76b7 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -47,6 +47,47 @@ def _now() -> dt.datetime: return dt.datetime.now() +def _parseiso( + value: dt.time | dt.date | dt.datetime | float | str, +) -> dt.time | dt.date | dt.datetime | float | str: + """If string attempts to parse as iso8601 into date, datetime or time. + + Args: + value (str or any object): String to be parsed. + + Returns: + str (str or any): If string, it will attempt parsing as + datetime.date, datetime.datetime or datetime.time, in that order, + and return the result of the first succesful parsing, if any. + + Parsing is attempted with the iso8601 function for each of the classes. + + If `value` is not a string or if all 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 + + try: + # catches eg '20:00', '20:00:16.123456', 'T20:00Z' + return dt.time.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. @@ -246,6 +287,7 @@ def naturaltime( str: A natural representation of the input in a resolution that makes sense. """ now = when or _now() + value = _parseiso(value) if isinstance(value, dt.datetime) and value.tzinfo is not None: value = dt.datetime.fromtimestamp(value.timestamp()) @@ -274,6 +316,8 @@ def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: formatted according to `format`. """ + value = _parseiso(value) + try: value = dt.date(value.year, value.month, value.day) except AttributeError: @@ -298,6 +342,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 = _parseiso(value) + try: value = dt.date(value.year, value.month, value.day) except AttributeError: From 625955679129105fd31bd2020cba5e59b17a55a0 Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 9 Jul 2023 22:03:10 +0200 Subject: [PATCH 2/7] I realized that humanize does not support datetime.time at all, so I removed the ability for _isoparse() to parse into datetime.time. I edited inline documentation and README.md accordingly. --- README.md | 2 -- src/humanize/time.py | 25 ++++++++----------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3303174..5d865cd 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,6 @@ python3 -m pip install -e . 'Jun 05 2007' >>> humanize.naturaldate('2007-06-05T22:00:10+02:00') 'Jun 05 2007' ->>> humanize.naturaltime('22:00:10') # run at 22:00:00 -'10 seconds from now' Relies on the native `fromisoformat` function that exists on date, datetime and time objects. ``` diff --git a/src/humanize/time.py b/src/humanize/time.py index 6aa76b7..5117dde 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -48,23 +48,20 @@ def _now() -> dt.datetime: def _parseiso( - value: dt.time | dt.date | dt.datetime | float | str, -) -> dt.time | dt.date | dt.datetime | float | str: - """If string attempts to parse as iso8601 into date, datetime or time. + value: dt.date | dt.datetime | float | str, +) -> dt.date | dt.datetime | float | str: + """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 attempt parsing as - datetime.date, datetime.datetime or datetime.time, in that order, - and return the result of the first succesful parsing, if any. + str (str or any): If string, it will first attempt parsing with + date.fromisoformat. If that fails it will attempt parsing with + datetime.isoformat. - Parsing is attempted with the iso8601 function for each of the classes. - - If `value` is not a string or if all attempts at parsing fail, + If `value` is not a string or both attempts at parsing fail, `value` is returned as is. - """ if isinstance(value, str): try: @@ -79,12 +76,6 @@ def _parseiso( except ValueError: pass - try: - # catches eg '20:00', '20:00:16.123456', 'T20:00Z' - return dt.time.fromisoformat(value) - except ValueError: - pass - return value @@ -261,7 +252,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", From 4ec839e3addbda7dd9646a613a48634a2e78e8cb Mon Sep 17 00:00:00 2001 From: velle Date: Sun, 9 Jul 2023 23:01:42 +0200 Subject: [PATCH 3/7] Update src/humanize/time.py Co-authored-by: Hugo van Kemenade --- src/humanize/time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 5117dde..477f7e7 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -47,7 +47,7 @@ def _now() -> dt.datetime: return dt.datetime.now() -def _parseiso( +def _parse_iso( value: dt.date | dt.datetime | float | str, ) -> dt.date | dt.datetime | float | str: """If string attempts to parse as iso8601 date or datetime. From 9c969ccf8992fae13db42691db195518bc5edf63 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 24 Jul 2023 09:06:36 +0200 Subject: [PATCH 4/7] every call of _parseiso changed to _parse_iso --- src/humanize/time.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index 477f7e7..dd88e30 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -278,7 +278,7 @@ def naturaltime( str: A natural representation of the input in a resolution that makes sense. """ now = when or _now() - value = _parseiso(value) + value = _parse_iso(value) if isinstance(value, dt.datetime) and value.tzinfo is not None: value = dt.datetime.fromtimestamp(value.timestamp()) @@ -307,7 +307,7 @@ def naturalday(value: dt.date | dt.datetime, format: str = "%b %d") -> str: formatted according to `format`. """ - value = _parseiso(value) + value = _parse_iso(value) try: value = dt.date(value.year, value.month, value.day) @@ -333,7 +333,7 @@ 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 = _parseiso(value) + value = _parse_iso(value) try: value = dt.date(value.year, value.month, value.day) From 3517a9d078e235e8dcb6bdd56d7906f04848db05 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 24 Jul 2023 09:55:53 +0200 Subject: [PATCH 5/7] test_time.py, added iso8601 tests in a few relevant test_x functions --- tests/test_time.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/test_time.py b/tests/test_time.py index a4d40f9..615bf7d 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,8 @@ 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 +273,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 +319,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 +493,8 @@ 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: From d5eb5ac83b7721548ec6c0ea3cbf036b9d49024b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 07:56:13 +0000 Subject: [PATCH 6/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_time.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_time.py b/tests/test_time.py index 615bf7d..35d2665 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -211,7 +211,10 @@ def test_naturaldelta(test_input: int | dt.timedelta, expected: str) -> None: (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"), + ( + (NOW - dt.timedelta(days=365 + 4)).isoformat(timespec="hours"), + "1 year, 4 days ago", + ), ], ) def test_naturaltime(test_input: dt.datetime, expected: str) -> None: @@ -494,7 +497,10 @@ def test_naturaltime_minimum_unit_explicit( (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" ), + ( + (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: From 32014abdbd4026cec7c91fd01ec61aec4a5b75fd Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 25 Jul 2023 16:03:11 +0200 Subject: [PATCH 7/7] fixed mypy errors, but not happy with code in time.naturaltime that verifies value is not of type dt.date --- src/humanize/time.py | 12 +++++++++--- tests/test_time.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/humanize/time.py b/src/humanize/time.py index dd88e30..0b5ee60 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -47,9 +47,12 @@ def _now() -> dt.datetime: return dt.datetime.now() +T = typing.TypeVar("T") + + def _parse_iso( - value: dt.date | dt.datetime | float | str, -) -> dt.date | dt.datetime | float | str: + value: T, +) -> T | dt.date | dt.datetime: """If string attempts to parse as iso8601 date or datetime. Args: @@ -278,7 +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) + _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()) diff --git a/tests/test_time.py b/tests/test_time.py index 35d2665..e88a15d 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -99,7 +99,7 @@ def assert_equal_timedelta(td1: dt.timedelta, td2: dt.timedelta) -> None: ), ], ) -def test_parse_iso(test_input: Object, expected: Object) -> None: +def test_parse_iso(test_input: object, expected: object) -> None: assert time._parse_iso(test_input) == expected