From 6238f6620c54d0343e923ef92130b35f2bd117d0 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sat, 28 May 2016 23:00:24 +0200 Subject: [PATCH 1/3] Remove explicit enforcement from Decimal rounding Allow the rounding mode to be controlled from the currently active decimal context. This gives the caller the ability to control rounding mode, precision, exponent range and all attributes that affect decimal operations. Fixes #90 --- babel/_compat.py | 8 ++------ babel/numbers.py | 6 +++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/babel/_compat.py b/babel/_compat.py index 75abf9eb1..ae290f42c 100644 --- a/babel/_compat.py +++ b/babel/_compat.py @@ -61,16 +61,12 @@ # Use cdecimal when available # from decimal import (Decimal as _dec, - InvalidOperation as _invop, - ROUND_HALF_EVEN as _RHE) + InvalidOperation as _invop) try: from cdecimal import (Decimal as _cdec, - InvalidOperation as _cinvop, - ROUND_HALF_EVEN as _CRHE) + InvalidOperation as _cinvop) Decimal = _cdec InvalidOperation = (_invop, _cinvop) - ROUND_HALF_EVEN = _CRHE except ImportError: Decimal = _dec InvalidOperation = _invop - ROUND_HALF_EVEN = _RHE diff --git a/babel/numbers.py b/babel/numbers.py index 3ab366ccc..67881ae56 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, InvalidOperation LC_NUMERIC = default_locale('LC_NUMERIC') @@ -604,7 +604,7 @@ def apply(self, value, locale, currency=None, force_frac=None): 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) + 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(1))) if scale <= 0: result = digits + '0' * -scale else: From 8341064c151b50135ac083b33117420efeda4d96 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sun, 29 May 2016 09:40:24 +0200 Subject: [PATCH 2/3] Simplify the selection of decimal implementation Instead of selecting individual symbols, expose only the chosen decimal module through babel._compat. This forces Babel to always use "decimal." prefix. Howver, it will simplify the code for users that need to manipulate the decimal context. --- babel/_compat.py | 20 +++++++++----------- babel/numbers.py | 16 ++++++++-------- babel/plural.py | 6 +++--- babel/units.py | 4 ++-- tests/test_numbers.py | 22 +++++++++++----------- tests/test_plural.py | 22 +++++++++++----------- tests/test_smoke.py | 6 +++--- 7 files changed, 47 insertions(+), 49 deletions(-) diff --git a/babel/_compat.py b/babel/_compat.py index ae290f42c..aea338938 100644 --- a/babel/_compat.py +++ b/babel/_compat.py @@ -58,15 +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) -try: - from cdecimal import (Decimal as _cdec, - InvalidOperation as _cinvop) - Decimal = _cdec - InvalidOperation = (_invop, _cinvop) -except ImportError: - Decimal = _dec - InvalidOperation = _invop +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 67881ae56..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 +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,7 +603,7 @@ 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]) + 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], @@ -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))) + 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/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 From f12e975b5af270b8846e8e01df37e3b3ad2e3b57 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sun, 29 May 2016 10:26:07 +0200 Subject: [PATCH 3/3] Add a documentation section about number rounding Explain how the user can control the behaviour of number rounding when formatting numeric values or currencies. --- docs/numbers.rst | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) 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 ===============