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
8 changes: 6 additions & 2 deletions src/humanize/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions tests/test_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
Loading