]> git.ipfire.org Git - thirdparty/babel.git/commitdiff
feat: Support for short compact currency formats (#926)
authorJonah Lawrence <jonah@freshidea.com>
Fri, 4 Nov 2022 15:16:24 +0000 (09:16 -0600)
committerGitHub <noreply@github.com>
Fri, 4 Nov 2022 15:16:24 +0000 (17:16 +0200)
Co-authored-by: Jun Omae (大前 潤) <42682+jun66j5@users.noreply.github.com>
babel/core.py
babel/numbers.py
babel/support.py
docs/api/numbers.rst
scripts/import_cldr.py
tests/test_numbers.py
tests/test_support.py

index 220cbaf0ad12368201f2ea73f310b1d0f0cec5ed..2a01c309fc77f98c7e074d2a0f326f624e322744 100644 (file)
@@ -590,6 +590,18 @@ class Locale:
         """
         return self._data['currency_formats']
 
+    @property
+    def compact_currency_formats(self):
+        """Locale patterns for compact currency number formatting.
+
+        .. note:: The format of the value returned may change between
+                  Babel versions.
+
+        >>> Locale('en', 'US').compact_currency_formats["short"]["one"]["1000"]
+        <NumberPattern u'¤0K'>
+        """
+        return self._data['compact_currency_formats']
+
     @property
     def percent_formats(self):
         """Locale patterns for percent number formatting.
index 8a341c4faf9b247604f6fd0ac149ac22e7ef0b6d..8baf110b8b41fe3df795e25b17f73d09c0f3b733 100644 (file)
@@ -440,18 +440,21 @@ def format_compact_decimal(number, *, format_type="short", locale=LC_NUMERIC, fr
     :param fraction_digits: Number of digits after the decimal point to use. Defaults to `0`.
     """
     locale = Locale.parse(locale)
-    number, format = _get_compact_format(number, format_type, locale, fraction_digits)
+    compact_format = locale.compact_decimal_formats[format_type]
+    number, format = _get_compact_format(number, compact_format, locale, fraction_digits)
+    # Did not find a format, fall back.
+    if format is None:
+        format = locale.decimal_formats.get(None)
     pattern = parse_pattern(format)
     return pattern.apply(number, locale, decimal_quantization=False)
 
 
-def _get_compact_format(number, format_type, locale, fraction_digits=0):
+def _get_compact_format(number, compact_format, locale, fraction_digits=0):
     """Returns the number after dividing by the unit and the format pattern to use.
     The algorithm is described here:
     https://www.unicode.org/reports/tr35/tr35-45/tr35-numbers.html#Compact_Number_Formats.
     """
     format = None
-    compact_format = locale.compact_decimal_formats[format_type]
     for magnitude in sorted([int(m) for m in compact_format["other"]], reverse=True):
         if abs(number) >= magnitude:
             # check the pattern using "other" as the amount
@@ -470,8 +473,6 @@ def _get_compact_format(number, format_type, locale, fraction_digits=0):
             plural_form = plural_form if plural_form in compact_format else "other"
             format = compact_format[plural_form][str(magnitude)]
             break
-    if format is None:  # Did not find a format, fall back.
-        format = locale.decimal_formats.get(None)
     return number, format
 
 
@@ -624,6 +625,44 @@ def _format_currency_long_name(
     return unit_pattern.format(number_part, display_name)
 
 
+def format_compact_currency(number, currency, *, format_type="short", locale=LC_NUMERIC, fraction_digits=0):
+    u"""Format a number as a currency value in compact form.
+
+    >>> format_compact_currency(12345, 'USD', locale='en_US')
+    u'$12K'
+    >>> format_compact_currency(123456789, 'USD', locale='en_US', fraction_digits=2)
+    u'$123.46M'
+    >>> format_compact_currency(123456789, 'EUR', locale='de_DE', fraction_digits=1)
+    '123,5\xa0Mio.\xa0€'
+
+    :param number: the number to format
+    :param currency: the currency code
+    :param format_type: the compact format type to use. Defaults to "short".
+    :param locale: the `Locale` object or locale identifier
+    :param fraction_digits: Number of digits after the decimal point to use. Defaults to `0`.
+    """
+    locale = Locale.parse(locale)
+    try:
+        compact_format = locale.compact_currency_formats[format_type]
+    except KeyError as error:
+        raise UnknownCurrencyFormatError(f"{format_type!r} is not a known compact currency format type") from error
+    number, format = _get_compact_format(number, compact_format, locale, fraction_digits)
+    # Did not find a format, fall back.
+    if format is None or "¤" not in str(format):
+        # find first format that has a currency symbol
+        for magnitude in compact_format['other']:
+            format = compact_format['other'][magnitude].pattern
+            if '¤' not in format:
+                continue
+            # remove characters that are not the currency symbol, 0's or spaces
+            format = re.sub(r'[^0\s\¤]', '', format)
+            # compress adjacent spaces into one
+            format = re.sub(r'(\s)\s+', r'\1', format).strip()
+            break
+    pattern = parse_pattern(format)
+    return pattern.apply(number, locale, currency=currency, currency_digits=False, decimal_quantization=False)
+
+
 def format_percent(
         number, format=None, locale=LC_NUMERIC, decimal_quantization=True, group_separator=True):
     """Return formatted percent value for a specific locale.
@@ -1082,6 +1121,10 @@ class NumberPattern:
             retval = retval.replace(u'¤¤', currency.upper())
             retval = retval.replace(u'¤', get_currency_symbol(currency, locale))
 
+        # remove single quotes around text, except for doubled single quotes
+        # which are replaced with a single quote
+        retval = re.sub(r"'([^']*)'", lambda m: m.group(1) or "'", retval)
+
         return retval
 
     #
index 3efc0036ba356cbd2253c8fe1f15f5091ca937c4..50f275274828587891ebb4498fa2104538b0deeb 100644 (file)
@@ -17,7 +17,7 @@ import locale
 from babel.core import Locale
 from babel.dates import format_date, format_datetime, format_time, \
     format_timedelta
-from babel.numbers import format_decimal, format_currency, \
+from babel.numbers import format_decimal, format_currency, format_compact_currency, \
     format_percent, format_scientific, format_compact_decimal
 
 
@@ -124,6 +124,13 @@ class Format:
         """
         return format_currency(number, currency, locale=self.locale)
 
+    def compact_currency(self, number, currency, format_type='short', fraction_digits=0):
+        """Return a number in the given currency formatted for the locale
+        using the compact number format.
+        """
+        return format_compact_currency(number, currency, format_type=format_type,
+                                        fraction_digits=fraction_digits, locale=self.locale)
+
     def percent(self, number, format=None):
         """Return a number formatted as percentage for the locale.
 
index eac569206e12ed73df95b6e276efc31c93165112..d3ab8b1166bc435c91a9bf58cd5f69fdf9b311df 100644 (file)
@@ -17,6 +17,8 @@ Number Formatting
 
 .. autofunction:: format_currency
 
+.. autofunction:: format_compact_currency
+
 .. autofunction:: format_percent
 
 .. autofunction:: format_scientific
index 92dd27234e73b8dd8b630ad4643ec51c6e63c9da..097840cecc9522f0446bd8ab60620cc76f2d3a22 100755 (executable)
@@ -915,10 +915,6 @@ def parse_currency_formats(data, tree):
             curr_length_type = length_elem.attrib.get('type')
             for elem in length_elem.findall('currencyFormat'):
                 type = elem.attrib.get('type')
-                if curr_length_type:
-                    # Handle `<currencyFormatLength type="short">`, etc.
-                    # TODO(3.x): use nested dicts instead of colon-separated madness
-                    type = '%s:%s' % (type, curr_length_type)
                 if _should_skip_elem(elem, type, currency_formats):
                     continue
                 for child in elem.iter():
@@ -928,8 +924,21 @@ def parse_currency_formats(data, tree):
                                              child.attrib['path'])
                         )
                     elif child.tag == 'pattern':
-                        pattern = str(child.text)
-                        currency_formats[type] = numbers.parse_pattern(pattern)
+                        pattern_type = child.attrib.get('type')
+                        pattern = numbers.parse_pattern(str(child.text))
+                        if pattern_type:
+                            # This is a compact currency format, see:
+                            # https://www.unicode.org/reports/tr35/tr35-45/tr35-numbers.html#Compact_Number_Formats
+
+                            # These are mapped into a `compact_currency_formats` dictionary
+                            # with the format {length: {count: {multiplier: pattern}}}.
+                            compact_currency_formats = data.setdefault('compact_currency_formats', {})
+                            length_map = compact_currency_formats.setdefault(curr_length_type, {})
+                            length_count_map = length_map.setdefault(child.attrib['count'], {})
+                            length_count_map[pattern_type] = pattern
+                        else:
+                            # Regular currency format
+                            currency_formats[type] = pattern
 
 
 def parse_currency_unit_patterns(data, tree):
index 1b955c95eec8b0718107f2f5d0086a5444cba91b..bb6c4e84ee4a16237a884c6d604841390a44e13b 100644 (file)
@@ -422,6 +422,27 @@ def test_format_currency_format_type():
             == u'1.099,98')
 
 
+def test_format_compact_currency():
+    assert numbers.format_compact_currency(1, 'USD', locale='en_US', format_type="short") == u'$1'
+    assert numbers.format_compact_currency(999, 'USD', locale='en_US', format_type="short") == u'$999'
+    assert numbers.format_compact_currency(123456789, 'USD', locale='en_US', format_type="short") == u'$123M'
+    assert numbers.format_compact_currency(123456789, 'USD', locale='en_US', fraction_digits=2, format_type="short") == u'$123.46M'
+    assert numbers.format_compact_currency(-123456789, 'USD', locale='en_US', fraction_digits=2, format_type="short") == u'-$123.46M'
+    assert numbers.format_compact_currency(1, 'JPY', locale='ja_JP', format_type="short") == u'¥1'
+    assert numbers.format_compact_currency(1234, 'JPY', locale='ja_JP', format_type="short") == u'¥1234'
+    assert numbers.format_compact_currency(123456, 'JPY', locale='ja_JP', format_type="short") == u'¥12万'
+    assert numbers.format_compact_currency(123456, 'JPY', locale='ja_JP', format_type="short", fraction_digits=2) == u'¥12.35万'
+    assert numbers.format_compact_currency(123, 'EUR', locale='yav', format_type="short") == '123\xa0€'
+    assert numbers.format_compact_currency(12345, 'EUR', locale='yav', format_type="short") == '12K\xa0€'
+    assert numbers.format_compact_currency(123456789, 'EUR', locale='de_DE', fraction_digits=1) == '123,5\xa0Mio.\xa0€'
+
+
+def test_format_compact_currency_invalid_format_type():
+    with pytest.raises(numbers.UnknownCurrencyFormatError):
+        numbers.format_compact_currency(1099.98, 'USD', locale='en_US',
+                                format_type='unknown')
+
+
 @pytest.mark.parametrize('input_value, expected_value', [
     ('10000', '$10,000.00'),
     ('1', '$1.00'),
@@ -696,3 +717,11 @@ def test_parse_decimal_nbsp_heuristics():
 
 def test_very_small_decimal_no_quantization():
     assert numbers.format_decimal(decimal.Decimal('1E-7'), locale='en', decimal_quantization=False) == '0.0000001'
+
+
+def test_single_quotes_in_pattern():
+    assert numbers.format_decimal(123, "'@0.#'00'@01'", locale='en') == '@0.#120@01'
+
+    assert numbers.format_decimal(123, "'$'''0", locale='en') == "$'123"
+
+    assert numbers.format_decimal(12, "'#'0 o''clock", locale='en') == "#12 o'clock"
index 93ad37e4a62ece078c27ab41a5a3c9f621f55557..944710751053d5f5ac803a122da7901f29b24ae6 100644 (file)
@@ -334,6 +334,11 @@ def test_format_compact_decimal():
     assert fmt.compact_decimal(1234567, format_type='long', fraction_digits=2) == '1.23 million'
 
 
+def test_format_compact_currency():
+    fmt = support.Format('en_US')
+    assert fmt.compact_currency(1234567, "USD", format_type='short', fraction_digits=2) == '$1.23M'
+
+
 def test_format_percent():
     fmt = support.Format('en_US')
     assert fmt.percent(0.34) == '34%'