Repository: python-babel/babel
Pull request #1173 (2025-01-14 15:17:39)

Prefer LC_MONETARY when formatting currencies
> POSIX defines the [`LC_MONETARY` category](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap07.html) to be used for monetary formatting, hence Babel could be more standards-compliant in this regard.

Closes #1051 (supersedes it).

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/python-babel/babel/pull/1173?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 91.34%. Comparing base [(`db48791`)](https://app.codecov.io/gh/python-babel/babel/commit/db4879136a7fbcef475f26b75dbdd65d0ce488f9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) to head [(`e071223`)](https://app.codecov.io/gh/python-babel/babel/commit/e07122307a5681117991bf7f258edb65c834aa6d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1173      +/-   ##
==========================================
+ Coverage   91.33%   91.34%   +0.01%     
==========================================
  Files          27       27              
  Lines        4650     4657       +7     
==========================================
+ Hits         4247     4254       +7     
  Misses        403      403              
```

| [Flag](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | Coverage Δ | |
|---|---|---|
| [macos-14-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.35% <100.00%> (+0.01%)` | :arrow_up: |
| [macos-14-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.29% <100.00%> (+0.01%)` | :arrow_up: |
| [macos-14-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-14-3.13](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.50% <100.00%> (+0.01%)` | :arrow_up: |
| [macos-14-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-14-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-14-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-24.04-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.38% <100.00%> (+0.01%)` | :arrow_up: |
| [ubuntu-24.04-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.31% <100.00%> (+0.01%)` | :arrow_up: |
| [ubuntu-24.04-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-24.04-3.13](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-24.04-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.24% <100.00%> (+0.01%)` | :arrow_up: |
| [ubuntu-24.04-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.30% <100.00%> (+0.01%)` | :arrow_up: |
| [ubuntu-24.04-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.39% <100.00%> (+0.01%)` | :arrow_up: |
| [windows-2022-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.13](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1173/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/python-babel/babel/pull/1173?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).

tomasr8: I'll take a look later today ;)

Patch:
diff --git a/babel/core.py b/babel/core.py
index 1c7732b..bffef94 100644
--- a/babel/core.py
+++ b/babel/core.py
@@ -1089,7 +1089,10 @@ class Locale:
         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.
 
@@ -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:
diff --git a/babel/numbers.py b/babel/numbers.py
index a2b89cc..8dd4d9e 100644
--- a/babel/numbers.py
+++ b/babel/numbers.py
@@ -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``
 
@@ -28,6 +29,7 @@ from typing import Any, Literal, cast, overload
 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')
 
 
@@ -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)
@@ -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:
@@ -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:
@@ -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
@@ -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(
@@ -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:


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index b6c5562..8770cf8 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -265,7 +265,7 @@ class TestLocaleClass:
 
 
 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'
@@ -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')
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index cf0e8d1..45892fe 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -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")
@@ -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)