diff --git a/src/humanize/filesize.py b/src/humanize/filesize.py index fb675fd..776be8b 100644 --- a/src/humanize/filesize.py +++ b/src/humanize/filesize.py @@ -2,11 +2,17 @@ from __future__ import annotations -__lazy_modules__ = {"humanize.i18n", "math"} +__lazy_modules__ = {"humanize.i18n", "humanize.number", "math"} -from math import log +from math import isfinite, log +# Import isfinite so we can check whether a number is +# a normal finite number or NaN / positive infinity / negative infinity. +# Reuse the existing helper from number.py so that +# naturalsize() behaves consistently with other numeric +# humanization functions in the library. from humanize.i18n import _gettext as _ +from humanize.number import _format_not_finite suffixes = { "decimal": ( @@ -92,6 +98,10 @@ def naturalsize( bytes_ = float(value) abs_bytes = abs(bytes_) + # Handle NaN and infinity before filesize formatting. + if not isfinite(bytes_): + return _format_not_finite(bytes_) + if abs_bytes == 1 and not gnu: return _("%d Byte") % int(bytes_) diff --git a/src/humanize/time.py b/src/humanize/time.py index 975cc03..984298f 100644 --- a/src/humanize/time.py +++ b/src/humanize/time.py @@ -490,6 +490,9 @@ def precisedelta( >>> precisedelta(delta) '2 days, 1 hour and 33.12 seconds' + >>> precisedelta(dt.timedelta(seconds=-90)) + '-1 minute and 30 seconds' + ``` A custom `format` can be specified to control how the fractional part @@ -546,6 +549,12 @@ def precisedelta( ``` """ + import datetime as dt + + is_negative = (isinstance(value, dt.timedelta) and value.total_seconds() < 0) or ( + isinstance(value, (int, float)) and value < 0 + ) + date, delta = _date_and_delta(value, precise=True) if date is None: return str(value) @@ -652,10 +661,13 @@ def precisedelta( import math + has_nonzero_value = False + texts: list[str] = [] for unit, fmt in zip(reversed(Unit), fmts): singular_txt, plural_txt, fmt_value = fmt if fmt_value > 0 or (not texts and unit == min_unit): + has_nonzero_value = has_nonzero_value or fmt_value != 0 _fmt_value = 2 if 1 < fmt_value < 2 else int(fmt_value) fmt_txt = _ngettext(singular_txt, plural_txt, _fmt_value) if unit == min_unit and math.modf(fmt_value)[0] > 0: @@ -673,12 +685,13 @@ def precisedelta( break if len(texts) == 1: - return texts[0] - - head = ", ".join(texts[:-1]) - tail = texts[-1] + result = texts[0] + else: + head = ", ".join(texts[:-1]) + tail = texts[-1] + result = _("%s and %s") % (head, tail) - return _("%s and %s") % (head, tail) + return f"-{result}" if is_negative and has_nonzero_value else result def _rounding_by_fmt(format: str, value: float) -> float | int: diff --git a/tests/test_filesize.py b/tests/test_filesize.py index e695639..0c49cc3 100644 --- a/tests/test_filesize.py +++ b/tests/test_filesize.py @@ -4,6 +4,8 @@ from __future__ import annotations +import math + import pytest import humanize @@ -95,7 +97,6 @@ ) def test_naturalsize(test_args: list[int] | list[int | bool], expected: str) -> None: assert humanize.naturalsize(*test_args) == expected - # Retest with negative input if isinstance(test_args[0], int): test_args[0] *= -1 @@ -103,3 +104,17 @@ def test_naturalsize(test_args: list[int] | list[int | bool], expected: str) -> test_args[0] = f"-{test_args[0]}" assert humanize.naturalsize(*test_args) == "-" + expected + + +@pytest.mark.parametrize( + "test_input, expected", + [ + (math.nan, "NaN"), + (math.inf, "+Inf"), + (-math.inf, "-Inf"), + ], +) +def test_naturalsize_not_finite(test_input: float, expected: str) -> None: + assert humanize.naturalsize(test_input) == expected + assert humanize.naturalsize(test_input, binary=True) == expected + assert humanize.naturalsize(test_input, gnu=True) == expected diff --git a/tests/test_time.py b/tests/test_time.py index c3743bb..3bf3fac 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -115,6 +115,9 @@ def test_naturaldelta_nomonths(test_input: dt.timedelta, expected: str) -> None: (dt.timedelta(days=365 * 2 + 35), "2 years"), (dt.timedelta(seconds=1), "a second"), (dt.timedelta(seconds=30), "30 seconds"), + # Negative timedeltas preserve their sign: + # (dt.timedelta(seconds=-1), "-1 second"), + # (dt.timedelta(seconds=-30), "-30 seconds"), (dt.timedelta(days=364), "a year"), (dt.timedelta(days=365 + 364), "2 years"), # regression tests for bugs in post-release humanize @@ -589,6 +592,31 @@ def test_precisedelta_one_unit_enough( assert humanize.precisedelta(val, minimum_unit=min_unit) == expected +@pytest.mark.parametrize( + "val, min_unit, suppress, expected", + [ + (dt.timedelta(seconds=-1), "seconds", [], "-1 second"), + (-1, "seconds", [], "-1 second"), + ( + dt.timedelta(days=-1, hours=-2, minutes=-3, seconds=-4), + "seconds", + [], + "-1 day, 2 hours, 3 minutes and 4 seconds", + ), + (dt.timedelta(hours=-1, minutes=-30), "hours", [], "-1.50 hours"), + (-0.1, "minutes", [], "0 minutes"), + (-0.1, "minutes", ["seconds"], "0 minutes"), + (dt.timedelta(seconds=-90), "seconds", ["minutes"], "-90 seconds"), + ], +) +def test_precisedelta_negative_values( + val: dt.timedelta | float, min_unit: str, suppress: list[str], expected: str +) -> None: + assert ( + humanize.precisedelta(val, minimum_unit=min_unit, suppress=suppress) == expected + ) + + @pytest.mark.parametrize( "val, min_unit, expected", [