From 701358ebe3428a0be5f7138604bc567d7d8481f5 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 25 Jan 2023 16:01:00 -0700 Subject: [PATCH 1/9] Make LocaleDataDict Generic and fix core and numbers types --- babel/core.py | 106 ++++++++++++++++++++++++++++++-------------- babel/localedata.py | 35 ++++++++------- 2 files changed, 93 insertions(+), 48 deletions(-) diff --git a/babel/core.py b/babel/core.py index 57a6b63b3..f014dc42f 100644 --- a/babel/core.py +++ b/babel/core.py @@ -24,6 +24,8 @@ if TYPE_CHECKING: from typing_extensions import Literal, TypeAlias + from babel import dates, numbers + _GLOBAL_KEY: TypeAlias = Literal[ "all_currencies", "currency_fractions", @@ -306,7 +308,7 @@ def parse( there is a locale ``en`` that can exist by itself. :raise `ValueError`: if the string does not appear to be a valid locale - identifier + identifier or ``None`` is passed :raise `UnknownLocaleError`: if no locale data is available for the requested locale :raise `TypeError`: if the identifier is not a string or a `Locale` @@ -423,7 +425,7 @@ def __str__(self) -> str: self.modifier)) @property - def _data(self) -> localedata.LocaleDataDict: + def _data(self) -> localedata.LocaleDataDict[str, Any]: if self.__data is None: self.__data = localedata.LocaleDataDict(localedata.load(str(self))) return self.__data @@ -541,7 +543,7 @@ def english_name(self) -> str | None: # { General Locale Display Names @property - def languages(self) -> localedata.LocaleDataDict: + def languages(self) -> localedata.LocaleDataDict[str, str]: """Mapping of language codes to translated language names. >>> Locale('de', 'DE').languages['ja'] @@ -553,7 +555,7 @@ def languages(self) -> localedata.LocaleDataDict: return self._data['languages'] @property - def scripts(self) -> localedata.LocaleDataDict: + def scripts(self) -> localedata.LocaleDataDict[str, str]: """Mapping of script codes to translated script names. >>> Locale('en', 'US').scripts['Hira'] @@ -565,7 +567,7 @@ def scripts(self) -> localedata.LocaleDataDict: return self._data['scripts'] @property - def territories(self) -> localedata.LocaleDataDict: + def territories(self) -> localedata.LocaleDataDict[str, str]: """Mapping of script codes to translated script names. >>> Locale('es', 'CO').territories['DE'] @@ -577,7 +579,7 @@ def territories(self) -> localedata.LocaleDataDict: return self._data['territories'] @property - def variants(self) -> localedata.LocaleDataDict: + def variants(self) -> localedata.LocaleDataDict[str, str]: """Mapping of script codes to translated script names. >>> Locale('de', 'DE').variants['1901'] @@ -588,7 +590,7 @@ def variants(self) -> localedata.LocaleDataDict: # { Number Formatting @property - def currencies(self) -> localedata.LocaleDataDict: + def currencies(self) -> localedata.LocaleDataDict[str, str]: """Mapping of currency codes to translated currency names. This only returns the generic form of the currency name, not the count specific one. If an actual number is requested use the @@ -602,7 +604,7 @@ def currencies(self) -> localedata.LocaleDataDict: return self._data['currency_names'] @property - def currency_symbols(self) -> localedata.LocaleDataDict: + def currency_symbols(self) -> localedata.LocaleDataDict[str, str]: """Mapping of currency codes to symbols. >>> Locale('en', 'US').currency_symbols['USD'] @@ -613,7 +615,7 @@ def currency_symbols(self) -> localedata.LocaleDataDict: return self._data['currency_symbols'] @property - def number_symbols(self) -> localedata.LocaleDataDict: + def number_symbols(self) -> localedata.LocaleDataDict[str, str]: """Symbols used in number formatting. .. note:: The format of the value returned may change between @@ -625,7 +627,7 @@ def number_symbols(self) -> localedata.LocaleDataDict: return self._data['number_symbols'] @property - def decimal_formats(self) -> localedata.LocaleDataDict: + def decimal_formats(self) -> localedata.LocaleDataDict[str | None, numbers.NumberPattern]: """Locale patterns for decimal number formatting. .. note:: The format of the value returned may change between @@ -637,7 +639,11 @@ def decimal_formats(self) -> localedata.LocaleDataDict: return self._data['decimal_formats'] @property - def compact_decimal_formats(self) -> localedata.LocaleDataDict: + def compact_decimal_formats( + self + ) -> localedata.LocaleDataDict[ + str, localedata.LocaleDataDict[str, localedata.LocaleDataDict[str, numbers.NumberPattern]] + ]: """Locale patterns for compact decimal number formatting. .. note:: The format of the value returned may change between @@ -649,7 +655,7 @@ def compact_decimal_formats(self) -> localedata.LocaleDataDict: return self._data['compact_decimal_formats'] @property - def currency_formats(self) -> localedata.LocaleDataDict: + def currency_formats(self) -> localedata.LocaleDataDict[str, numbers.NumberPattern]: """Locale patterns for currency number formatting. .. note:: The format of the value returned may change between @@ -663,7 +669,11 @@ def currency_formats(self) -> localedata.LocaleDataDict: return self._data['currency_formats'] @property - def compact_currency_formats(self) -> localedata.LocaleDataDict: + def compact_currency_formats( + self + ) -> localedata.LocaleDataDict[ + str, localedata.LocaleDataDict[str, localedata.LocaleDataDict[str, numbers.NumberPattern]] + ]: """Locale patterns for compact currency number formatting. .. note:: The format of the value returned may change between @@ -675,7 +685,7 @@ def compact_currency_formats(self) -> localedata.LocaleDataDict: return self._data['compact_currency_formats'] @property - def percent_formats(self) -> localedata.LocaleDataDict: + def percent_formats(self) -> localedata.LocaleDataDict[str | None, numbers.NumberPattern]: """Locale patterns for percent number formatting. .. note:: The format of the value returned may change between @@ -687,7 +697,7 @@ def percent_formats(self) -> localedata.LocaleDataDict: return self._data['percent_formats'] @property - def scientific_formats(self) -> localedata.LocaleDataDict: + def scientific_formats(self) -> localedata.LocaleDataDict[str | None, numbers.NumberPattern]: """Locale patterns for scientific number formatting. .. note:: The format of the value returned may change between @@ -701,7 +711,7 @@ def scientific_formats(self) -> localedata.LocaleDataDict: # { Calendar Information and Date Formatting @property - def periods(self) -> localedata.LocaleDataDict: + def periods(self) -> localedata.LocaleDataDict[str, str]: """Locale display names for day periods (AM/PM). >>> Locale('en', 'US').periods['am'] @@ -713,7 +723,11 @@ def periods(self) -> localedata.LocaleDataDict: return localedata.LocaleDataDict({}) # pragma: no cover @property - def day_periods(self) -> localedata.LocaleDataDict: + def day_periods( + self + ) -> localedata.LocaleDataDict[ + str, localedata.LocaleDataDict[str, localedata.LocaleDataDict[str, str]] + ]: """Locale display names for various day periods (not necessarily only AM/PM). These are not meant to be used without the relevant `day_period_rules`. @@ -721,13 +735,19 @@ def day_periods(self) -> localedata.LocaleDataDict: return self._data['day_periods'] @property - def day_period_rules(self) -> localedata.LocaleDataDict: + def day_period_rules( + self + ) -> localedata.LocaleDataDict[str | None, localedata.LocaleDataDict[str, str]]: """Day period rules for the locale. Used by `get_period_id`. """ return self._data.get('day_period_rules', localedata.LocaleDataDict({})) @property - def days(self) -> localedata.LocaleDataDict: + def days( + self + ) -> localedata.LocaleDataDict[ + str, localedata.LocaleDataDict[str, localedata.LocaleDataDict[int, str]] + ]: """Locale display names for weekdays. >>> Locale('de', 'DE').days['format']['wide'][3] @@ -736,7 +756,11 @@ def days(self) -> localedata.LocaleDataDict: return self._data['days'] @property - def months(self) -> localedata.LocaleDataDict: + def months( + self + ) -> localedata.LocaleDataDict[ + str, localedata.LocaleDataDict[str, localedata.LocaleDataDict[int, str]] + ]: """Locale display names for months. >>> Locale('de', 'DE').months['format']['wide'][10] @@ -745,7 +769,11 @@ def months(self) -> localedata.LocaleDataDict: return self._data['months'] @property - def quarters(self) -> localedata.LocaleDataDict: + def quarters( + self + ) -> localedata.LocaleDataDict[ + str, localedata.LocaleDataDict[str, localedata.LocaleDataDict[int, str]] + ]: """Locale display names for quarters. >>> Locale('de', 'DE').quarters['format']['wide'][1] @@ -754,7 +782,7 @@ def quarters(self) -> localedata.LocaleDataDict: return self._data['quarters'] @property - def eras(self) -> localedata.LocaleDataDict: + def eras(self) -> localedata.LocaleDataDict[str, localedata.LocaleDataDict[int, str]]: """Locale display names for eras. .. note:: The format of the value returned may change between @@ -768,7 +796,11 @@ def eras(self) -> localedata.LocaleDataDict: return self._data['eras'] @property - def time_zones(self) -> localedata.LocaleDataDict: + def time_zones( + self + ) -> localedata.LocaleDataDict[ + str, localedata.LocaleDataDict[str, localedata.LocaleDataDict[str, str]] + ]: """Locale display names for time zones. .. note:: The format of the value returned may change between @@ -782,7 +814,11 @@ def time_zones(self) -> localedata.LocaleDataDict: return self._data['time_zones'] @property - def meta_zones(self) -> localedata.LocaleDataDict: + def meta_zones( + self + ) -> localedata.LocaleDataDict[ + str, localedata.LocaleDataDict[str, localedata.LocaleDataDict[str, str]] + ]: """Locale display names for meta time zones. Meta time zones are basically groups of different Olson time zones that @@ -799,7 +835,7 @@ def meta_zones(self) -> localedata.LocaleDataDict: return self._data['meta_zones'] @property - def zone_formats(self) -> localedata.LocaleDataDict: + def zone_formats(self) -> localedata.LocaleDataDict[str, str]: """Patterns related to the formatting of time zones. .. note:: The format of the value returned may change between @@ -854,7 +890,7 @@ def min_week_days(self) -> int: return self._data['week_data']['min_days'] @property - def date_formats(self) -> localedata.LocaleDataDict: + def date_formats(self) -> localedata.LocaleDataDict[str, dates.DateTimePattern]: """Locale patterns for date formatting. .. note:: The format of the value returned may change between @@ -868,7 +904,7 @@ def date_formats(self) -> localedata.LocaleDataDict: return self._data['date_formats'] @property - def time_formats(self) -> localedata.LocaleDataDict: + def time_formats(self) -> localedata.LocaleDataDict[str, dates.DateTimePattern]: """Locale patterns for time formatting. .. note:: The format of the value returned may change between @@ -882,7 +918,7 @@ def time_formats(self) -> localedata.LocaleDataDict: return self._data['time_formats'] @property - def datetime_formats(self) -> localedata.LocaleDataDict: + def datetime_formats(self) -> localedata.LocaleDataDict[str, str]: """Locale patterns for datetime formatting. .. note:: The format of the value returned may change between @@ -896,7 +932,7 @@ def datetime_formats(self) -> localedata.LocaleDataDict: return self._data['datetime_formats'] @property - def datetime_skeletons(self) -> localedata.LocaleDataDict: + def datetime_skeletons(self) -> localedata.LocaleDataDict[str, dates.DateTimePattern]: """Locale patterns for formatting parts of a datetime. >>> Locale('en').datetime_skeletons['MEd'] @@ -909,7 +945,9 @@ def datetime_skeletons(self) -> localedata.LocaleDataDict: return self._data['datetime_skeletons'] @property - def interval_formats(self) -> localedata.LocaleDataDict: + def interval_formats( + self + ) -> localedata.LocaleDataDict[str | None, str | localedata.LocaleDataDict[str, list[str]]]: """Locale patterns for interval formatting. .. note:: The format of the value returned may change between @@ -946,7 +984,7 @@ def plural_form(self) -> PluralRule: return self._data.get('plural_form', _default_plural_rule) @property - def list_patterns(self) -> localedata.LocaleDataDict: + def list_patterns(self) -> localedata.LocaleDataDict[str, localedata.LocaleDataDict[str, str]]: """Patterns for generating lists .. note:: The format of the value returned may change between @@ -979,7 +1017,7 @@ def ordinal_form(self) -> PluralRule: return self._data.get('ordinal_form', _default_plural_rule) @property - def measurement_systems(self) -> localedata.LocaleDataDict: + def measurement_systems(self) -> localedata.LocaleDataDict[str, str]: """Localized names for various measurement systems. >>> Locale('fr', 'FR').measurement_systems['US'] @@ -1013,7 +1051,9 @@ def text_direction(self) -> str: return ''.join(word[0] for word in self.character_order.split('-')) @property - def unit_display_names(self) -> localedata.LocaleDataDict: + def unit_display_names( + self + ) -> localedata.LocaleDataDict[str, localedata.LocaleDataDict[str, str]]: """Display names for units of measurement. .. seealso:: diff --git a/babel/localedata.py b/babel/localedata.py index a9c7c75ec..6062b9a80 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -22,7 +22,10 @@ from collections.abc import Iterator, Mapping, MutableMapping from functools import lru_cache from itertools import chain -from typing import Any +from typing import Any, TypeVar + +_Key = TypeVar('_Key',) +_Value = TypeVar('_Value') _cache: dict[str, Any] = {} _cache_lock = threading.RLock() @@ -193,7 +196,7 @@ def __init__(self, keys: tuple[str, ...]) -> None: def __repr__(self) -> str: return f"<{type(self).__name__} {self.keys!r}>" - def resolve(self, data: Mapping[str | int | None, Any]) -> Mapping[str | int | None, Any]: + def resolve(self, data: Mapping[_Key, _Value]) -> dict[_Key, _Value]: """Resolve the alias based on the given data. This is done recursively, so if one alias resolves to a second alias, @@ -204,33 +207,32 @@ def resolve(self, data: Mapping[str | int | None, Any]) -> Mapping[str | int | N """ base = data for key in self.keys: - data = data[key] + data = data[key] # type: ignore if isinstance(data, Alias): data = data.resolve(base) elif isinstance(data, tuple): alias, others = data data = alias.resolve(base) - return data - + return dict(data) -class LocaleDataDict(abc.MutableMapping): +class LocaleDataDict(abc.MutableMapping[_Key, _Value]): """Dictionary wrapper that automatically resolves aliases to the actual values. """ - def __init__(self, data: MutableMapping[str | int | None, Any], base: Mapping[str | int | None, Any] | None = None): - self._data = data + def __init__(self, data: MutableMapping[_Key, _Value], base: Mapping[_Key, _Value] | None = None): + self._data = dict(data) # Storing as a dict allows copy() to work if base is None: base = data - self.base = base + self.base = dict(base) def __len__(self) -> int: return len(self._data) - def __iter__(self) -> Iterator[str | int | None]: + def __iter__(self) -> Iterator[_Key]: return iter(self._data) - def __getitem__(self, key: str | int | None) -> Any: + def __getitem__(self, key: _Key) -> _Value: orig = val = self._data[key] if isinstance(val, Alias): # resolve an alias val = val.resolve(self.base) @@ -241,14 +243,17 @@ def __getitem__(self, key: str | int | None) -> Any: if isinstance(val, dict): # Return a nested alias-resolving dict val = LocaleDataDict(val, base=self.base) if val is not orig: - self._data[key] = val - return val + self._data[key] = val # type: ignore # Cache the resolved value + return val # type: ignore - def __setitem__(self, key: str | int | None, value: Any) -> None: + def __setitem__(self, key: _Key, value: _Value) -> None: self._data[key] = value - def __delitem__(self, key: str | int | None) -> None: + def __delitem__(self, key: _Key) -> None: del self._data[key] def copy(self) -> LocaleDataDict: return LocaleDataDict(self._data.copy(), base=self.base) + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self._data!r}>" From 70aa6763ab6b73e9dfb3961250875be2d4149536 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 25 Jan 2023 17:08:32 -0700 Subject: [PATCH 2/9] try typing.MutableMapping for 3.7-3.8 support --- babel/localedata.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/babel/localedata.py b/babel/localedata.py index 6062b9a80..bef290b83 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -18,11 +18,10 @@ import re import sys import threading -from collections import abc from collections.abc import Iterator, Mapping, MutableMapping from functools import lru_cache from itertools import chain -from typing import Any, TypeVar +from typing import Any, TypeVar, MutableMapping _Key = TypeVar('_Key',) _Value = TypeVar('_Value') From a04777e5f1980ae4b21fb10caab6e002608d4772 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 25 Jan 2023 17:10:54 -0700 Subject: [PATCH 3/9] Update localedata.py --- babel/localedata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/localedata.py b/babel/localedata.py index bef290b83..6548ed1eb 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -214,7 +214,7 @@ def resolve(self, data: Mapping[_Key, _Value]) -> dict[_Key, _Value]: data = alias.resolve(base) return dict(data) -class LocaleDataDict(abc.MutableMapping[_Key, _Value]): +class LocaleDataDict(MutableMapping[_Key, _Value]): """Dictionary wrapper that automatically resolves aliases to the actual values. """ From fef8fc44889075b7c59eaf819006bcf3d25e48cb Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 25 Jan 2023 17:12:44 -0700 Subject: [PATCH 4/9] precommit fixes --- babel/localedata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/localedata.py b/babel/localedata.py index 6548ed1eb..4adb8aaa8 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -21,7 +21,7 @@ from collections.abc import Iterator, Mapping, MutableMapping from functools import lru_cache from itertools import chain -from typing import Any, TypeVar, MutableMapping +from typing import Any, MutableMapping, TypeVar _Key = TypeVar('_Key',) _Value = TypeVar('_Value') From 47ef03b8f11cd0a6ceb5e4cddb5299f5e95feb5a Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Fri, 3 Feb 2023 08:35:32 -0700 Subject: [PATCH 5/9] Revert "Numbers and core type fixes" --- babel/core.py | 30 ++++++++++++++++++++---------- babel/numbers.py | 45 ++++++++++++++++++++++----------------------- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/babel/core.py b/babel/core.py index f014dc42f..e74318a5f 100644 --- a/babel/core.py +++ b/babel/core.py @@ -13,7 +13,7 @@ import os import pickle from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, overload from babel import localedata from babel.plural import PluralRule @@ -262,13 +262,21 @@ def negotiate( if identifier: return Locale.parse(identifier, sep=sep) + @overload + @classmethod + def parse(cls, identifier: None, sep: str = ..., resolve_likely_subtags: bool = ...) -> None: ... + + @overload + @classmethod + def parse(cls, identifier: str | Locale, sep: str = ..., resolve_likely_subtags: bool = ...) -> Locale: ... + @classmethod def parse( cls, identifier: str | Locale | None, sep: str = '_', resolve_likely_subtags: bool = True, - ) -> Locale: + ) -> Locale | None: """Create a `Locale` instance for the given locale identifier. >>> l = Locale.parse('de-DE', sep='-') @@ -308,12 +316,14 @@ def parse( there is a locale ``en`` that can exist by itself. :raise `ValueError`: if the string does not appear to be a valid locale - identifier or ``None`` is passed + identifier :raise `UnknownLocaleError`: if no locale data is available for the requested locale :raise `TypeError`: if the identifier is not a string or a `Locale` """ - if isinstance(identifier, Locale): + if identifier is None: + return None + elif isinstance(identifier, Locale): return identifier elif not isinstance(identifier, str): raise TypeError(f"Unexpected value for identifier: {identifier!r}") @@ -357,9 +367,9 @@ def _try_load_reducing(parts): language, territory, script, variant = parts modifier = None language = get_global('language_aliases').get(language, language) - territory = get_global('territory_aliases').get(territory or '', (territory,))[0] - script = get_global('script_aliases').get(script or '', script) - variant = get_global('variant_aliases').get(variant or '', variant) + territory = get_global('territory_aliases').get(territory, (territory,))[0] + script = get_global('script_aliases').get(script, script) + variant = get_global('variant_aliases').get(variant, variant) if territory == 'ZZ': territory = None @@ -382,9 +392,9 @@ def _try_load_reducing(parts): if likely_subtag is not None: parts2 = parse_locale(likely_subtag) if len(parts2) == 5: - language2, _, script2, variant2, modifier2 = parts2 + language2, _, script2, variant2, modifier2 = parse_locale(likely_subtag) else: - language2, _, script2, variant2 = parts2 + language2, _, script2, variant2 = parse_locale(likely_subtag) modifier2 = None locale = _try_load_reducing((language2, territory, script2, variant2, modifier2)) if locale is not None: @@ -1178,7 +1188,7 @@ def negotiate_locale(preferred: Iterable[str], available: Iterable[str], sep: st def parse_locale( identifier: str, sep: str = '_' -) -> tuple[str, str | None, str | None, str | None] | tuple[str, str | None, str | None, str | None, str | None]: +) -> tuple[str, str | None, str | None, str | None, str | None]: """Parse a locale identifier into a tuple of the form ``(language, territory, script, variant, modifier)``. diff --git a/babel/numbers.py b/babel/numbers.py index 1a86d9e62..59acee212 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -23,7 +23,7 @@ import decimal import re import warnings -from typing import TYPE_CHECKING, Any, cast, overload +from typing import TYPE_CHECKING, Any, overload from babel.core import Locale, default_locale, get_global from babel.localedata import LocaleDataDict @@ -428,7 +428,7 @@ def get_decimal_quantum(precision: int | decimal.Decimal) -> decimal.Decimal: def format_decimal( number: float | decimal.Decimal | str, - format: str | NumberPattern | None = None, + format: str | None = None, locale: Locale | str | None = LC_NUMERIC, decimal_quantization: bool = True, group_separator: bool = True, @@ -474,8 +474,8 @@ def format_decimal( number format. """ locale = Locale.parse(locale) - if format is None: - format = locale.decimal_formats[format] + if not format: + format = locale.decimal_formats.get(format) pattern = parse_pattern(format) return pattern.apply( number, locale, decimal_quantization=decimal_quantization, group_separator=group_separator) @@ -513,7 +513,7 @@ def format_compact_decimal( 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] + format = locale.decimal_formats.get(None) pattern = parse_pattern(format) return pattern.apply(number, locale, decimal_quantization=False) @@ -521,7 +521,7 @@ def format_compact_decimal( def _get_compact_format( number: float | decimal.Decimal | str, compact_format: LocaleDataDict, - locale: Locale, + locale: Locale | str | None, fraction_digits: int, ) -> tuple[decimal.Decimal, NumberPattern | None]: """Returns the number after dividing by the unit and the format pattern to use. @@ -543,7 +543,7 @@ def _get_compact_format( 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)))) + number = 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 @@ -565,7 +565,7 @@ class UnknownCurrencyFormatError(KeyError): def format_currency( number: float | decimal.Decimal | str, currency: str, - format: str | NumberPattern | None = None, + format: str | None = None, locale: Locale | str | None = LC_NUMERIC, currency_digits: bool = True, format_type: Literal["name", "standard", "accounting"] = "standard", @@ -680,7 +680,7 @@ def format_currency( def _format_currency_long_name( number: float | decimal.Decimal | str, currency: str, - format: str | NumberPattern | None = None, + format: str | None = None, locale: Locale | str | None = LC_NUMERIC, currency_digits: bool = True, format_type: Literal["name", "standard", "accounting"] = "standard", @@ -706,7 +706,7 @@ def _format_currency_long_name( # Step 5. if not format: - format = locale.decimal_formats[format] + format = locale.decimal_formats.get(format) pattern = parse_pattern(format) @@ -758,15 +758,13 @@ def format_compact_currency( # 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) def format_percent( number: float | decimal.Decimal | str, - format: str | NumberPattern | None = None, + format: str | None = None, locale: Locale | str | None = LC_NUMERIC, decimal_quantization: bool = True, group_separator: bool = True, @@ -810,7 +808,7 @@ def format_percent( """ locale = Locale.parse(locale) if not format: - format = locale.percent_formats[format] + format = locale.percent_formats.get(format) pattern = parse_pattern(format) return pattern.apply( number, locale, decimal_quantization=decimal_quantization, group_separator=group_separator) @@ -818,7 +816,7 @@ def format_percent( def format_scientific( number: float | decimal.Decimal | str, - format: str | NumberPattern | None = None, + format: str | None = None, locale: Locale | str | None = LC_NUMERIC, decimal_quantization: bool = True, ) -> str: @@ -849,7 +847,7 @@ def format_scientific( """ locale = Locale.parse(locale) if not format: - format = locale.scientific_formats[format] + format = locale.scientific_formats.get(format) pattern = parse_pattern(format) return pattern.apply( number, locale, decimal_quantization=decimal_quantization) @@ -858,7 +856,7 @@ def format_scientific( 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: + def __init__(self, message: str, suggestions: str | None = None) -> None: super().__init__(message) #: a list of properly formatted numbers derived from the invalid input self.suggestions = suggestions @@ -1142,7 +1140,7 @@ def scientific_notation_elements(self, value: decimal.Decimal, locale: Locale | def apply( self, - value: float | decimal.Decimal | str, + value: float | decimal.Decimal, locale: Locale | str | None, currency: str | None = None, currency_digits: bool = True, @@ -1213,9 +1211,9 @@ def apply( number = ''.join([ self._quantize_value(value, locale, frac_prec, group_separator), get_exponential_symbol(locale), - exp_sign, # type: ignore # exp_sign is always defined here - self._format_int(str(exp), self.exp_prec[0], self.exp_prec[1], locale) # type: ignore # exp is always defined here - ]) + exp_sign, + self._format_int( + str(exp), self.exp_prec[0], self.exp_prec[1], locale)]) # Is it a significant digits pattern? elif '@' in self.pattern: @@ -1236,8 +1234,9 @@ def apply( 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)) + if '¤' in retval: + retval = retval.replace('¤¤¤', + get_currency_name(currency, value, locale)) retval = retval.replace('¤¤', currency.upper()) retval = retval.replace('¤', get_currency_symbol(currency, locale)) From 57c7819d91867178f0ac8b430872f937fb017e2f Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Fri, 3 Feb 2023 08:42:01 -0700 Subject: [PATCH 6/9] Remove type fixes to move to 966 --- babel/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/core.py b/babel/core.py index e74318a5f..582841def 100644 --- a/babel/core.py +++ b/babel/core.py @@ -515,7 +515,7 @@ def get_territory_name(self, locale: Locale | str | None = None) -> str | None: if locale is None: locale = self locale = Locale.parse(locale) - return locale.territories.get(self.territory or '') + return locale.territories.get(self.territory) territory_name = property(get_territory_name, doc="""\ The localized territory name of the locale if available. @@ -529,7 +529,7 @@ def get_script_name(self, locale: Locale | str | None = None) -> str | None: if locale is None: locale = self locale = Locale.parse(locale) - return locale.scripts.get(self.script or '') + return locale.scripts.get(self.script) script_name = property(get_script_name, doc="""\ The localized script name of the locale if available. From 2171b6c2c7dcf14631738be04c95e99e62c1d826 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Fri, 3 Feb 2023 09:17:26 -0700 Subject: [PATCH 7/9] Avoid copying into a dict --- babel/localedata.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/babel/localedata.py b/babel/localedata.py index 4adb8aaa8..45edf9d67 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -21,11 +21,23 @@ from collections.abc import Iterator, Mapping, MutableMapping from functools import lru_cache from itertools import chain -from typing import Any, MutableMapping, TypeVar +from typing import TYPE_CHECKING, Any, MutableMapping, TypeVar _Key = TypeVar('_Key',) _Value = TypeVar('_Value') +if TYPE_CHECKING: + from abc import abstractmethod + from typing import runtime_checkable + + @runtime_checkable + class CopyableMutableMapping(MutableMapping[_Key, _Value]): + """An abstract base class for copyable mutable mappings.""" + + @abstractmethod + def copy(self) -> CopyableMutableMapping[_Key, _Value]: + ... + _cache: dict[str, Any] = {} _cache_lock = threading.RLock() _dirname = os.path.join(os.path.dirname(__file__), 'locale-data') @@ -164,7 +176,7 @@ def merge(dict1: MutableMapping[Any, Any], dict2: Mapping[Any, Any]) -> None: for key, val2 in dict2.items(): if val2 is not None: val1 = dict1.get(key) - if isinstance(val2, dict): + if isinstance(val2, MutableMapping): if val1 is None: val1 = {} if isinstance(val1, Alias): @@ -195,7 +207,7 @@ def __init__(self, keys: tuple[str, ...]) -> None: def __repr__(self) -> str: return f"<{type(self).__name__} {self.keys!r}>" - def resolve(self, data: Mapping[_Key, _Value]) -> dict[_Key, _Value]: + def resolve(self, data: CopyableMutableMapping[_Key, _Value]) -> CopyableMutableMapping[_Key, _Value]: """Resolve the alias based on the given data. This is done recursively, so if one alias resolves to a second alias, @@ -212,18 +224,18 @@ def resolve(self, data: Mapping[_Key, _Value]) -> dict[_Key, _Value]: elif isinstance(data, tuple): alias, others = data data = alias.resolve(base) - return dict(data) + return data class LocaleDataDict(MutableMapping[_Key, _Value]): """Dictionary wrapper that automatically resolves aliases to the actual values. """ - def __init__(self, data: MutableMapping[_Key, _Value], base: Mapping[_Key, _Value] | None = None): - self._data = dict(data) # Storing as a dict allows copy() to work + def __init__(self, data: CopyableMutableMapping[_Key, _Value], base: CopyableMutableMapping[_Key, _Value] | None = None): + self._data = data if base is None: base = data - self.base = dict(base) + self.base = base def __len__(self) -> int: return len(self._data) @@ -239,8 +251,9 @@ def __getitem__(self, key: _Key) -> _Value: alias, others = val val = alias.resolve(self.base).copy() merge(val, others) - if isinstance(val, dict): # Return a nested alias-resolving dict - val = LocaleDataDict(val, base=self.base) + if isinstance(val, MutableMapping) and not isinstance(val, LocaleDataDict): + # Return a nested alias-resolving dict + val = LocaleDataDict(val, base=self.base) # type: ignore # assume copyable if val is not orig: self._data[key] = val # type: ignore # Cache the resolved value return val # type: ignore From a3f7535b42fb1f6e6d6c902e1ce7ee595900753f Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Fri, 24 Feb 2023 11:02:04 +0200 Subject: [PATCH 8/9] Revert "Revert "Numbers and core type fixes"" This reverts commit 47ef03b8f11cd0a6ceb5e4cddb5299f5e95feb5a. --- babel/core.py | 30 ++++++++++-------------------- babel/numbers.py | 45 +++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 42 deletions(-) diff --git a/babel/core.py b/babel/core.py index 582841def..256b1d3f2 100644 --- a/babel/core.py +++ b/babel/core.py @@ -13,7 +13,7 @@ import os import pickle from collections.abc import Iterable, Mapping -from typing import TYPE_CHECKING, Any, overload +from typing import TYPE_CHECKING, Any from babel import localedata from babel.plural import PluralRule @@ -262,21 +262,13 @@ def negotiate( if identifier: return Locale.parse(identifier, sep=sep) - @overload - @classmethod - def parse(cls, identifier: None, sep: str = ..., resolve_likely_subtags: bool = ...) -> None: ... - - @overload - @classmethod - def parse(cls, identifier: str | Locale, sep: str = ..., resolve_likely_subtags: bool = ...) -> Locale: ... - @classmethod def parse( cls, identifier: str | Locale | None, sep: str = '_', resolve_likely_subtags: bool = True, - ) -> Locale | None: + ) -> Locale: """Create a `Locale` instance for the given locale identifier. >>> l = Locale.parse('de-DE', sep='-') @@ -316,14 +308,12 @@ def parse( there is a locale ``en`` that can exist by itself. :raise `ValueError`: if the string does not appear to be a valid locale - identifier + identifier or ``None`` is passed :raise `UnknownLocaleError`: if no locale data is available for the requested locale :raise `TypeError`: if the identifier is not a string or a `Locale` """ - if identifier is None: - return None - elif isinstance(identifier, Locale): + if isinstance(identifier, Locale): return identifier elif not isinstance(identifier, str): raise TypeError(f"Unexpected value for identifier: {identifier!r}") @@ -367,9 +357,9 @@ def _try_load_reducing(parts): language, territory, script, variant = parts modifier = None language = get_global('language_aliases').get(language, language) - territory = get_global('territory_aliases').get(territory, (territory,))[0] - script = get_global('script_aliases').get(script, script) - variant = get_global('variant_aliases').get(variant, variant) + territory = get_global('territory_aliases').get(territory or '', (territory,))[0] + script = get_global('script_aliases').get(script or '', script) + variant = get_global('variant_aliases').get(variant or '', variant) if territory == 'ZZ': territory = None @@ -392,9 +382,9 @@ def _try_load_reducing(parts): if likely_subtag is not None: parts2 = parse_locale(likely_subtag) if len(parts2) == 5: - language2, _, script2, variant2, modifier2 = parse_locale(likely_subtag) + language2, _, script2, variant2, modifier2 = parts2 else: - language2, _, script2, variant2 = parse_locale(likely_subtag) + language2, _, script2, variant2 = parts2 modifier2 = None locale = _try_load_reducing((language2, territory, script2, variant2, modifier2)) if locale is not None: @@ -1188,7 +1178,7 @@ def negotiate_locale(preferred: Iterable[str], available: Iterable[str], sep: st def parse_locale( identifier: str, sep: str = '_' -) -> tuple[str, str | None, str | None, str | None, str | None]: +) -> tuple[str, str | None, str | None, str | None] | tuple[str, str | None, str | None, str | None, str | None]: """Parse a locale identifier into a tuple of the form ``(language, territory, script, variant, modifier)``. diff --git a/babel/numbers.py b/babel/numbers.py index 59acee212..1a86d9e62 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -23,7 +23,7 @@ import decimal import re import warnings -from typing import TYPE_CHECKING, Any, overload +from typing import TYPE_CHECKING, Any, cast, overload from babel.core import Locale, default_locale, get_global from babel.localedata import LocaleDataDict @@ -428,7 +428,7 @@ def get_decimal_quantum(precision: int | decimal.Decimal) -> decimal.Decimal: def format_decimal( number: float | decimal.Decimal | str, - format: str | None = None, + format: str | NumberPattern | None = None, locale: Locale | str | None = LC_NUMERIC, decimal_quantization: bool = True, group_separator: bool = True, @@ -474,8 +474,8 @@ def format_decimal( number format. """ locale = Locale.parse(locale) - if not format: - format = locale.decimal_formats.get(format) + 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) @@ -513,7 +513,7 @@ def format_compact_decimal( 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) + format = locale.decimal_formats[None] pattern = parse_pattern(format) return pattern.apply(number, locale, decimal_quantization=False) @@ -521,7 +521,7 @@ def format_compact_decimal( def _get_compact_format( number: float | decimal.Decimal | str, compact_format: LocaleDataDict, - locale: Locale | str | None, + locale: Locale, fraction_digits: int, ) -> tuple[decimal.Decimal, NumberPattern | None]: """Returns the number after dividing by the unit and the format pattern to use. @@ -543,7 +543,7 @@ def _get_compact_format( 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 = number / (magnitude // (10 ** (pattern.count("0") - 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 @@ -565,7 +565,7 @@ class UnknownCurrencyFormatError(KeyError): def format_currency( number: float | decimal.Decimal | str, currency: str, - format: str | None = None, + format: str | NumberPattern | None = None, locale: Locale | str | None = LC_NUMERIC, currency_digits: bool = True, format_type: Literal["name", "standard", "accounting"] = "standard", @@ -680,7 +680,7 @@ def format_currency( def _format_currency_long_name( number: float | decimal.Decimal | str, currency: str, - format: str | None = None, + format: str | NumberPattern | None = None, locale: Locale | str | None = LC_NUMERIC, currency_digits: bool = True, format_type: Literal["name", "standard", "accounting"] = "standard", @@ -706,7 +706,7 @@ def _format_currency_long_name( # Step 5. if not format: - format = locale.decimal_formats.get(format) + format = locale.decimal_formats[format] pattern = parse_pattern(format) @@ -758,13 +758,15 @@ def format_compact_currency( # 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) def format_percent( number: float | decimal.Decimal | str, - format: str | None = None, + format: str | NumberPattern | None = None, locale: Locale | str | None = LC_NUMERIC, decimal_quantization: bool = True, group_separator: bool = True, @@ -808,7 +810,7 @@ def format_percent( """ locale = Locale.parse(locale) if not format: - format = locale.percent_formats.get(format) + format = locale.percent_formats[format] pattern = parse_pattern(format) return pattern.apply( number, locale, decimal_quantization=decimal_quantization, group_separator=group_separator) @@ -816,7 +818,7 @@ def format_percent( def format_scientific( number: float | decimal.Decimal | str, - format: str | None = None, + format: str | NumberPattern | None = None, locale: Locale | str | None = LC_NUMERIC, decimal_quantization: bool = True, ) -> str: @@ -847,7 +849,7 @@ def format_scientific( """ locale = Locale.parse(locale) if not format: - format = locale.scientific_formats.get(format) + format = locale.scientific_formats[format] pattern = parse_pattern(format) return pattern.apply( number, locale, decimal_quantization=decimal_quantization) @@ -856,7 +858,7 @@ def format_scientific( class NumberFormatError(ValueError): """Exception raised when a string cannot be parsed into a number.""" - def __init__(self, message: str, suggestions: str | None = None) -> None: + 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 @@ -1140,7 +1142,7 @@ def scientific_notation_elements(self, value: decimal.Decimal, locale: Locale | def apply( self, - value: float | decimal.Decimal, + value: float | decimal.Decimal | str, locale: Locale | str | None, currency: str | None = None, currency_digits: bool = True, @@ -1211,9 +1213,9 @@ def apply( number = ''.join([ self._quantize_value(value, locale, frac_prec, group_separator), get_exponential_symbol(locale), - exp_sign, - self._format_int( - str(exp), self.exp_prec[0], self.exp_prec[1], locale)]) + exp_sign, # type: ignore # exp_sign is always defined here + self._format_int(str(exp), self.exp_prec[0], self.exp_prec[1], locale) # type: ignore # exp is always defined here + ]) # Is it a significant digits pattern? elif '@' in self.pattern: @@ -1234,9 +1236,8 @@ def apply( number if self.number_pattern != '' else '', self.suffix[is_negative]]) - if '¤' in retval: - retval = retval.replace('¤¤¤', - get_currency_name(currency, value, locale)) + 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)) From 96a7a52f7b526d290fd7734a940a1e0c4abc5ef5 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Fri, 24 Feb 2023 11:06:35 +0200 Subject: [PATCH 9/9] rebase fixes --- babel/core.py | 6 +++--- babel/localedata.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/babel/core.py b/babel/core.py index 256b1d3f2..01df6e740 100644 --- a/babel/core.py +++ b/babel/core.py @@ -308,7 +308,7 @@ def parse( there is a locale ``en`` that can exist by itself. :raise `ValueError`: if the string does not appear to be a valid locale - identifier or ``None`` is passed + identifier :raise `UnknownLocaleError`: if no locale data is available for the requested locale :raise `TypeError`: if the identifier is not a string or a `Locale` @@ -505,7 +505,7 @@ def get_territory_name(self, locale: Locale | str | None = None) -> str | None: if locale is None: locale = self locale = Locale.parse(locale) - return locale.territories.get(self.territory) + return locale.territories.get(self.territory or '') territory_name = property(get_territory_name, doc="""\ The localized territory name of the locale if available. @@ -519,7 +519,7 @@ def get_script_name(self, locale: Locale | str | None = None) -> str | None: if locale is None: locale = self locale = Locale.parse(locale) - return locale.scripts.get(self.script) + return locale.scripts.get(self.script or '') script_name = property(get_script_name, doc="""\ The localized script name of the locale if available. diff --git a/babel/localedata.py b/babel/localedata.py index 45edf9d67..ad17401bb 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -18,7 +18,7 @@ import re import sys import threading -from collections.abc import Iterator, Mapping, MutableMapping +from collections.abc import Iterator, Mapping from functools import lru_cache from itertools import chain from typing import TYPE_CHECKING, Any, MutableMapping, TypeVar