Skip to content

Commit

Permalink
Merge pull request #1173 from python-babel/lc-monetary-2
Browse files Browse the repository at this point in the history
Prefer LC_MONETARY when formatting currencies
  • Loading branch information
akx authored Jan 29, 2025
2 parents 2d8a808 + 56ef7c7 commit e9c3ef8
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 29 deletions.
22 changes: 18 additions & 4 deletions babel/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,10 @@ def unit_display_names(self) -> localedata.LocaleDataDict:
return self._data['unit_display_names']


def default_locale(category: str | None = None, aliases: Mapping[str, str] = LOCALE_ALIASES) -> str | None:
def default_locale(
category: str | tuple[str, ...] | list[str] | None = None,
aliases: Mapping[str, str] = LOCALE_ALIASES,
) -> str | None:
"""Returns the system default locale for a given category, based on
environment variables.
Expand All @@ -1113,11 +1116,22 @@ def default_locale(category: str | None = None, aliases: Mapping[str, str] = LOC
- ``LC_CTYPE``
- ``LANG``
:param category: one of the ``LC_XXX`` environment variable names
:param category: one or more of the ``LC_XXX`` environment variable names
:param aliases: a dictionary of aliases for locale identifiers
"""
varnames = (category, 'LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG')
for name in filter(None, varnames):

varnames = ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG')
if category:
if isinstance(category, str):
varnames = (category, *varnames)
elif isinstance(category, (list, tuple)):
varnames = (*category, *varnames)
else:
raise TypeError(f"Invalid type for category: {category!r}")

for name in varnames:
if not name:
continue
locale = os.getenv(name)
if locale:
if name == 'LANGUAGE' and ':' in locale:
Expand Down
29 changes: 18 additions & 11 deletions babel/numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
The default locale for the functions in this module is determined by the
following environment variables, in that order:
* ``LC_NUMERIC``,
* ``LC_MONETARY`` for currency related functions,
* ``LC_NUMERIC``, and
* ``LC_ALL``, and
* ``LANG``
Expand All @@ -28,6 +29,7 @@
from babel.core import Locale, default_locale, get_global
from babel.localedata import LocaleDataDict

LC_MONETARY = default_locale(('LC_MONETARY', 'LC_NUMERIC'))
LC_NUMERIC = default_locale('LC_NUMERIC')


