Skip to content

Commit 2ded879

Browse files
committed
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.
1 parent baf9431 commit 2ded879

2 files changed

Lines changed: 47 additions & 5 deletions

File tree

babel/numbers.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,15 +1528,15 @@ def apply(
15281528
# Render scientific notation.
15291529
if self.exp_prec:
15301530
number = ''.join([
1531-
self._quantize_value(value, locale, frac_prec, group_separator, numbering_system=numbering_system),
1531+
self._quantize_value(value, locale, frac_prec, group_separator, numbering_system=numbering_system, is_negative=is_negative),
15321532
get_exponential_symbol(locale, numbering_system=numbering_system),
15331533
exp_sign, # type: ignore # exp_sign is always defined here
15341534
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
15351535
]) # fmt: skip
15361536

15371537
# Is it a significant digits pattern?
15381538
elif '@' in self.pattern:
1539-
text = self._format_significant(value, self.int_prec[0], self.int_prec[1])
1539+
text = self._format_significant(value, self.int_prec[0], self.int_prec[1], is_negative=is_negative)
15401540
a, sep, b = text.partition(".")
15411541
number = self._format_int(a, 0, 1000, locale, numbering_system=numbering_system)
15421542
if sep:
@@ -1550,6 +1550,7 @@ def apply(
15501550
frac_prec,
15511551
group_separator,
15521552
numbering_system=numbering_system,
1553+
is_negative=is_negative,
15531554
)
15541555

15551556
retval = ''.join(
@@ -1591,10 +1592,17 @@ def apply(
15911592
# - Restore the original position of the decimal point, potentially
15921593
# padding with zeroes on either side
15931594
#
1594-
def _format_significant(self, value: decimal.Decimal, minimum: int, maximum: int) -> str:
1595+
def _format_significant(self, value: decimal.Decimal, minimum: int, maximum: int, *, is_negative: bool = False) -> str:
15951596
exp = value.adjusted()
15961597
scale = maximum - 1 - exp
1597-
digits = str(value.scaleb(scale).quantize(decimal.Decimal(1)))
1598+
# Round using the value's original sign so the directional rounding
1599+
# modes (ROUND_CEILING, ROUND_FLOOR) round toward the correct infinity
1600+
# for negative numbers. The magnitude alone is formatted here; the sign
1601+
# is rendered separately via the pattern's affixes.
1602+
scaled = value.scaleb(scale)
1603+
if is_negative:
1604+
scaled = scaled.copy_negate()
1605+
digits = str(scaled.quantize(decimal.Decimal(1)).copy_abs())
15981606
if scale <= 0:
15991607
result = digits + '0' * -scale
16001608
else:
@@ -1639,12 +1647,18 @@ def _quantize_value(
16391647
group_separator: bool,
16401648
*,
16411649
numbering_system: Literal["default"] | str,
1650+
is_negative: bool = False,
16421651
) -> str:
16431652
# If the number is +/-Infinity, we can't quantize it
16441653
if value.is_infinite():
16451654
return get_infinity_symbol(locale, numbering_system=numbering_system)
16461655
quantum = get_decimal_quantum(frac_prec[1])
1647-
rounded = value.quantize(quantum)
1656+
# Round using the value's original sign so the directional rounding
1657+
# modes (ROUND_CEILING, ROUND_FLOOR) round toward the correct infinity
1658+
# for negative numbers. The magnitude alone is formatted here; the sign
1659+
# is rendered separately via the pattern's affixes.
1660+
signed_value = value.copy_negate() if is_negative else value
1661+
rounded = signed_value.quantize(quantum).copy_abs()
16481662
a, sep, b = f"{rounded:f}".partition(".")
16491663
integer_part = a
16501664
if group_separator:

tests/test_numbers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,34 @@ def test_format_decimal_quantization():
247247
'0.9999999999', locale=locale_code, decimal_quantization=False).endswith('9999999999') is True
248248

249249

250+
@pytest.mark.parametrize('rounding, expected', [
251+
(decimal.ROUND_CEILING, '-100'),
252+
(decimal.ROUND_FLOOR, '-101'),
253+
(decimal.ROUND_UP, '-101'),
254+
(decimal.ROUND_DOWN, '-100'),
255+
(decimal.ROUND_HALF_UP, '-101'),
256+
(decimal.ROUND_HALF_EVEN, '-101'),
257+
])
258+
def test_format_decimal_negative_directional_rounding(rounding, expected):
259+
# The directional rounding modes (ROUND_CEILING / ROUND_FLOOR) must round
260+
# a negative number toward the correct infinity, matching decimal.quantize
261+
# on the signed value, rather than rounding its magnitude.
262+
# See https://github.com/python-babel/babel/issues/1096
263+
with decimal.localcontext(decimal.Context(rounding=rounding)):
264+
assert numbers.format_decimal(
265+
decimal.Decimal('-100.75'), format='#', locale='en_US') == expected
266+
267+
268+
def test_format_decimal_negative_directional_rounding_significant():
269+
# The significant-digits path must honor the sign for directional rounding too.
270+
with decimal.localcontext(decimal.Context(rounding=decimal.ROUND_CEILING)):
271+
assert numbers.format_decimal(
272+
decimal.Decimal('-100.75'), format='@@', locale='en_US') == '-100'
273+
with decimal.localcontext(decimal.Context(rounding=decimal.ROUND_FLOOR)):
274+
assert numbers.format_decimal(
275+
decimal.Decimal('-100.75'), format='@@', locale='en_US') == '-110'
276+
277+
250278
def test_format_currency():
251279
assert (numbers.format_currency(1099.98, 'USD', locale='en_US')
252280
== '$1,099.98')

0 commit comments

Comments
 (0)