From 00927aa169006d58105b98a66fc635c6e855ce95 Mon Sep 17 00:00:00 2001 From: Sreekant Baheti <60787859+Sreekant13@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:20:47 -0700 Subject: [PATCH 1/2] Select the compact pattern from the rounded value format_compact_decimal chose the magnitude from the unrounded number and only rounded afterwards, so a value that rounds up into the next magnitude kept the smaller unit: 999999 rendered as "1000K" instead of "1M", and likewise "1000M" for 999999999 and "1000 thousand" in the long format. The CLDR algorithm that _get_compact_format documents selects the pattern from the rounded value, so redo the lookup one magnitude up when rounding carries over. Values that do not carry over are unaffected. --- babel/numbers.py | 22 ++++++++++++++-------- tests/test_numbers_format_decimal.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index b782521e5..fe72409d2 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -668,7 +668,8 @@ def _get_compact_format( if number.is_nan() or number.is_infinite(): return number, None format = None - for magnitude in sorted([int(m) for m in compact_format["other"]], reverse=True): + magnitudes = sorted([int(m) for m in compact_format["other"]], reverse=True) + for index, magnitude in enumerate(magnitudes): if abs(number) >= magnitude: # check the pattern using "other" as the amount format = compact_format["other"][str(magnitude)] @@ -678,17 +679,22 @@ def _get_compact_format( break # otherwise, we need to divide the number by the magnitude but remove zeros # equal to the number of 0's in the pattern minus 1 - number = cast( - decimal.Decimal, - number / (magnitude // (10 ** (pattern.count("0") - 1))), - ) + divisor = magnitude // (10 ** (pattern.count("0") - 1)) + scaled = cast(decimal.Decimal, number / divisor) # round to the number of fraction digits requested - rounded = round(number, fraction_digits) + rounded = round(scaled, fraction_digits) + # Rounding can carry the value up into the next magnitude, e.g. + # 999_999 would otherwise render as "1000K". The CLDR algorithm + # selects the pattern from the rounded value, so redo the lookup + # one magnitude up to get "1M" instead. + if index > 0 and abs(rounded) * divisor >= magnitudes[index - 1]: + bumped = decimal.Decimal(magnitudes[index - 1]).copy_sign(number) + return _get_compact_format(bumped, compact_format, locale, fraction_digits) # if the remaining number is singular, use the singular format - plural_form = locale.plural_form(abs(number)) + plural_form = locale.plural_form(abs(scaled)) if plural_form not in compact_format: plural_form = "other" - if number == 1 and "1" in compact_format: + if scaled == 1 and "1" in compact_format: plural_form = "1" if str(magnitude) not in compact_format[plural_form]: plural_form = "other" # fall back to other as the implicit default diff --git a/tests/test_numbers_format_decimal.py b/tests/test_numbers_format_decimal.py index 98660a5ff..124c5b96d 100644 --- a/tests/test_numbers_format_decimal.py +++ b/tests/test_numbers_format_decimal.py @@ -175,3 +175,17 @@ def test_compact(): assert numbers.format_compact_decimal( 12345, format_type="short", locale='ar_EG', fraction_digits=2, numbering_system='latn', ) == '12.34\xa0ألف' + + +def test_compact_rounding_carries_to_next_magnitude(): + # Rounding can carry the value up into the next magnitude. The pattern has + # to be chosen from the rounded value, otherwise 999999 renders as the + # nonsensical "1000K" instead of "1M". + assert numbers.format_compact_decimal(999999, locale='en_US', format_type="short") == '1M' + assert numbers.format_compact_decimal(999999999, locale='en_US', format_type="short") == '1B' + assert numbers.format_compact_decimal(999999999999, locale='en_US', format_type="short") == '1T' + assert numbers.format_compact_decimal(-999999, locale='en_US', format_type="short") == '-1M' + assert numbers.format_compact_decimal(999999, locale='en_US', format_type="long") == '1 million' + # Values that do not carry over are unaffected. + assert numbers.format_compact_decimal(999499, locale='en_US', format_type="short") == '999K' + assert numbers.format_compact_decimal(999949, locale='en_US', format_type="short", fraction_digits=1) == '999.9K' From ea619b24c3a30edd5c6973a76c1e5efa6d82e82e Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 30 Oct 2026 13:09:49 +0200 Subject: [PATCH 2/2] Unnest _get_compact_format loop --- babel/numbers.py | 62 +++++++++++++++++++++++++----------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index fe72409d2..09e7a767f 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -669,38 +669,40 @@ def _get_compact_format( return number, None format = None magnitudes = sorted([int(m) for m in compact_format["other"]], reverse=True) + abs_number = abs(number) for index, magnitude in enumerate(magnitudes): - if abs(number) >= magnitude: - # check the pattern using "other" as the amount - format = compact_format["other"][str(magnitude)] - pattern = parse_pattern(format).pattern - # if the pattern is "0", we do not divide the number - if pattern == "0": - break - # otherwise, we need to divide the number by the magnitude but remove zeros - # equal to the number of 0's in the pattern minus 1 - divisor = magnitude // (10 ** (pattern.count("0") - 1)) - scaled = cast(decimal.Decimal, number / divisor) - # round to the number of fraction digits requested - rounded = round(scaled, fraction_digits) - # Rounding can carry the value up into the next magnitude, e.g. - # 999_999 would otherwise render as "1000K". The CLDR algorithm - # selects the pattern from the rounded value, so redo the lookup - # one magnitude up to get "1M" instead. - if index > 0 and abs(rounded) * divisor >= magnitudes[index - 1]: - bumped = decimal.Decimal(magnitudes[index - 1]).copy_sign(number) - return _get_compact_format(bumped, compact_format, locale, fraction_digits) - # if the remaining number is singular, use the singular format - plural_form = locale.plural_form(abs(scaled)) - if plural_form not in compact_format: - plural_form = "other" - if scaled == 1 and "1" in compact_format: - plural_form = "1" - if str(magnitude) not in compact_format[plural_form]: - plural_form = "other" # fall back to other as the implicit default - format = compact_format[plural_form][str(magnitude)] - number = rounded + if abs_number < magnitude: + continue + # check the pattern using "other" as the amount + format = compact_format["other"][str(magnitude)] + pattern = parse_pattern(format).pattern + # if the pattern is "0", we do not divide the number + if pattern == "0": break + # otherwise, we need to divide the number by the magnitude but remove zeros + # equal to the number of 0's in the pattern minus 1 + divisor = magnitude // (10 ** (pattern.count("0") - 1)) + scaled = cast(decimal.Decimal, number / divisor) + # round to the number of fraction digits requested + rounded = round(scaled, fraction_digits) + # Rounding can carry the value up into the next magnitude, e.g. + # 999_999 would otherwise render as "1000K". The CLDR algorithm + # selects the pattern from the rounded value, so redo the lookup + # one magnitude up to get "1M" instead. + if index > 0 and abs(rounded) * divisor >= magnitudes[index - 1]: + bumped = decimal.Decimal(magnitudes[index - 1]).copy_sign(number) + return _get_compact_format(bumped, compact_format, locale, fraction_digits) + # if the remaining number is singular, use the singular format + plural_form = locale.plural_form(abs(scaled)) + if plural_form not in compact_format: + plural_form = "other" + if scaled == 1 and "1" in compact_format: + plural_form = "1" + if str(magnitude) not in compact_format[plural_form]: + plural_form = "other" # fall back to other as the implicit default + format = compact_format[plural_form][str(magnitude)] + number = rounded + break return number, format