Repository: python-babel/babel

Issue:
Implement currency formatting with long display names. 
Namely, things like:

    "123,456.78 US dollars"

(en locale)

or

    "dólares estadounidenses 123,456.78"

(es-GT locale)

The algorithm for doing this is described here: https://www.unicode.org/reports/tr35/tr35-numbers.html#Currencies and CLDR contains the data.

I'd be happy to attempt to implement this myself. Two issues I'm aware of:

1) What should the API be? An extra parameter to `format_currency`? A new `format_type` (this risks clashing with other format types already defined)?

2) It looks like this would require data not currently available in the pickled locale data. Specifically it requires the `unitPattern` data. I'm not sure how to go about adding this - apart from the `import_cldr.py` script, is there a specific process to follow?

For comparison, browsers currently implement this as part of [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat) using options `{currencyDisplay: "name"}`. In a browser console you can do:

    > Intl.NumberFormat("en", {currencyDisplay:"name", style:"currency", currency:"USD"}).format(123456.78)
    "123,456.78 US dollars"


Comments:

Patch:
diff --git a/babel/numbers.py b/babel/numbers.py
index 564d7ce..cc825c8 100644
--- a/babel/numbers.py
+++ b/babel/numbers.py
@@ -157,6 +157,36 @@ def get_currency_precision(currency):
     return precisions.get(currency, precisions['DEFAULT'])[0]
 
 
+def get_currency_unit_pattern(currency, count=None, locale=LC_NUMERIC):
+    """
+    Return the unit pattern used for long display of a currency value
+    for a given locale.
+    This is a string containing ``{0}`` where the numeric part
+    should be substituted and ``{1}`` where the currency long display
+    name should be substituted.
+
+    >>> get_currency_unit_pattern('USD', locale='en_US', count=10)
+    u'{0} {1}'
+
+    .. versionadded:: 2.7.0
+
+    :param currency: the currency code.
+    :param count: the optional count.  If provided the unit
+                  pattern for that number will be returned.
+    :param locale: the `Locale` object or locale identifier.
+    """
+    loc = Locale.parse(locale)
+    if count is not None:
+        plural_form = loc.plural_form(count)
+        try:
+            return loc._data['currency_unit_patterns'][plural_form]
+        except LookupError:
+            # Fall back to 'other'
+            pass
+
+    return loc._data['currency_unit_patterns']['other']
+
+
 def get_territory_currencies(territory, start_date=None, end_date=None,
                              tender=True, non_tender=False,
                              include_details=False):
