From eda405bd598b7d5e17eca1c11b76cfbe011aa5da Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Mon, 6 Jul 2026 19:00:36 +0200 Subject: [PATCH] Fix intword() rounding carry for very large numbers intword() detected the post-rounding carry into the next magnitude with 'rounded_value * power == powers[ordinal + 1]'. Above ~10**22 that product is evaluated in floating point and no longer equals the exact next power, so the carry was skipped and the value was rendered against the lower magnitude: intword(10**24 - 1) returned '1000.0 sextillion' instead of '1.0 septillion' (same for 10**27, 10**30, 10**33). Compare rounded_value against the exact integer ratio powers[ordinal+1] // power instead, keeping the comparison in exact integer/short-float terms. This mirrors the recently fixed carry handling in metric() (#328) and naturalsize() (#329). --- src/humanize/number.py | 8 ++++++-- tests/test_number.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/humanize/number.py b/src/humanize/number.py index 2fb22c6..90885dc 100644 --- a/src/humanize/number.py +++ b/src/humanize/number.py @@ -255,8 +255,12 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str: chopped = value / power rounded_value = float(format % chopped) - if not largest_ordinal and rounded_value * power == powers[ordinal + 1]: - # After rounding, we end up just at the next power + if not largest_ordinal and rounded_value == powers[ordinal + 1] // power: + # After rounding, we end up just at the next power. Compare against the + # integer ratio between the two powers instead of ``rounded_value * power``: + # for values above ~10**22 the latter is evaluated in floating point and + # no longer equals the exact ``powers[ordinal + 1]``, so the carry was + # silently skipped (e.g. 10**24 - 1 rendered as "1000.0 sextillion"). ordinal += 1 rounded_value = 1.0 diff --git a/tests/test_number.py b/tests/test_number.py index 78639c3..5ed63f3 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -141,6 +141,47 @@ def test_intword(test_args: list[str], expected: str) -> None: assert humanize.intword(*test_args) == expected +def test_intword_rounding_rollover() -> None: + """Values that round up to the next power must carry to the next unit. + + Regression: for magnitudes above ~10**22 the carry was checked in floating + point (``rounded_value * power == powers[ordinal + 1]``) and no longer + matched the exact integer power, so e.g. ``10**24 - 1`` was rendered as + "1000.0 sextillion" instead of "1.0 septillion". + """ + units = [ + "thousand", + "million", + "billion", + "trillion", + "quadrillion", + "quintillion", + "sextillion", + "septillion", + "octillion", + "nonillion", + "decillion", + ] + # value = 10**e - 1 rounds up to 10**e with "%.1f"/"%.0f", i.e. exactly 1.0 + # of the unit sitting at 10**e (units[i] where 10**e == powers[i]). + for i, exponent in enumerate(range(6, 34, 3), start=1): + value = 10**exponent - 1 + assert humanize.intword(value) == f"1.0 {units[i]}" + assert humanize.intword(value, "%.0f") == f"1 {units[i]}" + + # The mantissa must never render at or above 1000 for values below a + # decillion; a bare "1000.0" is only expected in the sparse gap between + # decillion and googol, which has no dedicated unit. + for exponent in range(6, 34, 3): + rendered = humanize.intword(10**exponent - 1) + mantissa = float(rendered.split(" ", 1)[0]) + assert mantissa < 1000 + + # The documented decillion..googol gap must be left untouched. + assert humanize.intword(10**36) == "1000.0 decillion" + assert humanize.intword(2 * 10**100) == "2.0 googol" + + @pytest.mark.parametrize( "test_input, expected", [