From d6a3528d319e474f7a824050f7311e585ab810b2 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 2 Nov 2022 02:53:12 +0000 Subject: [PATCH 01/53] docs(changes): Fix broken issue links --- CHANGES.rst | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index cb09f4f74..949d16dd7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,35 +13,35 @@ Upcoming deprecation Improvements ~~~~~~~~~~~~ -* Support for hex escapes in JavaScript string literals :gh:`#877` - Przemyslaw Wegrzyn -* Add support for formatting decimals in compact form :gh:`#909` - Jonah Lawrence -* Adapt parse_date to handle ISO dates in ASCII format :gh:`#842` - Eric L. -* Use `ast` instead of `eval` for Python string extraction :gh:`#915` - Aarni Koskela +* Support for hex escapes in JavaScript string literals :gh:`877` - Przemyslaw Wegrzyn +* Add support for formatting decimals in compact form :gh:`909` - Jonah Lawrence +* Adapt parse_date to handle ISO dates in ASCII format :gh:`842` - Eric L. +* Use `ast` instead of `eval` for Python string extraction :gh:`915` - Aarni Koskela * This also enables extraction from static f-strings. F-strings with expressions are silently ignored (but won't raise an error as they used to). Infrastructure ~~~~~~~~~~~~~~ -* Tests: Use regular asserts and ``pytest.raises()`` :gh:`#875` – Aarni Koskela -* Wheels are now built in GitHub Actions :gh:`#888` – Aarni Koskela -* Small improvements to the CLDR downloader script :gh:`#894` – Aarni Koskela -* Remove antiquated `__nonzero__` methods :gh:`#896` - Nikita Sobolev -* Remove superfluous `__unicode__` declarations :gh:`#905` - Lukas Juhrich -* Mark package compatible with Python 3.11 :gh:`#913` - Aarni Koskela -* Quiesce pytest warnings :gh:`#916` - Aarni Koskela +* Tests: Use regular asserts and ``pytest.raises()`` :gh:`875` – Aarni Koskela +* Wheels are now built in GitHub Actions :gh:`888` – Aarni Koskela +* Small improvements to the CLDR downloader script :gh:`894` – Aarni Koskela +* Remove antiquated `__nonzero__` methods :gh:`896` - Nikita Sobolev +* Remove superfluous `__unicode__` declarations :gh:`905` - Lukas Juhrich +* Mark package compatible with Python 3.11 :gh:`913` - Aarni Koskela +* Quiesce pytest warnings :gh:`916` - Aarni Koskela Bugfixes ~~~~~~~~ -* Use email.Message for pofile header parsing instead of the deprecated ``cgi.parse_header`` function. :gh:`#876` – Aarni Koskela -* Remove determining time zone via systemsetup on macOS :gh:`#914` - Aarni Koskela +* Use email.Message for pofile header parsing instead of the deprecated ``cgi.parse_header`` function. :gh:`876` – Aarni Koskela +* Remove determining time zone via systemsetup on macOS :gh:`914` - Aarni Koskela Documentation ~~~~~~~~~~~~~ -* Update Python versions in documentation :gh:`#898` - Raphael Nestler -* Align BSD-3 license with OSI template :gh:`#912` - Lukas Kahwe Smith +* Update Python versions in documentation :gh:`898` - Raphael Nestler +* Align BSD-3 license with OSI template :gh:`912` - Lukas Kahwe Smith Version 2.10.3 -------------- From f7af1b72e765278b3431de3f3b92c6b9a189b18e Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 2 Nov 2022 08:25:47 +0200 Subject: [PATCH 02/53] Update docs build configuration (#924) * Use Python 3.11 * Use Sphinx 5 (and update extlinks configuration) * Use Python 3 intersphinx mapping * Install Babel from the build directory Refs https://github.com/python-babel/babel/issues/920 --- .readthedocs.yml | 13 ++++++++++--- docs/conf.py | 6 +++--- docs/requirements.txt | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index a4a09ac65..cc83f360b 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,10 +3,17 @@ version: 2 build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "3.9" - + python: "3.11" + jobs: + pre_build: + # Replace any Babel version something may have pulled in + # with the copy we're working on. We'll also need to build + # the data files at that point, or date formatting _within_ + # Sphinx will fail. + - pip install -e . + - make import-cldr sphinx: configuration: docs/conf.py diff --git a/docs/conf.py b/docs/conf.py index 24c6ce217..c0e23894b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -251,10 +251,10 @@ #texinfo_show_urls = 'footnote' intersphinx_mapping = { - 'https://docs.python.org/2/': None, + 'https://docs.python.org/3/': None, } extlinks = { - 'gh': ('https://github.com/python-babel/babel/issues/%s', '#'), - 'trac': ('http://babel.edgewall.org/ticket/%s', 'ticket #'), + 'gh': ('https://github.com/python-babel/babel/issues/%s', '#%s'), + 'trac': ('http://babel.edgewall.org/ticket/%s', 'ticket #%s'), } diff --git a/docs/requirements.txt b/docs/requirements.txt index aaedb2115..c133306c7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1 @@ -Sphinx~=4.4.0 +Sphinx~=5.3.0 From 103c214dcf57e53f8f18b30f59947c9752c18ab2 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 2 Nov 2022 00:35:41 -0600 Subject: [PATCH 03/53] feat: Add `Format.compact_decimal` utility (#921) --- babel/support.py | 13 ++++++++++++- tests/test_support.py | 5 +++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/babel/support.py b/babel/support.py index 80e93400d..3efc0036b 100644 --- a/babel/support.py +++ b/babel/support.py @@ -18,7 +18,7 @@ from babel.dates import format_date, format_datetime, format_time, \ format_timedelta from babel.numbers import format_decimal, format_currency, \ - format_percent, format_scientific + format_percent, format_scientific, format_compact_decimal class Format: @@ -108,6 +108,17 @@ def decimal(self, number, format=None): """ return format_decimal(number, format, locale=self.locale) + def compact_decimal(self, number, format_type='short', fraction_digits=0): + """Return a number formatted in compact form for the locale. + + >>> fmt = Format('en_US') + >>> fmt.compact_decimal(123456789) + u'123M' + """ + return format_compact_decimal(number, format_type=format_type, + fraction_digits=fraction_digits, + locale=self.locale) + def currency(self, number, currency): """Return a number in the given currency formatted for the locale. """ diff --git a/tests/test_support.py b/tests/test_support.py index 2d8e9332d..93ad37e4a 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -329,6 +329,11 @@ def test_format_decimal(): assert fmt.decimal(1.2345) == '1.234' +def test_format_compact_decimal(): + fmt = support.Format('en_US') + assert fmt.compact_decimal(1234567, format_type='long', fraction_digits=2) == '1.23 million' + + def test_format_percent(): fmt = support.Format('en_US') assert fmt.percent(0.34) == '34%' From 3add2c141783b1f590c753f475b8cba64d15cd0c Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 2 Nov 2022 08:36:42 +0200 Subject: [PATCH 04/53] Remove vestigial Python 2 `long` check (#925) --- babel/numbers.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 373a9bd16..8a341c4fa 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -24,13 +24,6 @@ from babel.core import default_locale, Locale, get_global -try: - # Python 2 - long -except NameError: - # Python 3 - long = int - LC_NUMERIC = default_locale('LC_NUMERIC') @@ -371,7 +364,7 @@ def get_decimal_precision(number): def get_decimal_quantum(precision): """Return minimal quantum of a number, as defined by precision.""" - assert isinstance(precision, (int, long, decimal.Decimal)) + assert isinstance(precision, (int, decimal.Decimal)) return decimal.Decimal(10) ** (-precision) From 5fcc2535f96bfce9c1a1ecf9d19a976b9bf6ab6b Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Fri, 4 Nov 2022 09:16:24 -0600 Subject: [PATCH 05/53] feat: Support for short compact currency formats (#926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jun Omae (大前 潤) <42682+jun66j5@users.noreply.github.com> --- babel/core.py | 12 ++++++++++ babel/numbers.py | 53 ++++++++++++++++++++++++++++++++++++++---- babel/support.py | 9 ++++++- docs/api/numbers.rst | 2 ++ scripts/import_cldr.py | 21 ++++++++++++----- tests/test_numbers.py | 29 +++++++++++++++++++++++ tests/test_support.py | 5 ++++ 7 files changed, 119 insertions(+), 12 deletions(-) diff --git a/babel/core.py b/babel/core.py index 220cbaf0a..2a01c309f 100644 --- a/babel/core.py +++ b/babel/core.py @@ -590,6 +590,18 @@ def currency_formats(self): """ return self._data['currency_formats'] + @property + def compact_currency_formats(self): + """Locale patterns for compact currency number formatting. + + .. note:: The format of the value returned may change between + Babel versions. + + >>> Locale('en', 'US').compact_currency_formats["short"]["one"]["1000"] + + """ + return self._data['compact_currency_formats'] + @property def percent_formats(self): """Locale patterns for percent number formatting. diff --git a/babel/numbers.py b/babel/numbers.py index 8a341c4fa..8baf110b8 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -440,18 +440,21 @@ def format_compact_decimal(number, *, format_type="short", locale=LC_NUMERIC, fr :param fraction_digits: Number of digits after the decimal point to use. Defaults to `0`. """ locale = Locale.parse(locale) - number, format = _get_compact_format(number, format_type, locale, fraction_digits) + compact_format = locale.compact_decimal_formats[format_type] + number, format = _get_compact_format(number, compact_format, locale, fraction_digits) + # Did not find a format, fall back. + if format is None: + format = locale.decimal_formats.get(None) pattern = parse_pattern(format) return pattern.apply(number, locale, decimal_quantization=False) -def _get_compact_format(number, format_type, locale, fraction_digits=0): +def _get_compact_format(number, compact_format, locale, fraction_digits=0): """Returns the number after dividing by the unit and the format pattern to use. The algorithm is described here: https://www.unicode.org/reports/tr35/tr35-45/tr35-numbers.html#Compact_Number_Formats. """ format = None - compact_format = locale.compact_decimal_formats[format_type] for magnitude in sorted([int(m) for m in compact_format["other"]], reverse=True): if abs(number) >= magnitude: # check the pattern using "other" as the amount @@ -470,8 +473,6 @@ def _get_compact_format(number, format_type, locale, fraction_digits=0): plural_form = plural_form if plural_form in compact_format else "other" format = compact_format[plural_form][str(magnitude)] break - if format is None: # Did not find a format, fall back. - format = locale.decimal_formats.get(None) return number, format @@ -624,6 +625,44 @@ def _format_currency_long_name( return unit_pattern.format(number_part, display_name) +def format_compact_currency(number, currency, *, format_type="short", locale=LC_NUMERIC, fraction_digits=0): + u"""Format a number as a currency value in compact form. + + >>> format_compact_currency(12345, 'USD', locale='en_US') + u'$12K' + >>> format_compact_currency(123456789, 'USD', locale='en_US', fraction_digits=2) + u'$123.46M' + >>> format_compact_currency(123456789, 'EUR', locale='de_DE', fraction_digits=1) + '123,5\xa0Mio.\xa0€' + + :param number: the number to format + :param currency: the currency code + :param format_type: the compact format type to use. Defaults to "short". + :param locale: the `Locale` object or locale identifier + :param fraction_digits: Number of digits after the decimal point to use. Defaults to `0`. + """ + locale = Locale.parse(locale) + try: + compact_format = locale.compact_currency_formats[format_type] + except KeyError as error: + raise UnknownCurrencyFormatError(f"{format_type!r} is not a known compact currency format type") from error + number, format = _get_compact_format(number, compact_format, locale, fraction_digits) + # Did not find a format, fall back. + if format is None or "¤" not in str(format): + # find first format that has a currency symbol + for magnitude in compact_format['other']: + format = compact_format['other'][magnitude].pattern + if '¤' not in format: + continue + # remove characters that are not the currency symbol, 0's or spaces + format = re.sub(r'[^0\s\¤]', '', format) + # compress adjacent spaces into one + format = re.sub(r'(\s)\s+', r'\1', format).strip() + break + pattern = parse_pattern(format) + return pattern.apply(number, locale, currency=currency, currency_digits=False, decimal_quantization=False) + + def format_percent( number, format=None, locale=LC_NUMERIC, decimal_quantization=True, group_separator=True): """Return formatted percent value for a specific locale. @@ -1082,6 +1121,10 @@ def apply( retval = retval.replace(u'¤¤', currency.upper()) retval = retval.replace(u'¤', get_currency_symbol(currency, locale)) + # remove single quotes around text, except for doubled single quotes + # which are replaced with a single quote + retval = re.sub(r"'([^']*)'", lambda m: m.group(1) or "'", retval) + return retval # diff --git a/babel/support.py b/babel/support.py index 3efc0036b..50f275274 100644 --- a/babel/support.py +++ b/babel/support.py @@ -17,7 +17,7 @@ from babel.core import Locale from babel.dates import format_date, format_datetime, format_time, \ format_timedelta -from babel.numbers import format_decimal, format_currency, \ +from babel.numbers import format_decimal, format_currency, format_compact_currency, \ format_percent, format_scientific, format_compact_decimal @@ -124,6 +124,13 @@ def currency(self, number, currency): """ return format_currency(number, currency, locale=self.locale) + def compact_currency(self, number, currency, format_type='short', fraction_digits=0): + """Return a number in the given currency formatted for the locale + using the compact number format. + """ + return format_compact_currency(number, currency, format_type=format_type, + fraction_digits=fraction_digits, locale=self.locale) + def percent(self, number, format=None): """Return a number formatted as percentage for the locale. diff --git a/docs/api/numbers.rst b/docs/api/numbers.rst index eac569206..d3ab8b116 100644 --- a/docs/api/numbers.rst +++ b/docs/api/numbers.rst @@ -17,6 +17,8 @@ Number Formatting .. autofunction:: format_currency +.. autofunction:: format_compact_currency + .. autofunction:: format_percent .. autofunction:: format_scientific diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 92dd27234..097840cec 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -915,10 +915,6 @@ def parse_currency_formats(data, tree): curr_length_type = length_elem.attrib.get('type') for elem in length_elem.findall('currencyFormat'): type = elem.attrib.get('type') - if curr_length_type: - # Handle ``, etc. - # TODO(3.x): use nested dicts instead of colon-separated madness - type = '%s:%s' % (type, curr_length_type) if _should_skip_elem(elem, type, currency_formats): continue for child in elem.iter(): @@ -928,8 +924,21 @@ def parse_currency_formats(data, tree): child.attrib['path']) ) elif child.tag == 'pattern': - pattern = str(child.text) - currency_formats[type] = numbers.parse_pattern(pattern) + pattern_type = child.attrib.get('type') + pattern = numbers.parse_pattern(str(child.text)) + if pattern_type: + # This is a compact currency format, see: + # https://www.unicode.org/reports/tr35/tr35-45/tr35-numbers.html#Compact_Number_Formats + + # These are mapped into a `compact_currency_formats` dictionary + # with the format {length: {count: {multiplier: pattern}}}. + compact_currency_formats = data.setdefault('compact_currency_formats', {}) + length_map = compact_currency_formats.setdefault(curr_length_type, {}) + length_count_map = length_map.setdefault(child.attrib['count'], {}) + length_count_map[pattern_type] = pattern + else: + # Regular currency format + currency_formats[type] = pattern def parse_currency_unit_patterns(data, tree): diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 1b955c95e..bb6c4e84e 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -422,6 +422,27 @@ def test_format_currency_format_type(): == u'1.099,98') +def test_format_compact_currency(): + assert numbers.format_compact_currency(1, 'USD', locale='en_US', format_type="short") == u'$1' + assert numbers.format_compact_currency(999, 'USD', locale='en_US', format_type="short") == u'$999' + assert numbers.format_compact_currency(123456789, 'USD', locale='en_US', format_type="short") == u'$123M' + assert numbers.format_compact_currency(123456789, 'USD', locale='en_US', fraction_digits=2, format_type="short") == u'$123.46M' + assert numbers.format_compact_currency(-123456789, 'USD', locale='en_US', fraction_digits=2, format_type="short") == u'-$123.46M' + assert numbers.format_compact_currency(1, 'JPY', locale='ja_JP', format_type="short") == u'¥1' + assert numbers.format_compact_currency(1234, 'JPY', locale='ja_JP', format_type="short") == u'¥1234' + assert numbers.format_compact_currency(123456, 'JPY', locale='ja_JP', format_type="short") == u'¥12万' + assert numbers.format_compact_currency(123456, 'JPY', locale='ja_JP', format_type="short", fraction_digits=2) == u'¥12.35万' + assert numbers.format_compact_currency(123, 'EUR', locale='yav', format_type="short") == '123\xa0€' + assert numbers.format_compact_currency(12345, 'EUR', locale='yav', format_type="short") == '12K\xa0€' + assert numbers.format_compact_currency(123456789, 'EUR', locale='de_DE', fraction_digits=1) == '123,5\xa0Mio.\xa0€' + + +def test_format_compact_currency_invalid_format_type(): + with pytest.raises(numbers.UnknownCurrencyFormatError): + numbers.format_compact_currency(1099.98, 'USD', locale='en_US', + format_type='unknown') + + @pytest.mark.parametrize('input_value, expected_value', [ ('10000', '$10,000.00'), ('1', '$1.00'), @@ -696,3 +717,11 @@ def test_parse_decimal_nbsp_heuristics(): def test_very_small_decimal_no_quantization(): assert numbers.format_decimal(decimal.Decimal('1E-7'), locale='en', decimal_quantization=False) == '0.0000001' + + +def test_single_quotes_in_pattern(): + assert numbers.format_decimal(123, "'@0.#'00'@01'", locale='en') == '@0.#120@01' + + assert numbers.format_decimal(123, "'$'''0", locale='en') == "$'123" + + assert numbers.format_decimal(12, "'#'0 o''clock", locale='en') == "#12 o'clock" diff --git a/tests/test_support.py b/tests/test_support.py index 93ad37e4a..944710751 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -334,6 +334,11 @@ def test_format_compact_decimal(): assert fmt.compact_decimal(1234567, format_type='long', fraction_digits=2) == '1.23 million' +def test_format_compact_currency(): + fmt = support.Format('en_US') + assert fmt.compact_currency(1234567, "USD", format_type='short', fraction_digits=2) == '$1.23M' + + def test_format_percent(): fmt = support.Format('en_US') assert fmt.percent(0.34) == '34%' From eb647ba3fa0ba580c4a44b9507f5e74c037a1f0c Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 23 Nov 2022 13:22:01 +0200 Subject: [PATCH 06/53] Drop support for EOL Python 3.6 (#919) --- .github/workflows/test.yml | 2 +- docs/dev.rst | 2 +- setup.py | 3 +-- tox.ini | 3 +-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8764b580..491581677 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: os: ["ubuntu-20.04", "windows-2022", "macos-11"] - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.7"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.7"] env: BABEL_CLDR_NO_DOWNLOAD_PROGRESS: "1" BABEL_CLDR_QUIET: "1" diff --git a/docs/dev.rst b/docs/dev.rst index c3627249c..1c4453d8e 100644 --- a/docs/dev.rst +++ b/docs/dev.rst @@ -30,7 +30,7 @@ Python Versions At the moment the following Python versions should be supported: -* Python 3.6 and up +* Python 3.7 and up * PyPy 3.7 and up Unicode diff --git a/setup.py b/setup.py index 6744277a7..06caa6a64 100755 --- a/setup.py +++ b/setup.py @@ -47,7 +47,6 @@ def run(self): 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', @@ -57,7 +56,7 @@ def run(self): 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries :: Python Modules', ], - python_requires='>=3.6', + python_requires='>=3.7', packages=['babel', 'babel.messages', 'babel.localtime'], include_package_data=True, install_requires=[ diff --git a/tox.ini b/tox.ini index 97b6bc09c..8b640015d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39,310,311} + py{37,38,39,310,311} pypy3 [testenv] @@ -22,7 +22,6 @@ passenv = [gh-actions] python = pypy3: pypy3 - 3.6: py36 3.7: py37 3.8: py38 3.9: py39 From 896c2ea72818011bc0e8bd84151b0b10935fd4fa Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 23 Nov 2022 04:25:49 -0700 Subject: [PATCH 07/53] Cast number to Decimal in _get_compact_format (#930) * fix rounding modes by using Decimal instead of float --- babel/numbers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 8baf110b8..6c9ab24b0 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -426,7 +426,7 @@ def format_compact_decimal(number, *, format_type="short", locale=LC_NUMERIC, fr >>> 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.35K' + u'12.34K' >>> format_compact_decimal(1234567, format_type="short", locale="ja_JP") u'123万' >>> format_compact_decimal(2345678, format_type="long", locale="mk") @@ -454,6 +454,8 @@ def _get_compact_format(number, compact_format, locale, fraction_digits=0): 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)) format = None for magnitude in sorted([int(m) for m in compact_format["other"]], reverse=True): if abs(number) >= magnitude: @@ -465,7 +467,7 @@ def _get_compact_format(number, compact_format, locale, fraction_digits=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 = number / (magnitude / (10 ** (pattern.count("0") - 1))) + number = number / (magnitude // (10 ** (pattern.count("0") - 1))) # round to the number of fraction digits requested number = round(number, fraction_digits) # if the remaining number is singular, use the singular format From a45e25e3125f6ee0a9f32387545df318b0b3b2d0 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 23 Nov 2022 13:33:10 +0200 Subject: [PATCH 08/53] Replace %/.format/concatenation with f-strings where feasible (#927) Original conversion suggestions via flynt, edited by hand. --- babel/core.py | 15 ++++---- babel/dates.py | 27 ++++++-------- babel/lists.py | 9 +++-- babel/localedata.py | 6 ++-- babel/localtime/_win32.py | 4 +-- babel/messages/catalog.py | 32 ++++++++--------- babel/messages/checkers.py | 2 +- babel/messages/extract.py | 13 ++++--- babel/messages/frontend.py | 36 +++++++++---------- babel/messages/pofile.py | 36 +++++++------------ babel/numbers.py | 47 +++++++++++++------------ babel/plural.py | 62 ++++++++++++--------------------- babel/support.py | 4 +-- babel/units.py | 10 +++--- babel/util.py | 8 ++--- scripts/generate_authors.py | 2 +- scripts/import_cldr.py | 17 ++++----- setup.py | 2 +- tests/messages/test_catalog.py | 4 +-- tests/messages/test_frontend.py | 13 +++---- tests/messages/test_pofile.py | 2 +- tests/test_core.py | 3 +- tests/test_plural.py | 2 +- tests/test_support.py | 9 ++--- 24 files changed, 161 insertions(+), 204 deletions(-) diff --git a/babel/core.py b/babel/core.py index 2a01c309f..825af8132 100644 --- a/babel/core.py +++ b/babel/core.py @@ -98,7 +98,7 @@ def __init__(self, identifier): :param identifier: the identifier string of the unsupported locale """ - Exception.__init__(self, 'unknown locale %r' % identifier) + Exception.__init__(self, f"unknown locale {identifier!r}") #: The identifier of the locale that could not be found. self.identifier = identifier @@ -262,7 +262,7 @@ def parse(cls, identifier, sep='_', resolve_likely_subtags=True): elif isinstance(identifier, Locale): return identifier elif not isinstance(identifier, str): - raise TypeError('Unexpected value for identifier: %r' % (identifier,)) + raise TypeError(f"Unexpected value for identifier: {identifier!r}") parts = parse_locale(identifier, sep=sep) input_id = get_locale_identifier(parts) @@ -349,9 +349,8 @@ def __repr__(self): for key in ('territory', 'script', 'variant'): value = getattr(self, key) if value is not None: - parameters.append('%s=%r' % (key, value)) - parameter_string = '%r' % self.language + ', '.join(parameters) - return 'Locale(%s)' % parameter_string + parameters.append(f"{key}={value!r}") + return f"Locale({self.language!r}{', '.join(parameters)})" def __str__(self): return get_locale_identifier((self.language, self.territory, @@ -388,7 +387,7 @@ def get_display_name(self, locale=None): details.append(locale.variants.get(self.variant)) details = filter(None, details) if details: - retval += ' (%s)' % u', '.join(details) + retval += f" ({', '.join(details)})" return retval display_name = property(get_display_name, doc="""\ @@ -1120,7 +1119,7 @@ def parse_locale(identifier, sep='_'): parts = identifier.split(sep) lang = parts.pop(0).lower() if not lang.isalpha(): - raise ValueError('expected only letters, got %r' % lang) + raise ValueError(f"expected only letters, got {lang!r}") script = territory = variant = None if parts: @@ -1139,7 +1138,7 @@ def parse_locale(identifier, sep='_'): variant = parts.pop().upper() if parts: - raise ValueError('%r is not a valid locale identifier' % identifier) + raise ValueError(f"{identifier!r} is not a valid locale identifier") return lang, territory, script, variant diff --git a/babel/dates.py b/babel/dates.py index 8228bef88..e9f6f6dd2 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -203,7 +203,7 @@ def get_timezone(zone=None): try: return _pytz.timezone(zone) except _pytz.UnknownTimeZoneError: - raise LookupError('Unknown timezone %s' % zone) + raise LookupError(f"Unknown timezone {zone}") def get_next_timezone_transition(zone=None, dt=None): @@ -312,11 +312,7 @@ def to_offset(self): return int(self.to_tzinfo._utcoffset.total_seconds()) def __repr__(self): - return ' %s (%s)>' % ( - self.from_tz, - self.to_tz, - self.activates, - ) + return f" {self.to_tz} ({self.activates})>" def get_period_names(width='wide', context='stand-alone', locale=LC_TIME): @@ -958,7 +954,7 @@ def _iter_patterns(a_unit): yield unit_rel_patterns['future'] else: yield unit_rel_patterns['past'] - a_unit = 'duration-' + a_unit + a_unit = f"duration-{a_unit}" yield locale._data['unit_patterns'].get(a_unit, {}).get(format) for unit, secs_per_unit in TIMEDELTA_UNITS: @@ -1293,7 +1289,7 @@ def __init__(self, pattern, format): self.format = format def __repr__(self): - return '<%s %r>' % (type(self).__name__, self.pattern) + return f"<{type(self).__name__} {self.pattern!r}>" def __str__(self): pat = self.pattern @@ -1365,7 +1361,7 @@ def __getitem__(self, name): elif char in ('z', 'Z', 'v', 'V', 'x', 'X', 'O'): return self.format_timezone(char, num) else: - raise KeyError('Unsupported date/time field %r' % char) + raise KeyError(f"Unsupported date/time field {char!r}") def extract(self, char): char = str(char)[0] @@ -1384,7 +1380,7 @@ def extract(self, char): elif char == 'a': return int(self.value.hour >= 12) # 0 for am, 1 for pm else: - raise NotImplementedError("Not implemented: extracting %r from %r" % (char, self.value)) + raise NotImplementedError(f"Not implemented: extracting {char!r} from {self.value!r}") def format_era(self, char, num): width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)] @@ -1429,7 +1425,7 @@ def format_week(self, char, num): if week == 0: date = self.value - timedelta(days=self.value.day) week = self.get_week_number(date.day, date.weekday()) - return '%d' % week + return str(week) def format_weekday(self, char='E', num=4): """ @@ -1475,7 +1471,7 @@ def format_day_of_year(self, num): return self.format(self.get_day_of_year(), num) def format_day_of_week_in_month(self): - return '%d' % ((self.value.day - 1) // 7 + 1) + return str((self.value.day - 1) // 7 + 1) def format_period(self, char, num): """ @@ -1517,7 +1513,7 @@ def format_period(self, char, num): period_names = get_period_names(context=context, width=width, locale=self.locale) if period in period_names: return period_names[period] - raise ValueError('Could not format period %s in %s' % (period, self.locale)) + raise ValueError(f"Could not format period {period} in {self.locale}") def format_frac_seconds(self, num): """ Return fractional seconds. @@ -1689,11 +1685,10 @@ def parse_pattern(pattern): fieldchar, fieldnum = tok_value limit = PATTERN_CHARS[fieldchar] if limit and fieldnum not in limit: - raise ValueError('Invalid length for field: %r' - % (fieldchar * fieldnum)) + raise ValueError(f"Invalid length for field: {fieldchar * fieldnum!r}") result.append('%%(%s)s' % (fieldchar * fieldnum)) else: - raise NotImplementedError("Unknown token type: %s" % tok_type) + raise NotImplementedError(f"Unknown token type: {tok_type}") _pattern_cache[pattern] = pat = DateTimePattern(pattern, u''.join(result)) return pat diff --git a/babel/lists.py b/babel/lists.py index 11cc7d725..ea983efe0 100644 --- a/babel/lists.py +++ b/babel/lists.py @@ -68,11 +68,10 @@ def format_list(lst, style='standard', locale=DEFAULT_LOCALE): return lst[0] if style not in locale.list_patterns: - raise ValueError('Locale %s does not support list formatting style %r (supported are %s)' % ( - locale, - style, - list(sorted(locale.list_patterns)), - )) + raise ValueError( + f'Locale {locale} does not support list formatting style {style!r} ' + f'(supported are {sorted(locale.list_patterns)})' + ) patterns = locale.list_patterns[style] if len(lst) == 2: diff --git a/babel/localedata.py b/babel/localedata.py index 14e6bcdf4..8ec8f4aaa 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -50,10 +50,10 @@ def resolve_locale_filename(name): # Ensure we're not left with one of the Windows reserved names. if sys.platform == "win32" and _windows_reserved_name_re.match(os.path.splitext(name)[0]): - raise ValueError("Name %s is invalid on Windows" % name) + raise ValueError(f"Name {name} is invalid on Windows") # Build the path. - return os.path.join(_dirname, '%s.dat' % name) + return os.path.join(_dirname, f"{name}.dat") def exists(name): @@ -194,7 +194,7 @@ def __init__(self, keys): self.keys = tuple(keys) def __repr__(self): - return '<%s %r>' % (type(self).__name__, self.keys) + return f"<{type(self).__name__} {self.keys!r}>" def resolve(self, data): """Resolve the alias based on the given data. diff --git a/babel/localtime/_win32.py b/babel/localtime/_win32.py index 09b87b14e..a4f6d557a 100644 --- a/babel/localtime/_win32.py +++ b/babel/localtime/_win32.py @@ -77,11 +77,11 @@ def get_localzone_name(): if timezone is None: # Nope, that didn't work. Try adding 'Standard Time', # it seems to work a lot of times: - timezone = tz_names.get(tzkeyname + ' Standard Time') + timezone = tz_names.get(f"{tzkeyname} Standard Time") # Return what we have. if timezone is None: - raise pytz.UnknownTimeZoneError('Can not find timezone ' + tzkeyname) + raise pytz.UnknownTimeZoneError(f"Can not find timezone {tzkeyname}") return timezone diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index e43a28c02..22ce66067 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -49,7 +49,7 @@ def _parse_datetime_header(value): hours_offset_s, mins_offset_s = rest[:2], rest[2:] # Make them all integers - plus_minus = int(plus_minus_s + '1') + plus_minus = int(f"{plus_minus_s}1") hours_offset = int(hours_offset_s) mins_offset = int(mins_offset_s) @@ -108,8 +108,7 @@ def __init__(self, id, string=u'', locations=(), flags=(), auto_comments=(), self.context = context def __repr__(self): - return '<%s %r (flags: %r)>' % (type(self).__name__, self.id, - list(self.flags)) + return f"<{type(self).__name__} {self.id!r} (flags: {list(self.flags)!r})>" def __cmp__(self, other): """Compare Messages, taking into account plural ids""" @@ -312,7 +311,7 @@ def _set_locale(self, locale): self._locale = None return - raise TypeError('`locale` must be a Locale, a locale identifier string, or None; got %r' % locale) + raise TypeError(f"`locale` must be a Locale, a locale identifier string, or None; got {locale!r}") def _get_locale(self): return self._locale @@ -334,7 +333,7 @@ def _get_header_comment(self): .replace('ORGANIZATION', self.copyright_holder) locale_name = (self.locale.english_name if self.locale else self.locale_identifier) if locale_name: - comment = comment.replace('Translations template', '%s translations' % locale_name) + comment = comment.replace("Translations template", f"{locale_name} translations") return comment def _set_header_comment(self, string): @@ -375,8 +374,7 @@ def _set_header_comment(self, string): def _get_mime_headers(self): headers = [] - headers.append(('Project-Id-Version', - '%s %s' % (self.project, self.version))) + headers.append(("Project-Id-Version", f"{self.project} {self.version}")) headers.append(('Report-Msgid-Bugs-To', self.msgid_bugs_address)) headers.append(('POT-Creation-Date', format_datetime(self.creation_date, 'yyyy-MM-dd HH:mmZ', @@ -399,10 +397,9 @@ def _get_mime_headers(self): if self.locale is not None: headers.append(('Plural-Forms', self.plural_forms)) headers.append(('MIME-Version', '1.0')) - headers.append(('Content-Type', - 'text/plain; charset=%s' % self.charset)) + headers.append(("Content-Type", f"text/plain; charset={self.charset}")) headers.append(('Content-Transfer-Encoding', '8bit')) - headers.append(('Generated-By', 'Babel %s\n' % VERSION)) + headers.append(("Generated-By", f"Babel {VERSION}\n")) return headers def _force_text(self, s, encoding='utf-8', errors='strict'): @@ -434,7 +431,7 @@ def _set_mime_headers(self, headers): if 'charset' in params: self.charset = params['charset'].lower() elif name == 'plural-forms': - params = parse_separated_header(' ;' + value) + params = parse_separated_header(f" ;{value}") self._num_plurals = int(params.get('nplurals', 2)) self._plural_expr = params.get('plural', '(n != 1)') elif name == 'pot-creation-date': @@ -541,7 +538,7 @@ def plural_forms(self): 'nplurals=2; plural=(n > 1);' :type: `str`""" - return 'nplurals=%s; plural=%s;' % (self.num_plurals, self.plural_expr) + return f"nplurals={self.num_plurals}; plural={self.plural_expr};" def __contains__(self, id): """Return whether the catalog has a message with the specified ID.""" @@ -560,7 +557,7 @@ def __iter__(self): :rtype: ``iterator``""" buf = [] for name, value in self.mime_headers: - buf.append('%s: %s' % (name, value)) + buf.append(f"{name}: {value}") flags = set() if self.fuzzy: flags |= {'fuzzy'} @@ -571,8 +568,8 @@ def __iter__(self): def __repr__(self): locale = '' if self.locale: - locale = ' %s' % self.locale - return '<%s %r%s>' % (type(self).__name__, self.domain, locale) + locale = f" {self.locale}" + return f"<{type(self).__name__} {self.domain!r}{locale}>" def __delitem__(self, id): """Delete the message with the specified ID.""" @@ -626,13 +623,12 @@ def __setitem__(self, id, message): elif id == '': # special treatment for the header message self.mime_headers = message_from_string(message.string).items() - self.header_comment = '\n'.join([('# %s' % c).rstrip() for c - in message.user_comments]) + self.header_comment = "\n".join([f"# {c}".rstrip() for c in message.user_comments]) self.fuzzy = message.fuzzy else: if isinstance(id, (list, tuple)): assert isinstance(message.string, (list, tuple)), \ - 'Expected sequence but got %s' % type(message.string) + f"Expected sequence but got {type(message.string)}" self._messages[key] = message def add(self, id, string=None, locations=(), flags=(), auto_comments=(), diff --git a/babel/messages/checkers.py b/babel/messages/checkers.py index 4292c02d3..2706c5bfe 100644 --- a/babel/messages/checkers.py +++ b/babel/messages/checkers.py @@ -144,7 +144,7 @@ def _check_positional(results): type_map = dict(a) for name, typechar in b: if name not in type_map: - raise TranslationError('unknown named placeholder %r' % name) + raise TranslationError(f'unknown named placeholder {name!r}') elif not _compatible(typechar, type_map[name]): raise TranslationError('incompatible format for ' 'placeholder %r: ' diff --git a/babel/messages/extract.py b/babel/messages/extract.py index 74e57a181..4f0f649b3 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -42,9 +42,6 @@ DEFAULT_MAPPING = [('**.py', 'python')] -empty_msgid_warning = ( - '%s: warning: Empty msgid. It is reserved by GNU gettext: gettext("") ' - 'returns the header entry with meta information, not the empty string.') def _strip_comment_tags(comments, tags): @@ -332,7 +329,7 @@ def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), func = builtin.get(method) if func is None: - raise ValueError('Unknown extraction method %r' % method) + raise ValueError(f"Unknown extraction method {method!r}") results = func(fileobj, keywords.keys(), comment_tags, options=options or {}) @@ -377,9 +374,11 @@ def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), first_msg_index = spec[0] - 1 if not messages[first_msg_index]: # An empty string msgid isn't valid, emit a warning - where = '%s:%i' % (hasattr(fileobj, 'name') and - fileobj.name or '(unknown)', lineno) - sys.stderr.write((empty_msgid_warning % where) + '\n') + filename = (getattr(fileobj, "name", None) or "(unknown)") + sys.stderr.write( + f"{filename}:{lineno}: warning: Empty msgid. It is reserved by GNU gettext: gettext(\"\") " + f"returns the header entry with meta information, not the empty string.\n" + ) continue messages = tuple(msgs) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 6e09d1095..c42cdc2e2 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -191,7 +191,7 @@ def run(self): for catalog, errors in self._run_domain(domain).items(): n_errors += len(errors) if n_errors: - self.log.error('%d errors encountered.' % n_errors) + self.log.error('%d errors encountered.', n_errors) return (1 if n_errors else 0) def _run_domain(self, domain): @@ -203,19 +203,19 @@ def _run_domain(self, domain): po_files.append((self.locale, os.path.join(self.directory, self.locale, 'LC_MESSAGES', - domain + '.po'))) + f"{domain}.po"))) mo_files.append(os.path.join(self.directory, self.locale, 'LC_MESSAGES', - domain + '.mo')) + f"{domain}.mo")) else: for locale in os.listdir(self.directory): po_file = os.path.join(self.directory, locale, - 'LC_MESSAGES', domain + '.po') + 'LC_MESSAGES', f"{domain}.po") if os.path.exists(po_file): po_files.append((locale, po_file)) mo_files.append(os.path.join(self.directory, locale, 'LC_MESSAGES', - domain + '.mo')) + f"{domain}.mo")) else: po_files.append((self.locale, self.input_file)) if self.output_file: @@ -223,7 +223,7 @@ def _run_domain(self, domain): else: mo_files.append(os.path.join(self.directory, self.locale, 'LC_MESSAGES', - domain + '.mo')) + f"{domain}.mo")) if not po_files: raise OptionError('no message catalogs found') @@ -451,7 +451,7 @@ def finalize_options(self): for path in self.input_paths: if not os.path.exists(path): - raise OptionError("Input path: %s does not exist" % path) + raise OptionError(f"Input path: {path} does not exist") self.add_comments = listify_value(self.add_comments or (), ",") @@ -498,8 +498,8 @@ def callback(filename, method, options): optstr = '' if options: - optstr = ' (%s)' % ', '.join(['%s="%s"' % (k, v) for - k, v in options.items()]) + opt_values = ", ".join(f'{k}="{v}"' for k, v in options.items()) + optstr = f" ({opt_values})" self.log.info('extracting messages from %s%s', filepath, optstr) if os.path.isfile(path): @@ -640,7 +640,7 @@ def finalize_options(self): raise OptionError('you must specify the output directory') if not self.output_file: self.output_file = os.path.join(self.output_dir, self.locale, - 'LC_MESSAGES', self.domain + '.po') + 'LC_MESSAGES', f"{self.domain}.po") if not os.path.exists(os.path.dirname(self.output_file)): os.makedirs(os.path.dirname(self.output_file)) @@ -782,12 +782,12 @@ def run(self): po_files.append((self.locale, os.path.join(self.output_dir, self.locale, 'LC_MESSAGES', - self.domain + '.po'))) + f"{self.domain}.po"))) else: for locale in os.listdir(self.output_dir): po_file = os.path.join(self.output_dir, locale, 'LC_MESSAGES', - self.domain + '.po') + f"{self.domain}.po") if os.path.exists(po_file): po_files.append((locale, po_file)) else: @@ -889,7 +889,7 @@ class CommandLineInterface: """ usage = '%%prog %s [options] %s' - version = '%%prog %s' % VERSION + version = f'%prog {VERSION}' commands = { 'compile': 'compile message catalogs to MO files', 'extract': 'extract messages from source files and generate a POT file', @@ -949,7 +949,7 @@ def run(self, argv=None): cmdname = args[0] if cmdname not in self.commands: - self.parser.error('unknown command "%s"' % cmdname) + self.parser.error(f'unknown command "{cmdname}"') cmdinst = self._configure_command(cmdname, args[1:]) return cmdinst.run() @@ -997,14 +997,14 @@ def _configure_command(self, cmdname, argv): as_args = getattr(cmdclass, "as_args", ()) for long, short, help in cmdclass.user_options: name = long.strip("=") - default = getattr(cmdinst, name.replace('-', '_')) - strs = ["--%s" % name] + default = getattr(cmdinst, name.replace("-", "_")) + strs = [f"--{name}"] if short: - strs.append("-%s" % short) + strs.append(f"-{short}") strs.extend(cmdclass.option_aliases.get(name, ())) choices = cmdclass.option_choices.get(name, None) if name == as_args: - parser.usage += "<%s>" % name + parser.usage += f"<{name}>" elif name in cmdclass.boolean_options: parser.add_option(*strs, action="store_true", help=help) elif name in cmdclass.multiple_value_options: diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index 00e084447..a213b2237 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -318,10 +318,7 @@ def _invalid_pofile(self, line, lineno, msg): if self.abort_invalid: raise PoFileError(msg, self.catalog, line, lineno) print("WARNING:", msg) - # `line` is guaranteed to be unicode so u"{}"-interpolating would always - # succeed, but on Python < 2 if not in a TTY, `sys.stdout.encoding` - # is `None`, unicode may not be printable so we `repr()` to ASCII. - print(u"WARNING: Problem on line {0}: {1}".format(lineno + 1, repr(line))) + print(f"WARNING: Problem on line {lineno + 1}: {line!r}") def read_po(fileobj, locale=None, domain=None, ignore_obsolete=False, charset=None, abort_invalid=False): @@ -525,34 +522,26 @@ def _write_comment(comment, prefix=''): else: _width = 76 for line in wraptext(comment, _width): - _write('#%s %s\n' % (prefix, line.strip())) + _write(f"#{prefix} {line.strip()}\n") def _write_message(message, prefix=''): if isinstance(message.id, (list, tuple)): if message.context: - _write('%smsgctxt %s\n' % (prefix, - _normalize(message.context, prefix))) - _write('%smsgid %s\n' % (prefix, _normalize(message.id[0], prefix))) - _write('%smsgid_plural %s\n' % ( - prefix, _normalize(message.id[1], prefix) - )) + _write(f"{prefix}msgctxt {_normalize(message.context, prefix)}\n") + _write(f"{prefix}msgid {_normalize(message.id[0], prefix)}\n") + _write(f"{prefix}msgid_plural {_normalize(message.id[1], prefix)}\n") for idx in range(catalog.num_plurals): try: string = message.string[idx] except IndexError: string = '' - _write('%smsgstr[%d] %s\n' % ( - prefix, idx, _normalize(string, prefix) - )) + _write(f"{prefix}msgstr[{idx:d}] {_normalize(string, prefix)}\n") else: if message.context: - _write('%smsgctxt %s\n' % (prefix, - _normalize(message.context, prefix))) - _write('%smsgid %s\n' % (prefix, _normalize(message.id, prefix))) - _write('%smsgstr %s\n' % ( - prefix, _normalize(message.string or '', prefix) - )) + _write(f"{prefix}msgctxt {_normalize(message.context, prefix)}\n") + _write(f"{prefix}msgid {_normalize(message.id, prefix)}\n") + _write(f"{prefix}msgstr {_normalize(message.string or '', prefix)}\n") sort_by = None if sort_output: @@ -571,7 +560,7 @@ def _write_message(message, prefix=''): lines += wraptext(line, width=width, subsequent_indent='# ') comment_header = u'\n'.join(lines) - _write(comment_header + u'\n') + _write(f"{comment_header}\n") for comment in message.user_comments: _write_comment(comment) @@ -592,10 +581,9 @@ def _write_message(message, prefix=''): locations = message.locations for filename, lineno in locations: + location = filename.replace(os.sep, '/') if lineno and include_lineno: - location = u'%s:%d' % (filename.replace(os.sep, '/'), lineno) - else: - location = u'%s' % filename.replace(os.sep, '/') + location = f"{location}:{lineno:d}" if location not in locs: locs.append(location) _write_comment(' '.join(locs), prefix=':') diff --git a/babel/numbers.py b/babel/numbers.py index 6c9ab24b0..2221e95a1 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -36,7 +36,7 @@ def __init__(self, identifier): """Create the exception. :param identifier: the identifier string of the unsupported currency """ - Exception.__init__(self, 'Unknown currency %r.' % identifier) + Exception.__init__(self, f"Unknown currency {identifier!r}.") #: The identifier of the locale that could not be found. self.identifier = identifier @@ -583,8 +583,7 @@ def format_currency( try: pattern = locale.currency_formats[format_type] except KeyError: - raise UnknownCurrencyFormatError( - "%r is not a known currency format type" % format_type) + raise UnknownCurrencyFormatError(f"{format_type!r} is not a known currency format type") return pattern.apply( number, locale, currency=currency, currency_digits=currency_digits, @@ -779,7 +778,7 @@ def parse_number(string, locale=LC_NUMERIC): try: return int(string.replace(get_group_symbol(locale), '')) except ValueError: - raise NumberFormatError('%r is not a valid number' % string) + raise NumberFormatError(f"{string!r} is not a valid number") def parse_decimal(string, locale=LC_NUMERIC, strict=False): @@ -835,7 +834,7 @@ def parse_decimal(string, locale=LC_NUMERIC, strict=False): parsed = decimal.Decimal(string.replace(group_symbol, '') .replace(decimal_symbol, '.')) except decimal.InvalidOperation: - raise NumberFormatError('%r is not a valid decimal number' % string) + raise NumberFormatError(f"{string!r} is not a valid decimal number") if strict and group_symbol in string: proper = format_decimal(parsed, locale=locale, decimal_quantization=False) if string != proper and string.rstrip('0') != (proper + decimal_symbol): @@ -843,22 +842,25 @@ def parse_decimal(string, locale=LC_NUMERIC, strict=False): parsed_alt = decimal.Decimal(string.replace(decimal_symbol, '') .replace(group_symbol, '.')) except decimal.InvalidOperation: - raise NumberFormatError(( - "%r is not a properly formatted decimal number. Did you mean %r?" % - (string, proper) - ), suggestions=[proper]) + raise NumberFormatError( + f"{string!r} is not a properly formatted decimal number. " + f"Did you mean {proper!r}?", + suggestions=[proper], + ) else: proper_alt = format_decimal(parsed_alt, locale=locale, decimal_quantization=False) if proper_alt == proper: - raise NumberFormatError(( - "%r is not a properly formatted decimal number. Did you mean %r?" % - (string, proper) - ), suggestions=[proper]) + raise NumberFormatError( + f"{string!r} is not a properly formatted decimal number. " + f"Did you mean {proper!r}?", + suggestions=[proper], + ) else: - raise NumberFormatError(( - "%r is not a properly formatted decimal number. Did you mean %r? Or maybe %r?" % - (string, proper, proper_alt) - ), suggestions=[proper, proper_alt]) + 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 @@ -869,8 +871,7 @@ def parse_decimal(string, locale=LC_NUMERIC, strict=False): NUMBER_PATTERN = r"(?P%s*)" % NUMBER_TOKEN SUFFIX_PATTERN = r"(?P.*)" -number_re = re.compile(r"%s%s%s" % (PREFIX_PATTERN, NUMBER_PATTERN, - SUFFIX_PATTERN)) +number_re = re.compile(f"{PREFIX_PATTERN}{NUMBER_PATTERN}{SUFFIX_PATTERN}") def parse_grouping(p): @@ -903,7 +904,7 @@ def parse_pattern(pattern): def _match_number(pattern): rv = number_re.search(pattern) if rv is None: - raise ValueError('Invalid number pattern %r' % pattern) + raise ValueError(f"Invalid number pattern {pattern!r}") return rv.groups() pos_pattern = pattern @@ -915,7 +916,7 @@ def _match_number(pattern): neg_prefix, _, neg_suffix = _match_number(neg_pattern) else: pos_prefix, number, pos_suffix = _match_number(pos_pattern) - neg_prefix = '-' + pos_prefix + neg_prefix = f"-{pos_prefix}" neg_suffix = pos_suffix if 'E' in number: number, exp = number.split('E', 1) @@ -978,7 +979,7 @@ def __init__(self, pattern, prefix, suffix, grouping, self.scale = self.compute_scale() def __repr__(self): - return '<%s %r>' % (type(self).__name__, self.pattern) + return f"<{type(self).__name__} {self.pattern!r}>" def compute_scale(self): """Return the scaling factor to apply to the number before rendering. @@ -1184,7 +1185,7 @@ def _format_int(self, value, min, max, locale): def _quantize_value(self, value, locale, frac_prec, group_separator): quantum = get_decimal_quantum(frac_prec[1]) rounded = value.quantize(quantum) - a, sep, b = "{:f}".format(rounded).partition(".") + 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) diff --git a/babel/plural.py b/babel/plural.py index d3dc22d32..712acec6b 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -111,9 +111,9 @@ def __init__(self, rules): self.abstract = [] for key, expr in sorted(list(rules)): if key not in _plural_tags: - raise ValueError('unknown tag %r' % key) + raise ValueError(f"unknown tag {key!r}") elif key in found: - raise ValueError('tag %r defined twice' % key) + raise ValueError(f"tag {key!r} defined twice") found.add(key) ast = _Parser(expr).ast if ast: @@ -121,11 +121,8 @@ def __init__(self, rules): def __repr__(self): rules = self.rules - return '<%s %r>' % ( - type(self).__name__, - ', '.join(['%s: %s' % (tag, rules[tag]) for tag in _plural_tags - if tag in rules]) - ) + args = ", ".join([f"{tag}: {rules[tag]}" for tag in _plural_tags if tag in rules]) + return f"<{type(self).__name__} {args!r}>" @classmethod def parse(cls, rules): @@ -185,7 +182,7 @@ def to_javascript(rule): to_js = _JavaScriptCompiler().compile result = ['(function(n) { return '] for tag, ast in PluralRule.parse(rule).abstract: - result.append('%s ? %r : ' % (to_js(ast), tag)) + result.append(f"{to_js(ast)} ? {tag!r} : ") result.append('%r; })' % _fallback_tag) return ''.join(result) @@ -223,8 +220,8 @@ def to_python(rule): for tag, ast in PluralRule.parse(rule).abstract: # the str() call is to coerce the tag to the native string. It's # a limited ascii restricted set of tags anyways so that is fine. - result.append(' if (%s): return %r' % (to_python_func(ast), str(tag))) - result.append(' return %r' % _fallback_tag) + result.append(f" if ({to_python_func(ast)}): return {str(tag)!r}") + result.append(f" return {_fallback_tag!r}") code = compile('\n'.join(result), '', 'exec') eval(code, namespace) return namespace['evaluate'] @@ -246,10 +243,10 @@ def to_gettext(rule): _compile = _GettextCompiler().compile _get_index = [tag for tag in _plural_tags if tag in used_tags].index - result = ['nplurals=%d; plural=(' % len(used_tags)] + result = [f"nplurals={len(used_tags)}; plural=("] for tag, ast in rule.abstract: - result.append('%s ? %d : ' % (_compile(ast), _get_index(tag))) - result.append('%d);' % _get_index(_fallback_tag)) + result.append(f"{_compile(ast)} ? {_get_index(tag)} : ") + result.append(f"{_get_index(_fallback_tag)});") return ''.join(result) @@ -427,8 +424,7 @@ def __init__(self, string): return self.ast = self.condition() if self.tokens: - raise RuleError('Expected end of rule, got %r' % - self.tokens[-1][1]) + raise RuleError(f"Expected end of rule, got {self.tokens[-1][1]!r}") def expect(self, type_, value=None, term=None): token = skip_token(self.tokens, type_, value) @@ -437,8 +433,8 @@ def expect(self, type_, value=None, term=None): if term is None: term = repr(value is None and type_ or value) if not self.tokens: - raise RuleError('expected %s but end of rule reached' % term) - raise RuleError('expected %s but got %r' % (term, self.tokens[-1][1])) + raise RuleError(f"expected {term} but end of rule reached") + raise RuleError(f"expected {term} but got {self.tokens[-1][1]!r}") def condition(self): op = self.and_condition() @@ -527,7 +523,7 @@ class _Compiler: def compile(self, arg): op, args = arg - return getattr(self, 'compile_' + op)(*args) + return getattr(self, f"compile_{op}")(*args) compile_n = lambda x: 'n' compile_i = lambda x: 'i' @@ -558,11 +554,8 @@ class _PythonCompiler(_Compiler): compile_mod = _binary_compiler('MOD(%s, %s)') def compile_relation(self, method, expr, range_list): - compile_range_list = '[%s]' % ','.join( - ['(%s, %s)' % tuple(map(self.compile, range_)) - for range_ in range_list[1]]) - return '%s(%s, %s)' % (method.upper(), self.compile(expr), - compile_range_list) + ranges = ",".join([f"({self.compile(a)}, {self.compile(b)})" for (a, b) in range_list[1]]) + return f"{method.upper()}({self.compile(expr)}, [{ranges}])" class _GettextCompiler(_Compiler): @@ -579,19 +572,11 @@ def compile_relation(self, method, expr, range_list): expr = self.compile(expr) for item in range_list[1]: if item[0] == item[1]: - rv.append('(%s == %s)' % ( - expr, - self.compile(item[0]) - )) + rv.append(f"({expr} == {self.compile(item[0])})") else: min, max = map(self.compile, item) - rv.append('(%s >= %s && %s <= %s)' % ( - expr, - min, - expr, - max - )) - return '(%s)' % ' || '.join(rv) + rv.append(f"({expr} >= {min} && {expr} <= {max})") + return f"({' || '.join(rv)})" class _JavaScriptCompiler(_GettextCompiler): @@ -610,7 +595,7 @@ def compile_relation(self, method, expr, range_list): self, method, expr, range_list) if method == 'in': expr = self.compile(expr) - code = '(parseInt(%s, 10) == %s && %s)' % (expr, expr, code) + code = f"(parseInt({expr}, 10) == {expr} && {code})" return code @@ -636,8 +621,5 @@ def compile_relation(self, method, expr, range_list, negated=False): if item[0] == item[1]: ranges.append(self.compile(item[0])) else: - ranges.append('%s..%s' % tuple(map(self.compile, item))) - return '%s%s %s %s' % ( - self.compile(expr), negated and ' not' or '', - method, ','.join(ranges) - ) + ranges.append(f"{self.compile(item[0])}..{self.compile(item[1])}") + return f"{self.compile(expr)}{' not' if negated else ''} {method} {','.join(ranges)}" diff --git a/babel/support.py b/babel/support.py index 50f275274..8cebd7d97 100644 --- a/babel/support.py +++ b/babel/support.py @@ -577,8 +577,8 @@ def load(cls, dirname=None, locales=None, domain=None): return cls(fp=fp, domain=domain) def __repr__(self): - return '<%s: "%s">' % (type(self).__name__, - self._info.get('project-id-version')) + version = self._info.get('project-id-version') + return f'<{type(self).__name__}: "{version}">' def add(self, translations, merge=True): """Add the given translations to the catalog. diff --git a/babel/units.py b/babel/units.py index 8a9ec7d38..f8f267579 100644 --- a/babel/units.py +++ b/babel/units.py @@ -4,7 +4,7 @@ class UnknownUnitError(ValueError): def __init__(self, unit, locale): - ValueError.__init__(self, "%s is not a known unit in %s" % (unit, locale)) + ValueError.__init__(self, f"{unit} is not a known unit in {locale}") def get_unit_name(measurement_unit, length='long', locale=LC_NUMERIC): @@ -128,10 +128,8 @@ def format_unit(value, measurement_unit, length='long', format=None, locale=LC_N # Fall back to a somewhat bad representation. # nb: This is marked as no-cover, as the current CLDR seemingly has no way for this to happen. - return '%s %s' % ( # pragma: no cover - formatted_value, - (get_unit_name(measurement_unit, length=length, locale=locale) or measurement_unit) - ) + fallback_name = get_unit_name(measurement_unit, length=length, locale=locale) # pragma: no cover + return f"{formatted_value} {fallback_name or measurement_unit}" # pragma: no cover def _find_compound_unit(numerator_unit, denominator_unit, locale=LC_NUMERIC): @@ -179,7 +177,7 @@ def _find_compound_unit(numerator_unit, denominator_unit, locale=LC_NUMERIC): # Now we can try and rebuild a compound unit specifier, then qualify it: - return _find_unit_pattern("%s-per-%s" % (bare_numerator_unit, bare_denominator_unit), locale=locale) + return _find_unit_pattern(f"{bare_numerator_unit}-per-{bare_denominator_unit}", locale=locale) def format_compound_unit( diff --git a/babel/util.py b/babel/util.py index f628844ab..0436b9ee4 100644 --- a/babel/util.py +++ b/babel/util.py @@ -82,9 +82,7 @@ def parse_encoding(fp): if m: magic_comment_encoding = m.group(1).decode('latin-1') if magic_comment_encoding != 'utf-8': - raise SyntaxError( - 'encoding problem: {0} with BOM'.format( - magic_comment_encoding)) + raise SyntaxError(f"encoding problem: {magic_comment_encoding} with BOM") return 'utf-8' elif m: return m.group(1).decode('latin-1') @@ -191,7 +189,7 @@ def pathmatch(pattern, filename): buf.append(symbols[part]) elif part: buf.append(re.escape(part)) - match = re.match(''.join(buf) + '$', filename.replace(os.sep, '/')) + match = re.match(f"{''.join(buf)}$", filename.replace(os.sep, "/")) return match is not None @@ -236,7 +234,7 @@ def __str__(self): return self.zone def __repr__(self): - return '' % (self.zone, self._offset) + return f'' def utcoffset(self, dt): return self._offset diff --git a/scripts/generate_authors.py b/scripts/generate_authors.py index 409f24e36..e2e3addcd 100644 --- a/scripts/generate_authors.py +++ b/scripts/generate_authors.py @@ -13,7 +13,7 @@ def get_sorted_authors_list(): def get_authors_file_content(): - author_list = '\n'.join('- %s' % a for a in get_sorted_authors_list()) + author_list = "\n".join(f"- {a}" for a in get_sorted_authors_list()) return ''' Babel is written and maintained by the Babel team and various contributors: diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 097840cec..5de707c89 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -156,7 +156,8 @@ def write_datafile(path, data, dump_json=False): pickle.dump(data, outfile, 2) if dump_json: import json - with open(path + '.json', 'w') as outfile: + + with open(f"{path}.json", "w") as outfile: json.dump(data, outfile, indent=4, default=debug_repr) @@ -358,8 +359,8 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False): if ext != '.xml': continue - full_filename = os.path.join(srcdir, 'main', filename) - data_filename = os.path.join(destdir, 'locale-data', stem + '.dat') + full_filename = os.path.join(srcdir, "main", filename) + data_filename = os.path.join(destdir, "locale-data", f"{stem}.dat") data = {} if not (force or need_conversion(data_filename, data, full_filename)): @@ -434,10 +435,10 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False): unsupported_number_systems_string = ', '.join(sorted(data.pop('unsupported_number_systems'))) if unsupported_number_systems_string: - log.warning('%s: unsupported number systems were ignored: %s' % ( - locale_id, - unsupported_number_systems_string, - )) + log.warning( + f"{locale_id}: unsupported number systems were ignored: " + f"{unsupported_number_systems_string}" + ) write_datafile(data_filename, data, dump_json=dump_json) @@ -902,7 +903,7 @@ def parse_interval_formats(data, tree): if item_sub.tag == "greatestDifference": skel_data[item_sub.attrib["id"]] = split_interval_pattern(item_sub.text) else: - raise NotImplementedError("Not implemented: %s(%r)" % (item_sub.tag, item_sub.attrib)) + raise NotImplementedError(f"Not implemented: {item_sub.tag}({item_sub.attrib!r})") def parse_currency_formats(data, tree): diff --git a/setup.py b/setup.py index 06caa6a64..157f7c160 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ try: from babel import __version__ except SyntaxError as exc: - sys.stderr.write("Unable to import Babel (%s). Are you running a supported version of Python?\n" % exc) + sys.stderr.write(f"Unable to import Babel ({exc}). Are you running a supported version of Python?\n") sys.exit(1) diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index cc5fb49fc..e9b15a88a 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -357,7 +357,7 @@ def test_catalog_mime_headers(): ('MIME-Version', '1.0'), ('Content-Type', 'text/plain; charset=utf-8'), ('Content-Transfer-Encoding', '8bit'), - ('Generated-By', 'Babel %s\n' % catalog.VERSION), + ('Generated-By', f'Babel {catalog.VERSION}\n'), ] @@ -380,7 +380,7 @@ def test_catalog_mime_headers_set_locale(): ('MIME-Version', '1.0'), ('Content-Type', 'text/plain; charset=utf-8'), ('Content-Transfer-Encoding', '8bit'), - ('Generated-By', 'Babel %s\n' % catalog.VERSION), + ('Generated-By', f'Babel {catalog.VERSION}\n'), ] diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index 0eb7e8f3a..9e3ac9f40 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -130,7 +130,7 @@ def test_input_paths_is_treated_as_list(self): assert ('file1.py' in msg.locations[0][0]) def test_input_paths_handle_spaces_after_comma(self): - self.cmd.input_paths = '%s, %s' % (this_dir, data_dir) + self.cmd.input_paths = f"{this_dir}, {data_dir}" self.cmd.output_file = pot_file self.cmd.finalize_options() assert self.cmd.input_paths == [this_dir, data_dir] @@ -1118,7 +1118,7 @@ def test_compile_catalog(self): self.cli.run(sys.argv + ['compile', '--locale', 'de_DE', '-d', i18n_dir]) - assert not os.path.isfile(mo_file), 'Expected no file at %r' % mo_file + assert not os.path.isfile(mo_file), f'Expected no file at {mo_file!r}' assert sys.stderr.getvalue() == f'catalog {po_file} is marked as fuzzy, skipping\n' def test_compile_fuzzy_catalog(self): @@ -1397,14 +1397,15 @@ def test_extract_keyword_args_384(split, arg_name): ] if split: # Generate a command line with multiple -ks - kwarg_text = " ".join("%s %s" % (arg_name, kwarg_spec) for kwarg_spec in kwarg_specs) + kwarg_text = " ".join(f"{arg_name} {kwarg_spec}" for kwarg_spec in kwarg_specs) else: # Generate a single space-separated -k - kwarg_text = "%s \"%s\"" % (arg_name, " ".join(kwarg_specs)) + specs = ' '.join(kwarg_specs) + kwarg_text = f'{arg_name} "{specs}"' # (Both of those invocation styles should be equivalent, so there is no parametrization from here on out) cmdinst = configure_cli_command( - "extract -F babel-django.cfg --add-comments Translators: -o django232.pot %s ." % kwarg_text + f"extract -F babel-django.cfg --add-comments Translators: -o django232.pot {kwarg_text} ." ) assert isinstance(cmdinst, extract_messages) assert set(cmdinst.keywords.keys()) == {'_', 'dgettext', 'dngettext', @@ -1489,7 +1490,7 @@ def test_extract_error_code(monkeypatch, capsys): def test_extract_ignore_dirs(monkeypatch, capsys, tmp_path, with_underscore_ignore): pot_file = tmp_path / 'temp.pot' monkeypatch.chdir(project_dir) - cmd = "extract . -o '{}' --ignore-dirs '*ignored*' ".format(pot_file) + cmd = f"extract . -o '{pot_file}' --ignore-dirs '*ignored*' " if with_underscore_ignore: # This also tests that multiple arguments are supported. cmd += "--ignore-dirs '_*'" diff --git a/tests/messages/test_pofile.py b/tests/messages/test_pofile.py index 99e59babc..a72368bcc 100644 --- a/tests/messages/test_pofile.py +++ b/tests/messages/test_pofile.py @@ -859,7 +859,7 @@ def test_denormalize_on_msgstr_without_empty_first_line(self): expected_denormalized = u'multi-line\n translation' assert expected_denormalized == pofile.denormalize(msgstr) - assert expected_denormalized == pofile.denormalize('""\n' + msgstr) + assert expected_denormalized == pofile.denormalize(f'""\n{msgstr}') def test_unknown_language_roundtrip(): diff --git a/tests/test_core.py b/tests/test_core.py index 2de79e2df..605bf5c02 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -309,8 +309,7 @@ def find_class(self, module, name): # *.dat files must have compatible classes between Python 2 and 3 if module.split('.')[0] == 'babel': return pickle.Unpickler.find_class(self, module, name) - raise pickle.UnpicklingError("global '%s.%s' is forbidden" % - (module, name)) + raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") with open(filename, 'rb') as f: assert Unpickler(f).load() diff --git a/tests/test_plural.py b/tests/test_plural.py index 421003324..16601cfec 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -273,7 +273,7 @@ def test_gettext_compilation(locale): chars = 'ivwft' # Test that these rules are valid for this test; i.e. that they contain at least one # of the gettext-unsupported characters. - assert any((" " + ch + " ") in rule for ch in chars for rule in ru_rules.values()) + assert any(f" {ch} " in rule for ch in chars for rule in ru_rules.values()) # Then test that the generated value indeed does not contain these. ru_rules_gettext = plural.to_gettext(ru_rules) assert not any(ch in ru_rules_gettext for ch in chars) diff --git a/tests/test_support.py b/tests/test_support.py index 944710751..c73e53ba7 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -63,8 +63,7 @@ def setUp(self): def assertEqualTypeToo(self, expected, result): assert expected == result - assert type(expected) == type(result), "instance type's do not " + \ - "match: %r!=%r" % (type(expected), type(result)) + assert type(expected) == type(result), f"instance types do not match: {type(expected)!r}!={type(result)!r}" def test_pgettext(self): self.assertEqualTypeToo('Voh', self.translations.gettext('foo')) @@ -210,7 +209,7 @@ def method_names(self): def test_same_methods(self): for name in self.method_names(): if not hasattr(self.null_translations, name): - self.fail('NullTranslations does not provide method %r' % name) + self.fail(f"NullTranslations does not provide method {name!r}") def test_method_signature_compatibility(self): for name in self.method_names(): @@ -346,11 +345,13 @@ def test_format_percent(): def test_lazy_proxy(): def greeting(name='world'): - return u'Hello, %s!' % name + return f"Hello, {name}!" + lazy_greeting = support.LazyProxy(greeting, name='Joe') assert str(lazy_greeting) == u"Hello, Joe!" assert u' ' + lazy_greeting == u' Hello, Joe!' assert u'(%s)' % lazy_greeting == u'(Hello, Joe!)' + assert f"[{lazy_greeting}]" == "[Hello, Joe!]" greetings = [ support.LazyProxy(greeting, 'world'), From 82c41ccda68da8322c96c1176936b07ad8af8f1b Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Mon, 26 Dec 2022 15:22:08 -0700 Subject: [PATCH 09/53] ci: Fix testing dependencies (#936) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 491581677..f9a7bee0b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel - python -m pip install tox tox-gh-actions==2.1.0 + python -m pip install 'tox<4.0.0' 'tox-gh-actions==2.12.0' - name: Run test via Tox run: tox --skip-missing-interpreters env: From d425f86a08d5f459d7380d7c196ecb33af564f5c Mon Sep 17 00:00:00 2001 From: Johannes Wilm Date: Fri, 6 Jan 2023 21:18:35 +0100 Subject: [PATCH 10/53] Improved javascript template string expression extracting (#939) Co-authored-by: Rik Co-authored-by: Aarni Koskela --- babel/messages/extract.py | 59 +++++++++++++++++++++++++++---- babel/messages/jslexer.py | 4 +-- tests/messages/test_js_extract.py | 39 ++++++++++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index 4f0f649b3..c19dd5af2 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -16,9 +16,10 @@ :license: BSD, see LICENSE for more details. """ import ast +import io import os -from os.path import relpath import sys +from os.path import relpath from tokenize import generate_tokens, COMMENT, NAME, OP, STRING from babel.util import parse_encoding, parse_future_flags, pathmatch @@ -532,7 +533,7 @@ def _parse_python_string(value, encoding, future_flags): return None -def extract_javascript(fileobj, keywords, comment_tags, options): +def extract_javascript(fileobj, keywords, comment_tags, options, lineno=1): """Extract messages from JavaScript source code. :param fileobj: the seekable, file-like object the messages should be @@ -544,7 +545,11 @@ def extract_javascript(fileobj, keywords, comment_tags, options): :param options: a dictionary of additional options (optional) Supported options are: * `jsx` -- set to false to disable JSX/E4X support. - * `template_string` -- set to false to disable ES6 template string support. + * `template_string` -- if `True`, supports gettext(`key`) + * `parse_template_string` -- if `True` will parse the + contents of javascript + template strings. + :param lineno: line number offset (for parsing embedded fragments) """ from babel.messages.jslexer import Token, tokenize, unquote_string funcname = message_lineno = None @@ -556,12 +561,12 @@ def extract_javascript(fileobj, keywords, comment_tags, options): last_token = None call_stack = -1 dotted = any('.' in kw for kw in keywords) - for token in tokenize( fileobj.read().decode(encoding), jsx=options.get("jsx", True), template_string=options.get("template_string", True), - dotted=dotted + dotted=dotted, + lineno=lineno ): if ( # Turn keyword`foo` expressions into keyword("foo") calls: funcname and # have a keyword... @@ -573,7 +578,11 @@ def extract_javascript(fileobj, keywords, comment_tags, options): call_stack = 0 token = Token('operator', ')', token.lineno) - if token.type == 'operator' and token.value == '(': + if options.get('parse_template_string') and not funcname and token.type == 'template_string': + for item in parse_template_string(token.value, keywords, comment_tags, options, token.lineno): + yield item + + elif token.type == 'operator' and token.value == '(': if funcname: message_lineno = token.lineno call_stack += 1 @@ -665,3 +674,41 @@ def extract_javascript(fileobj, keywords, comment_tags, options): funcname = token.value last_token = token + + +def parse_template_string(template_string, keywords, comment_tags, options, lineno=1): + """Parse JavaScript template string. + + :param template_string: the template string to be parsed + :param keywords: a list of keywords (i.e. function names) that should be + recognized as translation functions + :param comment_tags: a list of translator tags to search for and include + in the results + :param options: a dictionary of additional options (optional) + :param lineno: starting line number (optional) + """ + from babel.messages.jslexer import line_re + prev_character = None + level = 0 + inside_str = False + expression_contents = '' + for character in template_string[1:-1]: + if not inside_str and character in ('"', "'", '`'): + inside_str = character + elif inside_str == character and prev_character != r'\\': + inside_str = False + if level: + expression_contents += character + if not inside_str: + if character == '{' and prev_character == '$': + level += 1 + elif level and character == '}': + level -= 1 + if level == 0 and expression_contents: + expression_contents = expression_contents[0:-1] + fake_file_obj = io.BytesIO(expression_contents.encode()) + for item in extract_javascript(fake_file_obj, keywords, comment_tags, options, lineno): + yield item + lineno += len(line_re.findall(expression_contents)) + expression_contents = '' + prev_character = character diff --git a/babel/messages/jslexer.py b/babel/messages/jslexer.py index 1264b2dbc..886f69d20 100644 --- a/babel/messages/jslexer.py +++ b/babel/messages/jslexer.py @@ -151,17 +151,17 @@ def unquote_string(string): return u''.join(result) -def tokenize(source, jsx=True, dotted=True, template_string=True): +def tokenize(source, jsx=True, dotted=True, template_string=True, lineno=1): """ Tokenize JavaScript/JSX source. Returns a generator of tokens. :param jsx: Enable (limited) JSX parsing. :param dotted: Read dotted names as single name token. :param template_string: Support ES6 template strings + :param lineno: starting line number (optional) """ may_divide = False pos = 0 - lineno = 1 end = len(source) rules = get_rules(jsx=jsx, dotted=dotted, template_string=template_string) diff --git a/tests/messages/test_js_extract.py b/tests/messages/test_js_extract.py index 72c521144..95985c0f7 100644 --- a/tests/messages/test_js_extract.py +++ b/tests/messages/test_js_extract.py @@ -150,3 +150,42 @@ def test_template_string_tag_usage(): ) assert messages == [(1, 'Tag template, wow', [], None)] + + +def test_inside_template_string(): + buf = BytesIO(b"const msg = `${gettext('Hello')} ${user.name}`") + messages = list( + extract.extract('javascript', buf, {"gettext": None}, [], {'parse_template_string': True}) + ) + + assert messages == [(1, 'Hello', [], None)] + + +def test_inside_template_string_with_linebreaks(): + buf = BytesIO(b"""\ +const userName = gettext('Username') +const msg = `${ +gettext('Hello') +} ${userName} ${ +gettext('Are you having a nice day?') +}` +const msg2 = `${ +gettext('Howdy') +} ${userName} ${ +gettext('Are you doing ok?') +}` +""") + messages = list( + extract.extract('javascript', buf, {"gettext": None}, [], {'parse_template_string': True}) + ) + + assert messages == [(1, 'Username', [], None), (3, 'Hello', [], None), (5, 'Are you having a nice day?', [], None), (8, 'Howdy', [], None), (10, 'Are you doing ok?', [], None)] + + +def test_inside_nested_template_string(): + buf = BytesIO(b"const msg = `${gettext('Greetings!')} ${ evening ? `${user.name}: ${gettext('This is a lovely evening.')}` : `${gettext('The day is really nice!')} ${user.name}`}`") + messages = list( + extract.extract('javascript', buf, {"gettext": None}, [], {'parse_template_string': True}) + ) + + assert messages == [(1, 'Greetings!', [], None), (1, 'This is a lovely evening.', [], None), (1, 'The day is really nice!', [], None)] From 3594836cd9e64b6c98f008ee7e25614de7d9db68 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Fri, 6 Jan 2023 13:21:58 -0700 Subject: [PATCH 11/53] Fix compact singular formats and patterns with no numbers (#932) --- babel/numbers.py | 19 +++++++++++++------ tests/test_numbers.py | 7 ++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 2221e95a1..da5936dbf 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -17,6 +17,7 @@ # TODO: # Padding and rounding increments in pattern: # - https://www.unicode.org/reports/tr35/ (Appendix G.6) +from __future__ import annotations import decimal import re from datetime import date as date_, datetime as datetime_ @@ -431,7 +432,7 @@ def format_compact_decimal(number, *, format_type="short", locale=LC_NUMERIC, fr u'123万' >>> format_compact_decimal(2345678, format_type="long", locale="mk") u'2 милиони' - >>> format_compact_decimal(21098765, format_type="long", locale="mk") + >>> format_compact_decimal(21000000, format_type="long", locale="mk") u'21 милион' :param number: the number to format @@ -469,11 +470,15 @@ def _get_compact_format(number, compact_format, locale, fraction_digits=0): # equal to the number of 0's in the pattern minus 1 number = number / (magnitude // (10 ** (pattern.count("0") - 1))) # round to the number of fraction digits requested - number = round(number, fraction_digits) + rounded = round(number, fraction_digits) # if the remaining number is singular, use the singular format plural_form = locale.plural_form(abs(number)) - plural_form = plural_form if plural_form in compact_format else "other" + 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 @@ -960,17 +965,19 @@ def parse_precision(p): return NumberPattern(pattern, (pos_prefix, neg_prefix), (pos_suffix, neg_suffix), grouping, int_prec, frac_prec, - exp_prec, exp_plus) + exp_prec, exp_plus, number) class NumberPattern: def __init__(self, pattern, prefix, suffix, grouping, - int_prec, frac_prec, exp_prec, exp_plus): + int_prec, frac_prec, exp_prec, exp_plus, + number_pattern: str | 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 @@ -1115,7 +1122,7 @@ def apply( retval = ''.join([ self.prefix[is_negative], - number, + number if self.number_pattern != '' else '', self.suffix[is_negative]]) if u'¤' in retval: diff --git a/tests/test_numbers.py b/tests/test_numbers.py index bb6c4e84e..37d2f9ec6 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -153,7 +153,12 @@ def test_compact(self): assert numbers.format_compact_decimal(-123456789, format_type='short', locale='en_US') == u'-123M' assert numbers.format_compact_decimal(-123456789, format_type='long', locale='en_US') == u'-123 million' assert numbers.format_compact_decimal(2345678, locale='mk', format_type='long') == u'2 милиони' - assert numbers.format_compact_decimal(21098765, locale='mk', format_type='long') == u'21 милион' + assert numbers.format_compact_decimal(21000000, locale='mk', format_type='long') == u'21 милион' + assert numbers.format_compact_decimal(21345, locale="gv", format_type="short") == u'21K' + assert numbers.format_compact_decimal(1000, locale='it', format_type='long') == u'mille' + assert numbers.format_compact_decimal(1234, locale='it', format_type='long') == u'1 mila' + assert numbers.format_compact_decimal(1000, locale='fr', format_type='long') == u'mille' + assert numbers.format_compact_decimal(1234, locale='fr', format_type='long') == u'1 millier' class NumberParsingTestCase(unittest.TestCase): From 338bfa13c39df0837dd45cf492eb24d8d214ba96 Mon Sep 17 00:00:00 2001 From: lilinjie <102012657+uniontech-lilinjie@users.noreply.github.com> Date: Mon, 9 Jan 2023 17:54:42 +0000 Subject: [PATCH 12/53] fix typo (#941) Signed-off-by: lilinjie --- babel/numbers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/numbers.py b/babel/numbers.py index da5936dbf..94259f841 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -517,7 +517,7 @@ def format_currency( >>> format_currency(1099.98, 'COP', u'#,##0.00', locale='es_ES') u'1.099,98' - However, the number of decimal digits can be overriden from the currency + 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) From 61097845764f5a6afc2251172168e1b1732b290f Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 11 Jan 2023 10:42:08 +0200 Subject: [PATCH 13/53] Enable pre-commit (#943) * Update pre-commit config syntax * CI: run pre-commit * CI: set up pip caching --- .coveragerc | 2 +- .github/workflows/test.yml | 9 ++++++++- .pre-commit-config.yaml | 31 +++++++++++++++---------------- babel/messages/pofile.py | 2 +- docs/installation.rst | 2 +- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.coveragerc b/.coveragerc index a3d8ae65e..f98d8023f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,4 +2,4 @@ exclude_lines = NotImplemented pragma: no cover - warnings.warn \ No newline at end of file + warnings.warn diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9a7bee0b..1e8986179 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,11 @@ on: - '*-maint' jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: pre-commit/action@v3.0.0 test: runs-on: ${{ matrix.os }} strategy: @@ -27,9 +32,11 @@ jobs: path: cldr key: cldr-${{ hashFiles('scripts/*cldr*') }} - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: "**/setup.py" - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0a345cee9..b61dac46b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,19 +1,18 @@ -- repo: https://github.com/pre-commit/pre-commit-hooks - sha: 97b88d9610bcc03982ddac33caba98bb2b751f5f +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 hooks: - - id: autopep8-wrapper - exclude: (docs/conf.py|tests/messages/data/) - - id: check-added-large-files - - id: check-docstring-first + - id: check-added-large-files + - id: check-docstring-first exclude: (docs/conf.py) - - id: check-json - - id: check-yaml - - id: debug-statements - - id: end-of-file-fixer - - id: flake8 - exclude: (docs/conf.py|babel/messages/__init__.py|babel/__init__.py|tests/messages/data|scripts/import_cldr.py) - - id: name-tests-test - args: ['--django'] + - id: check-json + - id: check-yaml + - id: debug-statements exclude: (tests/messages/data/) - - id: requirements-txt-fixer - - id: trailing-whitespace + - id: end-of-file-fixer + exclude: (tests/messages/data/) + - id: name-tests-test + args: [ '--django' ] + exclude: (tests/messages/data/) + - id: requirements-txt-fixer + - id: trailing-whitespace diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index a213b2237..b366ccb44 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -575,7 +575,7 @@ def _write_message(message, prefix=''): # if no sorting possible, leave unsorted. # (see issue #606) try: - locations = sorted(message.locations, + locations = sorted(message.locations, key=lambda x: (x[0], isinstance(x[1], int) and x[1] or -1)) except TypeError: # e.g. "TypeError: unorderable types: NoneType() < int()" locations = message.locations diff --git a/docs/installation.rst b/docs/installation.rst index c1b7ab9fe..26fe23a47 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -86,7 +86,7 @@ Get the git checkout in a new virtualenv and run in development mode:: Finished processing dependencies for Babel Make sure to not forget about the ``pip install pytz`` and ``import_cldr`` steps -because otherwise you will be missing the locale data. +because otherwise you will be missing the locale data. The custom setup command will download the most appropriate CLDR release from the official website and convert it for Babel but will not work without ``pytz``. From 53637ddbacaef2474429b22176091a362ce6567f Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 11 Jan 2023 01:54:11 -0700 Subject: [PATCH 14/53] Add type annotations (#934) Refs e.g. https://github.com/python/typeshed/pull/9455 Co-authored-by: Spencer Brown Co-authored-by: Aarni Koskela --- .coveragerc | 1 + babel/core.py | 186 ++++++++++++++++++++------------ babel/dates.py | 169 ++++++++++++++++------------- babel/languages.py | 6 +- babel/lists.py | 11 +- babel/localedata.py | 43 ++++---- babel/localtime/__init__.py | 16 +-- babel/localtime/_unix.py | 4 +- babel/localtime/_win32.py | 14 ++- babel/messages/catalog.py | 157 +++++++++++++++++---------- babel/messages/checkers.py | 25 +++-- babel/messages/extract.py | 133 ++++++++++++++++++----- babel/messages/jslexer.py | 23 ++-- babel/messages/mofile.py | 12 ++- babel/messages/plurals.py | 11 +- babel/messages/pofile.py | 92 ++++++++++------ babel/numbers.py | 205 ++++++++++++++++++++++++++---------- babel/plural.py | 74 +++++++------ babel/py.typed | 1 + babel/support.py | 201 +++++++++++++++++++++-------------- babel/units.py | 45 ++++++-- babel/util.py | 33 +++--- 22 files changed, 958 insertions(+), 504 deletions(-) create mode 100644 babel/py.typed diff --git a/.coveragerc b/.coveragerc index f98d8023f..32128ff86 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,3 +3,4 @@ exclude_lines = NotImplemented pragma: no cover warnings.warn + if TYPE_CHECKING: diff --git a/babel/core.py b/babel/core.py index 825af8132..704957b7e 100644 --- a/babel/core.py +++ b/babel/core.py @@ -8,8 +8,12 @@ :license: BSD, see LICENSE for more details. """ -import pickle +from __future__ import annotations + import os +import pickle +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, overload from babel import localedata from babel.plural import PluralRule @@ -17,11 +21,31 @@ __all__ = ['UnknownLocaleError', 'Locale', 'default_locale', 'negotiate_locale', 'parse_locale'] +if TYPE_CHECKING: + from typing_extensions import Literal, TypeAlias + + _GLOBAL_KEY: TypeAlias = Literal[ + "all_currencies", + "currency_fractions", + "language_aliases", + "likely_subtags", + "parent_exceptions", + "script_aliases", + "territory_aliases", + "territory_currencies", + "territory_languages", + "territory_zones", + "variant_aliases", + "windows_zone_mapping", + "zone_aliases", + "zone_territories", + ] + + _global_data: Mapping[_GLOBAL_KEY, Mapping[str, Any]] | None _global_data = None _default_plural_rule = PluralRule({}) - def _raise_no_data_error(): raise RuntimeError('The babel data files are not available. ' 'This usually happens because you are using ' @@ -31,7 +55,7 @@ def _raise_no_data_error(): 'installing the library.') -def get_global(key): +def get_global(key: _GLOBAL_KEY) -> Mapping[str, Any]: """Return the dictionary for the given key in the global data. The global data is stored in the ``babel/global.dat`` file and contains @@ -73,6 +97,7 @@ def get_global(key): _raise_no_data_error() with open(filename, 'rb') as fileobj: _global_data = pickle.load(fileobj) + assert _global_data is not None return _global_data.get(key, {}) @@ -93,7 +118,7 @@ class UnknownLocaleError(Exception): is available. """ - def __init__(self, identifier): + def __init__(self, identifier: str) -> None: """Create the exception. :param identifier: the identifier string of the unsupported locale @@ -136,7 +161,13 @@ class Locale: For more information see :rfc:`3066`. """ - def __init__(self, language, territory=None, script=None, variant=None): + def __init__( + self, + language: str, + territory: str | None = None, + script: str | None = None, + variant: str | None = None, + ) -> None: """Initialize the locale object from the given identifier components. >>> locale = Locale('en', 'US') @@ -167,7 +198,7 @@ def __init__(self, language, territory=None, script=None, variant=None): raise UnknownLocaleError(identifier) @classmethod - def default(cls, category=None, aliases=LOCALE_ALIASES): + def default(cls, category: str | None = None, aliases: Mapping[str, str] = LOCALE_ALIASES) -> Locale: """Return the system default locale for the specified category. >>> for name in ['LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES']: @@ -192,7 +223,13 @@ def default(cls, category=None, aliases=LOCALE_ALIASES): return cls.parse(locale_string) @classmethod - def negotiate(cls, preferred, available, sep='_', aliases=LOCALE_ALIASES): + def negotiate( + cls, + preferred: Iterable[str], + available: Iterable[str], + sep: str = '_', + aliases: Mapping[str, str] = LOCALE_ALIASES, + ) -> Locale | None: """Find the best match between available and requested locale strings. >>> Locale.negotiate(['de_DE', 'en_US'], ['de_DE', 'de_AT']) @@ -217,8 +254,21 @@ def negotiate(cls, preferred, available, sep='_', aliases=LOCALE_ALIASES): 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, sep='_', resolve_likely_subtags=True): + def parse( + cls, + identifier: str | Locale | None, + sep: str = '_', + resolve_likely_subtags: bool = True, + ) -> Locale | None: """Create a `Locale` instance for the given locale identifier. >>> l = Locale.parse('de-DE', sep='-') @@ -329,22 +379,22 @@ def _try_load_reducing(parts): raise UnknownLocaleError(input_id) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: for key in ('language', 'territory', 'script', 'variant'): if not hasattr(other, key): return False - return (self.language == other.language) and \ - (self.territory == other.territory) and \ - (self.script == other.script) and \ - (self.variant == other.variant) + return (self.language == getattr(other, 'language')) and \ + (self.territory == getattr(other, 'territory')) and \ + (self.script == getattr(other, 'script')) and \ + (self.variant == getattr(other, 'variant')) - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not self.__eq__(other) - def __hash__(self): + def __hash__(self) -> int: return hash((self.language, self.territory, self.script, self.variant)) - def __repr__(self): + def __repr__(self) -> str: parameters = [''] for key in ('territory', 'script', 'variant'): value = getattr(self, key) @@ -352,17 +402,17 @@ def __repr__(self): parameters.append(f"{key}={value!r}") return f"Locale({self.language!r}{', '.join(parameters)})" - def __str__(self): + def __str__(self) -> str: return get_locale_identifier((self.language, self.territory, self.script, self.variant)) @property - def _data(self): + def _data(self) -> localedata.LocaleDataDict: if self.__data is None: self.__data = localedata.LocaleDataDict(localedata.load(str(self))) return self.__data - def get_display_name(self, locale=None): + def get_display_name(self, locale: Locale | str | None = None) -> str | None: """Return the display name of the locale using the given locale. The display name will include the language, territory, script, and @@ -403,7 +453,7 @@ def get_display_name(self, locale=None): :type: `unicode` """) - def get_language_name(self, locale=None): + def get_language_name(self, locale: Locale | str | None = None) -> str | None: """Return the language of this locale in the given locale. >>> Locale('zh', 'CN', script='Hans').get_language_name('de') @@ -425,7 +475,7 @@ def get_language_name(self, locale=None): u'English' """) - def get_territory_name(self, locale=None): + def get_territory_name(self, locale: Locale | str | None = None) -> str | None: """Return the territory name in the given locale.""" if locale is None: locale = self @@ -439,7 +489,7 @@ def get_territory_name(self, locale=None): u'Deutschland' """) - def get_script_name(self, locale=None): + def get_script_name(self, locale: Locale | str | None = None) -> str | None: """Return the script name in the given locale.""" if locale is None: locale = self @@ -454,7 +504,7 @@ def get_script_name(self, locale=None): """) @property - def english_name(self): + def english_name(self) -> str | None: """The english display name of the locale. >>> Locale('de').english_name @@ -468,7 +518,7 @@ def english_name(self): # { General Locale Display Names @property - def languages(self): + def languages(self) -> localedata.LocaleDataDict: """Mapping of language codes to translated language names. >>> Locale('de', 'DE').languages['ja'] @@ -480,7 +530,7 @@ def languages(self): return self._data['languages'] @property - def scripts(self): + def scripts(self) -> localedata.LocaleDataDict: """Mapping of script codes to translated script names. >>> Locale('en', 'US').scripts['Hira'] @@ -492,7 +542,7 @@ def scripts(self): return self._data['scripts'] @property - def territories(self): + def territories(self) -> localedata.LocaleDataDict: """Mapping of script codes to translated script names. >>> Locale('es', 'CO').territories['DE'] @@ -504,7 +554,7 @@ def territories(self): return self._data['territories'] @property - def variants(self): + def variants(self) -> localedata.LocaleDataDict: """Mapping of script codes to translated script names. >>> Locale('de', 'DE').variants['1901'] @@ -515,7 +565,7 @@ def variants(self): # { Number Formatting @property - def currencies(self): + def currencies(self) -> localedata.LocaleDataDict: """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 @@ -529,7 +579,7 @@ def currencies(self): return self._data['currency_names'] @property - def currency_symbols(self): + def currency_symbols(self) -> localedata.LocaleDataDict: """Mapping of currency codes to symbols. >>> Locale('en', 'US').currency_symbols['USD'] @@ -540,7 +590,7 @@ def currency_symbols(self): return self._data['currency_symbols'] @property - def number_symbols(self): + def number_symbols(self) -> localedata.LocaleDataDict: """Symbols used in number formatting. .. note:: The format of the value returned may change between @@ -552,7 +602,7 @@ def number_symbols(self): return self._data['number_symbols'] @property - def decimal_formats(self): + def decimal_formats(self) -> localedata.LocaleDataDict: """Locale patterns for decimal number formatting. .. note:: The format of the value returned may change between @@ -564,7 +614,7 @@ def decimal_formats(self): return self._data['decimal_formats'] @property - def compact_decimal_formats(self): + def compact_decimal_formats(self) -> localedata.LocaleDataDict: """Locale patterns for compact decimal number formatting. .. note:: The format of the value returned may change between @@ -576,7 +626,7 @@ def compact_decimal_formats(self): return self._data['compact_decimal_formats'] @property - def currency_formats(self): + def currency_formats(self) -> localedata.LocaleDataDict: """Locale patterns for currency number formatting. .. note:: The format of the value returned may change between @@ -590,7 +640,7 @@ def currency_formats(self): return self._data['currency_formats'] @property - def compact_currency_formats(self): + def compact_currency_formats(self) -> localedata.LocaleDataDict: """Locale patterns for compact currency number formatting. .. note:: The format of the value returned may change between @@ -602,7 +652,7 @@ def compact_currency_formats(self): return self._data['compact_currency_formats'] @property - def percent_formats(self): + def percent_formats(self) -> localedata.LocaleDataDict: """Locale patterns for percent number formatting. .. note:: The format of the value returned may change between @@ -614,7 +664,7 @@ def percent_formats(self): return self._data['percent_formats'] @property - def scientific_formats(self): + def scientific_formats(self) -> localedata.LocaleDataDict: """Locale patterns for scientific number formatting. .. note:: The format of the value returned may change between @@ -628,7 +678,7 @@ def scientific_formats(self): # { Calendar Information and Date Formatting @property - def periods(self): + def periods(self) -> localedata.LocaleDataDict: """Locale display names for day periods (AM/PM). >>> Locale('en', 'US').periods['am'] @@ -637,10 +687,10 @@ def periods(self): try: return self._data['day_periods']['stand-alone']['wide'] except KeyError: - return {} + return localedata.LocaleDataDict({}) # pragma: no cover @property - def day_periods(self): + def day_periods(self) -> localedata.LocaleDataDict: """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`. @@ -648,13 +698,13 @@ def day_periods(self): return self._data['day_periods'] @property - def day_period_rules(self): + def day_period_rules(self) -> localedata.LocaleDataDict: """Day period rules for the locale. Used by `get_period_id`. """ - return self._data.get('day_period_rules', {}) + return self._data.get('day_period_rules', localedata.LocaleDataDict({})) @property - def days(self): + def days(self) -> localedata.LocaleDataDict: """Locale display names for weekdays. >>> Locale('de', 'DE').days['format']['wide'][3] @@ -663,7 +713,7 @@ def days(self): return self._data['days'] @property - def months(self): + def months(self) -> localedata.LocaleDataDict: """Locale display names for months. >>> Locale('de', 'DE').months['format']['wide'][10] @@ -672,7 +722,7 @@ def months(self): return self._data['months'] @property - def quarters(self): + def quarters(self) -> localedata.LocaleDataDict: """Locale display names for quarters. >>> Locale('de', 'DE').quarters['format']['wide'][1] @@ -681,7 +731,7 @@ def quarters(self): return self._data['quarters'] @property - def eras(self): + def eras(self) -> localedata.LocaleDataDict: """Locale display names for eras. .. note:: The format of the value returned may change between @@ -695,7 +745,7 @@ def eras(self): return self._data['eras'] @property - def time_zones(self): + def time_zones(self) -> localedata.LocaleDataDict: """Locale display names for time zones. .. note:: The format of the value returned may change between @@ -709,7 +759,7 @@ def time_zones(self): return self._data['time_zones'] @property - def meta_zones(self): + def meta_zones(self) -> localedata.LocaleDataDict: """Locale display names for meta time zones. Meta time zones are basically groups of different Olson time zones that @@ -726,7 +776,7 @@ def meta_zones(self): return self._data['meta_zones'] @property - def zone_formats(self): + def zone_formats(self) -> localedata.LocaleDataDict: """Patterns related to the formatting of time zones. .. note:: The format of the value returned may change between @@ -742,7 +792,7 @@ def zone_formats(self): return self._data['zone_formats'] @property - def first_week_day(self): + def first_week_day(self) -> int: """The first day of a week, with 0 being Monday. >>> Locale('de', 'DE').first_week_day @@ -753,7 +803,7 @@ def first_week_day(self): return self._data['week_data']['first_day'] @property - def weekend_start(self): + def weekend_start(self) -> int: """The day the weekend starts, with 0 being Monday. >>> Locale('de', 'DE').weekend_start @@ -762,7 +812,7 @@ def weekend_start(self): return self._data['week_data']['weekend_start'] @property - def weekend_end(self): + def weekend_end(self) -> int: """The day the weekend ends, with 0 being Monday. >>> Locale('de', 'DE').weekend_end @@ -771,7 +821,7 @@ def weekend_end(self): return self._data['week_data']['weekend_end'] @property - def min_week_days(self): + def min_week_days(self) -> int: """The minimum number of days in a week so that the week is counted as the first week of a year or month. @@ -781,7 +831,7 @@ def min_week_days(self): return self._data['week_data']['min_days'] @property - def date_formats(self): + def date_formats(self) -> localedata.LocaleDataDict: """Locale patterns for date formatting. .. note:: The format of the value returned may change between @@ -795,7 +845,7 @@ def date_formats(self): return self._data['date_formats'] @property - def time_formats(self): + def time_formats(self) -> localedata.LocaleDataDict: """Locale patterns for time formatting. .. note:: The format of the value returned may change between @@ -809,7 +859,7 @@ def time_formats(self): return self._data['time_formats'] @property - def datetime_formats(self): + def datetime_formats(self) -> localedata.LocaleDataDict: """Locale patterns for datetime formatting. .. note:: The format of the value returned may change between @@ -823,7 +873,7 @@ def datetime_formats(self): return self._data['datetime_formats'] @property - def datetime_skeletons(self): + def datetime_skeletons(self) -> localedata.LocaleDataDict: """Locale patterns for formatting parts of a datetime. >>> Locale('en').datetime_skeletons['MEd'] @@ -836,7 +886,7 @@ def datetime_skeletons(self): return self._data['datetime_skeletons'] @property - def interval_formats(self): + def interval_formats(self) -> localedata.LocaleDataDict: """Locale patterns for interval formatting. .. note:: The format of the value returned may change between @@ -858,7 +908,7 @@ def interval_formats(self): return self._data['interval_formats'] @property - def plural_form(self): + def plural_form(self) -> PluralRule: """Plural rules for the locale. >>> Locale('en').plural_form(1) @@ -873,7 +923,7 @@ def plural_form(self): return self._data.get('plural_form', _default_plural_rule) @property - def list_patterns(self): + def list_patterns(self) -> localedata.LocaleDataDict: """Patterns for generating lists .. note:: The format of the value returned may change between @@ -889,7 +939,7 @@ def list_patterns(self): return self._data['list_patterns'] @property - def ordinal_form(self): + def ordinal_form(self) -> PluralRule: """Plural rules for the locale. >>> Locale('en').ordinal_form(1) @@ -906,7 +956,7 @@ def ordinal_form(self): return self._data.get('ordinal_form', _default_plural_rule) @property - def measurement_systems(self): + def measurement_systems(self) -> localedata.LocaleDataDict: """Localized names for various measurement systems. >>> Locale('fr', 'FR').measurement_systems['US'] @@ -918,7 +968,7 @@ def measurement_systems(self): return self._data['measurement_systems'] @property - def character_order(self): + def character_order(self) -> str: """The text direction for the language. >>> Locale('de', 'DE').character_order @@ -929,7 +979,7 @@ def character_order(self): return self._data['character_order'] @property - def text_direction(self): + def text_direction(self) -> str: """The text direction for the language in CSS short-hand form. >>> Locale('de', 'DE').text_direction @@ -940,7 +990,7 @@ def text_direction(self): return ''.join(word[0] for word in self.character_order.split('-')) @property - def unit_display_names(self): + def unit_display_names(self) -> localedata.LocaleDataDict: """Display names for units of measurement. .. seealso:: @@ -954,7 +1004,7 @@ def unit_display_names(self): return self._data['unit_display_names'] -def default_locale(category=None, aliases=LOCALE_ALIASES): +def default_locale(category: str | None = None, aliases: Mapping[str, str] = LOCALE_ALIASES) -> str | None: """Returns the system default locale for a given category, based on environment variables. @@ -999,7 +1049,7 @@ def default_locale(category=None, aliases=LOCALE_ALIASES): pass -def negotiate_locale(preferred, available, sep='_', aliases=LOCALE_ALIASES): +def negotiate_locale(preferred: Iterable[str], available: Iterable[str], sep: str = '_', aliases: Mapping[str, str] = LOCALE_ALIASES) -> str | None: """Find the best match between available and requested locale strings. >>> negotiate_locale(['de_DE', 'en_US'], ['de_DE', 'de_AT']) @@ -1062,7 +1112,7 @@ def negotiate_locale(preferred, available, sep='_', aliases=LOCALE_ALIASES): return None -def parse_locale(identifier, sep='_'): +def parse_locale(identifier: str, sep: str = '_') -> tuple[str, str | None, str | None, str | None]: """Parse a locale identifier into a tuple of the form ``(language, territory, script, variant)``. @@ -1143,7 +1193,7 @@ def parse_locale(identifier, sep='_'): return lang, territory, script, variant -def get_locale_identifier(tup, sep='_'): +def get_locale_identifier(tup: tuple[str, str | None, str | None, str | None], sep: str = '_') -> str: """The reverse of :func:`parse_locale`. It creates a locale identifier out of a ``(language, territory, script, variant)`` tuple. Items can be set to ``None`` and trailing ``None``\\s can also be left out of the tuple. diff --git a/babel/dates.py b/babel/dates.py index e9f6f6dd2..27f3fe648 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -15,16 +15,28 @@ :license: BSD, see LICENSE for more details. """ +from __future__ import annotations import re import warnings +from bisect import bisect_right +from collections.abc import Iterable +from datetime import date, datetime, time, timedelta, tzinfo +from typing import TYPE_CHECKING, SupportsInt + import pytz as _pytz -from datetime import date, datetime, time, timedelta -from bisect import bisect_right +from babel.core import Locale, default_locale, get_global +from babel.localedata import LocaleDataDict +from babel.util import LOCALTZ, UTC + +if TYPE_CHECKING: + from typing_extensions import Literal, TypeAlias -from babel.core import default_locale, get_global, Locale -from babel.util import UTC, LOCALTZ + _Instant: TypeAlias = date | time | float | None + _PredefinedTimeFormat: TypeAlias = Literal['full', 'long', 'medium', 'short'] + _Context: TypeAlias = Literal['format', 'stand-alone'] + _DtOrTzinfo: TypeAlias = datetime | tzinfo | str | int | time | None # "If a given short metazone form is known NOT to be understood in a given # locale and the parent locale has this value such that it would normally @@ -44,7 +56,7 @@ time_ = time -def _get_dt_and_tzinfo(dt_or_tzinfo): +def _get_dt_and_tzinfo(dt_or_tzinfo: _DtOrTzinfo) -> tuple[datetime_ | None, tzinfo]: """ Parse a `dt_or_tzinfo` value into a datetime and a tzinfo. @@ -73,7 +85,7 @@ def _get_dt_and_tzinfo(dt_or_tzinfo): return dt, tzinfo -def _get_tz_name(dt_or_tzinfo): +def _get_tz_name(dt_or_tzinfo: _DtOrTzinfo) -> str: """ Get the timezone name out of a time, datetime, or tzinfo object. @@ -88,7 +100,7 @@ def _get_tz_name(dt_or_tzinfo): return tzinfo.tzname(dt or datetime.utcnow()) -def _get_datetime(instant): +def _get_datetime(instant: _Instant) -> datetime_: """ Get a datetime out of an "instant" (date, time, datetime, number). @@ -130,7 +142,7 @@ def _get_datetime(instant): return instant -def _ensure_datetime_tzinfo(datetime, tzinfo=None): +def _ensure_datetime_tzinfo(datetime: datetime_, tzinfo: tzinfo | None = None) -> datetime_: """ Ensure the datetime passed has an attached tzinfo. @@ -159,7 +171,7 @@ def _ensure_datetime_tzinfo(datetime, tzinfo=None): return datetime -def _get_time(time, tzinfo=None): +def _get_time(time: time | datetime | None, tzinfo: tzinfo | None = None) -> time: """ Get a timezoned time from a given instant. @@ -185,7 +197,7 @@ def _get_time(time, tzinfo=None): return time -def get_timezone(zone=None): +def get_timezone(zone: str | _pytz.BaseTzInfo | None = None) -> _pytz.BaseTzInfo: """Looks up a timezone by name and returns it. The timezone object returned comes from ``pytz`` and corresponds to the `tzinfo` interface and can be used with all of the functions of Babel that operate with dates. @@ -206,7 +218,7 @@ def get_timezone(zone=None): raise LookupError(f"Unknown timezone {zone}") -def get_next_timezone_transition(zone=None, dt=None): +def get_next_timezone_transition(zone: _pytz.BaseTzInfo | None = None, dt: _Instant = None) -> TimezoneTransition: """Given a timezone it will return a :class:`TimezoneTransition` object that holds the information about the next timezone transition that's going to happen. For instance this can be used to detect when the next DST @@ -278,7 +290,7 @@ class TimezoneTransition: to the :func:`get_next_timezone_transition`. """ - def __init__(self, activates, from_tzinfo, to_tzinfo, reference_date=None): + def __init__(self, activates: datetime_, from_tzinfo: tzinfo, to_tzinfo: tzinfo, reference_date: datetime_ | None = None): warnings.warn( "TimezoneTransition is deprecated and will be " "removed in the next version of Babel. " @@ -292,30 +304,31 @@ def __init__(self, activates, from_tzinfo, to_tzinfo, reference_date=None): self.reference_date = reference_date @property - def from_tz(self): + def from_tz(self) -> str: """The name of the timezone before the transition.""" return self.from_tzinfo._tzname @property - def to_tz(self): + def to_tz(self) -> str: """The name of the timezone after the transition.""" return self.to_tzinfo._tzname @property - def from_offset(self): + def from_offset(self) -> int: """The UTC offset in seconds before the transition.""" return int(self.from_tzinfo._utcoffset.total_seconds()) @property - def to_offset(self): + def to_offset(self) -> int: """The UTC offset in seconds after the transition.""" return int(self.to_tzinfo._utcoffset.total_seconds()) - def __repr__(self): + def __repr__(self) -> str: return f" {self.to_tz} ({self.activates})>" -def get_period_names(width='wide', context='stand-alone', locale=LC_TIME): +def get_period_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide', + context: _Context = 'stand-alone', locale: Locale | str | None = LC_TIME) -> LocaleDataDict: """Return the names for day periods (AM/PM) used by the locale. >>> get_period_names(locale='en_US')['am'] @@ -328,7 +341,8 @@ def get_period_names(width='wide', context='stand-alone', locale=LC_TIME): return Locale.parse(locale).day_periods[context][width] -def get_day_names(width='wide', context='format', locale=LC_TIME): +def get_day_names(width: Literal['abbreviated', 'narrow', 'short', 'wide'] = 'wide', + context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict: """Return the day names used by the locale for the specified format. >>> get_day_names('wide', locale='en_US')[1] @@ -347,7 +361,8 @@ def get_day_names(width='wide', context='format', locale=LC_TIME): return Locale.parse(locale).days[context][width] -def get_month_names(width='wide', context='format', locale=LC_TIME): +def get_month_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide', + context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict: """Return the month names used by the locale for the specified format. >>> get_month_names('wide', locale='en_US')[1] @@ -364,7 +379,8 @@ def get_month_names(width='wide', context='format', locale=LC_TIME): return Locale.parse(locale).months[context][width] -def get_quarter_names(width='wide', context='format', locale=LC_TIME): +def get_quarter_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide', + context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict: """Return the quarter names used by the locale for the specified format. >>> get_quarter_names('wide', locale='en_US')[1] @@ -381,7 +397,8 @@ def get_quarter_names(width='wide', context='format', locale=LC_TIME): return Locale.parse(locale).quarters[context][width] -def get_era_names(width='wide', locale=LC_TIME): +def get_era_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide', + locale: Locale | str | None = LC_TIME) -> LocaleDataDict: """Return the era names used by the locale for the specified format. >>> get_era_names('wide', locale='en_US')[1] @@ -395,7 +412,7 @@ def get_era_names(width='wide', locale=LC_TIME): return Locale.parse(locale).eras[width] -def get_date_format(format='medium', locale=LC_TIME): +def get_date_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern: """Return the date formatting patterns used by the locale for the specified format. @@ -411,7 +428,7 @@ def get_date_format(format='medium', locale=LC_TIME): return Locale.parse(locale).date_formats[format] -def get_datetime_format(format='medium', locale=LC_TIME): +def get_datetime_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern: """Return the datetime formatting patterns used by the locale for the specified format. @@ -428,7 +445,7 @@ def get_datetime_format(format='medium', locale=LC_TIME): return patterns[format] -def get_time_format(format='medium', locale=LC_TIME): +def get_time_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern: """Return the time formatting patterns used by the locale for the specified format. @@ -444,7 +461,8 @@ def get_time_format(format='medium', locale=LC_TIME): return Locale.parse(locale).time_formats[format] -def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME, return_z=False): +def get_timezone_gmt(datetime: _Instant = None, width: Literal['long', 'short', 'iso8601', 'iso8601_short'] = 'long', + locale: Locale | str | None = LC_TIME, return_z: bool = False) -> str: """Return the timezone associated with the given `datetime` object formatted as string indicating the offset from GMT. @@ -498,7 +516,8 @@ def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME, return_z=False return pattern % (hours, seconds // 60) -def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME, return_city=False): +def get_timezone_location(dt_or_tzinfo: _DtOrTzinfo = None, locale: Locale | str | None = LC_TIME, + return_city: bool = False) -> str: u"""Return a representation of the given timezone using "location format". The result depends on both the local display name of the country and the @@ -574,8 +593,9 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME, return_city=False): }) -def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, - locale=LC_TIME, zone_variant=None, return_zone=False): +def get_timezone_name(dt_or_tzinfo: _DtOrTzinfo = None, width: Literal['long', 'short'] = 'long', uncommon: bool = False, + locale: Locale | str | None = LC_TIME, zone_variant: Literal['generic', 'daylight', 'standard'] | None = None, + return_zone: bool = False) -> str: r"""Return the localized display name for the given timezone. The timezone may be specified using a ``datetime`` or `tzinfo` object. @@ -693,7 +713,8 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, return get_timezone_location(dt_or_tzinfo, locale=locale) -def format_date(date=None, format='medium', locale=LC_TIME): +def format_date(date: date | None = None, format: _PredefinedTimeFormat | str = 'medium', + locale: Locale | str | None = LC_TIME) -> str: """Return a date formatted according to the given pattern. >>> d = date(2007, 4, 1) @@ -726,8 +747,8 @@ def format_date(date=None, format='medium', locale=LC_TIME): return pattern.apply(date, locale) -def format_datetime(datetime=None, format='medium', tzinfo=None, - locale=LC_TIME): +def format_datetime(datetime: _Instant = None, format: _PredefinedTimeFormat | str = 'medium', tzinfo: tzinfo | None = None, + locale: Locale | str | None = LC_TIME) -> str: r"""Return a date formatted according to the given pattern. >>> dt = datetime(2007, 4, 1, 15, 30) @@ -764,7 +785,8 @@ def format_datetime(datetime=None, format='medium', tzinfo=None, return parse_pattern(format).apply(datetime, locale) -def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): +def format_time(time: time | datetime | float | None = None, format: _PredefinedTimeFormat | str = 'medium', + tzinfo: tzinfo | None = None, locale: Locale | str | None = LC_TIME) -> str: r"""Return a time formatted according to the given pattern. >>> t = time(15, 30) @@ -827,7 +849,8 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): return parse_pattern(format).apply(time, locale) -def format_skeleton(skeleton, datetime=None, tzinfo=None, fuzzy=True, locale=LC_TIME): +def format_skeleton(skeleton: str, datetime: _Instant = None, tzinfo: tzinfo | None = None, + fuzzy: bool = True, locale: Locale | str | None = LC_TIME) -> str: r"""Return a time and/or date formatted according to the given pattern. The skeletons are defined in the CLDR data and provide more flexibility @@ -865,7 +888,7 @@ def format_skeleton(skeleton, datetime=None, tzinfo=None, fuzzy=True, locale=LC_ return format_datetime(datetime, format, tzinfo, locale) -TIMEDELTA_UNITS = ( +TIMEDELTA_UNITS: tuple[tuple[str, int], ...] = ( ('year', 3600 * 24 * 365), ('month', 3600 * 24 * 30), ('week', 3600 * 24 * 7), @@ -876,9 +899,10 @@ def format_skeleton(skeleton, datetime=None, tzinfo=None, fuzzy=True, locale=LC_ ) -def format_timedelta(delta, granularity='second', threshold=.85, - add_direction=False, format='long', - locale=LC_TIME): +def format_timedelta(delta: timedelta | int, + granularity: Literal['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] = 'second', + threshold: float = .85, add_direction: bool = False, format: Literal['narrow', 'short', 'medium', 'long'] = 'long', + locale: Locale | str | None = LC_TIME) -> str: """Return a time delta according to the rules of the given locale. >>> format_timedelta(timedelta(weeks=12), locale='en_US') @@ -977,7 +1001,8 @@ def _iter_patterns(a_unit): return u'' -def _format_fallback_interval(start, end, skeleton, tzinfo, locale): +def _format_fallback_interval(start: _Instant, end: _Instant, skeleton: str | None, tzinfo: tzinfo | None, + locale: Locale | str | None = LC_TIME) -> str: if skeleton in locale.datetime_skeletons: # Use the given skeleton format = lambda dt: format_skeleton(skeleton, dt, tzinfo, locale=locale) elif all((isinstance(d, date) and not isinstance(d, datetime)) for d in (start, end)): # Both are just dates @@ -1000,7 +1025,8 @@ def _format_fallback_interval(start, end, skeleton, tzinfo, locale): ) -def format_interval(start, end, skeleton=None, tzinfo=None, fuzzy=True, locale=LC_TIME): +def format_interval(start: _Instant, end: _Instant, skeleton: str | None = None, tzinfo: tzinfo | None = None, + fuzzy: bool = True, locale: Locale | str | None = LC_TIME) -> str: """ Format an interval between two instants according to the locale's rules. @@ -1098,7 +1124,8 @@ def format_interval(start, end, skeleton=None, tzinfo=None, fuzzy=True, locale=L return _format_fallback_interval(start, end, skeleton, tzinfo, locale) -def get_period_id(time, tzinfo=None, type=None, locale=LC_TIME): +def get_period_id(time: _Instant, tzinfo: _pytz.BaseTzInfo | None = None, type: Literal['selection'] | None = None, + locale: Locale | str | None = LC_TIME) -> str: """ Get the day period ID for a given time. @@ -1172,7 +1199,7 @@ class ParseError(ValueError): pass -def parse_date(string, locale=LC_TIME, format='medium'): +def parse_date(string: str, locale: Locale | str | None = LC_TIME, format: _PredefinedTimeFormat = 'medium') -> date: """Parse a date from a string. This function first tries to interpret the string as ISO-8601 @@ -1232,7 +1259,7 @@ def parse_date(string, locale=LC_TIME, format='medium'): return date(year, month, day) -def parse_time(string, locale=LC_TIME, format='medium'): +def parse_time(string: str, locale: Locale | str | None = LC_TIME, format: _PredefinedTimeFormat = 'medium') -> time: """Parse a time from a string. This function uses the time format for the locale as a hint to determine @@ -1284,36 +1311,36 @@ def parse_time(string, locale=LC_TIME, format='medium'): class DateTimePattern: - def __init__(self, pattern, format): + def __init__(self, pattern: str, format: DateTimeFormat): self.pattern = pattern self.format = format - def __repr__(self): + def __repr__(self) -> str: return f"<{type(self).__name__} {self.pattern!r}>" - def __str__(self): + def __str__(self) -> str: pat = self.pattern return pat - def __mod__(self, other): + def __mod__(self, other: DateTimeFormat) -> str: if type(other) is not DateTimeFormat: return NotImplemented return self.format % other - def apply(self, datetime, locale): + def apply(self, datetime: date | time, locale: Locale | str | None) -> str: return self % DateTimeFormat(datetime, locale) class DateTimeFormat: - def __init__(self, value, locale): + def __init__(self, value: date | time, locale: Locale | str): assert isinstance(value, (date, datetime, time)) if isinstance(value, (datetime, time)) and value.tzinfo is None: value = value.replace(tzinfo=UTC) self.value = value self.locale = Locale.parse(locale) - def __getitem__(self, name): + def __getitem__(self, name: str) -> str: char = name[0] num = len(name) if char == 'G': @@ -1363,7 +1390,7 @@ def __getitem__(self, name): else: raise KeyError(f"Unsupported date/time field {char!r}") - def extract(self, char): + def extract(self, char: str) -> int: char = str(char)[0] if char == 'y': return self.value.year @@ -1382,12 +1409,12 @@ def extract(self, char): else: raise NotImplementedError(f"Not implemented: extracting {char!r} from {self.value!r}") - def format_era(self, char, num): + def format_era(self, char: str, num: int) -> str: width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)] era = int(self.value.year >= 0) return get_era_names(width, self.locale)[era] - def format_year(self, char, num): + def format_year(self, char: str, num: int) -> str: value = self.value.year if char.isupper(): value = self.value.isocalendar()[0] @@ -1396,7 +1423,7 @@ def format_year(self, char, num): year = year[-2:] return year - def format_quarter(self, char, num): + def format_quarter(self, char: str, num: int) -> str: quarter = (self.value.month - 1) // 3 + 1 if num <= 2: return '%0*d' % (num, quarter) @@ -1404,14 +1431,14 @@ def format_quarter(self, char, num): context = {'Q': 'format', 'q': 'stand-alone'}[char] return get_quarter_names(width, context, self.locale)[quarter] - def format_month(self, char, num): + def format_month(self, char: str, num: int) -> str: if num <= 2: return '%0*d' % (num, self.value.month) width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num] context = {'M': 'format', 'L': 'stand-alone'}[char] return get_month_names(width, context, self.locale)[self.value.month] - def format_week(self, char, num): + def format_week(self, char: str, num: int) -> str: if char.islower(): # week of year day_of_year = self.get_day_of_year() week = self.get_week_number(day_of_year) @@ -1427,7 +1454,7 @@ def format_week(self, char, num): week = self.get_week_number(date.day, date.weekday()) return str(week) - def format_weekday(self, char='E', num=4): + def format_weekday(self, char: str = 'E', num: int = 4) -> str: """ Return weekday from parsed datetime according to format pattern. @@ -1467,13 +1494,13 @@ def format_weekday(self, char='E', num=4): context = 'format' return get_day_names(width, context, self.locale)[weekday] - def format_day_of_year(self, num): + def format_day_of_year(self, num: int) -> str: return self.format(self.get_day_of_year(), num) - def format_day_of_week_in_month(self): + def format_day_of_week_in_month(self) -> str: return str((self.value.day - 1) // 7 + 1) - def format_period(self, char, num): + def format_period(self, char: str, num: int) -> str: """ Return period from parsed datetime according to format pattern. @@ -1515,7 +1542,7 @@ def format_period(self, char, num): return period_names[period] raise ValueError(f"Could not format period {period} in {self.locale}") - def format_frac_seconds(self, num): + def format_frac_seconds(self, num: int) -> str: """ Return fractional seconds. Rounds the time's microseconds to the precision given by the number \ @@ -1529,7 +1556,7 @@ def format_milliseconds_in_day(self, num): self.value.minute * 60000 + self.value.hour * 3600000 return self.format(msecs, num) - def format_timezone(self, char, num): + def format_timezone(self, char: str, num: int) -> str: width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)] if char == 'z': return get_timezone_name(self.value, width, locale=self.locale) @@ -1572,15 +1599,15 @@ def format_timezone(self, char, num): elif num in (3, 5): return get_timezone_gmt(self.value, width='iso8601', locale=self.locale) - def format(self, value, length): + def format(self, value: SupportsInt, length: int) -> str: return '%0*d' % (length, value) - def get_day_of_year(self, date=None): + def get_day_of_year(self, date: date | None = None) -> int: if date is None: date = self.value return (date - date.replace(month=1, day=1)).days + 1 - def get_week_number(self, day_of_period, day_of_week=None): + def get_week_number(self, day_of_period: int, day_of_week: int | None = None) -> int: """Return the number of the week of a day within a period. This may be the week number in a year or the week number in a month. @@ -1625,7 +1652,7 @@ def get_week_number(self, day_of_period, day_of_week=None): return week_number -PATTERN_CHARS = { +PATTERN_CHARS: dict[str, list[int] | None] = { 'G': [1, 2, 3, 4, 5], # era 'y': None, 'Y': None, 'u': None, # year 'Q': [1, 2, 3, 4, 5], 'q': [1, 2, 3, 4, 5], # quarter @@ -1649,7 +1676,7 @@ def get_week_number(self, day_of_period, day_of_week=None): _pattern_cache = {} -def parse_pattern(pattern): +def parse_pattern(pattern: str) -> DateTimePattern: """Parse date, time, and datetime format patterns. >>> parse_pattern("MMMMd").format @@ -1694,7 +1721,7 @@ def parse_pattern(pattern): return pat -def tokenize_pattern(pattern): +def tokenize_pattern(pattern: str) -> list[tuple[str, str | tuple[str, int]]]: """ Tokenize date format patterns. @@ -1763,7 +1790,7 @@ def append_field(): return result -def untokenize_pattern(tokens): +def untokenize_pattern(tokens: Iterable[tuple[str, str | tuple[str, int]]]) -> str: """ Turn a date format pattern token stream back into a string. @@ -1784,7 +1811,7 @@ def untokenize_pattern(tokens): return "".join(output) -def split_interval_pattern(pattern): +def split_interval_pattern(pattern: str) -> list[str]: """ Split an interval-describing datetime pattern into multiple pieces. @@ -1822,7 +1849,7 @@ def split_interval_pattern(pattern): return [untokenize_pattern(tokens) for tokens in parts] -def match_skeleton(skeleton, options, allow_different_fields=False): +def match_skeleton(skeleton: str, options: Iterable[str], allow_different_fields: bool = False) -> str | None: """ Find the closest match for the given datetime skeleton among the options given. diff --git a/babel/languages.py b/babel/languages.py index cac59c162..564f555d2 100644 --- a/babel/languages.py +++ b/babel/languages.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from babel.core import get_global -def get_official_languages(territory, regional=False, de_facto=False): +def get_official_languages(territory: str, regional: bool = False, de_facto: bool = False) -> tuple[str, ...]: """ Get the official language(s) for the given territory. @@ -41,7 +43,7 @@ def get_official_languages(territory, regional=False, de_facto=False): return tuple(lang for _, lang in pairs) -def get_territory_language_info(territory): +def get_territory_language_info(territory: str) -> dict[str, dict[str, float | str | None]]: """ Get a dictionary of language information for a territory. diff --git a/babel/lists.py b/babel/lists.py index ea983efe0..97fc49a71 100644 --- a/babel/lists.py +++ b/babel/lists.py @@ -13,13 +13,22 @@ :copyright: (c) 2015-2022 by the Babel Team. :license: BSD, see LICENSE for more details. """ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING from babel.core import Locale, default_locale +if TYPE_CHECKING: + from typing_extensions import Literal + DEFAULT_LOCALE = default_locale() -def format_list(lst, style='standard', locale=DEFAULT_LOCALE): +def format_list(lst: Sequence[str], + style: Literal['standard', 'standard-short', 'or', 'or-short', 'unit', 'unit-short', 'unit-narrow'] = 'standard', + locale: Locale | str | None = DEFAULT_LOCALE) -> str: """ Format the items in `lst` as a list. diff --git a/babel/localedata.py b/babel/localedata.py index 8ec8f4aaa..0d3508d1e 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -11,22 +11,25 @@ :license: BSD, see LICENSE for more details. """ -import pickle +from __future__ import annotations + import os +import pickle import re import sys import threading from collections import abc +from collections.abc import Iterator, Mapping, MutableMapping from itertools import chain +from typing import Any - -_cache = {} +_cache: dict[str, Any] = {} _cache_lock = threading.RLock() _dirname = os.path.join(os.path.dirname(__file__), 'locale-data') _windows_reserved_name_re = re.compile("^(con|prn|aux|nul|com[0-9]|lpt[0-9])$", re.I) -def normalize_locale(name): +def normalize_locale(name: str) -> str | None: """Normalize a locale ID by stripping spaces and apply proper casing. Returns the normalized locale ID string or `None` if the ID is not @@ -40,7 +43,7 @@ def normalize_locale(name): return locale_id -def resolve_locale_filename(name): +def resolve_locale_filename(name: os.PathLike[str] | str) -> str: """ Resolve a locale identifier to a `.dat` path on disk. """ @@ -56,7 +59,7 @@ def resolve_locale_filename(name): return os.path.join(_dirname, f"{name}.dat") -def exists(name): +def exists(name: str) -> bool: """Check whether locale data is available for the given locale. Returns `True` if it exists, `False` otherwise. @@ -71,7 +74,7 @@ def exists(name): return True if file_found else bool(normalize_locale(name)) -def locale_identifiers(): +def locale_identifiers() -> list[str]: """Return a list of all locale identifiers for which locale data is available. @@ -95,7 +98,7 @@ def locale_identifiers(): return data -def load(name, merge_inherited=True): +def load(name: os.PathLike[str] | str, merge_inherited: bool = True) -> dict[str, Any]: """Load the locale data for the given locale. The locale data is a dictionary that contains much of the data defined by @@ -150,7 +153,7 @@ def load(name, merge_inherited=True): _cache_lock.release() -def merge(dict1, dict2): +def merge(dict1: MutableMapping[Any, Any], dict2: Mapping[Any, Any]) -> None: """Merge the data from `dict2` into the `dict1` dictionary, making copies of nested dictionaries. @@ -190,13 +193,13 @@ class Alias: as specified by the `keys`. """ - def __init__(self, keys): + def __init__(self, keys: tuple[str, ...]) -> None: self.keys = tuple(keys) - def __repr__(self): + def __repr__(self) -> str: return f"<{type(self).__name__} {self.keys!r}>" - def resolve(self, data): + def resolve(self, data: Mapping[str | int | None, Any]) -> Mapping[str | int | None, Any]: """Resolve the alias based on the given data. This is done recursively, so if one alias resolves to a second alias, @@ -221,19 +224,19 @@ class LocaleDataDict(abc.MutableMapping): values. """ - def __init__(self, data, base=None): + def __init__(self, data: MutableMapping[str | int | None, Any], base: Mapping[str | int | None, Any] | None = None): self._data = data if base is None: base = data self.base = base - def __len__(self): + def __len__(self) -> int: return len(self._data) - def __iter__(self): + def __iter__(self) -> Iterator[str | int | None]: return iter(self._data) - def __getitem__(self, key): + def __getitem__(self, key: str | int | None) -> Any: orig = val = self._data[key] if isinstance(val, Alias): # resolve an alias val = val.resolve(self.base) @@ -241,17 +244,17 @@ def __getitem__(self, key): alias, others = val val = alias.resolve(self.base).copy() merge(val, others) - if type(val) is dict: # Return a nested alias-resolving dict + 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 - def __setitem__(self, key, value): + def __setitem__(self, key: str | int | None, value: Any) -> None: self._data[key] = value - def __delitem__(self, key): + def __delitem__(self, key: str | int | None) -> None: del self._data[key] - def copy(self): + def copy(self) -> LocaleDataDict: return LocaleDataDict(self._data.copy(), base=self.base) diff --git a/babel/localtime/__init__.py b/babel/localtime/__init__.py index 7e626a0f1..ffe2d49f1 100644 --- a/babel/localtime/__init__.py +++ b/babel/localtime/__init__.py @@ -10,12 +10,12 @@ """ import sys -import pytz import time -from datetime import timedelta -from datetime import tzinfo +from datetime import datetime, timedelta, tzinfo from threading import RLock +import pytz + if sys.platform == 'win32': from babel.localtime._win32 import _get_localzone else: @@ -37,22 +37,22 @@ class _FallbackLocalTimezone(tzinfo): - def utcoffset(self, dt): + def utcoffset(self, dt: datetime) -> timedelta: if self._isdst(dt): return DSTOFFSET else: return STDOFFSET - def dst(self, dt): + def dst(self, dt: datetime) -> timedelta: if self._isdst(dt): return DSTDIFF else: return ZERO - def tzname(self, dt): + def tzname(self, dt: datetime) -> str: return time.tzname[self._isdst(dt)] - def _isdst(self, dt): + def _isdst(self, dt: datetime) -> bool: tt = (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.weekday(), 0, -1) @@ -61,7 +61,7 @@ def _isdst(self, dt): return tt.tm_isdst > 0 -def get_localzone(): +def get_localzone() -> pytz.BaseTzInfo: """Returns the current underlying local timezone object. Generally this function does not need to be used, it's a better idea to use the :data:`LOCALTZ` singleton instead. diff --git a/babel/localtime/_unix.py b/babel/localtime/_unix.py index 3d1480ed8..beb7f6021 100644 --- a/babel/localtime/_unix.py +++ b/babel/localtime/_unix.py @@ -3,7 +3,7 @@ import pytz -def _tz_from_env(tzenv): +def _tz_from_env(tzenv: str) -> pytz.BaseTzInfo: if tzenv[0] == ':': tzenv = tzenv[1:] @@ -23,7 +23,7 @@ def _tz_from_env(tzenv): "Please use a timezone in the form of Continent/City") -def _get_localzone(_root='/'): +def _get_localzone(_root: str = '/') -> pytz.BaseTzInfo: """Tries to find the local timezone configuration. This method prefers finding the timezone name and passing that to pytz, over passing in the localtime file, as in the later case the zoneinfo diff --git a/babel/localtime/_win32.py b/babel/localtime/_win32.py index a4f6d557a..98d51708d 100644 --- a/babel/localtime/_win32.py +++ b/babel/localtime/_win32.py @@ -1,23 +1,27 @@ +from __future__ import annotations + try: import winreg except ImportError: winreg = None -from babel.core import get_global +from typing import Any, Dict, cast + import pytz +from babel.core import get_global # When building the cldr data on windows this module gets imported. # Because at that point there is no global.dat yet this call will # fail. We want to catch it down in that case then and just assume # the mapping was empty. try: - tz_names = get_global('windows_zone_mapping') + tz_names: dict[str, str] = cast(Dict[str, str], get_global('windows_zone_mapping')) except RuntimeError: tz_names = {} -def valuestodict(key): +def valuestodict(key) -> dict[str, Any]: """Convert a registry key's values to a dictionary.""" dict = {} size = winreg.QueryInfoKey(key)[1] @@ -27,7 +31,7 @@ def valuestodict(key): return dict -def get_localzone_name(): +def get_localzone_name() -> str: # Windows is special. It has unique time zone names (in several # meanings of the word) available, but unfortunately, they can be # translated to the language of the operating system, so we need to @@ -86,7 +90,7 @@ def get_localzone_name(): return timezone -def _get_localzone(): +def _get_localzone() -> pytz.BaseTzInfo: if winreg is None: raise pytz.UnknownTimeZoneError( 'Runtime support not available') diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 22ce66067..0801de371 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -7,14 +7,17 @@ :copyright: (c) 2013-2022 by the Babel Team. :license: BSD, see LICENSE for more details. """ +from __future__ import annotations import re from collections import OrderedDict +from collections.abc import Generator, Iterable, Iterator from datetime import datetime, time as time_ from difflib import get_close_matches from email import message_from_string from copy import copy +from typing import TYPE_CHECKING from babel import __version__ as VERSION from babel.core import Locale, UnknownLocaleError @@ -22,6 +25,11 @@ from babel.messages.plurals import get_plural from babel.util import distinct, LOCALTZ, FixedOffsetTimezone, _cmp +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + _MessageID: TypeAlias = str | tuple[str, ...] | list[str] + __all__ = ['Message', 'Catalog', 'TranslationError'] @@ -37,7 +45,7 @@ ''', re.VERBOSE) -def _parse_datetime_header(value): +def _parse_datetime_header(value: str) -> datetime: match = re.match(r'^(?P.*?)(?P[+-]\d{4})?$', value) dt = datetime.strptime(match.group('datetime'), '%Y-%m-%d %H:%M') @@ -70,8 +78,18 @@ def _parse_datetime_header(value): class Message: """Representation of a single message in a catalog.""" - def __init__(self, id, string=u'', locations=(), flags=(), auto_comments=(), - user_comments=(), previous_id=(), lineno=None, context=None): + def __init__( + self, + id: _MessageID, + string: _MessageID | None = u'', + locations: Iterable[tuple[str, int]] = (), + flags: Iterable[str] = (), + auto_comments: Iterable[str] = (), + user_comments: Iterable[str] = (), + previous_id: _MessageID = (), + lineno: int | None = None, + context: str | None = None, + ) -> None: """Create the message object. :param id: the message ID, or a ``(singular, plural)`` tuple for @@ -107,10 +125,10 @@ def __init__(self, id, string=u'', locations=(), flags=(), auto_comments=(), self.lineno = lineno self.context = context - def __repr__(self): + def __repr__(self) -> str: return f"<{type(self).__name__} {self.id!r} (flags: {list(self.flags)!r})>" - def __cmp__(self, other): + def __cmp__(self, other: object) -> int: """Compare Messages, taking into account plural ids""" def values_to_compare(obj): if isinstance(obj, Message) and obj.pluralizable: @@ -118,38 +136,38 @@ def values_to_compare(obj): return obj.id, obj.context or '' return _cmp(values_to_compare(self), values_to_compare(other)) - def __gt__(self, other): + def __gt__(self, other: object) -> bool: return self.__cmp__(other) > 0 - def __lt__(self, other): + def __lt__(self, other: object) -> bool: return self.__cmp__(other) < 0 - def __ge__(self, other): + def __ge__(self, other: object) -> bool: return self.__cmp__(other) >= 0 - def __le__(self, other): + def __le__(self, other: object) -> bool: return self.__cmp__(other) <= 0 - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return self.__cmp__(other) == 0 - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return self.__cmp__(other) != 0 - def is_identical(self, other): + def is_identical(self, other: Message) -> bool: """Checks whether messages are identical, taking into account all properties. """ assert isinstance(other, Message) return self.__dict__ == other.__dict__ - def clone(self): + def clone(self) -> Message: return Message(*map(copy, (self.id, self.string, self.locations, self.flags, self.auto_comments, self.user_comments, self.previous_id, self.lineno, self.context))) - def check(self, catalog=None): + def check(self, catalog: Catalog | None = None) -> list[TranslationError]: """Run various validation checks on the message. Some validations are only performed if the catalog is provided. This method returns a sequence of `TranslationError` objects. @@ -160,7 +178,7 @@ def check(self, catalog=None): in a catalog. """ from babel.messages.checkers import checkers - errors = [] + errors: list[TranslationError] = [] for checker in checkers: try: checker(catalog, self) @@ -169,7 +187,7 @@ def check(self, catalog=None): return errors @property - def fuzzy(self): + def fuzzy(self) -> bool: """Whether the translation is fuzzy. >>> Message('foo').fuzzy @@ -184,7 +202,7 @@ def fuzzy(self): return 'fuzzy' in self.flags @property - def pluralizable(self): + def pluralizable(self) -> bool: """Whether the message is plurizable. >>> Message('foo').pluralizable @@ -196,7 +214,7 @@ def pluralizable(self): return isinstance(self.id, (list, tuple)) @property - def python_format(self): + def python_format(self) -> bool: """Whether the message contains Python-style parameters. >>> Message('foo %(name)s bar').python_format @@ -223,7 +241,7 @@ class TranslationError(Exception): # FIRST AUTHOR , YEAR. #""" -def parse_separated_header(value: str): +def parse_separated_header(value: str) -> dict[str, str]: # Adapted from https://peps.python.org/pep-0594/#cgi from email.message import Message m = Message() @@ -234,11 +252,22 @@ def parse_separated_header(value: str): class Catalog: """Representation of a message catalog.""" - def __init__(self, locale=None, domain=None, header_comment=DEFAULT_HEADER, - project=None, version=None, copyright_holder=None, - msgid_bugs_address=None, creation_date=None, - revision_date=None, last_translator=None, language_team=None, - charset=None, fuzzy=True): + def __init__( + self, + locale: str | Locale | None = None, + domain: str | None = None, + header_comment: str | None = DEFAULT_HEADER, + project: str | None = None, + version: str | None = None, + copyright_holder: str | None = None, + msgid_bugs_address: str | None = None, + creation_date: datetime | str | None = None, + revision_date: datetime | time_ | float | str | None = None, + last_translator: str | None = None, + language_team: str | None = None, + charset: str | None = None, + fuzzy: bool = True, + ) -> None: """Initialize the catalog object. :param locale: the locale identifier or `Locale` object, or `None` @@ -262,7 +291,7 @@ def __init__(self, locale=None, domain=None, header_comment=DEFAULT_HEADER, self.domain = domain self.locale = locale self._header_comment = header_comment - self._messages = OrderedDict() + self._messages: OrderedDict[str | tuple[str, str], Message] = OrderedDict() self.project = project or 'PROJECT' self.version = version or 'VERSION' @@ -288,11 +317,12 @@ def __init__(self, locale=None, domain=None, header_comment=DEFAULT_HEADER, self.revision_date = revision_date self.fuzzy = fuzzy - self.obsolete = OrderedDict() # Dictionary of obsolete messages + # Dictionary of obsolete messages + self.obsolete: OrderedDict[str | tuple[str, str], Message] = OrderedDict() self._num_plurals = None self._plural_expr = None - def _set_locale(self, locale): + def _set_locale(self, locale: Locale | str | None) -> None: if locale is None: self._locale_identifier = None self._locale = None @@ -313,16 +343,16 @@ def _set_locale(self, locale): raise TypeError(f"`locale` must be a Locale, a locale identifier string, or None; got {locale!r}") - def _get_locale(self): + def _get_locale(self) -> Locale | None: return self._locale - def _get_locale_identifier(self): + def _get_locale_identifier(self) -> str | None: return self._locale_identifier locale = property(_get_locale, _set_locale) locale_identifier = property(_get_locale_identifier) - def _get_header_comment(self): + def _get_header_comment(self) -> str: comment = self._header_comment year = datetime.now(LOCALTZ).strftime('%Y') if hasattr(self.revision_date, 'strftime'): @@ -336,7 +366,7 @@ def _get_header_comment(self): comment = comment.replace("Translations template", f"{locale_name} translations") return comment - def _set_header_comment(self, string): + def _set_header_comment(self, string: str | None) -> None: self._header_comment = string header_comment = property(_get_header_comment, _set_header_comment, doc="""\ @@ -372,8 +402,8 @@ def _set_header_comment(self, string): :type: `unicode` """) - def _get_mime_headers(self): - headers = [] + def _get_mime_headers(self) -> list[tuple[str, str]]: + headers: list[tuple[str, str]] = [] headers.append(("Project-Id-Version", f"{self.project} {self.version}")) headers.append(('Report-Msgid-Bugs-To', self.msgid_bugs_address)) headers.append(('POT-Creation-Date', @@ -402,14 +432,14 @@ def _get_mime_headers(self): headers.append(("Generated-By", f"Babel {VERSION}\n")) return headers - def _force_text(self, s, encoding='utf-8', errors='strict'): + def _force_text(self, s: str | bytes, encoding: str = 'utf-8', errors: str = 'strict') -> str: if isinstance(s, str): return s if isinstance(s, bytes): return s.decode(encoding, errors) return str(s) - def _set_mime_headers(self, headers): + def _set_mime_headers(self, headers: Iterable[tuple[str, str]]) -> None: for name, value in headers: name = self._force_text(name.lower(), encoding=self.charset) value = self._force_text(value, encoding=self.charset) @@ -493,7 +523,7 @@ def _set_mime_headers(self, headers): """) @property - def num_plurals(self): + def num_plurals(self) -> int: """The number of plurals used by the catalog or locale. >>> Catalog(locale='en').num_plurals @@ -510,7 +540,7 @@ def num_plurals(self): return self._num_plurals @property - def plural_expr(self): + def plural_expr(self) -> str: """The plural expression used by the catalog or locale. >>> Catalog(locale='en').plural_expr @@ -529,7 +559,7 @@ def plural_expr(self): return self._plural_expr @property - def plural_forms(self): + def plural_forms(self) -> str: """Return the plural forms declaration for the locale. >>> Catalog(locale='en').plural_forms @@ -540,17 +570,17 @@ def plural_forms(self): :type: `str`""" return f"nplurals={self.num_plurals}; plural={self.plural_expr};" - def __contains__(self, id): + def __contains__(self, id: _MessageID) -> bool: """Return whether the catalog has a message with the specified ID.""" return self._key_for(id) in self._messages - def __len__(self): + def __len__(self) -> int: """The number of messages in the catalog. This does not include the special ``msgid ""`` entry.""" return len(self._messages) - def __iter__(self): + def __iter__(self) -> Iterator[Message]: """Iterates through all the entries in the catalog, in the order they were added, yielding a `Message` object for every entry. @@ -565,24 +595,24 @@ def __iter__(self): for key in self._messages: yield self._messages[key] - def __repr__(self): + def __repr__(self) -> str: locale = '' if self.locale: locale = f" {self.locale}" return f"<{type(self).__name__} {self.domain!r}{locale}>" - def __delitem__(self, id): + def __delitem__(self, id: _MessageID) -> None: """Delete the message with the specified ID.""" self.delete(id) - def __getitem__(self, id): + def __getitem__(self, id: _MessageID) -> Message: """Return the message with the specified ID. :param id: the message ID """ return self.get(id) - def __setitem__(self, id, message): + def __setitem__(self, id: _MessageID, message: Message) -> None: """Add or update the message with the specified ID. >>> catalog = Catalog() @@ -631,8 +661,18 @@ def __setitem__(self, id, message): f"Expected sequence but got {type(message.string)}" self._messages[key] = message - def add(self, id, string=None, locations=(), flags=(), auto_comments=(), - user_comments=(), previous_id=(), lineno=None, context=None): + def add( + self, + id: _MessageID, + string: _MessageID | None = None, + locations: Iterable[tuple[str, int]] = (), + flags: Iterable[str] = (), + auto_comments: Iterable[str] = (), + user_comments: Iterable[str] = (), + previous_id: _MessageID = (), + lineno: int | None = None, + context: str | None = None, + ) -> Message: """Add or update the message with the specified ID. >>> catalog = Catalog() @@ -664,21 +704,21 @@ def add(self, id, string=None, locations=(), flags=(), auto_comments=(), self[id] = message return message - def check(self): + def check(self) -> Iterable[tuple[Message, list[TranslationError]]]: """Run various validation checks on the translations in the catalog. For every message which fails validation, this method yield a ``(message, errors)`` tuple, where ``message`` is the `Message` object and ``errors`` is a sequence of `TranslationError` objects. - :rtype: ``iterator`` + :rtype: ``generator`` of ``(message, errors)`` """ for message in self._messages.values(): errors = message.check(catalog=self) if errors: yield message, errors - def get(self, id, context=None): + def get(self, id: _MessageID, context: str | None = None) -> Message | None: """Return the message with the specified ID and context. :param id: the message ID @@ -686,7 +726,7 @@ def get(self, id, context=None): """ return self._messages.get(self._key_for(id, context)) - def delete(self, id, context=None): + def delete(self, id: _MessageID, context: str | None = None) -> None: """Delete the message with the specified ID and context. :param id: the message ID @@ -696,7 +736,12 @@ def delete(self, id, context=None): if key in self._messages: del self._messages[key] - def update(self, template, no_fuzzy_matching=False, update_header_comment=False, keep_user_comments=True): + def update(self, + template: Catalog, + no_fuzzy_matching: bool = False, + update_header_comment: bool = False, + keep_user_comments: bool = True, + ) -> None: """Update the catalog based on the given template catalog. >>> from babel.messages import Catalog @@ -762,19 +807,21 @@ def update(self, template, no_fuzzy_matching=False, update_header_comment=False, } fuzzy_matches = set() - def _merge(message, oldkey, newkey): + def _merge(message: Message, oldkey: tuple[str, str] | str, newkey: tuple[str, str] | str) -> None: message = message.clone() fuzzy = False if oldkey != newkey: fuzzy = True fuzzy_matches.add(oldkey) oldmsg = messages.get(oldkey) + assert oldmsg is not None if isinstance(oldmsg.id, str): message.previous_id = [oldmsg.id] else: message.previous_id = list(oldmsg.id) else: oldmsg = remaining.pop(oldkey, None) + assert oldmsg is not None message.string = oldmsg.string if keep_user_comments: @@ -834,7 +881,7 @@ def _merge(message, oldkey, newkey): # used to update the catalog self.creation_date = template.creation_date - def _key_for(self, id, context=None): + def _key_for(self, id: _MessageID, context: str | None = None) -> tuple[str, str] | str: """The key for a message is just the singular ID even for pluralizable messages, but is a ``(msgid, msgctxt)`` tuple for context-specific messages. @@ -846,7 +893,7 @@ def _key_for(self, id, context=None): key = (key, context) return key - def is_identical(self, other): + def is_identical(self, other: Catalog) -> bool: """Checks if catalogs are identical, taking into account messages and headers. """ diff --git a/babel/messages/checkers.py b/babel/messages/checkers.py index 2706c5bfe..9231c678e 100644 --- a/babel/messages/checkers.py +++ b/babel/messages/checkers.py @@ -9,8 +9,11 @@ :copyright: (c) 2013-2022 by the Babel Team. :license: BSD, see LICENSE for more details. """ +from __future__ import annotations -from babel.messages.catalog import TranslationError, PYTHON_FORMAT +from collections.abc import Callable + +from babel.messages.catalog import Catalog, Message, TranslationError, PYTHON_FORMAT #: list of format chars that are compatible to each other @@ -21,7 +24,7 @@ ] -def num_plurals(catalog, message): +def num_plurals(catalog: Catalog | None, message: Message) -> None: """Verify the number of plurals in the translation.""" if not message.pluralizable: if not isinstance(message.string, str): @@ -41,7 +44,7 @@ def num_plurals(catalog, message): catalog.num_plurals) -def python_format(catalog, message): +def python_format(catalog: Catalog | None, message: Message) -> None: """Verify the format string placeholders in the translation.""" if 'python-format' not in message.flags: return @@ -57,7 +60,7 @@ def python_format(catalog, message): _validate_format(msgid, msgstr) -def _validate_format(format, alternative): +def _validate_format(format: str, alternative: str) -> None: """Test format string `alternative` against `format`. `format` can be the msgid of a message and `alternative` one of the `msgstr`\\s. The two arguments are not interchangeable as `alternative` may contain less @@ -89,8 +92,8 @@ def _validate_format(format, alternative): :raises TranslationError: on formatting errors """ - def _parse(string): - result = [] + def _parse(string: str) -> list[tuple[str, str]]: + result: list[tuple[str, str]] = [] for match in PYTHON_FORMAT.finditer(string): name, format, typechar = match.groups() if typechar == '%' and name is None: @@ -98,7 +101,7 @@ def _parse(string): result.append((name, str(typechar))) return result - def _compatible(a, b): + def _compatible(a: str, b: str) -> bool: if a == b: return True for set in _string_format_compatibilities: @@ -106,7 +109,7 @@ def _compatible(a, b): return True return False - def _check_positional(results): + def _check_positional(results: list[tuple[str, str]]) -> bool: positional = None for name, char in results: if positional is None: @@ -152,8 +155,8 @@ def _check_positional(results): (name, typechar, type_map[name])) -def _find_checkers(): - checkers = [] +def _find_checkers() -> list[Callable[[Catalog | None, Message], object]]: + checkers: list[Callable[[Catalog | None, Message], object]] = [] try: from pkg_resources import working_set except ImportError: @@ -168,4 +171,4 @@ def _find_checkers(): return checkers -checkers = _find_checkers() +checkers: list[Callable[[Catalog | None, Message], object]] = _find_checkers() diff --git a/babel/messages/extract.py b/babel/messages/extract.py index c19dd5af2..5c331c0a5 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -15,20 +15,58 @@ :copyright: (c) 2013-2022 by the Babel Team. :license: BSD, see LICENSE for more details. """ +from __future__ import annotations + import ast +from collections.abc import Callable, Collection, Generator, Iterable, Mapping, MutableSequence import io import os import sys from os.path import relpath from tokenize import generate_tokens, COMMENT, NAME, OP, STRING +from typing import Any, TYPE_CHECKING from babel.util import parse_encoding, parse_future_flags, pathmatch from textwrap import dedent +if TYPE_CHECKING: + from typing import IO, Protocol + from typing_extensions import Final, TypeAlias, TypedDict + from _typeshed import SupportsItems, SupportsRead, SupportsReadline + + class _PyOptions(TypedDict, total=False): + encoding: str + + class _JSOptions(TypedDict, total=False): + encoding: str + jsx: bool + template_string: bool + parse_template_string: bool + + class _FileObj(SupportsRead[bytes], SupportsReadline[bytes], Protocol): + def seek(self, __offset: int, __whence: int = ...) -> int: ... + def tell(self) -> int: ... + + _Keyword: TypeAlias = tuple[int | tuple[int, int] | tuple[int, str], ...] | None + + # 5-tuple of (filename, lineno, messages, comments, context) + _FileExtractionResult: TypeAlias = tuple[str, int, str | tuple[str, ...], list[str], str | None] + + # 4-tuple of (lineno, message, comments, context) + _ExtractionResult: TypeAlias = tuple[int, str | tuple[str, ...], list[str], str | None] + + # Required arguments: fileobj, keywords, comment_tags, options + # Return value: Iterable of (lineno, message, comments, context) + _CallableExtractionMethod: TypeAlias = Callable[ + [_FileObj | IO[bytes], Mapping[str, _Keyword], Collection[str], Mapping[str, Any]], + Iterable[_ExtractionResult], + ] + + _ExtractionMethod: TypeAlias = _CallableExtractionMethod | str -GROUP_NAME = 'babel.extractors' +GROUP_NAME: Final[str] = 'babel.extractors' -DEFAULT_KEYWORDS = { +DEFAULT_KEYWORDS: dict[str, _Keyword] = { '_': None, 'gettext': None, 'ngettext': (1, 2), @@ -41,15 +79,15 @@ 'npgettext': ((1, 'c'), 2, 3) } -DEFAULT_MAPPING = [('**.py', 'python')] +DEFAULT_MAPPING: list[tuple[str, str]] = [('**.py', 'python')] -def _strip_comment_tags(comments, tags): +def _strip_comment_tags(comments: MutableSequence[str], tags: Iterable[str]): """Helper function for `extract` that strips comment tags from strings in a list of comment lines. This functions operates in-place. """ - def _strip(line): + def _strip(line: str): for tag in tags: if line.startswith(tag): return line[len(tag):].strip() @@ -57,22 +95,22 @@ def _strip(line): comments[:] = map(_strip, comments) -def default_directory_filter(dirpath): +def default_directory_filter(dirpath: str | os.PathLike[str]) -> bool: subdir = os.path.basename(dirpath) # Legacy default behavior: ignore dot and underscore directories return not (subdir.startswith('.') or subdir.startswith('_')) def extract_from_dir( - dirname=None, - method_map=DEFAULT_MAPPING, - options_map=None, - keywords=DEFAULT_KEYWORDS, - comment_tags=(), - callback=None, - strip_comment_tags=False, - directory_filter=None, -): + dirname: str | os.PathLike[str] | None = None, + method_map: Iterable[tuple[str, str]] = DEFAULT_MAPPING, + options_map: SupportsItems[str, dict[str, Any]] | None = None, + keywords: Mapping[str, _Keyword] = DEFAULT_KEYWORDS, + comment_tags: Collection[str] = (), + callback: Callable[[str, str, dict[str, Any]], object] | None = None, + strip_comment_tags: bool = False, + directory_filter: Callable[[str], bool] | None = None, +) -> Generator[_FileExtractionResult, None, None]: """Extract messages from any source files found in the given directory. This function generates tuples of the form ``(filename, lineno, message, @@ -172,9 +210,16 @@ def extract_from_dir( ) -def check_and_call_extract_file(filepath, method_map, options_map, - callback, keywords, comment_tags, - strip_comment_tags, dirpath=None): +def check_and_call_extract_file( + filepath: str | os.PathLike[str], + method_map: Iterable[tuple[str, str]], + options_map: SupportsItems[str, dict[str, Any]], + callback: Callable[[str, str, dict[str, Any]], object] | None, + keywords: Mapping[str, _Keyword], + comment_tags: Collection[str], + strip_comment_tags: bool, + dirpath: str | os.PathLike[str] | None = None, +) -> Generator[_FileExtractionResult, None, None]: """Checks if the given file matches an extraction method mapping, and if so, calls extract_from_file. Note that the extraction method mappings are based relative to dirpath. @@ -229,8 +274,14 @@ def check_and_call_extract_file(filepath, method_map, options_map, break -def extract_from_file(method, filename, keywords=DEFAULT_KEYWORDS, - comment_tags=(), options=None, strip_comment_tags=False): +def extract_from_file( + method: _ExtractionMethod, + filename: str | os.PathLike[str], + keywords: Mapping[str, _Keyword] = DEFAULT_KEYWORDS, + comment_tags: Collection[str] = (), + options: Mapping[str, Any] | None = None, + strip_comment_tags: bool = False, +) -> list[_ExtractionResult]: """Extract messages from a specific file. This function returns a list of tuples of the form ``(lineno, message, comments, context)``. @@ -257,8 +308,14 @@ def extract_from_file(method, filename, keywords=DEFAULT_KEYWORDS, options, strip_comment_tags)) -def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), - options=None, strip_comment_tags=False): +def extract( + method: _ExtractionMethod, + fileobj: _FileObj, + keywords: Mapping[str, _Keyword] = DEFAULT_KEYWORDS, + comment_tags: Collection[str] = (), + options: Mapping[str, Any] | None = None, + strip_comment_tags: bool = False, +) -> Generator[_ExtractionResult, None, None]: """Extract messages from the given file-like object using the specified extraction method. @@ -391,14 +448,24 @@ def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), yield lineno, messages, comments, context -def extract_nothing(fileobj, keywords, comment_tags, options): +def extract_nothing( + fileobj: _FileObj, + keywords: Mapping[str, _Keyword], + comment_tags: Collection[str], + options: Mapping[str, Any], +) -> list[_ExtractionResult]: """Pseudo extractor that does not actually extract anything, but simply returns an empty list. """ return [] -def extract_python(fileobj, keywords, comment_tags, options): +def extract_python( + fileobj: IO[bytes], + keywords: Mapping[str, _Keyword], + comment_tags: Collection[str], + options: _PyOptions, +) -> Generator[_ExtractionResult, None, None]: """Extract messages from Python source code. It returns an iterator yielding tuples in the following form ``(lineno, @@ -511,7 +578,7 @@ def extract_python(fileobj, keywords, comment_tags, options): funcname = value -def _parse_python_string(value, encoding, future_flags): +def _parse_python_string(value: str, encoding: str, future_flags: int) -> str | None: # Unwrap quotes in a safe manner, maintaining the string's encoding # https://sourceforge.net/tracker/?func=detail&atid=355470&aid=617979&group_id=5470 code = compile( @@ -533,7 +600,13 @@ def _parse_python_string(value, encoding, future_flags): return None -def extract_javascript(fileobj, keywords, comment_tags, options, lineno=1): +def extract_javascript( + fileobj: _FileObj, + keywords: Mapping[str, _Keyword], + comment_tags: Collection[str], + options: _JSOptions, + lineno: int = 1, +) -> Generator[_ExtractionResult, None, None]: """Extract messages from JavaScript source code. :param fileobj: the seekable, file-like object the messages should be @@ -676,7 +749,13 @@ def extract_javascript(fileobj, keywords, comment_tags, options, lineno=1): last_token = token -def parse_template_string(template_string, keywords, comment_tags, options, lineno=1): +def parse_template_string( + template_string: str, + keywords: Mapping[str, _Keyword], + comment_tags: Collection[str], + options: _JSOptions, + lineno: int = 1, +) -> Generator[_ExtractionResult, None, None]: """Parse JavaScript template string. :param template_string: the template string to be parsed diff --git a/babel/messages/jslexer.py b/babel/messages/jslexer.py index 886f69d20..07fffdec7 100644 --- a/babel/messages/jslexer.py +++ b/babel/messages/jslexer.py @@ -9,17 +9,21 @@ :copyright: (c) 2013-2022 by the Babel Team. :license: BSD, see LICENSE for more details. """ +from __future__ import annotations + from collections import namedtuple +from collections.abc import Generator, Iterator, Sequence import re +from typing import NamedTuple -operators = sorted([ +operators: list[str] = sorted([ '+', '-', '*', '%', '!=', '==', '<', '>', '<=', '>=', '=', '+=', '-=', '*=', '%=', '<<', '>>', '>>>', '<<=', '>>=', '>>>=', '&', '&=', '|', '|=', '&&', '||', '^', '^=', '(', ')', '[', ']', '{', '}', '!', '--', '++', '~', ',', ';', '.', ':' ], key=len, reverse=True) -escapes = {'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t'} +escapes: dict[str, str] = {'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t'} name_re = re.compile(r'[\w$_][\w\d$_]*', re.UNICODE) dotted_name_re = re.compile(r'[\w$_][\w\d$_.]*[\w\d$_.]', re.UNICODE) @@ -30,9 +34,12 @@ uni_escape_re = re.compile(r'[a-fA-F0-9]{1,4}') hex_escape_re = re.compile(r'[a-fA-F0-9]{1,2}') -Token = namedtuple('Token', 'type value lineno') +class Token(NamedTuple): + type: str + value: str + lineno: int -_rules = [ +_rules: list[tuple[str | None, re.Pattern[str]]] = [ (None, re.compile(r'\s+', re.UNICODE)), (None, re.compile(r'