Repository: python-babel/babel (python)

Issue:
Bypass number rounding
`babel.numbers.format_decimal()` and `babel.numbers.format_currency()` have a built-in banker rounding implemented. See: https://github.com/python-babel/babel/blob/3ec7bb104e808d1fda16fbf9fec4b9d7efccaa3f/babel/numbers.py#L648-L649

If we need to localize numbers without rounding, we can't use these methods.

The minimal default decimal precision per method are:

  * `babel.numbers.format_decimal()` => 3 digits after the dot
  * `babel.numbers.format_currency()` => 2 digits after the dot

Proof:

```python
>>> import babel
>>> set([babel.Locale.parse(l).decimal_formats._data[None].frac_prec
...      for l in babel.localedata.locale_identifiers()])
set([(0, 6), (0, 3)])
>>>
```

```python
>>> import babel
>>> set([babel.Locale.parse(l).currency_formats._data[None].frac_prec
...      for l in babel.localedata.locale_identifiers()])
set([(2, 2)])
>>>
```

So if you have monetary amounts to localize, and they're already rounded to 2 trailing digits, then you're lucky. Using any of the `format_*()` methods will have no side effects.

But for other numbers with higher precision, using `format_*()` as-is is dangerous. It will introduce unwanted rounding. For example, calling `format_currency(0.9999, 'EUR', locale='fr')` will return `1,00 €`. I expect here to get the pristine `0,9999 €` string.

I think there must be a clean and documented way to bypass arbitrary rounding when localizing numbers.


Pull request:
Allow full control on Decimal behaviour
It turns out that the solution to allow the user to change the rounding mode was way simpler than expected at the beginning.

