diff --git a/babel/_compat.py b/babel/_compat.py index 75abf9eb1..aea338938 100644 --- a/babel/_compat.py +++ b/babel/_compat.py @@ -58,19 +58,13 @@ # -# 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 3ab366ccc..f9be88dd7 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -22,7 +22,7 @@ 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 @@ def __repr__(self): 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 @@ def apply(self, value, locale, currency=None, force_frac=None): 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 @@ def apply(self, value, locale, currency=None, force_frac=None): 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 980629dcc..0b8e425d5 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 798ade20a..1ea5b17cc 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 fbba1f3ed..1443b7cf5 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 =============== diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 19f9bc76d..32a969d14 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -17,7 +17,7 @@ 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 @@ def test_significant_digits(self): 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 @@ def test_scientific_notation(self): 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 @@ def test_scientific_notation(self): 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 @@ def test_formatting_of_very_small_decimals(self): 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 60a2de4a1..6406af872 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -14,7 +14,7 @@ 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 @@ def test_or_and(self): @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 eda10ed34..3993f608c 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -11,7 +11,7 @@ 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