Skip to content

Commit ca892b3

Browse files
authored
Fix intword() rounding carry for very large numbers (#346)
1 parent f971127 commit ca892b3

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

src/humanize/number.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,12 @@ def intword(value: NumberOrString, format: str = "%.1f") -> str:
282282
chopped = value / power
283283
rounded_value = float(format % chopped)
284284

285-
if not largest_ordinal and rounded_value * power == powers[ordinal + 1]:
286-
# After rounding, we end up just at the next power
285+
if not largest_ordinal and rounded_value == powers[ordinal + 1] // power:
286+
# After rounding, we end up just at the next power. Compare against the
287+
# integer ratio between the two powers instead of ``rounded_value * power``:
288+
# for values above ~10**22 the latter is evaluated in floating point and
289+
# no longer equals the exact ``powers[ordinal + 1]``, so the carry was
290+
# silently skipped (e.g. 10**24 - 1 rendered as "1000.0 sextillion").
287291
ordinal += 1
288292
rounded_value = 1.0
289293

tests/test_number.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,47 @@ def test_intword(test_args: list[str], expected: str) -> None:
148148
assert humanize.intword(*test_args) == expected
149149

150150

151+
def test_intword_rounding_rollover() -> None:
152+
"""Values that round up to the next power must carry to the next unit.
153+
154+
Regression: for magnitudes above ~10**22 the carry was checked in floating
155+
point (``rounded_value * power == powers[ordinal + 1]``) and no longer
156+
matched the exact integer power, so e.g. ``10**24 - 1`` was rendered as
157+
"1000.0 sextillion" instead of "1.0 septillion".
158+
"""
159+
units = [
160+
"thousand",
161+
"million",
162+
"billion",
163+
"trillion",
164+
"quadrillion",
165+
"quintillion",
166+
"sextillion",
167+
"septillion",
168+
"octillion",
169+
"nonillion",
170+
"decillion",
171+
]
172+
# value = 10**e - 1 rounds up to 10**e with "%.1f"/"%.0f", i.e. exactly 1.0
173+
# of the unit sitting at 10**e (units[i] where 10**e == powers[i]).
174+
for i, exponent in enumerate(range(6, 34, 3), start=1):
175+
value = 10**exponent - 1
176+
assert humanize.intword(value) == f"1.0 {units[i]}"
177+
assert humanize.intword(value, "%.0f") == f"1 {units[i]}"
178+
179+
# The mantissa must never render at or above 1000 for values below a
180+
# decillion; a bare "1000.0" is only expected in the sparse gap between
181+
# decillion and googol, which has no dedicated unit.
182+
for exponent in range(6, 34, 3):
183+
rendered = humanize.intword(10**exponent - 1)
184+
mantissa = float(rendered.split(" ", 1)[0])
185+
assert mantissa < 1000
186+
187+
# The documented decillion..googol gap must be left untouched.
188+
assert humanize.intword(10**36) == "1000.0 decillion"
189+
assert humanize.intword(2 * 10**100) == "2.0 googol"
190+
191+
151192
@pytest.mark.parametrize(
152193
"test_input, expected",
153194
[

0 commit comments

Comments
 (0)