diff --git a/src/humanize/number.py b/src/humanize/number.py index 52a5356a..d4f0bd11 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -77,6 +77,15 @@ ) +def _is_integer(value: NumberOrString) -> bool: + """Whether a value is an integer, including one too large for a float.""" + try: + int(value) + except (TypeError, ValueError, OverflowError): + return False + return True + + def _format_not_finite(value: float) -> str: """Utility function to handle infinite and nan cases.""" import math @@ -329,10 +338,16 @@ def apnumber(value: NumberOrString) -> str: import math try: - if not math.isfinite(float(value)): - return _format_not_finite(float(value)) value = int(value) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): + # Not int-able directly: it may still be a float or float-like string, + # in which case the non-finite spellings are reported as such. + try: + float_value = float(value) + except (TypeError, ValueError): + return str(value) + if not math.isfinite(float_value): + return _format_not_finite(float_value) return str(value) if not 0 <= value < 10: return str(value) @@ -380,10 +395,33 @@ def fractional(value: NumberOrString) -> str: """ import math + if isinstance(value, int) and not isinstance(value, bool): + # An integer has no fractional part, and float() would silently round + # one beyond 2**53 (e.g. 2**53 + 1), so return it exactly. + return str(value) + + if isinstance(value, str): + # An integer written as a string rounds the same way, so take it + # through int() rather than float(). A non-integer string such as + # "1.5" falls through to the fraction logic below. + try: + return str(int(value)) + except ValueError: + pass + try: number = float(value) if not math.isfinite(number): + # float() reports an integer beyond its range as infinite rather + # than raising, so an infinity here is only genuine if the value + # is not itself an integer. Either way there is no fractional + # part to extract, but only one of the two is "+Inf". + if _is_integer(value): + return str(value) return _format_not_finite(number) + except OverflowError: + # An integer too large for float to represent at all. + return str(value) except (TypeError, ValueError): return str(value) from fractions import Fraction diff --git a/tests/test_number.py b/tests/test_number.py index 5fb12fa6..1a611742 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -206,6 +206,13 @@ def test_intword_rounding_rollover() -> None: (-math.inf, "-Inf"), ("nan", "NaN"), ("-inf", "-Inf"), + # Integers beyond the float range are still int-able, so they are + # returned rather than raising OverflowError. + (10**400, "1" + "0" * 400), + ("1" + "0" * 400, "1" + "0" * 400), + (-(10**400), "-1" + "0" * 400), + # Still genuinely infinite, because this is not an integer. + ("1e400", "+Inf"), ], ) def test_apnumber(test_input: int | str, expected: str) -> None: @@ -240,6 +247,18 @@ def test_apnumber(test_input: int | str, expected: str) -> None: (2.9999999, "3"), (0.9999999, "1"), (-2.9999999, "-3"), + # An integer beyond the float range has no fractional part to take, + # and float() reports it as infinite rather than raising. + (10**400, "1" + "0" * 400), + ("1" + "0" * 400, "1" + "0" * 400), + (-(10**400), "-1" + "0" * 400), + # Still genuinely infinite, because this is not an integer. + ("1e400", "+Inf"), + (2**53 + 1, "9007199254740993"), + (-(2**53 + 1), "-9007199254740993"), + # The same integer written as a string rounds through float() too. + (str(2**53 + 1), "9007199254740993"), + (str(-(2**53 + 1)), "-9007199254740993"), ], ) def test_fractional(test_input: float | str, expected: str) -> None: