From f5e0a3a17e2f227fa395c9ed48ae7dc72f53c713 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Fri, 7 Apr 2017 16:09:14 +0200 Subject: [PATCH] Allow bypass of decimal quantization. --- babel/numbers.py | 102 ++++++++++++++++++++++++++++++----- tests/test_numbers.py | 122 +++++++++++++++++++++++++++--------------- 2 files changed, 168 insertions(+), 56 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 036513217..1f77bcd23 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -22,7 +22,6 @@ 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 @@ def scientific_notation_elements(self, value, locale): 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 @@ def apply(self, value, locale, currency=None, currency_digits=True): 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 5c8da3422..48a260b40 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -16,10 +16,10 @@ 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():