From 2ded879a28b1c15e4bdb2fb8000f0394a2e1b9a3 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Date: Thu, 25 Jun 2026 12:39:40 -0700 Subject: [PATCH] Fix directional rounding for negative numbers ROUND_CEILING and ROUND_FLOOR produced the wrong result when formatting a negative number. NumberPattern.apply() strips the sign before any rounding (it formats the magnitude and renders the sign via the pattern affixes), so the directional rounding modes rounded the magnitude and ended up rounding toward the wrong infinity. For example, with ROUND_CEILING, format_decimal of -100.75 returned -101 instead of -100 (ceil(-100.75) == -100). Round using the value's original sign in both rounding sites, _quantize_value and _format_significant, then format the magnitude. The sign-symmetric modes (ROUND_HALF_*, ROUND_UP, ROUND_DOWN, ROUND_05UP) and all positive-number formatting are unaffected. Fixes #1096. --- babel/numbers.py | 24 +++++++++++++++++++----- tests/test_numbers.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 2ef9031aa..0e10919fa 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -1528,7 +1528,7 @@ def apply( # Render scientific notation. if self.exp_prec: number = ''.join([ - self._quantize_value(value, locale, frac_prec, group_separator, numbering_system=numbering_system), + self._quantize_value(value, locale, frac_prec, group_separator, numbering_system=numbering_system, is_negative=is_negative), get_exponential_symbol(locale, numbering_system=numbering_system), exp_sign, # type: ignore # exp_sign is always defined here self._format_int(str(exp), self.exp_prec[0], self.exp_prec[1], locale, numbering_system=numbering_system), # type: ignore # exp is always defined here @@ -1536,7 +1536,7 @@ def apply( # Is it a significant digits pattern? elif '@' in self.pattern: - text = self._format_significant(value, self.int_prec[0], self.int_prec[1]) + text = self._format_significant(value, self.int_prec[0], self.int_prec[1], is_negative=is_negative) a, sep, b = text.partition(".") number = self._format_int(a, 0, 1000, locale, numbering_system=numbering_system) if sep: @@ -1550,6 +1550,7 @@ def apply( frac_prec, group_separator, numbering_system=numbering_system, + is_negative=is_negative, ) retval = ''.join( @@ -1591,10 +1592,17 @@ def apply( # - Restore the original position of the decimal point, potentially # padding with zeroes on either side # - def _format_significant(self, value: decimal.Decimal, minimum: int, maximum: int) -> str: + def _format_significant(self, value: decimal.Decimal, minimum: int, maximum: int, *, is_negative: bool = False) -> str: exp = value.adjusted() scale = maximum - 1 - exp - digits = str(value.scaleb(scale).quantize(decimal.Decimal(1))) + # Round using the value's original sign so the directional rounding + # modes (ROUND_CEILING, ROUND_FLOOR) round toward the correct infinity + # for negative numbers. The magnitude alone is formatted here; the sign + # is rendered separately via the pattern's affixes. + scaled = value.scaleb(scale) + if is_negative: + scaled = scaled.copy_negate() + digits = str(scaled.quantize(decimal.Decimal(1)).copy_abs()) if scale <= 0: result = digits + '0' * -scale else: @@ -1639,12 +1647,18 @@ def _quantize_value( group_separator: bool, *, numbering_system: Literal["default"] | str, + is_negative: bool = False, ) -> str: # If the number is +/-Infinity, we can't quantize it if value.is_infinite(): return get_infinity_symbol(locale, numbering_system=numbering_system) quantum = get_decimal_quantum(frac_prec[1]) - rounded = value.quantize(quantum) + # Round using the value's original sign so the directional rounding + # modes (ROUND_CEILING, ROUND_FLOOR) round toward the correct infinity + # for negative numbers. The magnitude alone is formatted here; the sign + # is rendered separately via the pattern's affixes. + signed_value = value.copy_negate() if is_negative else value + rounded = signed_value.quantize(quantum).copy_abs() a, sep, b = f"{rounded:f}".partition(".") integer_part = a if group_separator: diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 4f24f5b88..47a89687a 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -247,6 +247,34 @@ def test_format_decimal_quantization(): '0.9999999999', locale=locale_code, decimal_quantization=False).endswith('9999999999') is True +@pytest.mark.parametrize('rounding, expected', [ + (decimal.ROUND_CEILING, '-100'), + (decimal.ROUND_FLOOR, '-101'), + (decimal.ROUND_UP, '-101'), + (decimal.ROUND_DOWN, '-100'), + (decimal.ROUND_HALF_UP, '-101'), + (decimal.ROUND_HALF_EVEN, '-101'), +]) +def test_format_decimal_negative_directional_rounding(rounding, expected): + # The directional rounding modes (ROUND_CEILING / ROUND_FLOOR) must round + # a negative number toward the correct infinity, matching decimal.quantize + # on the signed value, rather than rounding its magnitude. + # See https://github.com/python-babel/babel/issues/1096 + with decimal.localcontext(decimal.Context(rounding=rounding)): + assert numbers.format_decimal( + decimal.Decimal('-100.75'), format='#', locale='en_US') == expected + + +def test_format_decimal_negative_directional_rounding_significant(): + # The significant-digits path must honor the sign for directional rounding too. + with decimal.localcontext(decimal.Context(rounding=decimal.ROUND_CEILING)): + assert numbers.format_decimal( + decimal.Decimal('-100.75'), format='@@', locale='en_US') == '-100' + with decimal.localcontext(decimal.Context(rounding=decimal.ROUND_FLOOR)): + assert numbers.format_decimal( + decimal.Decimal('-100.75'), format='@@', locale='en_US') == '-110' + + def test_format_currency(): assert (numbers.format_currency(1099.98, 'USD', locale='en_US') == '$1,099.98')