@@ -442,6 +472,17 @@ def format_currency(
         ...
     UnknownCurrencyFormatError: "'unknown' is not a known currency format type"
 
+    You can also pass format_type='name' to use long display names. The order of
+    the number and currency name, along with the correct localized plural form
+    of the currency name, is chosen according to locale:
+
+    >>> format_currency(1, 'USD', locale='en_US', format_type='name')
+    u'1.00 US dollar'
+    >>> format_currency(1099.98, 'USD', locale='en_US', format_type='name')
+    u'1,099.98 US dollars'
+    >>> format_currency(1099.98, 'USD', locale='ee', format_type='name')
+    u'us ga dollar 1,099.98'
+
     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:
@@ -459,7 +500,12 @@ def format_currency(
     :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`.
+
     """
+    if format_type == 'name':
+        return _format_currency_long_name(number, currency, format=format,
+                                          locale=locale, currency_digits=currency_digits,
+                                          decimal_quantization=decimal_quantization)
     locale = Locale.parse(locale)
     if format:
         pattern = parse_pattern(format)
@@ -475,6 +521,42 @@ def format_currency(
         decimal_quantization=decimal_quantization)
 
 
+def _format_currency_long_name(
+        number, currency, format=None, locale=LC_NUMERIC, currency_digits=True,
+        format_type='standard', decimal_quantization=True):
+    # Algorithm described here:
+    # https://www.unicode.org/reports/tr35/tr35-numbers.html#Currencies
+    locale = Locale.parse(locale)
+    # Step 1.
+    # There are no examples of items with explicit count (0 or 1) in current
+    # locale data. So there is no point implementing that.
+    # Step 2.
+
+    # Correct number to numeric type, important for looking up plural rules:
+    if isinstance(number, string_types):
+        number_n = float(number)
+    else:
+        number_n = number
+
+    # Step 3.
+    unit_pattern = get_currency_unit_pattern(currency, count=number_n, locale=locale)
+
+    # Step 4.
+    display_name = get_currency_name(currency, count=number_n, locale=locale)
+
+    # Step 5.
+    if not format:
+        format = locale.decimal_formats.get(format)
+
+    pattern = parse_pattern(format)
+
+    number_part = pattern.apply(
+        number, locale, currency=currency, currency_digits=currency_digits,
+        decimal_quantization=decimal_quantization)
+
+    return unit_pattern.format(number_part, display_name)
+
+
 def format_percent(
         number, format=None, locale=LC_NUMERIC, decimal_quantization=True):
     """Return formatted percent value for a specific locale.
diff --git a/docs/api/numbers.rst b/docs/api/numbers.rst
index 1b21425..758ceba 100644
--- a/docs/api/numbers.rst
+++ b/docs/api/numbers.rst
@@ -38,6 +38,8 @@ Data Access
 
 .. autofunction:: get_currency_symbol
 
+.. autofunction:: get_currency_unit_pattern
+
 .. autofunction:: get_decimal_symbol
 
 .. autofunction:: get_plus_sign_symbol
diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py
index 60aa6c2..f1dd391 100755
--- a/scripts/import_cldr.py
+++ b/scripts/import_cldr.py
@@ -423,6 +423,7 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False):
         parse_percent_formats(data, tree)
 
         parse_currency_formats(data, tree)
+        parse_currency_unit_patterns(data, tree)
         parse_currency_names(data, tree)
         parse_unit_patterns(data, tree)
         parse_date_fields(data, tree)
@@ -903,6 +904,17 @@ def parse_currency_formats(data, tree):
                         currency_formats[type] = numbers.parse_pattern(pattern)
 
 
+def parse_currency_unit_patterns(data, tree):
+    currency_unit_patterns = data.setdefault('currency_unit_patterns', {})
+    for currency_formats_elem in tree.findall('.//currencyFormats'):
+        if _should_skip_number_elem(data, currency_formats_elem):  # TODO: Support other number systems
+            continue
+        for unit_pattern_elem in currency_formats_elem.findall('./unitPattern'):
+            count = unit_pattern_elem.attrib['count']
+            pattern = text_type(unit_pattern_elem.text)
+            currency_unit_patterns[count] = pattern
+
+
 def parse_day_period_rules(tree):
     """
     Parse dayPeriodRule data into a dict.


Test patch:
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index 493c1a7..32f4280 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -19,7 +19,7 @@ from datetime import date
 from babel import Locale, localedata, numbers
 from babel.numbers import (
     list_currencies, validate_currency, UnknownCurrencyError, is_currency, normalize_currency,
-    get_currency_precision, get_decimal_precision)
+    get_currency_precision, get_decimal_precision, get_currency_unit_pattern)
 from babel.localedata import locale_identifiers
 from babel._compat import decimal
 
@@ -228,6 +228,17 @@ def test_get_currency_precision():
     assert get_currency_precision('JPY') == 0
 
 
+def test_get_currency_unit_pattern():
+    assert get_currency_unit_pattern('USD', locale='en_US') == '{0} {1}'
+    assert get_currency_unit_pattern('USD', locale='es_GT') == '{1} {0}'
+
+    # 'ro' locale various pattern according to count
+    assert get_currency_unit_pattern('USD', locale='ro', count=1) == '{0} {1}'
+    assert get_currency_unit_pattern('USD', locale='ro', count=2) == '{0} {1}'
+    assert get_currency_unit_pattern('USD', locale='ro', count=100) == '{0} de {1}'
+    assert get_currency_unit_pattern('USD', locale='ro') == '{0} de {1}'
+
+
 def test_get_territory_currencies():
     assert numbers.get_territory_currencies('AT', date(1995, 1, 1)) == ['ATS']
     assert numbers.get_territory_currencies('AT', date(2011, 1, 1)) == ['EUR']
@@ -415,6 +426,52 @@ def test_format_currency_quantization():
             '0.9999999999', 'USD', locale=locale_code, decimal_quantization=False).find('9999999999') > -1
 
 
+def test_format_currency_long_display_name():
+    assert (numbers.format_currency(1099.98, 'USD', locale='en_US', format_type='name')
+            == u'1,099.98 US dollars')
+    assert (numbers.format_currency(1.00, 'USD', locale='en_US', format_type='name')
+            == u'1.00 US dollar')
+    assert (numbers.format_currency(1.00, 'EUR', locale='en_US', format_type='name')
+            == u'1.00 euro')
+    assert (numbers.format_currency(2, 'EUR', locale='en_US', format_type='name')
+            == u'2.00 euros')
+    # This tests that '{1} {0}' unitPatterns are found:
+    assert (numbers.format_currency(1, 'USD', locale='sw', format_type='name')
+            == u'dola ya Marekani 1.00')
+    # This tests unicode chars:
+    assert (numbers.format_currency(1099.98, 'USD', locale='es_GT', format_type='name')
+            == u'dólares estadounidenses 1,099.98')
+    # Test for completely unknown currency, should fallback to currency code
+    assert (numbers.format_currency(1099.98, 'XAB', locale='en_US', format_type='name')
+            == u'1,099.98 XAB')
+
+    # Test for finding different unit patterns depending on count
+    assert (numbers.format_currency(1, 'USD', locale='ro', format_type='name')
+            == u'1,00 dolar american')
+    assert (numbers.format_currency(2, 'USD', locale='ro', format_type='name')
+            == u'2,00 dolari americani')
+    assert (numbers.format_currency(100, 'USD', locale='ro', format_type='name')
+            == u'100,00 de dolari americani')
+
+
+def test_format_currency_long_display_name_all():
+    for locale_code in localedata.locale_identifiers():
+        assert numbers.format_currency(
+            1, 'USD', locale=locale_code, format_type='name').find('1') > -1
+        assert numbers.format_currency(
+            '1', 'USD', locale=locale_code, format_type='name').find('1') > -1
+
+
+def test_format_currency_long_display_name_custom_format():
+    assert (numbers.format_currency(1099.98, 'USD', locale='en_US',
+                                    format_type='name', format='##0')
+            == '1099.98 US dollars')
+    assert (numbers.format_currency(1099.98, 'USD', locale='en_US',
+                                    format_type='name', format='##0',
+                                    currency_digits=False)
+            == '1100 US dollars')
+
+
 def test_format_percent():
     assert numbers.format_percent(0.34, locale='en_US') == u'34%'
     assert numbers.format_percent(0, locale='en_US') == u'0%'