Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions babel/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file contains unused source code.

PyUnusedCodeBear, severity NORMAL, section default.

The issue can be fixed by applying the following patch:

--- a/babel/_compat.py
+++ b/babel/_compat.py
@@ -67,4 +67,4 @@
     try:
         import cdecimal as decimal
     except ImportError:
-        import decimal
+        pass

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file contains unused source code.

PyUnusedCodeBear, severity NORMAL, section default.

The issue can be fixed by applying the following patch:

--- a/babel/_compat.py
+++ b/babel/_compat.py
@@ -67,4 +67,4 @@
     try:
         import cdecimal as decimal
     except ImportError:
-        import decimal
+        pass

Copy link
Contributor Author

@etanol etanol May 29, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems odd, according to Codecov, babel/_compat.py is 100% covered.

try:
import cdecimal as decimal
except ImportError:
import decimal
18 changes: 9 additions & 9 deletions babel/numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) +
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions babel/plural.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 ()
Expand Down
4 changes: 2 additions & 2 deletions babel/units.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions docs/numbers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,58 @@ the specification. The following table is just a relatively brief overview.
+----------+-----------------------------------------------------------------+


Rounding Modes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+++++++

Thanks for the great docs!

Could you add a doctest or a note to the docstring for babel.numbers.format_decimal? Same for format_currency - especially around the interaction with custom currency formats and currency_digits. (omg so complicated)

==============

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... is this considered a non-backwards compatible change?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohhh nevermind, we were already using cdecimal...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, in terms of Babel API is fully backwards compatible. If your code manipulates the current decimal context while also using Babel, you may see slightly different results.

But if you are playing with decimal precision and exponents, aside from the rounding mode, you should have seen this for some time already

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
===============

Expand Down
22 changes: 11 additions & 11 deletions tests/test_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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)
Expand All @@ -146,17 +146,17 @@ 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)


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'))
Expand Down Expand Up @@ -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')
Expand Down
22 changes: 11 additions & 11 deletions tests/test_plural.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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'


Expand Down Expand Up @@ -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'))
Expand Down
6 changes: 3 additions & 3 deletions tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down