Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support for short compact currency formats #926

Merged
merged 17 commits into from
Nov 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions babel/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,18 @@ def currency_formats(self):
"""
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.
Expand Down
53 changes: 48 additions & 5 deletions babel/numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1082,6 +1121,10 @@ def apply(
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

#
Expand Down
9 changes: 8 additions & 1 deletion babel/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
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


Expand Down Expand Up @@ -124,6 +124,13 @@ def currency(self, number, currency):
"""
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.

Expand Down
2 changes: 2 additions & 0 deletions docs/api/numbers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Number Formatting

.. autofunction:: format_currency

.. autofunction:: format_compact_currency

.. autofunction:: format_percent

.. autofunction:: format_scientific
Expand Down
21 changes: 15 additions & 6 deletions scripts/import_cldr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
akx marked this conversation as resolved.
Show resolved Hide resolved
type = '%s:%s' % (type, curr_length_type)
if _should_skip_elem(elem, type, currency_formats):
continue
for child in elem.iter():
Expand All @@ -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):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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"
5 changes: 5 additions & 0 deletions tests/test_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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%'
Expand Down