Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/humanize/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def _date_and_delta(
If that's not possible, return `(None, value)`.
"""
import datetime as dt
import math

if not now:
now = _now()
Expand All @@ -85,11 +86,19 @@ def _date_and_delta(
date = now - value
delta = value
else:
# Reject non-finite floats (inf, -inf, NaN) before they reach
# round() or dt.timedelta(): `round(float("inf"))` raises
# OverflowError, `dt.timedelta(seconds=float("inf"))` raises
# OverflowError, and `round(float("nan"))` raises ValueError. The
# downstream callers already treat `(None, value)` as "return
# str(value) unchanged", which matches the docstring contract.
if isinstance(value, float) and not math.isfinite(value):
return None, value
try:
value = value if precise else round(value)
delta = dt.timedelta(seconds=value)
date = now - delta
except (ValueError, TypeError):
except (ValueError, TypeError, OverflowError):
return None, value
return date, _abs_timedelta(delta)

Expand Down Expand Up @@ -275,6 +284,8 @@ def naturaltime(

Returns:
str: A natural representation of the input in a resolution that makes sense.
Non-finite floats (`inf`, `-inf`, `nan`) and unparseable inputs are
returned as their `str()` representation unchanged.
"""
import datetime as dt

Expand Down
58 changes: 58 additions & 0 deletions tests/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,3 +852,61 @@ def test_time_unit() -> None:
)
def test_rounding_by_fmt(fmt: str, value: float, expected: float) -> None:
assert time._rounding_by_fmt(fmt, value) == pytest.approx(expected)


@pytest.mark.parametrize(
"value, expected",
[
(float("inf"), "inf"),
(float("-inf"), "-inf"),
(float("nan"), "nan"),
],
)
def test_naturaltime_non_finite_returns_str(value: float, expected: str) -> None:
"""naturaltime(float) must not crash on non-finite floats.

Pre-fix, naturaltime(float('inf')) and naturaltime(float('-inf')) raised
OverflowError: cannot convert float infinity to integer from inside
_date_and_delta's round() call. NaN was already handled because
round(float('nan')) raises ValueError, which the except clause caught.
"""
assert humanize.naturaltime(value) == expected
assert humanize.naturaltime(value, future=True) == expected


@pytest.mark.parametrize(
"value, expected",
[
(float("inf"), "inf"),
(float("-inf"), "-inf"),
(float("nan"), "nan"),
],
)
def test_precisedelta_non_finite_returns_str(value: float, expected: str) -> None:
"""precisedelta(float) must not crash on non-finite floats.

Pre-fix, precisedelta(float('inf')) and precisedelta(float('-inf'))
raised OverflowError: cannot convert float infinity to integer from
_date_and_delta(value, precise=True). NaN was already handled.
"""
assert humanize.precisedelta(value) == expected
assert humanize.precisedelta(value, minimum_unit="microseconds") == expected


@pytest.mark.parametrize(
"value",
[float("inf"), float("-inf"), float("nan")],
)
def test_date_and_delta_non_finite_returns_none_tuple(value: float) -> None:
"""_date_and_delta must return (None, value) for non-finite floats.

This is the contract that the public-facing naturaltime/precisedelta
functions rely on to fall through to return str(value).
"""
date, delta = time._date_and_delta(value)
assert date is None
assert delta is value
# precise=True exercises the precisedelta code path
date_p, delta_p = time._date_and_delta(value, precise=True)
assert date_p is None
assert delta_p is value