1574 lines
60 KiB
Python
1574 lines
60 KiB
Python
"""
|
||
babel.numbers
|
||
~~~~~~~~~~~~~
|
||
|
||
Locale dependent formatting and parsing of numeric data.
|
||
|
||
The default locale for the functions in this module is determined by the
|
||
following environment variables, in that order:
|
||
|
||
* ``LC_NUMERIC``,
|
||
* ``LC_ALL``, and
|
||
* ``LANG``
|
||
|
||
:copyright: (c) 2013-2024 by the Babel Team.
|
||
:license: BSD, see LICENSE for more details.
|
||
"""
|
||
# TODO:
|
||
# Padding and rounding increments in pattern:
|
||
# - https://www.unicode.org/reports/tr35/ (Appendix G.6)
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
import decimal
|
||
import re
|
||
import warnings
|
||
from typing import TYPE_CHECKING, Any, cast, overload
|
||
|
||
from babel.core import Locale, default_locale, get_global
|
||
from babel.localedata import LocaleDataDict
|
||
|
||
if TYPE_CHECKING:
|
||
from typing_extensions import Literal
|
||
|
||
LC_NUMERIC = default_locale('LC_NUMERIC')
|
||
|
||
|
||
class UnknownCurrencyError(Exception):
|
||
"""Exception thrown when a currency is requested for which no data is available.
|
||
"""
|
||
|
||
def __init__(self, identifier: str) -> None:
|
||
"""Create the exception.
|
||
:param identifier: the identifier string of the unsupported currency
|
||
"""
|
||
Exception.__init__(self, f"Unknown currency {identifier!r}.")
|
||
|
||
#: The identifier of the locale that could not be found.
|
||
self.identifier = identifier
|
||
|
||
|
||
def list_currencies(locale: Locale | str | None = None) -> set[str]:
|
||
""" Return a `set` of normalized currency codes.
|
||
|
||
.. versionadded:: 2.5.0
|
||
|
||
:param locale: filters returned currency codes by the provided locale.
|
||
Expected to be a locale instance or code. If no locale is
|
||
provided, returns the list of all currencies from all
|
||
locales.
|
||
"""
|
||
# Get locale-scoped currencies.
|
||
if locale:
|
||
return set(Locale.parse(locale).currencies)
|
||
return set(get_global('all_currencies'))
|
||
|
||
|
||
def validate_currency(currency: str, locale: Locale | str | None = None) -> None:
|
||
""" Check the currency code is recognized by Babel.
|
||
|
||
Accepts a ``locale`` parameter for fined-grained validation, working as
|
||
the one defined above in ``list_currencies()`` method.
|
||
|
||
Raises a `UnknownCurrencyError` exception if the currency is unknown to Babel.
|
||
"""
|
||
if currency not in list_currencies(locale):
|
||
raise UnknownCurrencyError(currency)
|
||
|
||
|
||
def is_currency(currency: str, locale: Locale | str | None = None) -> bool:
|
||
""" Returns `True` only if a currency is recognized by Babel.
|
||
|
||
This method always return a Boolean and never raise.
|
||
"""
|
||
if not currency or not isinstance(currency, str):
|
||
return False
|
||
try:
|
||
validate_currency(currency, locale)
|
||
except UnknownCurrencyError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def normalize_currency(currency: str, locale: Locale | str | None = None) -> str | None:
|
||
"""Returns the normalized identifier of any currency code.
|
||
|
||
Accepts a ``locale`` parameter for fined-grained validation, working as
|
||
the one defined above in ``list_currencies()`` method.
|
||
|
||
Returns None if the currency is unknown to Babel.
|
||
"""
|
||
if isinstance(currency, str):
|
||
currency = currency.upper()
|
||
if not is_currency(currency, locale):
|
||
return None
|
||
return currency
|
||
|
||
|
||
def get_currency_name(
|
||
currency: str,
|
||
count: float | decimal.Decimal | None = None,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
) -> str:
|
||
"""Return the name used by the locale for the specified currency.
|
||
|
||
>>> get_currency_name('USD', locale='en_US')
|
||
u'US Dollar'
|
||
|
||
.. versionadded:: 0.9.4
|
||
|
||
: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.
|
||
"""
|
||
loc = Locale.parse(locale)
|
||
if count is not None:
|
||
try:
|
||
plural_form = loc.plural_form(count)
|
||
except (OverflowError, ValueError):
|
||
plural_form = 'other'
|
||
plural_names = loc._data['currency_names_plural']
|
||
if currency in plural_names:
|
||
currency_plural_names = plural_names[currency]
|
||
if plural_form in currency_plural_names:
|
||
return currency_plural_names[plural_form]
|
||
if 'other' in currency_plural_names:
|
||
return currency_plural_names['other']
|
||
return loc.currencies.get(currency, currency)
|
||
|
||
|
||
def get_currency_symbol(currency: str, locale: Locale | str | None = LC_NUMERIC) -> str:
|
||
"""Return the symbol used by the locale for the specified currency.
|
||
|
||
>>> get_currency_symbol('USD', locale='en_US')
|
||
u'$'
|
||
|
||
:param currency: the currency code.
|
||
:param locale: the `Locale` object or locale identifier.
|
||
"""
|
||
return Locale.parse(locale).currency_symbols.get(currency, currency)
|
||
|
||
|
||
def get_currency_precision(currency: str) -> int:
|
||
"""Return currency's precision.
|
||
|
||
Precision is the number of decimals found after the decimal point in the
|
||
currency's format pattern.
|
||
|
||
.. versionadded:: 2.5.0
|
||
|
||
:param currency: the currency code.
|
||
"""
|
||
precisions = get_global('currency_fractions')
|
||
return precisions.get(currency, precisions['DEFAULT'])[0]
|
||
|
||
|
||
def get_currency_unit_pattern(
|
||
currency: str,
|
||
count: float | decimal.Decimal | None = None,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
) -> str:
|
||
"""
|
||
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']
|
||
|
||
|
||
@overload
|
||
def get_territory_currencies(
|
||
territory: str,
|
||
start_date: datetime.date | None = ...,
|
||
end_date: datetime.date | None = ...,
|
||
tender: bool = ...,
|
||
non_tender: bool = ...,
|
||
include_details: Literal[False] = ...,
|
||
) -> list[str]:
|
||
... # pragma: no cover
|
||
|
||
|
||
@overload
|
||
def get_territory_currencies(
|
||
territory: str,
|
||
start_date: datetime.date | None = ...,
|
||
end_date: datetime.date | None = ...,
|
||
tender: bool = ...,
|
||
non_tender: bool = ...,
|
||
include_details: Literal[True] = ...,
|
||
) -> list[dict[str, Any]]:
|
||
... # pragma: no cover
|
||
|
||
|
||
def get_territory_currencies(
|
||
territory: str,
|
||
start_date: datetime.date | None = None,
|
||
end_date: datetime.date | None = None,
|
||
tender: bool = True,
|
||
non_tender: bool = False,
|
||
include_details: bool = False,
|
||
) -> list[str] | list[dict[str, Any]]:
|
||
"""Returns the list of currencies for the given territory that are valid for
|
||
the given date range. In addition to that the currency database
|
||
distinguishes between tender and non-tender currencies. By default only
|
||
tender currencies are returned.
|
||
|
||
The return value is a list of all currencies roughly ordered by the time
|
||
of when the currency became active. The longer the currency is being in
|
||
use the more to the left of the list it will be.
|
||
|
||
The start date defaults to today. If no end date is given it will be the
|
||
same as the start date. Otherwise a range can be defined. For instance
|
||
this can be used to find the currencies in use in Austria between 1995 and
|
||
2011:
|
||
|
||
>>> from datetime import date
|
||
>>> get_territory_currencies('AT', date(1995, 1, 1), date(2011, 1, 1))
|
||
['ATS', 'EUR']
|
||
|
||
Likewise it's also possible to find all the currencies in use on a
|
||
single date:
|
||
|
||
>>> get_territory_currencies('AT', date(1995, 1, 1))
|
||
['ATS']
|
||
>>> get_territory_currencies('AT', date(2011, 1, 1))
|
||
['EUR']
|
||
|
||
By default the return value only includes tender currencies. This
|
||
however can be changed:
|
||
|
||
>>> get_territory_currencies('US')
|
||
['USD']
|
||
>>> get_territory_currencies('US', tender=False, non_tender=True,
|
||
... start_date=date(2014, 1, 1))
|
||
['USN', 'USS']
|
||
|
||
.. versionadded:: 2.0
|
||
|
||
:param territory: the name of the territory to find the currency for.
|
||
:param start_date: the start date. If not given today is assumed.
|
||
:param end_date: the end date. If not given the start date is assumed.
|
||
:param tender: controls whether tender currencies should be included.
|
||
:param non_tender: controls whether non-tender currencies should be
|
||
included.
|
||
:param include_details: if set to `True`, instead of returning currency
|
||
codes the return value will be dictionaries
|
||
with detail information. In that case each
|
||
dictionary will have the keys ``'currency'``,
|
||
``'from'``, ``'to'``, and ``'tender'``.
|
||
"""
|
||
currencies = get_global('territory_currencies')
|
||
if start_date is None:
|
||
start_date = datetime.date.today()
|
||
elif isinstance(start_date, datetime.datetime):
|
||
start_date = start_date.date()
|
||
if end_date is None:
|
||
end_date = start_date
|
||
elif isinstance(end_date, datetime.datetime):
|
||
end_date = end_date.date()
|
||
|
||
curs = currencies.get(territory.upper(), ())
|
||
# TODO: validate that the territory exists
|
||
|
||
def _is_active(start, end):
|
||
return (start is None or start <= end_date) and \
|
||
(end is None or end >= start_date)
|
||
|
||
result = []
|
||
for currency_code, start, end, is_tender in curs:
|
||
if start:
|
||
start = datetime.date(*start)
|
||
if end:
|
||
end = datetime.date(*end)
|
||
if ((is_tender and tender) or
|
||
(not is_tender and non_tender)) and _is_active(start, end):
|
||
if include_details:
|
||
result.append({
|
||
'currency': currency_code,
|
||
'from': start,
|
||
'to': end,
|
||
'tender': is_tender,
|
||
})
|
||
else:
|
||
result.append(currency_code)
|
||
|
||
return result
|
||
|
||
|
||
def _get_numbering_system(locale: Locale, numbering_system: Literal["default"] | str = "latn") -> str:
|
||
if numbering_system == "default":
|
||
return locale.default_numbering_system
|
||
else:
|
||
return numbering_system
|
||
|
||
|
||
def _get_number_symbols(
|
||
locale: Locale | str | None,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> LocaleDataDict:
|
||
parsed_locale = Locale.parse(locale)
|
||
numbering_system = _get_numbering_system(parsed_locale, numbering_system)
|
||
try:
|
||
return parsed_locale.number_symbols[numbering_system]
|
||
except KeyError as error:
|
||
raise UnsupportedNumberingSystemError(f"Unknown numbering system {numbering_system} for Locale {parsed_locale}.") from error
|
||
|
||
|
||
class UnsupportedNumberingSystemError(Exception):
|
||
"""Exception thrown when an unsupported numbering system is requested for the given Locale."""
|
||
pass
|
||
|
||
|
||
def get_decimal_symbol(
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return the symbol used by the locale to separate decimal fractions.
|
||
|
||
>>> get_decimal_symbol('en_US')
|
||
u'.'
|
||
>>> get_decimal_symbol('ar_EG', numbering_system='default')
|
||
u'٫'
|
||
>>> get_decimal_symbol('ar_EG', numbering_system='latn')
|
||
u'.'
|
||
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param numbering_system: The numbering system used for fetching the symbol. 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.
|
||
"""
|
||
return _get_number_symbols(locale, numbering_system=numbering_system).get('decimal', '.')
|
||
|
||
|
||
def get_plus_sign_symbol(
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return the plus sign symbol used by the current locale.
|
||
|
||
>>> get_plus_sign_symbol('en_US')
|
||
u'+'
|
||
>>> get_plus_sign_symbol('ar_EG', numbering_system='default')
|
||
u'\u061c+'
|
||
>>> get_plus_sign_symbol('ar_EG', numbering_system='latn')
|
||
u'\u200e+'
|
||
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param numbering_system: The numbering system used for fetching the symbol. 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.
|
||
"""
|
||
return _get_number_symbols(locale, numbering_system=numbering_system).get('plusSign', '+')
|
||
|
||
|
||
def get_minus_sign_symbol(
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return the plus sign symbol used by the current locale.
|
||
|
||
>>> get_minus_sign_symbol('en_US')
|
||
u'-'
|
||
>>> get_minus_sign_symbol('ar_EG', numbering_system='default')
|
||
u'\u061c-'
|
||
>>> get_minus_sign_symbol('ar_EG', numbering_system='latn')
|
||
u'\u200e-'
|
||
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param numbering_system: The numbering system used for fetching the symbol. 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.
|
||
"""
|
||
return _get_number_symbols(locale, numbering_system=numbering_system).get('minusSign', '-')
|
||
|
||
|
||
def get_exponential_symbol(
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return the symbol used by the locale to separate mantissa and exponent.
|
||
|
||
>>> get_exponential_symbol('en_US')
|
||
u'E'
|
||
>>> get_exponential_symbol('ar_EG', numbering_system='default')
|
||
u'أس'
|
||
>>> get_exponential_symbol('ar_EG', numbering_system='latn')
|
||
u'E'
|
||
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param numbering_system: The numbering system used for fetching the symbol. 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.
|
||
"""
|
||
return _get_number_symbols(locale, numbering_system=numbering_system).get('exponential', 'E')
|
||
|
||
|
||
def get_group_symbol(
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return the symbol used by the locale to separate groups of thousands.
|
||
|
||
>>> get_group_symbol('en_US')
|
||
u','
|
||
>>> get_group_symbol('ar_EG', numbering_system='default')
|
||
u'٬'
|
||
>>> get_group_symbol('ar_EG', numbering_system='latn')
|
||
u','
|
||
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param numbering_system: The numbering system used for fetching the symbol. 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.
|
||
"""
|
||
return _get_number_symbols(locale, numbering_system=numbering_system).get('group', ',')
|
||
|
||
|
||
def get_infinity_symbol(
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return the symbol used by the locale to represent infinity.
|
||
|
||
>>> get_infinity_symbol('en_US')
|
||
u'∞'
|
||
>>> get_infinity_symbol('ar_EG', numbering_system='default')
|
||
u'∞'
|
||
>>> get_infinity_symbol('ar_EG', numbering_system='latn')
|
||
u'∞'
|
||
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param numbering_system: The numbering system used for fetching the symbol. 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.
|
||
"""
|
||
return _get_number_symbols(locale, numbering_system=numbering_system).get('infinity', '∞')
|
||
|
||
|
||
def format_number(number: float | decimal.Decimal | str, locale: Locale | str | None = LC_NUMERIC) -> str:
|
||
"""Return the given number formatted for a specific locale.
|
||
|
||
>>> format_number(1099, locale='en_US') # doctest: +SKIP
|
||
u'1,099'
|
||
>>> format_number(1099, locale='de_DE') # doctest: +SKIP
|
||
u'1.099'
|
||
|
||
.. deprecated:: 2.6.0
|
||
|
||
Use babel.numbers.format_decimal() instead.
|
||
|
||
:param number: the number to format
|
||
:param locale: the `Locale` object or locale identifier
|
||
|
||
|
||
"""
|
||
warnings.warn('Use babel.numbers.format_decimal() instead.', DeprecationWarning, stacklevel=2)
|
||
return format_decimal(number, locale=locale)
|
||
|
||
|
||
def get_decimal_precision(number: decimal.Decimal) -> int:
|
||
"""Return maximum precision of a decimal instance's fractional part.
|
||
|
||
Precision is extracted from the fractional part only.
|
||
"""
|
||
# Copied from: https://github.com/mahmoud/boltons/pull/59
|
||
assert isinstance(number, decimal.Decimal)
|
||
decimal_tuple = number.normalize().as_tuple()
|
||
# Note: DecimalTuple.exponent can be 'n' (qNaN), 'N' (sNaN), or 'F' (Infinity)
|
||
if not isinstance(decimal_tuple.exponent, int) or decimal_tuple.exponent >= 0:
|
||
return 0
|
||
return abs(decimal_tuple.exponent)
|
||
|
||
|
||
def get_decimal_quantum(precision: int | decimal.Decimal) -> decimal.Decimal:
|
||
"""Return minimal quantum of a number, as defined by precision."""
|
||
assert isinstance(precision, (int, decimal.Decimal))
|
||
return decimal.Decimal(10) ** (-precision)
|
||
|
||
|
||
def format_decimal(
|
||
number: float | decimal.Decimal | str,
|
||
format: str | NumberPattern | None = None,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
decimal_quantization: bool = True,
|
||
group_separator: bool = True,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return the given decimal number formatted for a specific locale.
|
||
|
||
>>> format_decimal(1.2345, locale='en_US')
|
||
u'1.234'
|
||
>>> format_decimal(1.2346, locale='en_US')
|
||
u'1.235'
|
||
>>> format_decimal(-1.2346, locale='en_US')
|
||
u'-1.235'
|
||
>>> format_decimal(1.2345, locale='sv_SE')
|
||
u'1,234'
|
||
>>> format_decimal(1.2345, locale='de')
|
||
u'1,234'
|
||
>>> format_decimal(1.2345, locale='ar_EG', numbering_system='default')
|
||
u'1٫234'
|
||
>>> format_decimal(1.2345, locale='ar_EG', numbering_system='latn')
|
||
u'1.234'
|
||
|
||
The appropriate thousands grouping and the decimal separator are used for
|
||
each locale:
|
||
|
||
>>> format_decimal(12345.5, locale='en_US')
|
||
u'12,345.5'
|
||
|
||
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:
|
||
|
||
>>> format_decimal(1.2346, locale='en_US')
|
||
u'1.235'
|
||
>>> format_decimal(1.2346, locale='en_US', decimal_quantization=False)
|
||
u'1.2346'
|
||
>>> format_decimal(12345.67, locale='fr_CA', group_separator=False)
|
||
u'12345,67'
|
||
>>> format_decimal(12345.67, locale='en_US', group_separator=True)
|
||
u'12,345.67'
|
||
|
||
:param number: the number to format
|
||
:param format:
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param decimal_quantization: Truncate and round high-precision numbers to
|
||
the format pattern. Defaults to `True`.
|
||
:param group_separator: Boolean to switch group separator on/off in a locale's
|
||
number format.
|
||
: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)
|
||
if format is None:
|
||
format = locale.decimal_formats[format]
|
||
pattern = parse_pattern(format)
|
||
return pattern.apply(
|
||
number, locale, decimal_quantization=decimal_quantization, group_separator=group_separator, numbering_system=numbering_system)
|
||
|
||
|
||
def format_compact_decimal(
|
||
number: float | decimal.Decimal | str,
|
||
*,
|
||
format_type: Literal["short", "long"] = "short",
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
fraction_digits: int = 0,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return the given decimal number formatted for a specific locale in compact form.
|
||
|
||
>>> format_compact_decimal(12345, format_type="short", locale='en_US')
|
||
u'12K'
|
||
>>> format_compact_decimal(12345, format_type="long", locale='en_US')
|
||
u'12 thousand'
|
||
>>> format_compact_decimal(12345, format_type="short", locale='en_US', fraction_digits=2)
|
||
u'12.34K'
|
||
>>> format_compact_decimal(1234567, format_type="short", locale="ja_JP")
|
||
u'123万'
|
||
>>> format_compact_decimal(2345678, format_type="long", locale="mk")
|
||
u'2 милиони'
|
||
>>> format_compact_decimal(21000000, format_type="long", locale="mk")
|
||
u'21 милион'
|
||
>>> format_compact_decimal(12345, format_type="short", locale='ar_EG', fraction_digits=2, numbering_system='default')
|
||
u'12٫34\xa0ألف'
|
||
|
||
:param number: the number to format
|
||
:param format_type: Compact format to use ("short" or "long")
|
||
:param locale: the `Locale` object or locale identifier
|
||
: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)
|
||
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[None]
|
||
pattern = parse_pattern(format)
|
||
return pattern.apply(number, locale, decimal_quantization=False, numbering_system=numbering_system)
|
||
|
||
|
||
def _get_compact_format(
|
||
number: float | decimal.Decimal | str,
|
||
compact_format: LocaleDataDict,
|
||
locale: Locale,
|
||
fraction_digits: int,
|
||
) -> tuple[decimal.Decimal, NumberPattern | None]:
|
||
"""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.
|
||
"""
|
||
if not isinstance(number, decimal.Decimal):
|
||
number = decimal.Decimal(str(number))
|
||
if number.is_nan() or number.is_infinite():
|
||
return number, None
|
||
format = None
|
||
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
|
||
format = compact_format["other"][str(magnitude)]
|
||
pattern = parse_pattern(format).pattern
|
||
# if the pattern is "0", we do not divide the number
|
||
if pattern == "0":
|
||
break
|
||
# otherwise, we need to divide the number by the magnitude but remove zeros
|
||
# equal to the number of 0's in the pattern minus 1
|
||
number = cast(decimal.Decimal, number / (magnitude // (10 ** (pattern.count("0") - 1))))
|
||
# round to the number of fraction digits requested
|
||
rounded = round(number, fraction_digits)
|
||
# if the remaining number is singular, use the singular format
|
||
plural_form = locale.plural_form(abs(number))
|
||
if plural_form not in compact_format:
|
||
plural_form = "other"
|
||
if number == 1 and "1" in compact_format:
|
||
plural_form = "1"
|
||
format = compact_format[plural_form][str(magnitude)]
|
||
number = rounded
|
||
break
|
||
return number, format
|
||
|
||
|
||
class UnknownCurrencyFormatError(KeyError):
|
||
"""Exception raised when an unknown currency format is requested."""
|
||
|
||
|
||
def format_currency(
|
||
number: float | decimal.Decimal | str,
|
||
currency: str,
|
||
format: str | NumberPattern | None = None,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
currency_digits: bool = True,
|
||
format_type: Literal["name", "standard", "accounting"] = "standard",
|
||
decimal_quantization: bool = True,
|
||
group_separator: bool = True,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return formatted currency value.
|
||
|
||
>>> format_currency(1099.98, 'USD', locale='en_US')
|
||
'$1,099.98'
|
||
>>> format_currency(1099.98, 'USD', locale='es_CO')
|
||
u'US$1.099,98'
|
||
>>> format_currency(1099.98, 'EUR', locale='de_DE')
|
||
u'1.099,98\\xa0\\u20ac'
|
||
>>> format_currency(1099.98, 'EGP', locale='ar_EG', numbering_system='default')
|
||
u'\u200f1٬099٫98\xa0ج.م.\u200f'
|
||
|
||
The format can also be specified explicitly. The currency is
|
||
placed with the '¤' sign. As the sign gets repeated the format
|
||
expands (¤ being the symbol, ¤¤ is the currency abbreviation and
|
||
¤¤¤ is the full name of the currency):
|
||
|
||
>>> format_currency(1099.98, 'EUR', u'\xa4\xa4 #,##0.00', locale='en_US')
|
||
u'EUR 1,099.98'
|
||
>>> format_currency(1099.98, 'EUR', u'#,##0.00 \xa4\xa4\xa4', locale='en_US')
|
||
u'1,099.98 euros'
|
||
|
||
Currencies usually have a specific number of decimal digits. This function
|
||
favours that information over the given format:
|
||
|
||
>>> format_currency(1099.98, 'JPY', locale='en_US')
|
||
u'\\xa51,100'
|
||
>>> format_currency(1099.98, 'COP', u'#,##0.00', locale='es_ES')
|
||
u'1.099,98'
|
||
|
||
However, the number of decimal digits can be overridden from the currency
|
||
information, by setting the last parameter to ``False``:
|
||
|
||
>>> format_currency(1099.98, 'JPY', locale='en_US', currency_digits=False)
|
||
u'\\xa51,099.98'
|
||
>>> format_currency(1099.98, 'COP', u'#,##0.00', locale='es_ES', currency_digits=False)
|
||
u'1.099,98'
|
||
|
||
If a format is not specified the type of currency format to use
|
||
from the locale can be specified:
|
||
|
||
>>> format_currency(1099.98, 'EUR', locale='en_US', format_type='standard')
|
||
u'\\u20ac1,099.98'
|
||
|
||
When the given currency format type is not available, an exception is
|
||
raised:
|
||
|
||
>>> format_currency('1099.98', 'EUR', locale='root', format_type='unknown')
|
||
Traceback (most recent call last):
|
||
...
|
||
UnknownCurrencyFormatError: "'unknown' is not a known currency format type"
|
||
|
||
>>> format_currency(101299.98, 'USD', locale='en_US', group_separator=False)
|
||
u'$101299.98'
|
||
|
||
>>> format_currency(101299.98, 'USD', locale='en_US', group_separator=True)
|
||
u'$101,299.98'
|
||
|
||
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:
|
||
|
||
>>> format_currency(1099.9876, 'USD', locale='en_US')
|
||
u'$1,099.99'
|
||
>>> format_currency(1099.9876, 'USD', locale='en_US', decimal_quantization=False)
|
||
u'$1,099.9876'
|
||
|
||
: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
|
||
: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
|
||
the format pattern. Defaults to `True`.
|
||
:param group_separator: Boolean to switch group separator on/off in a locale's
|
||
number format.
|
||
: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.
|
||
"""
|
||
if format_type == 'name':
|
||
return _format_currency_long_name(number, currency, format=format,
|
||
locale=locale, currency_digits=currency_digits,
|
||
decimal_quantization=decimal_quantization, group_separator=group_separator,
|
||
numbering_system=numbering_system)
|
||
locale = Locale.parse(locale)
|
||
if format:
|
||
pattern = parse_pattern(format)
|
||
else:
|
||
try:
|
||
pattern = locale.currency_formats[format_type]
|
||
except KeyError:
|
||
raise UnknownCurrencyFormatError(f"{format_type!r} is not a known currency format type") from None
|
||
|
||
return pattern.apply(
|
||
number, locale, currency=currency, currency_digits=currency_digits,
|
||
decimal_quantization=decimal_quantization, group_separator=group_separator, numbering_system=numbering_system)
|
||
|
||
|
||
def _format_currency_long_name(
|
||
number: float | decimal.Decimal | str,
|
||
currency: str,
|
||
format: str | NumberPattern | None = None,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
currency_digits: bool = True,
|
||
format_type: Literal["name", "standard", "accounting"] = "standard",
|
||
decimal_quantization: bool = True,
|
||
group_separator: bool = True,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
# 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:
|
||
number_n = float(number) if isinstance(number, str) else 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[None]
|
||
|
||
pattern = parse_pattern(format)
|
||
|
||
number_part = pattern.apply(
|
||
number, locale, currency=currency, currency_digits=currency_digits,
|
||
decimal_quantization=decimal_quantization, group_separator=group_separator, numbering_system=numbering_system)
|
||
|
||
return unit_pattern.format(number_part, display_name)
|
||
|
||
|
||
def format_compact_currency(
|
||
number: float | decimal.Decimal | str,
|
||
currency: str,
|
||
*,
|
||
format_type: Literal["short"] = "short",
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
fraction_digits: int = 0,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""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`.
|
||
: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)
|
||
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
|
||
if format is None:
|
||
raise ValueError('No compact currency format found for the given number and locale.')
|
||
pattern = parse_pattern(format)
|
||
return pattern.apply(number, locale, currency=currency, currency_digits=False, decimal_quantization=False,
|
||
numbering_system=numbering_system)
|
||
|
||
|
||
def format_percent(
|
||
number: float | decimal.Decimal | str,
|
||
format: str | NumberPattern | None = None,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
decimal_quantization: bool = True,
|
||
group_separator: bool = True,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return formatted percent value for a specific locale.
|
||
|
||
>>> format_percent(0.34, locale='en_US')
|
||
u'34%'
|
||
>>> format_percent(25.1234, locale='en_US')
|
||
u'2,512%'
|
||
>>> format_percent(25.1234, locale='sv_SE')
|
||
u'2\\xa0512\\xa0%'
|
||
>>> format_percent(25.1234, locale='ar_EG', numbering_system='default')
|
||
u'2٬512%'
|
||
|
||
The format pattern can also be specified explicitly:
|
||
|
||
>>> format_percent(25.1234, u'#,##0\u2030', locale='en_US')
|
||
u'25,123\u2030'
|
||
|
||
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:
|
||
|
||
>>> format_percent(23.9876, locale='en_US')
|
||
u'2,399%'
|
||
>>> format_percent(23.9876, locale='en_US', decimal_quantization=False)
|
||
u'2,398.76%'
|
||
|
||
>>> format_percent(229291.1234, locale='pt_BR', group_separator=False)
|
||
u'22929112%'
|
||
|
||
>>> format_percent(229291.1234, locale='pt_BR', group_separator=True)
|
||
u'22.929.112%'
|
||
|
||
:param number: the percent number to format
|
||
:param format:
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param decimal_quantization: Truncate and round high-precision numbers to
|
||
the format pattern. Defaults to `True`.
|
||
:param group_separator: Boolean to switch group separator on/off in a locale's
|
||
number format.
|
||
: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)
|
||
if not format:
|
||
format = locale.percent_formats[None]
|
||
pattern = parse_pattern(format)
|
||
return pattern.apply(
|
||
number, locale, decimal_quantization=decimal_quantization, group_separator=group_separator,
|
||
numbering_system=numbering_system,
|
||
)
|
||
|
||
|
||
def format_scientific(
|
||
number: float | decimal.Decimal | str,
|
||
format: str | NumberPattern | None = None,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
decimal_quantization: bool = True,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> str:
|
||
"""Return value formatted in scientific notation for a specific locale.
|
||
|
||
>>> format_scientific(10000, locale='en_US')
|
||
u'1E4'
|
||
>>> format_scientific(10000, locale='ar_EG', numbering_system='default')
|
||
u'1أس4'
|
||
|
||
The format pattern can also be specified explicitly:
|
||
|
||
>>> format_scientific(1234567, u'##0.##E00', locale='en_US')
|
||
u'1.23E06'
|
||
|
||
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:
|
||
|
||
>>> format_scientific(1234.9876, u'#.##E0', locale='en_US')
|
||
u'1.23E3'
|
||
>>> format_scientific(1234.9876, u'#.##E0', locale='en_US', decimal_quantization=False)
|
||
u'1.2349876E3'
|
||
|
||
:param number: the number to format
|
||
:param format:
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param decimal_quantization: Truncate and round high-precision numbers to
|
||
the format pattern. Defaults to `True`.
|
||
: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)
|
||
if not format:
|
||
format = locale.scientific_formats[None]
|
||
pattern = parse_pattern(format)
|
||
return pattern.apply(
|
||
number, locale, decimal_quantization=decimal_quantization, numbering_system=numbering_system)
|
||
|
||
|
||
class NumberFormatError(ValueError):
|
||
"""Exception raised when a string cannot be parsed into a number."""
|
||
|
||
def __init__(self, message: str, suggestions: list[str] | None = None) -> None:
|
||
super().__init__(message)
|
||
#: a list of properly formatted numbers derived from the invalid input
|
||
self.suggestions = suggestions
|
||
|
||
|
||
SPACE_CHARS = {
|
||
' ', # space
|
||
'\xa0', # no-break space
|
||
'\u202f', # narrow no-break space
|
||
}
|
||
|
||
SPACE_CHARS_RE = re.compile('|'.join(SPACE_CHARS))
|
||
|
||
|
||
def parse_number(
|
||
string: str,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> int:
|
||
"""Parse localized number string into an integer.
|
||
|
||
>>> parse_number('1,099', locale='en_US')
|
||
1099
|
||
>>> parse_number('1.099', locale='de_DE')
|
||
1099
|
||
|
||
When the given string cannot be parsed, an exception is raised:
|
||
|
||
>>> parse_number('1.099,98', locale='de')
|
||
Traceback (most recent call last):
|
||
...
|
||
NumberFormatError: '1.099,98' is not a valid number
|
||
|
||
:param string: the string to parse
|
||
:param locale: the `Locale` object or locale identifier
|
||
: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.
|
||
:return: the parsed number
|
||
:raise `NumberFormatError`: if the string can not be converted to a number
|
||
:raise `UnsupportedNumberingSystemError`: if the numbering system is not supported by the locale.
|
||
"""
|
||
group_symbol = get_group_symbol(locale, numbering_system=numbering_system)
|
||
|
||
if (
|
||
group_symbol in SPACE_CHARS and # if the grouping symbol is a kind of space,
|
||
group_symbol not in string and # and the string to be parsed does not contain it,
|
||
SPACE_CHARS_RE.search(string) # but it does contain any other kind of space instead,
|
||
):
|
||
# ... it's reasonable to assume it is taking the place of the grouping symbol.
|
||
string = SPACE_CHARS_RE.sub(group_symbol, string)
|
||
|
||
try:
|
||
return int(string.replace(group_symbol, ''))
|
||
except ValueError as ve:
|
||
raise NumberFormatError(f"{string!r} is not a valid number") from ve
|
||
|
||
|
||
def parse_decimal(
|
||
string: str,
|
||
locale: Locale | str | None = LC_NUMERIC,
|
||
strict: bool = False,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> decimal.Decimal:
|
||
"""Parse localized decimal string into a decimal.
|
||
|
||
>>> parse_decimal('1,099.98', locale='en_US')
|
||
Decimal('1099.98')
|
||
>>> parse_decimal('1.099,98', locale='de')
|
||
Decimal('1099.98')
|
||
>>> parse_decimal('12 345,123', locale='ru')
|
||
Decimal('12345.123')
|
||
>>> parse_decimal('1٬099٫98', locale='ar_EG', numbering_system='default')
|
||
Decimal('1099.98')
|
||
|
||
When the given string cannot be parsed, an exception is raised:
|
||
|
||
>>> parse_decimal('2,109,998', locale='de')
|
||
Traceback (most recent call last):
|
||
...
|
||
NumberFormatError: '2,109,998' is not a valid decimal number
|
||
|
||
If `strict` is set to `True` and the given string contains a number
|
||
formatted in an irregular way, an exception is raised:
|
||
|
||
>>> parse_decimal('30.00', locale='de', strict=True)
|
||
Traceback (most recent call last):
|
||
...
|
||
NumberFormatError: '30.00' is not a properly formatted decimal number. Did you mean '3.000'? Or maybe '30,00'?
|
||
|
||
>>> parse_decimal('0.00', locale='de', strict=True)
|
||
Traceback (most recent call last):
|
||
...
|
||
NumberFormatError: '0.00' is not a properly formatted decimal number. Did you mean '0'?
|
||
|
||
:param string: the string to parse
|
||
:param locale: the `Locale` object or locale identifier
|
||
:param strict: controls whether numbers formatted in a weird way are
|
||
accepted or rejected
|
||
: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 NumberFormatError: if the string can not be converted to a
|
||
decimal number
|
||
:raise UnsupportedNumberingSystemError: if the numbering system is not supported by the locale.
|
||
"""
|
||
locale = Locale.parse(locale)
|
||
group_symbol = get_group_symbol(locale, numbering_system=numbering_system)
|
||
decimal_symbol = get_decimal_symbol(locale, numbering_system=numbering_system)
|
||
|
||
if not strict and (
|
||
group_symbol in SPACE_CHARS and # if the grouping symbol is a kind of space,
|
||
group_symbol not in string and # and the string to be parsed does not contain it,
|
||
SPACE_CHARS_RE.search(string) # but it does contain any other kind of space instead,
|
||
):
|
||
# ... it's reasonable to assume it is taking the place of the grouping symbol.
|
||
string = SPACE_CHARS_RE.sub(group_symbol, string)
|
||
|
||
try:
|
||
parsed = decimal.Decimal(string.replace(group_symbol, '')
|
||
.replace(decimal_symbol, '.'))
|
||
except decimal.InvalidOperation as exc:
|
||
raise NumberFormatError(f"{string!r} is not a valid decimal number") from exc
|
||
if strict and group_symbol in string:
|
||
proper = format_decimal(parsed, locale=locale, decimal_quantization=False, numbering_system=numbering_system)
|
||
if string != proper and proper != _remove_trailing_zeros_after_decimal(string, decimal_symbol):
|
||
try:
|
||
parsed_alt = decimal.Decimal(string.replace(decimal_symbol, '')
|
||
.replace(group_symbol, '.'))
|
||
except decimal.InvalidOperation as exc:
|
||
raise NumberFormatError(
|
||
f"{string!r} is not a properly formatted decimal number. "
|
||
f"Did you mean {proper!r}?",
|
||
suggestions=[proper],
|
||
) from exc
|
||
else:
|
||
proper_alt = format_decimal(
|
||
parsed_alt,
|
||
locale=locale,
|
||
decimal_quantization=False,
|
||
numbering_system=numbering_system,
|
||
)
|
||
if proper_alt == proper:
|
||
raise NumberFormatError(
|
||
f"{string!r} is not a properly formatted decimal number. "
|
||
f"Did you mean {proper!r}?",
|
||
suggestions=[proper],
|
||
)
|
||
else:
|
||
raise NumberFormatError(
|
||
f"{string!r} is not a properly formatted decimal number. "
|
||
f"Did you mean {proper!r}? Or maybe {proper_alt!r}?",
|
||
suggestions=[proper, proper_alt],
|
||
)
|
||
return parsed
|
||
|
||
|
||
def _remove_trailing_zeros_after_decimal(string: str, decimal_symbol: str) -> str:
|
||
"""
|
||
Remove trailing zeros from the decimal part of a numeric string.
|
||
|
||
This function takes a string representing a numeric value and a decimal symbol.
|
||
It removes any trailing zeros that appear after the decimal symbol in the number.
|
||
If the decimal part becomes empty after removing trailing zeros, the decimal symbol
|
||
is also removed. If the string does not contain the decimal symbol, it is returned unchanged.
|
||
|
||
:param string: The numeric string from which to remove trailing zeros.
|
||
:type string: str
|
||
:param decimal_symbol: The symbol used to denote the decimal point.
|
||
:type decimal_symbol: str
|
||
:return: The numeric string with trailing zeros removed from its decimal part.
|
||
:rtype: str
|
||
|
||
Example:
|
||
>>> _remove_trailing_zeros_after_decimal("123.4500", ".")
|
||
'123.45'
|
||
>>> _remove_trailing_zeros_after_decimal("100.000", ".")
|
||
'100'
|
||
>>> _remove_trailing_zeros_after_decimal("100", ".")
|
||
'100'
|
||
"""
|
||
integer_part, _, decimal_part = string.partition(decimal_symbol)
|
||
|
||
if decimal_part:
|
||
decimal_part = decimal_part.rstrip("0")
|
||
if decimal_part:
|
||
return integer_part + decimal_symbol + decimal_part
|
||
return integer_part
|
||
|
||
return string
|
||
|
||
|
||
PREFIX_END = r'[^0-9@#.,]'
|
||
NUMBER_TOKEN = r'[0-9@#.,E+]'
|
||
|
||
PREFIX_PATTERN = r"(?P<prefix>(?:'[^']*'|%s)*)" % PREFIX_END
|
||
NUMBER_PATTERN = r"(?P<number>%s*)" % NUMBER_TOKEN
|
||
SUFFIX_PATTERN = r"(?P<suffix>.*)"
|
||
|
||
number_re = re.compile(f"{PREFIX_PATTERN}{NUMBER_PATTERN}{SUFFIX_PATTERN}")
|
||
|
||
|
||
def parse_grouping(p: str) -> tuple[int, int]:
|
||
"""Parse primary and secondary digit grouping
|
||
|
||
>>> parse_grouping('##')
|
||
(1000, 1000)
|
||
>>> parse_grouping('#,###')
|
||
(3, 3)
|
||
>>> parse_grouping('#,####,###')
|
||
(3, 4)
|
||
"""
|
||
width = len(p)
|
||
g1 = p.rfind(',')
|
||
if g1 == -1:
|
||
return 1000, 1000
|
||
g1 = width - g1 - 1
|
||
g2 = p[:-g1 - 1].rfind(',')
|
||
if g2 == -1:
|
||
return g1, g1
|
||
g2 = width - g1 - g2 - 2
|
||
return g1, g2
|
||
|
||
|
||
def parse_pattern(pattern: NumberPattern | str) -> NumberPattern:
|
||
"""Parse number format patterns"""
|
||
if isinstance(pattern, NumberPattern):
|
||
return pattern
|
||
|
||
def _match_number(pattern):
|
||
rv = number_re.search(pattern)
|
||
if rv is None:
|
||
raise ValueError(f"Invalid number pattern {pattern!r}")
|
||
return rv.groups()
|
||
|
||
pos_pattern = pattern
|
||
|
||
# Do we have a negative subpattern?
|
||
if ';' in pattern:
|
||
pos_pattern, neg_pattern = pattern.split(';', 1)
|
||
pos_prefix, number, pos_suffix = _match_number(pos_pattern)
|
||
neg_prefix, _, neg_suffix = _match_number(neg_pattern)
|
||
else:
|
||
pos_prefix, number, pos_suffix = _match_number(pos_pattern)
|
||
neg_prefix = f"-{pos_prefix}"
|
||
neg_suffix = pos_suffix
|
||
if 'E' in number:
|
||
number, exp = number.split('E', 1)
|
||
else:
|
||
exp = None
|
||
if '@' in number and '.' in number and '0' in number:
|
||
raise ValueError('Significant digit patterns can not contain "@" or "0"')
|
||
if '.' in number:
|
||
integer, fraction = number.rsplit('.', 1)
|
||
else:
|
||
integer = number
|
||
fraction = ''
|
||
|
||
def parse_precision(p):
|
||
"""Calculate the min and max allowed digits"""
|
||
min = max = 0
|
||
for c in p:
|
||
if c in '@0':
|
||
min += 1
|
||
max += 1
|
||
elif c == '#':
|
||
max += 1
|
||
elif c == ',':
|
||
continue
|
||
else:
|
||
break
|
||
return min, max
|
||
|
||
int_prec = parse_precision(integer)
|
||
frac_prec = parse_precision(fraction)
|
||
if exp:
|
||
exp_plus = exp.startswith('+')
|
||
exp = exp.lstrip('+')
|
||
exp_prec = parse_precision(exp)
|
||
else:
|
||
exp_plus = None
|
||
exp_prec = None
|
||
grouping = parse_grouping(integer)
|
||
return NumberPattern(pattern, (pos_prefix, neg_prefix),
|
||
(pos_suffix, neg_suffix), grouping,
|
||
int_prec, frac_prec,
|
||
exp_prec, exp_plus, number)
|
||
|
||
|
||
class NumberPattern:
|
||
|
||
def __init__(
|
||
self,
|
||
pattern: str,
|
||
prefix: tuple[str, str],
|
||
suffix: tuple[str, str],
|
||
grouping: tuple[int, int],
|
||
int_prec: tuple[int, int],
|
||
frac_prec: tuple[int, int],
|
||
exp_prec: tuple[int, int] | None,
|
||
exp_plus: bool | None,
|
||
number_pattern: str | None = None,
|
||
) -> None:
|
||
# Metadata of the decomposed parsed pattern.
|
||
self.pattern = pattern
|
||
self.prefix = prefix
|
||
self.suffix = suffix
|
||
self.number_pattern = number_pattern
|
||
self.grouping = grouping
|
||
self.int_prec = int_prec
|
||
self.frac_prec = frac_prec
|
||
self.exp_prec = exp_prec
|
||
self.exp_plus = exp_plus
|
||
self.scale = self.compute_scale()
|
||
|
||
def __repr__(self) -> str:
|
||
return f"<{type(self).__name__} {self.pattern!r}>"
|
||
|
||
def compute_scale(self) -> Literal[0, 2, 3]:
|
||
"""Return the scaling factor to apply to the number before rendering.
|
||
|
||
Auto-set to a factor of 2 or 3 if presence of a ``%`` or ``‰`` sign is
|
||
detected in the prefix or suffix of the pattern. Default is to not mess
|
||
with the scale at all and keep it to 0.
|
||
"""
|
||
scale = 0
|
||
if '%' in ''.join(self.prefix + self.suffix):
|
||
scale = 2
|
||
elif '‰' in ''.join(self.prefix + self.suffix):
|
||
scale = 3
|
||
return scale
|
||
|
||
def scientific_notation_elements(
|
||
self,
|
||
value: decimal.Decimal,
|
||
locale: Locale | str | None,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
) -> tuple[decimal.Decimal, int, str]:
|
||
""" Returns normalized scientific notation components of a value.
|
||
"""
|
||
# Normalize value to only have one lead digit.
|
||
exp = value.adjusted()
|
||
value = value * get_decimal_quantum(exp)
|
||
assert value.adjusted() == 0
|
||
|
||
# Shift exponent and value by the minimum number of leading digits
|
||
# imposed by the rendering pattern. And always make that number
|
||
# greater or equal to 1.
|
||
lead_shift = max([1, min(self.int_prec)]) - 1
|
||
exp = exp - lead_shift
|
||
value = value * get_decimal_quantum(-lead_shift)
|
||
|
||
# Get exponent sign symbol.
|
||
exp_sign = ''
|
||
if exp < 0:
|
||
exp_sign = get_minus_sign_symbol(locale, numbering_system=numbering_system)
|
||
elif self.exp_plus:
|
||
exp_sign = get_plus_sign_symbol(locale, numbering_system=numbering_system)
|
||
|
||
# Normalize exponent value now that we have the sign.
|
||
exp = abs(exp)
|
||
|
||
return value, exp, exp_sign
|
||
|
||
def apply(
|
||
self,
|
||
value: float | decimal.Decimal | str,
|
||
locale: Locale | str | None,
|
||
currency: str | None = None,
|
||
currency_digits: bool = True,
|
||
decimal_quantization: bool = True,
|
||
force_frac: tuple[int, int] | None = None,
|
||
group_separator: bool = True,
|
||
*,
|
||
numbering_system: Literal["default"] | str = "latn",
|
||
):
|
||
"""Renders into a string a number following the defined pattern.
|
||
|
||
Forced decimal quantization is active by default so we'll produce a
|
||
number string that is strictly following CLDR pattern definitions.
|
||
|
||
:param value: The value to format. If this is not a Decimal object,
|
||
it will be cast to one.
|
||
:type value: decimal.Decimal|float|int
|
||
:param locale: The locale to use for formatting.
|
||
:type locale: str|babel.core.Locale
|
||
:param currency: Which currency, if any, to format as.
|
||
:type currency: str|None
|
||
:param currency_digits: Whether or not to use the currency's precision.
|
||
If false, the pattern's precision is used.
|
||
:type currency_digits: bool
|
||
:param decimal_quantization: Whether decimal numbers should be forcibly
|
||
quantized to produce a formatted output
|
||
strictly matching the CLDR definition for
|
||
the locale.
|
||
:type decimal_quantization: bool
|
||
:param force_frac: DEPRECATED - a forced override for `self.frac_prec`
|
||
for a single formatting invocation.
|
||
: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.
|
||
:return: Formatted decimal string.
|
||
:rtype: str
|
||
:raise UnsupportedNumberingSystemError: If the numbering system is not supported by the locale.
|
||
"""
|
||
if not isinstance(value, decimal.Decimal):
|
||
value = decimal.Decimal(str(value))
|
||
|
||
value = value.scaleb(self.scale)
|
||
|
||
# Separate the absolute value from its sign.
|
||
is_negative = int(value.is_signed())
|
||
value = abs(value).normalize()
|
||
|
||
# Prepare scientific notation metadata.
|
||
if self.exp_prec:
|
||
value, exp, exp_sign = self.scientific_notation_elements(value, locale, numbering_system=numbering_system)
|
||
|
||
# Adjust the precision of the fractional part and force it to the
|
||
# currency's if necessary.
|
||
if force_frac:
|
||
# TODO (3.x?): Remove this parameter
|
||
warnings.warn(
|
||
'The force_frac parameter to NumberPattern.apply() is deprecated.',
|
||
DeprecationWarning,
|
||
stacklevel=2,
|
||
)
|
||
frac_prec = force_frac
|
||
elif currency and currency_digits:
|
||
frac_prec = (get_currency_precision(currency), ) * 2
|
||
else:
|
||
frac_prec = self.frac_prec
|
||
|
||
# Bump decimal precision to the natural precision of the number if it
|
||
# exceeds the one we're about to use. This adaptative precision is only
|
||
# triggered if the decimal quantization is disabled or if a scientific
|
||
# notation pattern has a missing mandatory fractional part (as in the
|
||
# default '#E0' pattern). This special case has been extensively
|
||
# discussed at https://github.com/python-babel/babel/pull/494#issuecomment-307649969 .
|
||
if not decimal_quantization or (self.exp_prec and frac_prec == (0, 0)):
|
||
frac_prec = (frac_prec[0], max([frac_prec[1], get_decimal_precision(value)]))
|
||
|
||
# Render scientific notation.
|
||
if self.exp_prec:
|
||
number = ''.join([
|
||
self._quantize_value(value, locale, frac_prec, group_separator, numbering_system=numbering_system),
|
||
get_exponential_symbol(locale, numbering_system=numbering_system),
|
||
exp_sign, # type: ignore # exp_sign is always defined here
|
||
self._format_int(str(exp), self.exp_prec[0], self.exp_prec[1], locale, numbering_system=numbering_system), # type: ignore # exp is always defined here
|
||
])
|
||
|
||
# Is it a significant digits pattern?
|
||
elif '@' in self.pattern:
|
||
text = self._format_significant(value,
|
||
self.int_prec[0],
|
||
self.int_prec[1])
|
||
a, sep, b = text.partition(".")
|
||
number = self._format_int(a, 0, 1000, locale, numbering_system=numbering_system)
|
||
if sep:
|
||
number += get_decimal_symbol(locale, numbering_system=numbering_system) + b
|
||
|
||
# A normal number pattern.
|
||
else:
|
||
number = self._quantize_value(value, locale, frac_prec, group_separator, numbering_system=numbering_system)
|
||
|
||
retval = ''.join([
|
||
self.prefix[is_negative],
|
||
number if self.number_pattern != '' else '',
|
||
self.suffix[is_negative]])
|
||
|
||
if '¤' in retval and currency is not None:
|
||
retval = retval.replace('¤¤¤', get_currency_name(currency, value, locale))
|
||
retval = retval.replace('¤¤', currency.upper())
|
||
retval = retval.replace('¤', 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
|
||
|
||
#
|
||
# This is one tricky piece of code. The idea is to rely as much as possible
|
||
# on the decimal module to minimize the amount of code.
|
||
#
|
||
# Conceptually, the implementation of this method can be summarized in the
|
||
# following steps:
|
||
#
|
||
# - Move or shift the decimal point (i.e. the exponent) so the maximum
|
||
# amount of significant digits fall into the integer part (i.e. to the
|
||
# left of the decimal point)
|
||
#
|
||
# - Round the number to the nearest integer, discarding all the fractional
|
||
# part which contained extra digits to be eliminated
|
||
#
|
||
# - Convert the rounded integer to a string, that will contain the final
|
||
# sequence of significant digits already trimmed to the maximum
|
||
#
|
||
# - Restore the original position of the decimal point, potentially
|
||
# padding with zeroes on either side
|
||
#
|
||
def _format_significant(self, value: decimal.Decimal, minimum: int, maximum: int) -> str:
|
||
exp = value.adjusted()
|
||
scale = maximum - 1 - exp
|
||
digits = str(value.scaleb(scale).quantize(decimal.Decimal(1)))
|
||
if scale <= 0:
|
||
result = digits + '0' * -scale
|
||
else:
|
||
intpart = digits[:-scale]
|
||
i = len(intpart)
|
||
j = i + max(minimum - i, 0)
|
||
result = "{intpart}.{pad:0<{fill}}{fracpart}{fracextra}".format(
|
||
intpart=intpart or '0',
|
||
pad='',
|
||
fill=-min(exp + 1, 0),
|
||
fracpart=digits[i:j],
|
||
fracextra=digits[j:].rstrip('0'),
|
||
).rstrip('.')
|
||
return result
|
||
|
||
def _format_int(
|
||
self,
|
||
value: str,
|
||
min: int,
|
||
max: int,
|
||
locale: Locale | str | None,
|
||
*,
|
||
numbering_system: Literal["default"] | str,
|
||
) -> str:
|
||
width = len(value)
|
||
if width < min:
|
||
value = '0' * (min - width) + value
|
||
gsize = self.grouping[0]
|
||
ret = ''
|
||
symbol = get_group_symbol(locale, numbering_system=numbering_system)
|
||
while len(value) > gsize:
|
||
ret = symbol + value[-gsize:] + ret
|
||
value = value[:-gsize]
|
||
gsize = self.grouping[1]
|
||
return value + ret
|
||
|
||
def _quantize_value(
|
||
self,
|
||
value: decimal.Decimal,
|
||
locale: Locale | str | None,
|
||
frac_prec: tuple[int, int],
|
||
group_separator: bool,
|
||
*,
|
||
numbering_system: Literal["default"] | str,
|
||
) -> str:
|
||
# If the number is +/-Infinity, we can't quantize it
|
||
if value.is_infinite():
|
||
return get_infinity_symbol(locale, numbering_system=numbering_system)
|
||
quantum = get_decimal_quantum(frac_prec[1])
|
||
rounded = value.quantize(quantum)
|
||
a, sep, b = f"{rounded:f}".partition(".")
|
||
integer_part = a
|
||
if group_separator:
|
||
integer_part = self._format_int(a, self.int_prec[0], self.int_prec[1], locale, numbering_system=numbering_system)
|
||
number = integer_part + self._format_frac(b or '0', locale=locale, force_frac=frac_prec, numbering_system=numbering_system)
|
||
return number
|
||
|
||
def _format_frac(
|
||
self,
|
||
value: str,
|
||
locale: Locale | str | None,
|
||
force_frac: tuple[int, int] | None = None,
|
||
*,
|
||
numbering_system: Literal["default"] | str,
|
||
) -> str:
|
||
min, max = force_frac or self.frac_prec
|
||
if len(value) < min:
|
||
value += ('0' * (min - len(value)))
|
||
if max == 0 or (min == 0 and int(value) == 0):
|
||
return ''
|
||
while len(value) > min and value[-1] == '0':
|
||
value = value[:-1]
|
||
return get_decimal_symbol(locale, numbering_system=numbering_system) + value
|