]> git.ipfire.org Git - thirdparty/babel.git/commitdiff
Allow bypass of decimal quantization.
authorKevin Deldycke <kevin@deldycke.com>
Fri, 7 Apr 2017 14:09:14 +0000 (16:09 +0200)
committerIsaac Jurado <diptongo@gmail.com>
Mon, 23 Oct 2017 19:55:25 +0000 (21:55 +0200)
babel/numbers.py
tests/test_numbers.py

index 036513217f730d71c51cb84d4203434c76d01b28..1f77bcd233aa04ac847631c88db5cd5aa83cc5b9 100644 (file)
@@ -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([
index 5c8da3422d0f0339f5dbb22c1dceaf79118d9a3a..48a260b40a85ab70a0a234dc05c86e3b3516d7ca 100644 (file)
@@ -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():