Expand Down Expand Up @@ -117,9 +119,10 @@ def get_currency_name(
:param currency: the currency code.
:param count: the optional count. If provided the currency name
will be pluralized to that number if possible.
:param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param locale: the `Locale` object or locale identifier.
Defaults to the system currency locale or numeric locale.
"""
loc = Locale.parse(locale or LC_NUMERIC)
loc = Locale.parse(locale or LC_MONETARY)
if count is not None:
try:
plural_form = loc.plural_form(count)
Expand All @@ -142,9 +145,10 @@ def get_currency_symbol(currency: str, locale: Locale | str | None = None) -> st
u'$'
:param currency: the currency code.
:param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param locale: the `Locale` object or locale identifier.
Defaults to the system currency locale or numeric locale.
"""
return Locale.parse(locale or LC_NUMERIC).currency_symbols.get(currency, currency)
return Locale.parse(locale or LC_MONETARY).currency_symbols.get(currency, currency)


def get_currency_precision(currency: str) -> int:
Expand Down Expand Up @@ -181,9 +185,10 @@ def get_currency_unit_pattern(
: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. Defaults to the system numeric locale.
:param locale: the `Locale` object or locale identifier.
Defaults to the system currency locale or numeric locale.
"""
loc = Locale.parse(locale or LC_NUMERIC)
loc = Locale.parse(locale or LC_MONETARY)
if count is not None:
plural_form = loc.plural_form(count)
try:
Expand Down Expand Up @@ -760,7 +765,8 @@ def format_currency(
: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. Defaults to the system numeric locale.
:param locale: the `Locale` object or locale identifier.
Defaults to the system currency locale or numeric locale.
: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
Expand All @@ -771,7 +777,7 @@ def format_currency(
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
locale = Locale.parse(locale or LC_NUMERIC)
locale = Locale.parse(locale or LC_MONETARY)

if format_type == 'name':
return _format_currency_long_name(
Expand Down Expand Up @@ -860,13 +866,14 @@ def format_compact_currency(
: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. Defaults to the system numeric locale.
:param locale: the `Locale` object or locale identifier.
Defaults to the system currency locale or numeric locale.
:param fraction_digits: Number of digits after the decimal point to use. Defaults to `0`.
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
locale = Locale.parse(locale or LC_NUMERIC)
locale = Locale.parse(locale or LC_MONETARY)
try:
compact_format = locale.compact_currency_formats[format_type]
except KeyError as error:
Expand Down
19 changes: 18 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ def test_plural_form_property(self):


def test_default_locale(monkeypatch):
for name in ['LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES']:
for name in ['LANGUAGE', 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES']:
monkeypatch.setenv(name, '')
monkeypatch.setenv('LANG', 'fr_FR.UTF-8')
assert default_locale('LC_MESSAGES') == 'fr_FR'
Expand All @@ -277,6 +277,23 @@ def test_default_locale(monkeypatch):
assert default_locale() == 'en_US_POSIX'


def test_default_locale_multiple_args(monkeypatch):
for name in ['LANGUAGE', 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES', 'LC_MONETARY', 'LC_NUMERIC']:
monkeypatch.setenv(name, '')
assert default_locale(["", 0, None]) is None
monkeypatch.setenv('LANG', 'en_US')
assert default_locale(('LC_MONETARY', 'LC_NUMERIC')) == 'en_US' # No LC_MONETARY or LC_NUMERIC set
monkeypatch.setenv('LC_NUMERIC', 'fr_FR.UTF-8')
assert default_locale(('LC_MONETARY', 'LC_NUMERIC')) == 'fr_FR' # LC_NUMERIC set
monkeypatch.setenv('LC_MONETARY', 'fi_FI.UTF-8')
assert default_locale(('LC_MONETARY', 'LC_NUMERIC')) == 'fi_FI' # LC_MONETARY set, it takes precedence


def test_default_locale_bad_arg():
with pytest.raises(TypeError):
default_locale(42)


def test_negotiate_locale():
assert (core.negotiate_locale(['de_DE', 'en_US'], ['de_DE', 'de_AT']) ==
'de_DE')
Expand Down
41 changes: 28 additions & 13 deletions tests/test_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,19 +485,6 @@ def test_format_currency():
== 'US$0,00') # other


def test_format_currency_with_none_locale_with_default(monkeypatch):
"""Test that the default locale is used when locale is None."""
monkeypatch.setattr(numbers, "LC_NUMERIC", "fi_FI")
assert numbers.format_currency(0, "USD", locale=None) == "0,00\xa0$"


def test_format_currency_with_none_locale(monkeypatch):
"""Test that the API raises the "Empty locale identifier" error when locale is None, and the default is too."""
monkeypatch.setattr(numbers, "LC_NUMERIC", None) # Pretend we couldn't find any locale when importing the module
with pytest.raises(TypeError, match="Empty"):
numbers.format_currency(0, "USD", locale=None)


def test_format_currency_format_type():
assert (numbers.format_currency(1099.98, 'USD', locale='en_US',
format_type="standard")
Expand Down Expand Up @@ -867,3 +854,31 @@ def test_single_quotes_in_pattern():
assert numbers.format_decimal(123, "'$'''0", locale='en') == "$'123"

assert numbers.format_decimal(12, "'#'0 o''clock", locale='en') == "#12 o'clock"


def test_format_currency_with_none_locale_with_default(monkeypatch):
"""Test that the default locale is used when locale is None."""
monkeypatch.setattr(numbers, "LC_MONETARY", "fi_FI")
monkeypatch.setattr(numbers, "LC_NUMERIC", None)
assert numbers.format_currency(0, "USD", locale=None) == "0,00\xa0$"


def test_format_currency_with_none_locale(monkeypatch):
"""Test that the API raises the "Empty locale identifier" error when locale is None, and the default is too."""
monkeypatch.setattr(numbers, "LC_MONETARY", None) # Pretend we couldn't find any locale when importing the module
with pytest.raises(TypeError, match="Empty"):
numbers.format_currency(0, "USD", locale=None)


def test_format_decimal_with_none_locale_with_default(monkeypatch):
"""Test that the default locale is used when locale is None."""
monkeypatch.setattr(numbers, "LC_NUMERIC", "fi_FI")
monkeypatch.setattr(numbers, "LC_MONETARY", None)
assert numbers.format_decimal("1.23", locale=None) == "1,23"


def test_format_decimal_with_none_locale(monkeypatch):
"""Test that the API raises the "Empty locale identifier" error when locale is None, and the default is too."""
monkeypatch.setattr(numbers, "LC_NUMERIC", None) # Pretend we couldn't find any locale when importing the module
with pytest.raises(TypeError, match="Empty"):
numbers.format_decimal(0, locale=None)

0 comments on commit e9c3ef8

Please sign in to comment.