According to [UTS #35][1] sections [3.3][2] and [3.7][3], the user is allowed to change rounding mode.  But the default should be _half even_ mode.  This is already the default Python rounding mode in all decimal implementations, so the solution was to stop indicating the rounding mode on `Decimal` operations.

Some documentation has been added to show how to properly change the rounding mode and other `Context` parameters.

Therefore, this should fix #90 

[1]: http://www.unicode.org/reports/tr35/tr35-numbers.html
[2]: http://www.unicode.org/reports/tr35/tr35-numbers.html#Formatting
[3]: http://www.unicode.org/reports/tr35/tr35-numbers.html#Rounding

Patch:
diff --git a/babel/_compat.py b/babel/_compat.py
index 75abf9e..aea3389 100644
--- a/babel/_compat.py
+++ b/babel/_compat.py
@@ -58,19 +58,13 @@ number_types = integer_types + (float,)
 
 
 #
-# Use cdecimal when available
+# Since Python 3.3, a fast decimal implementation is already included in the
+# standard library.  Otherwise use cdecimal when available
 #
-from decimal import (Decimal as _dec,
-                     InvalidOperation as _invop,
-                     ROUND_HALF_EVEN as _RHE)
-try:
-    from cdecimal import (Decimal as _cdec,
-                          InvalidOperation as _cinvop,
-                          ROUND_HALF_EVEN as _CRHE)
-    Decimal = _cdec
-    InvalidOperation = (_invop, _cinvop)
-    ROUND_HALF_EVEN = _CRHE
-except ImportError:
-    Decimal = _dec
-    InvalidOperation = _invop
-    ROUND_HALF_EVEN = _RHE
+if sys.version_info[:2] >= (3, 3):
+    import decimal
+else:
+    try:
+        import cdecimal as decimal
+    except ImportError:
+        import decimal
diff --git a/babel/numbers.py b/babel/numbers.py
index 3ab366c..f9be88d 100644
--- a/babel/numbers.py
+++ b/babel/numbers.py
@@ -22,7 +22,7 @@ import re
 from datetime import date as date_, datetime as datetime_
 
 from babel.core import default_locale, Locale, get_global
-from babel._compat import Decimal, InvalidOperation, ROUND_HALF_EVEN
+from babel._compat import decimal
 
 
 LC_NUMERIC = default_locale('LC_NUMERIC')
@@ -437,9 +437,9 @@ def parse_decimal(string, locale=LC_NUMERIC):
     """
     locale = Locale.parse(locale)
     try:
-        return Decimal(string.replace(get_group_symbol(locale), '')
-                       .replace(get_decimal_symbol(locale), '.'))
-    except InvalidOperation:
+        return decimal.Decimal(string.replace(get_group_symbol(locale), '')
+                               .replace(get_decimal_symbol(locale), '.'))
+    except decimal.InvalidOperation:
         raise NumberFormatError('%r is not a valid decimal number' % string)
 
 
@@ -566,8 +566,8 @@ class NumberPattern(object):
 
     def apply(self, value, locale, currency=None, force_frac=None):
         frac_prec = force_frac or self.frac_prec
-        if not isinstance(value, Decimal):
-            value = Decimal(str(value))
+        if not isinstance(value, decimal.Decimal):
+            value = decimal.Decimal(str(value))
         value = value.scaleb(self.scale)
         is_negative = int(value.is_signed())
         if self.exp_prec:  # Scientific notation
@@ -603,8 +603,8 @@ class NumberPattern(object):
             if sep:
                 number += get_decimal_symbol(locale) + b
         else:  # A normal number pattern
-            precision = Decimal('1.' + '1' * frac_prec[1])
-            rounded = value.quantize(precision, ROUND_HALF_EVEN)
+            precision = decimal.Decimal('1.' + '1' * frac_prec[1])
+            rounded = value.quantize(precision)
             a, sep, b = str(abs(rounded)).partition(".")
             number = (self._format_int(a, self.int_prec[0],
                                        self.int_prec[1], locale) +
@@ -641,7 +641,7 @@ class NumberPattern(object):
     def _format_significant(self, value, minimum, maximum):
         exp = value.adjusted()
         scale = maximum - 1 - exp
-        digits = str(value.scaleb(scale).quantize(Decimal(1), ROUND_HALF_EVEN))
+        digits = str(value.scaleb(scale).quantize(decimal.Decimal(1)))
         if scale <= 0:
             result = digits + '0' * -scale
         else:
diff --git a/babel/plural.py b/babel/plural.py
index 980629d..0b8e425 100644
--- a/babel/plural.py
+++ b/babel/plural.py
@@ -11,7 +11,7 @@
 import re
 import sys
 
-from babel._compat import Decimal
+from babel._compat import decimal
 
 
 _plural_tags = ('zero', 'one', 'two', 'few', 'many', 'other')
@@ -33,9 +33,9 @@ def extract_operands(source):
             # 2.6's Decimal cannot convert from float directly
             if sys.version_info < (2, 7):
                 n = str(n)
-            n = Decimal(n)
+            n = decimal.Decimal(n)
 
-    if isinstance(n, Decimal):
+    if isinstance(n, decimal.Decimal):
         dec_tuple = n.as_tuple()
         exp = dec_tuple.exponent
         fraction_digits = dec_tuple.digits[exp:] if exp < 0 else ()
diff --git a/babel/units.py b/babel/units.py
index 798ade2..1ea5b17 100644
--- a/babel/units.py
+++ b/babel/units.py
@@ -80,8 +80,8 @@ def format_unit(value, measurement_unit, length='long', format=None, locale=LC_N
 
     Number formats may be overridden with the ``format`` parameter.
 
-    >>> from babel._compat import Decimal
-    >>> format_unit(Decimal("-42.774"), 'temperature-celsius', 'short', format='#.0', locale='fr')
+    >>> from babel._compat import decimal
+    >>> format_unit(decimal.Decimal("-42.774"), 'temperature-celsius', 'short', format='#.0', locale='fr')
     u'-42,8 \\xb0C'
 
     The locale's usual pluralization rules are respected.
diff --git a/docs/numbers.rst b/docs/numbers.rst
index fbba1f3..1443b7c 100644
--- a/docs/numbers.rst
+++ b/docs/numbers.rst
@@ -86,6 +86,58 @@ the specification. The following table is just a relatively brief overview.
   +----------+-----------------------------------------------------------------+
 
 
+Rounding Modes
+==============
+
+Since Babel makes full use of Python's `Decimal`_ type to perform number
+rounding before formatting, users have the chance to control the rounding mode
+and other configurable parameters through the active `Context`_ instance.
+
+By default, Python rounding mode is ``ROUND_HALF_EVEN`` which complies with
+`UTS #35 section 3.3`_.  Yet, the caller has the opportunity to tweak the
+current context before formatting a number or currency:
+
+.. code-block:: pycon
+
+    >>> from babel.numbers import decimal, format_decimal
+    >>> with decimal.localcontext(decimal.Context(rounding=decimal.ROUND_DOWN)):
+    >>>    txt = format_decimal(123.99, format='#', locale='en_US')
+    >>> txt
+    u'123'
+
+It is also possible to use ``decimal.setcontext`` or directly modifying the
+instance returned by ``decimal.getcontext``.  However, using a context manager
+is always more convenient due to the automatic restoration and the ability to
+nest them.
+
+Whatever mechanism is chosen, always make use of the ``decimal`` module imported
+from ``babel.numbers``.  For efficiency reasons, Babel uses the fastest decimal
+implementation available, such as `cdecimal`_.  These various implementation
+offer an identical API, but their types and instances do **not** interoperate
+with each other.
+
+For example, the previous example can be slightly modified to generate
+unexpected results on Python 2.7, with the `cdecimal`_ module installed:
+
+.. code-block:: pycon
+
+    >>> from decimal import localcontext, Context, ROUND_DOWN
+    >>> from babel.numbers import format_decimal
+    >>> with localcontext(Context(rounding=ROUND_DOWN)):
+    >>>    txt = format_decimal(123.99, format='#', locale='en_US')
+    >>> txt
+    u'124'
+
+Changing other parameters such as the precision may also alter the results of
+the number formatting functions.  Remember to test your code to make sure it
+behaves as desired.
+
+.. _Decimal: https://docs.python.org/3/library/decimal.html#decimal-objects
+.. _Context: https://docs.python.org/3/library/decimal.html#context-objects
+.. _`UTS #35 section 3.3`: http://www.unicode.org/reports/tr35/tr35-numbers.html#Formatting
+.. _cdecimal: https://pypi.python.org/pypi/cdecimal
+
+
 Parsing Numbers
 ===============
 


Test patch:
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index 19f9bc7..32a969d 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -17,7 +17,7 @@ import pytest
 from datetime import date
 
 from babel import numbers
-from babel._compat import Decimal
+from babel._compat import decimal
 
 
 class FormatDecimalTestCase(unittest.TestCase):
@@ -94,16 +94,16 @@ class FormatDecimalTestCase(unittest.TestCase):
 
     def test_decimals(self):
         """Test significant digits patterns"""
-        self.assertEqual(numbers.format_decimal(Decimal('1.2345'),
+        self.assertEqual(numbers.format_decimal(decimal.Decimal('1.2345'),
                                                 '#.00', locale='en_US'),
                          '1.23')
-        self.assertEqual(numbers.format_decimal(Decimal('1.2345000'),
+        self.assertEqual(numbers.format_decimal(decimal.Decimal('1.2345000'),
                                                 '#.00', locale='en_US'),
                          '1.23')
-        self.assertEqual(numbers.format_decimal(Decimal('1.2345000'),
+        self.assertEqual(numbers.format_decimal(decimal.Decimal('1.2345000'),
                                                 '@@', locale='en_US'),
                          '1.2')
-        self.assertEqual(numbers.format_decimal(Decimal('12345678901234567890.12345'),
+        self.assertEqual(numbers.format_decimal(decimal.Decimal('12345678901234567890.12345'),
                                                 '#.00', locale='en_US'),
                          '12345678901234567890.12')
 
@@ -136,7 +136,7 @@ class FormatDecimalTestCase(unittest.TestCase):
         self.assertEqual(fmt, '1.23E02 m/s')
         fmt = numbers.format_scientific(0.012345, '#.##E00 m/s', locale='en_US')
         self.assertEqual(fmt, '1.23E-02 m/s')
-        fmt = numbers.format_scientific(Decimal('12345'), '#.##E+00 m/s',
+        fmt = numbers.format_scientific(decimal.Decimal('12345'), '#.##E+00 m/s',
                                         locale='en_US')
         self.assertEqual(fmt, '1.23E+04 m/s')
         # 0 (see ticket #99)
@@ -146,7 +146,7 @@ class FormatDecimalTestCase(unittest.TestCase):
     def test_formatting_of_very_small_decimals(self):
         # previously formatting very small decimals could lead to a type error
         # because the Decimal->string conversion was too simple (see #214)
-        number = Decimal("7E-7")
+        number = decimal.Decimal("7E-7")
         fmt = numbers.format_decimal(number, format="@@@", locale='en_US')
         self.assertEqual('0.000000700', fmt)
 
@@ -154,9 +154,9 @@ class FormatDecimalTestCase(unittest.TestCase):
 class NumberParsingTestCase(unittest.TestCase):
 
     def test_can_parse_decimals(self):
-        self.assertEqual(Decimal('1099.98'),
+        self.assertEqual(decimal.Decimal('1099.98'),
                          numbers.parse_decimal('1,099.98', locale='en_US'))
-        self.assertEqual(Decimal('1099.98'),
+        self.assertEqual(decimal.Decimal('1099.98'),
                          numbers.parse_decimal('1.099,98', locale='de'))
         self.assertRaises(numbers.NumberFormatError,
                           lambda: numbers.parse_decimal('2,109,998', locale='de'))
@@ -302,8 +302,8 @@ def test_parse_number():
 
 def test_parse_decimal():
     assert (numbers.parse_decimal('1,099.98', locale='en_US')
-            == Decimal('1099.98'))
-    assert numbers.parse_decimal('1.099,98', locale='de') == Decimal('1099.98')
+            == decimal.Decimal('1099.98'))
+    assert numbers.parse_decimal('1.099,98', locale='de') == decimal.Decimal('1099.98')
 
     with pytest.raises(numbers.NumberFormatError) as excinfo:
         numbers.parse_decimal('2,109,998', locale='de')
diff --git a/tests/test_plural.py b/tests/test_plural.py
index 60a2de4..6406af8 100644
--- a/tests/test_plural.py
+++ b/tests/test_plural.py
@@ -14,7 +14,7 @@ import unittest
 import pytest
 
 from babel import plural, localedata
-from babel._compat import Decimal
+from babel._compat import decimal
 
 
 def test_plural_rule():
@@ -34,29 +34,29 @@ def test_plural_rule_operands_i():
 
 def test_plural_rule_operands_v():
     rule = plural.PluralRule({'one': 'v is 2'})
-    assert rule(Decimal('1.20')) == 'one'
-    assert rule(Decimal('1.2')) == 'other'
+    assert rule(decimal.Decimal('1.20')) == 'one'
+    assert rule(decimal.Decimal('1.2')) == 'other'
     assert rule(2) == 'other'
 
 
 def test_plural_rule_operands_w():
     rule = plural.PluralRule({'one': 'w is 2'})
-    assert rule(Decimal('1.23')) == 'one'
-    assert rule(Decimal('1.20')) == 'other'
+    assert rule(decimal.Decimal('1.23')) == 'one'
+    assert rule(decimal.Decimal('1.20')) == 'other'
     assert rule(1.2) == 'other'
 
 
 def test_plural_rule_operands_f():
     rule = plural.PluralRule({'one': 'f is 20'})
-    assert rule(Decimal('1.23')) == 'other'
-    assert rule(Decimal('1.20')) == 'one'
+    assert rule(decimal.Decimal('1.23')) == 'other'
+    assert rule(decimal.Decimal('1.20')) == 'one'
     assert rule(1.2) == 'other'
 
 
 def test_plural_rule_operands_t():
     rule = plural.PluralRule({'one': 't = 5'})
-    assert rule(Decimal('1.53')) == 'other'
-    assert rule(Decimal('1.50')) == 'one'
+    assert rule(decimal.Decimal('1.53')) == 'other'
+    assert rule(decimal.Decimal('1.50')) == 'one'
     assert rule(1.5) == 'one'
 
 
@@ -253,9 +253,9 @@ EXTRACT_OPERANDS_TESTS = (
 
 @pytest.mark.parametrize('source,n,i,v,w,f,t', EXTRACT_OPERANDS_TESTS)
 def test_extract_operands(source, n, i, v, w, f, t):
-    source = Decimal(source) if isinstance(source, str) else source
+    source = decimal.Decimal(source) if isinstance(source, str) else source
     assert (plural.extract_operands(source) ==
-            Decimal(n), i, v, w, f, t)
+            decimal.Decimal(n), i, v, w, f, t)
 
 
 @pytest.mark.parametrize('locale', ('ru', 'pl'))
diff --git a/tests/test_smoke.py b/tests/test_smoke.py
index eda10ed..3993f60 100644
--- a/tests/test_smoke.py
+++ b/tests/test_smoke.py
@@ -11,7 +11,7 @@ import pytest
 from babel import Locale
 from babel import dates
 from babel import numbers
-from babel._compat import Decimal
+from babel._compat import decimal
 
 
 @pytest.mark.all_locales
@@ -28,8 +28,8 @@ def test_smoke_dates(locale):
 def test_smoke_numbers(locale):
     locale = Locale.parse(locale)
     for number in (
-        Decimal("-33.76"),  # Negative Decimal
-        Decimal("13.37"),  # Positive Decimal
+        decimal.Decimal("-33.76"),  # Negative Decimal
+        decimal.Decimal("13.37"),  # Positive Decimal
         1.2 - 1.0,  # Inaccurate float
         10,  # Plain old integer
         0,  # Zero