diff --git a/src/humanize/number.py b/src/humanize/number.py index bfd2b48..f060b79 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -26,6 +26,17 @@ NumberOrString: TypeAlias = "float | str" +def _format_not_finite(value: float) -> str: + """Utility function to handle infinite and nan cases.""" + if math.isnan(value): + return "NaN" + if math.isinf(value) and value < 0: + return "-Inf" + if math.isinf(value) and value > 0: + return "+Inf" + return "" + + def ordinal(value: NumberOrString, gender: str = "male") -> str: """Converts an integer to its ordinal as a string. @@ -63,6 +74,8 @@ def ordinal(value: NumberOrString, gender: str = "male") -> str: str: Ordinal string. """ try: + if not math.isfinite(float(value)): + return _format_not_finite(float(value)) value = int(value) except (TypeError, ValueError): return str(value) @@ -135,11 +148,15 @@ def intcomma(value: NumberOrString, ndigits: int | None = None) -> str: try: if isinstance(value, str): value = value.replace(thousands_sep, "").replace(decimal_sep, ".") + if not math.isfinite(float(value)): + return _format_not_finite(float(value)) if "." in value: value = float(value) else: value = int(value) else: + if not math.isfinite(float(value)): + return _format_not_finite(float(value)) float(value) except (TypeError, ValueError): return str(value) @@ -208,6 +225,8 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: be coaxed into an `int`. """ try: + if not math.isfinite(float(value)): + return _format_not_finite(float(value)) value = int(value) except (TypeError, ValueError): return str(value) @@ -271,6 +290,8 @@ def apnumber(value: NumberOrString) -> str: is returned. """ try: + if not math.isfinite(float(value)): + return _format_not_finite(float(value)) value = int(value) except (TypeError, ValueError): return str(value) @@ -330,6 +351,8 @@ def fractional(value: NumberOrString) -> str: """ try: number = float(value) + if not math.isfinite(number): + return _format_not_finite(number) except (TypeError, ValueError): return str(value) whole_number = int(number) @@ -393,6 +416,8 @@ def scientific(value: NumberOrString, precision: int = 2) -> str: } try: value = float(value) + if not math.isfinite(value): + return _format_not_finite(value) except (ValueError, TypeError): return str(value) fmt = "{:.%se}" % str(int(precision)) @@ -460,6 +485,9 @@ def clamp( if value is None: return None + if not math.isfinite(value): + return _format_not_finite(value) + if floor is not None and value < floor: value = floor token = floor_token @@ -516,6 +544,8 @@ def metric(value: float, unit: str = "", precision: int = 3) -> str: Returns: str: """ + if not math.isfinite(value): + return _format_not_finite(value) exponent = int(math.floor(math.log10(abs(value)))) if value != 0 else 0 if exponent >= 27 or exponent < -24: diff --git a/tests/test_number.py b/tests/test_number.py index b429931..9f57c64 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -1,6 +1,7 @@ """Number tests.""" from __future__ import annotations +import math import typing import pytest @@ -25,6 +26,11 @@ ("111", "111th"), ("something else", "something else"), (None, "None"), + (math.nan, "NaN"), + (math.inf, "+Inf"), + (-math.inf, "-Inf"), + ("nan", "NaN"), + ("-inf", "-Inf"), ], ) def test_ordinal(test_input: str, expected: str) -> None: @@ -63,6 +69,11 @@ def test_ordinal(test_input: str, expected: str) -> None: ([1234.5454545, 2], "1,234.55"), ([1234.5454545, 3], "1,234.545"), ([1234.5454545, 10], "1,234.5454545000"), + ([math.nan], "NaN"), + ([math.inf], "+Inf"), + ([-math.inf], "-Inf"), + (["nan"], "NaN"), + (["-inf"], "-Inf"), ], ) def test_intcomma( @@ -107,6 +118,11 @@ def test_intword_powers() -> None: ([None], "None"), (["1230000", "%0.2f"], "1.23 million"), ([10**101], "1" + "0" * 101), + ([math.nan], "NaN"), + ([math.inf], "+Inf"), + ([-math.inf], "-Inf"), + (["nan"], "NaN"), + (["-inf"], "-Inf"), ], ) def test_intword(test_args: list[str], expected: str) -> None: @@ -125,6 +141,11 @@ def test_intword(test_args: list[str], expected: str) -> None: (10, "10"), ("7", "seven"), (None, "None"), + (math.nan, "NaN"), + (math.inf, "+Inf"), + (-math.inf, "-Inf"), + ("nan", "NaN"), + ("-inf", "-Inf"), ], ) def test_apnumber(test_input: int | str, expected: str) -> None: @@ -146,6 +167,11 @@ def test_apnumber(test_input: int | str, expected: str) -> None: (1.5, "1 1/2"), (0.3, "3/10"), (0.333, "333/1000"), + (math.nan, "NaN"), + (math.inf, "+Inf"), + (-math.inf, "-Inf"), + ("nan", "NaN"), + ("-inf", "-Inf"), ], ) def test_fractional(test_input: float | str, expected: str) -> None: @@ -172,6 +198,11 @@ def test_fractional(test_input: float | str, expected: str) -> None: ([float(2e-20)], "2.00 x 10⁻²⁰"), ([float(-3e20)], "-3.00 x 10²⁰"), ([float(-4e-20)], "-4.00 x 10⁻²⁰"), + ([math.nan], "NaN"), + ([math.inf], "+Inf"), + ([-math.inf], "-Inf"), + (["nan"], "NaN"), + (["-inf"], "-Inf"), ], ) def test_scientific(test_args: list[typing.Any], expected: str) -> None: @@ -189,6 +220,9 @@ def test_scientific(test_args: list[typing.Any], expected: str) -> None: ([0.0001, "{:.0%}", 0.01, None, "under ", None], "under 1%"), ([0.9999, "{:.0%}", None, 0.99, None, "above "], "above 99%"), ([1, humanize.intword, 1e6, None, "under "], "under 1.0 million"), + ([math.nan], "NaN"), + ([math.inf], "+Inf"), + ([-math.inf], "-Inf"), ], ) def test_clamp(test_args: list[typing.Any], expected: str) -> None: @@ -226,6 +260,10 @@ def test_clamp(test_args: list[typing.Any], expected: str) -> None: ([0.1, "°"], "100m°"), ([100], "100"), ([0.1], "100 m"), + ([math.nan], "NaN"), + ([math.nan, "m"], "NaN"), + ([math.inf], "+Inf"), + ([-math.inf], "-Inf"), ], ids=str, )