summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKevin Deldycke <kevin@deldycke.com>2017-04-07 16:09:14 +0200
committerIsaac Jurado <diptongo@gmail.com>2017-10-23 21:55:25 +0200
commit1539c8a89119f37890f46586d705d5a90cc98ae6 (patch)
treee7e0a7beaf0f2441863911b4daf091e1ace3ef9a
parenta97b426476638c815bdfe11196aa2e9940444cb4 (diff)
downloadbabel-1539c8a89119f37890f46586d705d5a90cc98ae6.tar.gz
Allow bypass of decimal quantization.
-rw-r--r--babel/numbers.py102
-rw-r--r--tests/test_numbers.py122
2 files changed, 168 insertions, 56 deletions
diff --git a/babel/numbers.py b/babel/numbers.py
index 0365132..1f77bcd 100644
--- a/babel/numbers.py
+++ b/babel/numbers.py
@@ -22,7 +22,6 @@ import re
from datetime import date as date_, datetime as datetime_
from itertools import chain
import warnings
-from itertools import chain
from babel.core import default_locale, Locale, get_global
from babel._compat import decimal, string_types
@@ -324,13 +323,27 @@ def format_number(number, locale=LC_NUMERIC):
return format_decimal(number, locale=locale)
+def get_decimal_precision(number):
+ """Return maximum precision of a decimal instance's fractional part.
+
+ Precision is extracted from the fractional part only.
+ """
+ # Copied from: https://github.com/mahmoud/boltons/pull/59
+ assert isinstance(number, decimal.Decimal)
+ decimal_tuple = number.normalize().as_tuple()
+ if decimal_tuple.exponent >= 0:
+ return 0
+ return abs(decimal_tuple.exponent)
+
+
def get_decimal_quantum(precision):
"""Return minimal quantum of a number, as defined by precision."""
assert isinstance(precision, (int, long, decimal.Decimal))
return decimal.Decimal(10) ** (-precision)
-def format_decimal(number, format=None, locale=LC_NUMERIC):
+def format_decimal(
+ number, format=None, locale=LC_NUMERIC, decimal_quantization=True):
u"""Return the given decimal number formatted for a specific locale.
>>> format_decimal(1.2345, locale='en_US')
@@ -350,23 +363,36 @@ def format_decimal(number, format=None, locale=LC_NUMERIC):
>>> format_decimal(12345.5, locale='en_US')
u'12,345.5'
+ By default the locale is allowed to truncate and round a high-precision
+ number by forcing its format pattern onto the decimal part. You can bypass
+ this behavior with the `decimal_quantization` parameter:
+
+ >>> format_decimal(1.2346, locale='en_US')
+ u'1.235'
+ >>> format_decimal(1.2346, locale='en_US', decimal_quantization=False)
+ u'1.2346'
+
:param number: the number to format
:param format:
:param locale: the `Locale` object or locale identifier
+ :param decimal_quantization: Truncate and round high-precision numbers to
+ the format pattern. Defaults to `True`.
"""
locale = Locale.parse(locale)
if not format:
format = locale.decimal_formats.get(format)
pattern = parse_pattern(format)
- return pattern.apply(number, locale)
+ return pattern.apply(
+ number, locale, decimal_quantization=decimal_quantization)
class UnknownCurrencyFormatError(KeyError):
"""Exception raised when an unknown currency format is requested."""
-def format_currency(number, currency, format=None, locale=LC_NUMERIC,
- currency_digits=True, format_type='standard'):
+def format_currency(
+ number, currency, format=None, locale=LC_NUMERIC, currency_digits=True,
+ format_type='standard', decimal_quantization=True):
u"""Return formatted currency value.
>>> format_currency(1099.98, 'USD', locale='en_US')
@@ -416,12 +442,23 @@ def format_currency(number, currency, format=None, locale=LC_NUMERIC,
...
UnknownCurrencyFormatError: "'unknown' is not a known currency format type"
+ By default the locale is allowed to truncate and round a high-precision
+ number by forcing its format pattern onto the decimal part. You can bypass
+ this behavior with the `decimal_quantization` parameter:
+
+ >>> format_currency(1099.9876, 'USD', locale='en_US')
+ u'$1,099.99'
+ >>> format_currency(1099.9876, 'USD', locale='en_US', decimal_quantization=False)
+ u'$1,099.9876'
+
:param number: the number to format
:param currency: the currency code
:param format: the format string to use
:param locale: the `Locale` object or locale identifier
- :param currency_digits: use the currency's number of decimal digits
+ :param currency_digits: use the currency's natural number of decimal digits
:param format_type: the currency format type to use
+ :param decimal_quantization: Truncate and round high-precision numbers to
+ the format pattern. Defaults to `True`.
"""
locale = Locale.parse(locale)
if format:
@@ -434,10 +471,12 @@ def format_currency(number, currency, format=None, locale=LC_NUMERIC,
"%r is not a known currency format type" % format_type)
return pattern.apply(
- number, locale, currency=currency, currency_digits=currency_digits)
+ number, locale, currency=currency, currency_digits=currency_digits,
+ decimal_quantization=decimal_quantization)
-def format_percent(number, format=None, locale=LC_NUMERIC):
+def format_percent(
+ number, format=None, locale=LC_NUMERIC, decimal_quantization=True):
"""Return formatted percent value for a specific locale.
>>> format_percent(0.34, locale='en_US')
@@ -452,18 +491,31 @@ def format_percent(number, format=None, locale=LC_NUMERIC):
>>> format_percent(25.1234, u'#,##0\u2030', locale='en_US')
u'25,123\u2030'
+ By default the locale is allowed to truncate and round a high-precision
+ number by forcing its format pattern onto the decimal part. You can bypass
+ this behavior with the `decimal_quantization` parameter:
+
+ >>> format_percent(23.9876, locale='en_US')
+ u'2,399%'
+ >>> format_percent(23.9876, locale='en_US', decimal_quantization=False)
+ u'2,398.76%'
+
:param number: the percent number to format
:param format:
:param locale: the `Locale` object or locale identifier
+ :param decimal_quantization: Truncate and round high-precision numbers to
+ the format pattern. Defaults to `True`.
"""
locale = Locale.parse(locale)
if not format:
format = locale.percent_formats.get(format)
pattern = parse_pattern(format)
- return pattern.apply(number, locale)
+ return pattern.apply(
+ number, locale, decimal_quantization=decimal_quantization)
-def format_scientific(number, format=None, locale=LC_NUMERIC):
+def format_scientific(
+ number, format=None, locale=LC_NUMERIC, decimal_quantization=True):
"""Return value formatted in scientific notation for a specific locale.
>>> format_scientific(10000, locale='en_US')
@@ -474,15 +526,27 @@ def format_scientific(number, format=None, locale=LC_NUMERIC):
>>> format_scientific(1234567, u'##0.##E00', locale='en_US')
u'1.23E06'
+ By default the locale is allowed to truncate and round a high-precision
+ number by forcing its format pattern onto the decimal part. You can bypass
+ this behavior with the `decimal_quantization` parameter:
+
+ >>> format_scientific(1234.9876, u'#.##E0', locale='en_US')
+ u'1.23E3'
+ >>> format_scientific(1234.9876, u'#.##E0', locale='en_US', decimal_quantization=False)
+ u'1.2349876E3'
+
:param number: the number to format
:param format:
:param locale: the `Locale` object or locale identifier
+ :param decimal_quantization: Truncate and round high-precision numbers to
+ the format pattern. Defaults to `True`.
"""
locale = Locale.parse(locale)
if not format:
format = locale.scientific_formats.get(format)
pattern = parse_pattern(format)
- return pattern.apply(number, locale)
+ return pattern.apply(
+ number, locale, decimal_quantization=decimal_quantization)
class NumberFormatError(ValueError):
@@ -702,8 +766,13 @@ class NumberPattern(object):
return value, exp, exp_sign
- def apply(self, value, locale, currency=None, currency_digits=True):
+ def apply(
+ self, value, locale, currency=None, currency_digits=True,
+ decimal_quantization=True):
"""Renders into a string a number following the defined pattern.
+
+ Forced decimal quantization is active by default so we'll produce a
+ number string that is strictly following CLDR pattern definitions.
"""
if not isinstance(value, decimal.Decimal):
value = decimal.Decimal(str(value))
@@ -724,6 +793,15 @@ class NumberPattern(object):
if currency and currency_digits:
frac_prec = (get_currency_precision(currency), ) * 2
+ # Bump decimal precision to the natural precision of the number if it
+ # exceeds the one we're about to use. This adaptative precision is only
+ # triggered if the decimal quantization is disabled or if a scientific
+ # notation pattern has a missing mandatory fractional part (as in the
+ # default '#E0' pattern). This special case has been extensively
+ # discussed at https://github.com/python-babel/babel/pull/494#issuecomment-307649969 .
+ if not decimal_quantization or (self.exp_prec and frac_prec == (0, 0)):
+ frac_prec = (frac_prec[0], max([frac_prec[1], get_decimal_precision(value)]))
+
# Render scientific notation.
if self.exp_prec:
number = ''.join([
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index 5c8da34..48a260b 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -16,10 +16,10 @@ import pytest
from datetime import date
-from babel import numbers
+from babel import Locale, localedata, numbers
from babel.numbers import (
- list_currencies, validate_currency, UnknownCurrencyError, is_currency, normalize_currency, get_currency_precision)
-from babel.core import Locale
+ list_currencies, validate_currency, UnknownCurrencyError, is_currency, normalize_currency,
+ get_currency_precision, get_decimal_precision)
from babel.localedata import locale_identifiers
from babel._compat import decimal
@@ -271,6 +271,12 @@ def test_get_group_symbol():
assert numbers.get_group_symbol('en_US') == u','
+def test_decimal_precision():
+ assert get_decimal_precision(decimal.Decimal('0.110')) == 2
+ assert get_decimal_precision(decimal.Decimal('1.0')) == 0
+ assert get_decimal_precision(decimal.Decimal('10000')) == 0
+
+
def test_format_number():
assert numbers.format_number(1099, locale='en_US') == u'1,099'
assert numbers.format_number(1099, locale='de_DE') == u'1.099'
@@ -314,7 +320,14 @@ def test_format_decimal():
def test_format_decimal_precision(input_value, expected_value):
# Test precision conservation.
assert numbers.format_decimal(
- decimal.Decimal(input_value), locale='en_US') == expected_value
+ decimal.Decimal(input_value), locale='en_US', decimal_quantization=False) == expected_value
+
+
+def test_format_decimal_quantization():
+ # Test all locales.
+ for locale_code in localedata.locale_identifiers():
+ assert numbers.format_decimal(
+ '0.9999999999', locale=locale_code, decimal_quantization=False).endswith('9999999999') is True
def test_format_currency():
@@ -375,25 +388,32 @@ def test_format_currency_format_type():
('1.1', '$1.10'),
('1.11', '$1.11'),
('1.110', '$1.11'),
- ('1.001', '$1.00'),
- ('1.00100', '$1.00'),
- ('01.00100', '$1.00'),
- ('101.00100', '$101.00'),
+ ('1.001', '$1.001'),
+ ('1.00100', '$1.001'),
+ ('01.00100', '$1.001'),
+ ('101.00100', '$101.001'),
('00000', '$0.00'),
('0', '$0.00'),
('0.0', '$0.00'),
('0.1', '$0.10'),
('0.11', '$0.11'),
('0.110', '$0.11'),
- ('0.001', '$0.00'),
- ('0.00100', '$0.00'),
- ('00.00100', '$0.00'),
- ('000.00100', '$0.00'),
+ ('0.001', '$0.001'),
+ ('0.00100', '$0.001'),
+ ('00.00100', '$0.001'),
+ ('000.00100', '$0.001'),
])
def test_format_currency_precision(input_value, expected_value):
# Test precision conservation.
assert numbers.format_currency(
- decimal.Decimal(input_value), 'USD', locale='en_US') == expected_value
+ decimal.Decimal(input_value), 'USD', locale='en_US', decimal_quantization=False) == expected_value
+
+
+def test_format_currency_quantization():
+ # Test all locales.
+ for locale_code in localedata.locale_identifiers():
+ assert numbers.format_currency(
+ '0.9999999999', 'USD', locale=locale_code, decimal_quantization=False).find('9999999999') > -1
def test_format_percent():
@@ -412,36 +432,43 @@ def test_format_percent():
('100', '10,000%'),
('0.01', '1%'),
('0.010', '1%'),
- ('0.011', '1%'),
- ('0.0111', '1%'),
- ('0.01110', '1%'),
- ('0.01001', '1%'),
- ('0.0100100', '1%'),
- ('0.010100100', '1%'),
+ ('0.011', '1.1%'),
+ ('0.0111', '1.11%'),
+ ('0.01110', '1.11%'),
+ ('0.01001', '1.001%'),
+ ('0.0100100', '1.001%'),
+ ('0.010100100', '1.01001%'),
('0.000000', '0%'),
('0', '0%'),
('0.00', '0%'),
('0.01', '1%'),
- ('0.011', '1%'),
- ('0.0110', '1%'),
- ('0.0001', '0%'),
- ('0.000100', '0%'),
- ('0.0000100', '0%'),
- ('0.00000100', '0%'),
+ ('0.011', '1.1%'),
+ ('0.0110', '1.1%'),
+ ('0.0001', '0.01%'),
+ ('0.000100', '0.01%'),
+ ('0.0000100', '0.001%'),
+ ('0.00000100', '0.0001%'),
])
def test_format_percent_precision(input_value, expected_value):
# Test precision conservation.
assert numbers.format_percent(
- decimal.Decimal(input_value), locale='en_US') == expected_value
+ decimal.Decimal(input_value), locale='en_US', decimal_quantization=False) == expected_value
+
+
+def test_format_percent_quantization():
+ # Test all locales.
+ for locale_code in localedata.locale_identifiers():
+ assert numbers.format_percent(
+ '0.9999999999', locale=locale_code, decimal_quantization=False).find('99999999') > -1
def test_format_scientific():
assert numbers.format_scientific(10000, locale='en_US') == u'1E4'
assert numbers.format_scientific(4234567, u'#.#E0', locale='en_US') == u'4.2E6'
- assert numbers.format_scientific(4234567, u'0E0000', locale='en_US') == u'4E0006'
- assert numbers.format_scientific(4234567, u'##0E00', locale='en_US') == u'4E06'
- assert numbers.format_scientific(4234567, u'##00E00', locale='en_US') == u'42E05'
- assert numbers.format_scientific(4234567, u'0,000E00', locale='en_US') == u'4,235E03'
+ assert numbers.format_scientific(4234567, u'0E0000', locale='en_US') == u'4.234567E0006'
+ assert numbers.format_scientific(4234567, u'##0E00', locale='en_US') == u'4.234567E06'
+ assert numbers.format_scientific(4234567, u'##00E00', locale='en_US') == u'42.34567E05'
+ assert numbers.format_scientific(4234567, u'0,000E00', locale='en_US') == u'4,234.567E03'
assert numbers.format_scientific(4234567, u'##0.#####E00', locale='en_US') == u'4.23457E06'
assert numbers.format_scientific(4234567, u'##0.##E00', locale='en_US') == u'4.23E06'
assert numbers.format_scientific(42, u'00000.000000E0000', locale='en_US') == u'42000.000000E-0003'
@@ -451,29 +478,29 @@ def test_default_scientific_format():
""" Check the scientific format method auto-correct the rendering pattern
in case of a missing fractional part.
"""
- assert numbers.format_scientific(12345, locale='en_US') == u'1E4'
- assert numbers.format_scientific(12345.678, locale='en_US') == u'1E4'
- assert numbers.format_scientific(12345, u'#E0', locale='en_US') == u'1E4'
- assert numbers.format_scientific(12345.678, u'#E0', locale='en_US') == u'1E4'
+ assert numbers.format_scientific(12345, locale='en_US') == u'1.2345E4'
+ assert numbers.format_scientific(12345.678, locale='en_US') == u'1.2345678E4'
+ assert numbers.format_scientific(12345, u'#E0', locale='en_US') == u'1.2345E4'
+ assert numbers.format_scientific(12345.678, u'#E0', locale='en_US') == u'1.2345678E4'
@pytest.mark.parametrize('input_value, expected_value', [
('10000', '1E4'),
('1', '1E0'),
('1.0', '1E0'),
- ('1.1', '1E0'),
- ('1.11', '1E0'),
- ('1.110', '1E0'),
- ('1.001', '1E0'),
- ('1.00100', '1E0'),
- ('01.00100', '1E0'),
- ('101.00100', '1E2'),
+ ('1.1', '1.1E0'),
+ ('1.11', '1.11E0'),
+ ('1.110', '1.11E0'),
+ ('1.001', '1.001E0'),
+ ('1.00100', '1.001E0'),
+ ('01.00100', '1.001E0'),
+ ('101.00100', '1.01001E2'),
('00000', '0E0'),
('0', '0E0'),
('0.0', '0E0'),
('0.1', '1E-1'),
- ('0.11', '1E-1'),
- ('0.110', '1E-1'),
+ ('0.11', '1.1E-1'),
+ ('0.110', '1.1E-1'),
('0.001', '1E-3'),
('0.00100', '1E-3'),
('00.00100', '1E-3'),
@@ -482,7 +509,14 @@ def test_default_scientific_format():
def test_format_scientific_precision(input_value, expected_value):
# Test precision conservation.
assert numbers.format_scientific(
- decimal.Decimal(input_value), locale='en_US') == expected_value
+ decimal.Decimal(input_value), locale='en_US', decimal_quantization=False) == expected_value
+
+
+def test_format_scientific_quantization():
+ # Test all locales.
+ for locale_code in localedata.locale_identifiers():
+ assert numbers.format_scientific(
+ '0.9999999999', locale=locale_code, decimal_quantization=False).find('999999999') > -1
def test_parse_number():