diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 05935074c..000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: CI -"on": - push: - branches: - - master - - '*-maint' - pull_request: - branches: - - master - - '*-maint' -jobs: - Build: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - cache: "pip" - cache-dependency-path: "**/setup.py" - - run: pip install build -e . - - run: make import-cldr - - run: python -m build - - uses: actions/upload-artifact@v3 - with: - name: dist - path: dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..2c0eaafdf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: + - master + - '*-maint' + tags: + - 'v*' + pull_request: + branches: + - master + - '*-maint' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pre-commit/action@v3.0.0 + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - "ubuntu-22.04" + - "windows-2022" + - "macos-11" + python-version: + - "3.7" + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "pypy-3.7" + - "3.12" + env: + BABEL_CLDR_NO_DOWNLOAD_PROGRESS: "1" + BABEL_CLDR_QUIET: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v3 + with: + path: cldr + key: cldr-${{ hashFiles('scripts/*cldr*') }} + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true # For Python 3.12 + cache: "pip" + cache-dependency-path: "**/setup.py" + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install 'tox~=4.0' 'tox-gh-actions~=3.0' + - name: Run test via Tox + run: tox --skip-missing-interpreters + env: + COVERAGE_XML_PATH: ${{ runner.temp }} + - uses: codecov/codecov-action@v3 + with: + directory: ${{ runner.temp }} + build: + runs-on: ubuntu-22.04 + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: "**/setup.py" + - run: pip install build -e . + - run: make import-cldr + - run: python -m build + - uses: actions/upload-artifact@v3 + with: + name: dist + path: dist + publish: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + needs: + - build + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/babel/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + verbose: true + print-hash: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index a35a1b4f3..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Test - -on: - push: - branches: - - master - - '*-maint' - pull_request: - branches: - - master - - '*-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: - matrix: - os: ["ubuntu-20.04", "windows-2022", "macos-11"] - 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" - steps: - - uses: actions/checkout@v3 - - uses: actions/cache@v3 - with: - path: cldr - key: cldr-${{ hashFiles('scripts/*cldr*') }} - - name: Set up Python ${{ matrix.python-version }} - 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 - python -m pip install 'tox~=4.0' 'tox-gh-actions~=3.0' - - name: Run test via Tox - run: tox --skip-missing-interpreters - env: - COVERAGE_XML_PATH: ${{ runner.temp }} - - uses: codecov/codecov-action@v3 - with: - directory: ${{ runner.temp }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d1935c006..a96908e97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.247 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.291 hooks: - id: ruff args: diff --git a/AUTHORS b/AUTHORS index 9cde0106c..72ad591ad 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,8 +18,8 @@ Babel is written and maintained by the Babel team and various contributors: - Michael Birtwell - Jonas Borgström - Kevin Deldycke -- Jon Dufresne - Ville Skyttä +- Jon Dufresne - Jun Omae - Hugo - Heungsub Lee @@ -49,6 +49,11 @@ Babel is written and maintained by the Babel team and various contributors: - Arturas Moskvinas - Leonardo Pistone - Hyunjun Kim +- Petr Viktorin +- Jean Abou-Samra +- Joe Portela +- Marc-Etienne Vargenau +- Michał Górny - Alex Waygood - Maciej Olko - martin f. krafft diff --git a/CHANGES.rst b/CHANGES.rst index 3a8f1d25b..265f8eac2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,41 @@ Babel Changelog =============== +Version 2.13.0 +-------------- + +Upcoming deprecation +~~~~~~~~~~~~~~~~~~~~ + +* This version, Babel 2.13, is the last version of Babel to support Python 3.7. + Babel 2.14 will require Python 3.8 or newer. + +Features +~~~~~~~~ + +* Add flag to ignore POT-Creation-Date for updates by @joeportela in :gh:`999` +* Support 't' specifier in keywords by @jeanas in :gh:`1015` +* Add f-string parsing for Python 3.12 (PEP 701) by @encukou in :gh:`1027` + +Fixes +~~~~~ + +* Various typing-related fixes by @akx in :gh:`979`, in :gh:`978`, :gh:`981`, :gh:`983` +* babel.messages.catalog: deduplicate _to_fuzzy_match_key logic by @akx in :gh:`980` +* Freeze format_time() tests to a specific date to fix test failures by @mgorny in :gh:`998` +* Spelling and grammar fixes by @scop in :gh:`1008` +* Renovate lint tools by @akx in :gh:`1017`, :gh:`1028` +* Use SPDX license identifier by @vargenau in :gh:`994` +* Use aware UTC datetimes internally by @scop in :gh:`1009` + +New Contributors +~~~~~~~~~~~~~~~~ + +* @mgorny made their first contribution in :gh:`998` +* @vargenau made their first contribution in :gh:`994` +* @joeportela made their first contribution in :gh:`999` +* @encukou made their first contribution in :gh:`1027` + Version 2.12.1 -------------- @@ -18,6 +53,8 @@ Deprecations & breaking changes * Python 3.6 is no longer supported (:gh:`919`) - Aarni Koskela * The `get_next_timezone_transition` function is no more (:gh:`958`) - Aarni Koskela +* `Locale.parse()` will no longer return `None`; it will always return a Locale or raise an exception. + Passing in `None`, though technically allowed by the typing, will raise. (:gh:`966`) New features ~~~~~~~~~~~~ @@ -442,8 +479,8 @@ Version 2.3.4 Bugfixes ~~~~~~~~ -* CLDR: The lxml library is no longer used for CLDR importing, so it should not cause strange failures either. Thanks to @aronbierbaum for the bug report and @jtwang for the fix. (https://github.com/python-babel/babel/pull/393) -* CLI: Every last single CLI usage regression should now be gone, and both distutils and stand-alone CLIs should work as they have in the past. Thanks to @paxswill and @ajaeger for bug reports. (https://github.com/python-babel/babel/pull/389) +* CLDR: The lxml library is no longer used for CLDR importing, so it should not cause strange failures either. Thanks to @aronbierbaum for the bug report and @jtwang for the fix. (:gh:`393`) +* CLI: Every last single CLI usage regression should now be gone, and both distutils and stand-alone CLIs should work as they have in the past. Thanks to @paxswill and @ajaeger for bug reports. (:gh:`389`) Version 2.3.3 ------------- @@ -453,7 +490,7 @@ Version 2.3.3 Bugfixes ~~~~~~~~ -* CLI: Usage regressions that had snuck in between 2.2 and 2.3 should be no more. (https://github.com/python-babel/babel/pull/386) Thanks to @ajaeger, @sebdiem and @jcristovao for bug reports and patches. +* CLI: Usage regressions that had snuck in between 2.2 and 2.3 should be no more. (:gh:`386`) Thanks to @ajaeger, @sebdiem and @jcristovao for bug reports and patches. Version 2.3.2 ------------- @@ -478,34 +515,34 @@ Version 2.3 Internal improvements ~~~~~~~~~~~~~~~~~~~~~ -* The CLI frontend and Distutils commands use a shared implementation (https://github.com/python-babel/babel/pull/311) -* PyPy3 is supported (https://github.com/python-babel/babel/pull/343) +* The CLI frontend and Distutils commands use a shared implementation (:gh:`311`) +* PyPy3 is supported (:gh:`343`) Features ~~~~~~~~ -* CLDR: Add an API for territory language data (https://github.com/python-babel/babel/pull/315) -* Core: Character order and measurement system data is imported and exposed (https://github.com/python-babel/babel/pull/368) -* Dates: Add an API for time interval formatting (https://github.com/python-babel/babel/pull/316) -* Dates: More pattern formats and lengths are supported (https://github.com/python-babel/babel/pull/347) -* Dates: Period IDs are imported and exposed (https://github.com/python-babel/babel/pull/349) -* Dates: Support for date-time skeleton formats has been added (https://github.com/python-babel/babel/pull/265) -* Dates: Timezone formatting has been improved (https://github.com/python-babel/babel/pull/338) -* Messages: JavaScript extraction now supports dotted names, ES6 template strings and JSX tags (https://github.com/python-babel/babel/pull/332) -* Messages: npgettext is recognized by default (https://github.com/python-babel/babel/pull/341) -* Messages: The CLI learned to accept multiple domains (https://github.com/python-babel/babel/pull/335) -* Messages: The extraction commands now accept filenames in addition to directories (https://github.com/python-babel/babel/pull/324) -* Units: A new API for unit formatting is implemented (https://github.com/python-babel/babel/pull/369) +* CLDR: Add an API for territory language data (:gh:`315`) +* Core: Character order and measurement system data is imported and exposed (:gh:`368`) +* Dates: Add an API for time interval formatting (:gh:`316`) +* Dates: More pattern formats and lengths are supported (:gh:`347`) +* Dates: Period IDs are imported and exposed (:gh:`349`) +* Dates: Support for date-time skeleton formats has been added (:gh:`265`) +* Dates: Timezone formatting has been improved (:gh:`338`) +* Messages: JavaScript extraction now supports dotted names, ES6 template strings and JSX tags (:gh:`332`) +* Messages: npgettext is recognized by default (:gh:`341`) +* Messages: The CLI learned to accept multiple domains (:gh:`335`) +* Messages: The extraction commands now accept filenames in addition to directories (:gh:`324`) +* Units: A new API for unit formatting is implemented (:gh:`369`) Bugfixes ~~~~~~~~ -* Core: Mixed-case locale IDs work more reliably (https://github.com/python-babel/babel/pull/361) -* Dates: S...S formats work correctly now (https://github.com/python-babel/babel/pull/360) -* Messages: All messages are now sorted correctly if sorting has been specified (https://github.com/python-babel/babel/pull/300) -* Messages: Fix the unexpected behavior caused by catalog header updating (e0e7ef1) (https://github.com/python-babel/babel/pull/320) -* Messages: Gettext operands are now generated correctly (https://github.com/python-babel/babel/pull/295) -* Messages: Message extraction has been taught to detect encodings better (https://github.com/python-babel/babel/pull/274) +* Core: Mixed-case locale IDs work more reliably (:gh:`361`) +* Dates: S...S formats work correctly now (:gh:`360`) +* Messages: All messages are now sorted correctly if sorting has been specified (:gh:`300`) +* Messages: Fix the unexpected behavior caused by catalog header updating (e0e7ef1) (:gh:`320`) +* Messages: Gettext operands are now generated correctly (:gh:`295`) +* Messages: Message extraction has been taught to detect encodings better (:gh:`274`) Version 2.2 ----------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d634ee797..f77368087 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,25 @@ still need proper phrasing - if you'd like to help - be sure to make a PR. Please know that we do appreciate all contributions - bug reports as well as Pull Requests. -## PR Merge Criteria +## Setting up a development environment and running tests + +After you've cloned the repository, + +1. Set up a Python virtualenv (the methods vary depending on tooling and operating system) + and activate it. +2. Install Babel in editable mode with development dependencies: `pip install -e .[dev]` +3. Run `make import-cldr` to import the CLDR database. + This will download the CLDR database and convert it to a format that Babel can use. +4. Run `make test` to run the tests. You can also run e.g. `pytest --cov babel .` to + run the tests with coverage reporting enabled. + +You can also use [Tox][tox] to run the tests in separate virtualenvs +for all supported Python versions; a `tox.ini` configuration (which is what the CI process +uses) is included in the repository. + +## On pull requests + +### PR Merge Criteria For a PR to be merged, the following statements must hold true: @@ -19,14 +37,16 @@ For a PR to be merged, the following statements must hold true: To begin contributing have a look at the open [easy issues](https://github.com/python-babel/babel/issues?q=is%3Aopen+is%3Aissue+label%3Adifficulty%2Flow) which could be fixed. -## Correcting PRs +### Correcting PRs Rebasing PRs is preferred over merging master into the source branches again and again cluttering our history. If a reviewer has suggestions, the commit shall be amended so the history is not cluttered by "fixup commits". -## Writing Good Commits +### Writing Good Commits Please see https://api.coala.io/en/latest/Developers/Writing_Good_Commits.html for guidelines on how to write good commits and proper commit messages. + +[tox]: https://tox.wiki/en/latest/ diff --git a/Makefile b/Makefile index 114f0c753..05f4d8434 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,7 @@ test: import-cldr python ${PYTHON_TEST_FLAGS} -m pytest ${PYTEST_FLAGS} -test-env: - virtualenv test-env - test-env/bin/pip install pytest - test-env/bin/pip install --editable . - -clean-test-env: - rm -rf test-env - -standalone-test: import-cldr test-env - test-env/bin/pytest tests ${PYTEST_FLAGS} - -clean: clean-cldr clean-pyc clean-test-env +clean: clean-cldr clean-pyc import-cldr: python scripts/download_import_cldr.py @@ -28,7 +17,7 @@ clean-pyc: develop: pip install --editable . -tox-test: import-cldr +tox-test: tox -.PHONY: test develop tox-test clean-pyc clean-cldr import-cldr clean clean-test-env standalone-test +.PHONY: test develop tox-test clean-pyc clean-cldr import-cldr clean standalone-test diff --git a/babel/__init__.py b/babel/__init__.py index e4aca9347..a4e7de934 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -25,7 +25,7 @@ parse_locale, ) -__version__ = '2.12.1' +__version__ = '2.13.0' __all__ = [ 'Locale', diff --git a/babel/core.py b/babel/core.py index 6df585083..f63b97b65 100644 --- a/babel/core.py +++ b/babel/core.py @@ -29,6 +29,7 @@ "currency_fractions", "language_aliases", "likely_subtags", + "meta_zones", "parent_exceptions", "script_aliases", "territory_aliases", @@ -196,7 +197,7 @@ def __init__( self.variant = variant #: the modifier self.modifier = modifier - self.__data = None + self.__data: localedata.LocaleDataDict | None = None identifier = str(self) identifier_without_modifier = identifier.partition('@')[0] @@ -259,6 +260,7 @@ def negotiate( aliases=aliases) if identifier: return Locale.parse(identifier, sep=sep) + return None @classmethod def parse( @@ -279,6 +281,15 @@ def parse( >>> Locale.parse(l) Locale('de', territory='DE') + If the `identifier` parameter is neither of these, such as `None` + e.g. because a default locale identifier could not be determined, + a `TypeError` is raised: + + >>> Locale.parse(None) + Traceback (most recent call last): + ... + TypeError: ... + This also can perform resolving of likely subtags which it does by default. This is for instance useful to figure out the most likely locale for a territory you can use ``'und'`` as the @@ -458,9 +469,9 @@ def get_display_name(self, locale: Locale | str | None = None) -> str | None: details.append(locale.variants.get(self.variant)) if self.modifier: details.append(self.modifier) - details = filter(None, details) - if details: - retval += f" ({', '.join(details)})" + detail_string = ', '.join(atom for atom in details if atom) + if detail_string: + retval += f" ({detail_string})" return retval display_name = property(get_display_name, doc="""\ @@ -1070,6 +1081,7 @@ def default_locale(category: str | None = None, aliases: Mapping[str, str] = LOC return get_locale_identifier(parse_locale(locale)) except ValueError: pass + return None def negotiate_locale(preferred: Iterable[str], available: Iterable[str], sep: str = '_', aliases: Mapping[str, str] = LOCALE_ALIASES) -> str | None: diff --git a/babel/dates.py b/babel/dates.py index eb1019e89..ddc8e7105 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -112,7 +112,7 @@ def _get_tz_name(dt_or_tzinfo: _DtOrTzinfo) -> str: elif hasattr(tzinfo, 'key') and tzinfo.key is not None: # ZoneInfo object return tzinfo.key else: - return tzinfo.tzname(dt or datetime.datetime.utcnow()) + return tzinfo.tzname(dt or datetime.datetime.now(UTC)) def _get_datetime(instant: _Instant) -> datetime.datetime: @@ -147,9 +147,9 @@ def _get_datetime(instant: _Instant) -> datetime.datetime: :rtype: datetime """ if instant is None: - return datetime.datetime.utcnow() + return datetime.datetime.now(UTC).replace(tzinfo=None) elif isinstance(instant, (int, float)): - return datetime.datetime.utcfromtimestamp(instant) + return datetime.datetime.fromtimestamp(instant, UTC).replace(tzinfo=None) elif isinstance(instant, datetime.time): return datetime.datetime.combine(datetime.date.today(), instant) elif isinstance(instant, datetime.date) and not isinstance(instant, datetime.datetime): @@ -201,9 +201,9 @@ def _get_time( :rtype: time """ if time is None: - time = datetime.datetime.utcnow() + time = datetime.datetime.now(UTC) elif isinstance(time, (int, float)): - time = datetime.datetime.utcfromtimestamp(time) + time = datetime.datetime.fromtimestamp(time, UTC) if time.tzinfo is None: time = time.replace(tzinfo=UTC) @@ -538,11 +538,11 @@ def get_timezone_name( >>> from datetime import time >>> dt = time(15, 30, tzinfo=get_timezone('America/Los_Angeles')) - >>> get_timezone_name(dt, locale='en_US') + >>> get_timezone_name(dt, locale='en_US') # doctest: +SKIP u'Pacific Standard Time' >>> get_timezone_name(dt, locale='en_US', return_zone=True) 'America/Los_Angeles' - >>> get_timezone_name(dt, width='short', locale='en_US') + >>> get_timezone_name(dt, width='short', locale='en_US') # doctest: +SKIP u'PST' If this function gets passed only a `tzinfo` object and no concrete @@ -774,10 +774,10 @@ def format_time( >>> t = time(15, 30) >>> format_time(t, format='full', tzinfo=get_timezone('Europe/Paris'), - ... locale='fr_FR') + ... locale='fr_FR') # doctest: +SKIP u'15:30:00 heure normale d\u2019Europe centrale' >>> format_time(t, format='full', tzinfo=get_timezone('US/Eastern'), - ... locale='en_US') + ... locale='en_US') # doctest: +SKIP u'3:30:00\u202fPM Eastern Standard Time' :param time: the ``time`` or ``datetime`` object; if `None`, the current @@ -922,9 +922,12 @@ def format_timedelta( if format not in ('narrow', 'short', 'medium', 'long'): raise TypeError('Format must be one of "narrow", "short" or "long"') if format == 'medium': - warnings.warn('"medium" value for format param of format_timedelta' - ' is deprecated. Use "long" instead', - category=DeprecationWarning) + warnings.warn( + '"medium" value for format param of format_timedelta' + ' is deprecated. Use "long" instead', + category=DeprecationWarning, + stacklevel=2, + ) format = 'long' if isinstance(delta, datetime.timedelta): seconds = int((delta.days * 86400) + delta.seconds) diff --git a/babel/lists.py b/babel/lists.py index 6ea4f014a..5c435dd63 100644 --- a/babel/lists.py +++ b/babel/lists.py @@ -46,7 +46,7 @@ def format_list(lst: Sequence[str], A typical 'and' list for arbitrary placeholders. eg. "January, February, and March" * standard-short: - A short version of a 'and' list, suitable for use with short or abbreviated placeholder values. + A short version of an 'and' list, suitable for use with short or abbreviated placeholder values. eg. "Jan., Feb., and Mar." * or: A typical 'or' list for arbitrary placeholders. diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 20a3166e4..8faf046d3 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -769,6 +769,7 @@ def update( no_fuzzy_matching: bool = False, update_header_comment: bool = False, keep_user_comments: bool = True, + update_creation_date: bool = True, ) -> None: """Update the catalog based on the given template catalog. @@ -827,15 +828,13 @@ def update( self._messages = OrderedDict() # Prepare for fuzzy matching - fuzzy_candidates = [] + fuzzy_candidates = {} if not no_fuzzy_matching: - fuzzy_candidates = {} for msgid in messages: if msgid and messages[msgid].string: key = self._key_for(msgid) ctxt = messages[msgid].context - modified_key = key.lower().strip() - fuzzy_candidates[modified_key] = (key, ctxt) + fuzzy_candidates[self._to_fuzzy_match_key(key)] = (key, ctxt) fuzzy_matches = set() def _merge(message: Message, oldkey: tuple[str, str] | str, newkey: tuple[str, str] | str) -> None: @@ -883,12 +882,11 @@ def _merge(message: Message, oldkey: tuple[str, str] | str, newkey: tuple[str, s else: if not no_fuzzy_matching: # do some fuzzy matching with difflib - if isinstance(key, tuple): - matchkey = key[0] # just the msgid, no context - else: - matchkey = key - matches = get_close_matches(matchkey.lower().strip(), - fuzzy_candidates.keys(), 1) + matches = get_close_matches( + self._to_fuzzy_match_key(key), + fuzzy_candidates.keys(), + 1, + ) if matches: modified_key = matches[0] newkey, newctxt = fuzzy_candidates[modified_key] @@ -910,7 +908,16 @@ def _merge(message: Message, oldkey: tuple[str, str] | str, newkey: tuple[str, s # Make updated catalog's POT-Creation-Date equal to the template # used to update the catalog - self.creation_date = template.creation_date + if update_creation_date: + self.creation_date = template.creation_date + + def _to_fuzzy_match_key(self, key: tuple[str, str] | str) -> str: + """Converts a message key to a string suitable for fuzzy matching.""" + if isinstance(key, tuple): + matchkey = key[0] # just the msgid, no context + else: + matchkey = key + return matchkey.lower().strip() 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 diff --git a/babel/messages/checkers.py b/babel/messages/checkers.py index 056f3e982..f4070e56d 100644 --- a/babel/messages/checkers.py +++ b/babel/messages/checkers.py @@ -148,10 +148,10 @@ def _check_positional(results: list[tuple[str, str]]) -> bool: if name not in type_map: raise TranslationError(f'unknown named placeholder {name!r}') elif not _compatible(typechar, type_map[name]): - raise TranslationError('incompatible format for ' - 'placeholder %r: ' - '%r and %r are not compatible' % - (name, typechar, type_map[name])) + raise TranslationError( + f'incompatible format for placeholder {name!r}: ' + f'{typechar!r} and {type_map[name]!r} are not compatible' + ) def _find_checkers() -> list[Callable[[Catalog | None, Message], object]]: diff --git a/babel/messages/extract.py b/babel/messages/extract.py index b6dce6fdb..a5b6eab07 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -21,6 +21,7 @@ import io import os import sys +import tokenize from collections.abc import ( Callable, Collection, @@ -55,7 +56,8 @@ 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 + _SimpleKeyword: TypeAlias = tuple[int | tuple[int, int] | tuple[int, str], ...] | None + _Keyword: TypeAlias = dict[int | None, _SimpleKeyword] | _SimpleKeyword # 5-tuple of (filename, lineno, messages, comments, context) _FileExtractionResult: TypeAlias = tuple[str, int, str | tuple[str, ...], list[str], str | None] @@ -89,6 +91,11 @@ def tell(self) -> int: ... DEFAULT_MAPPING: list[tuple[str, str]] = [('**.py', 'python')] +# New tokens in Python 3.12, or None on older versions +FSTRING_START = getattr(tokenize, "FSTRING_START", None) +FSTRING_MIDDLE = getattr(tokenize, "FSTRING_MIDDLE", None) +FSTRING_END = getattr(tokenize, "FSTRING_END", None) + def _strip_comment_tags(comments: MutableSequence[str], tags: Iterable[str]): """Helper function for `extract` that strips comment tags from strings @@ -315,6 +322,47 @@ def extract_from_file( options, strip_comment_tags)) +def _match_messages_against_spec(lineno: int, messages: list[str|None], comments: list[str], + fileobj: _FileObj, spec: tuple[int|tuple[int, str], ...]): + translatable = [] + context = None + + # last_index is 1 based like the keyword spec + last_index = len(messages) + for index in spec: + if isinstance(index, tuple): # (n, 'c') + context = messages[index[0] - 1] + continue + if last_index < index: + # Not enough arguments + return + message = messages[index - 1] + if message is None: + return + translatable.append(message) + + # keyword spec indexes are 1 based, therefore '-1' + if isinstance(spec[0], tuple): + # context-aware *gettext method + first_msg_index = spec[1] - 1 + else: + first_msg_index = spec[0] - 1 + # An empty string msgid isn't valid, emit a warning + if not messages[first_msg_index]: + 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" + ) + return + + translatable = tuple(translatable) + if len(translatable) == 1: + translatable = translatable[0] + + return lineno, translatable, comments, context + + def extract( method: _ExtractionMethod, fileobj: _FileObj, @@ -400,56 +448,30 @@ def extract( options=options or {}) for lineno, funcname, messages, comments in results: - spec = keywords[funcname] or (1,) if funcname else (1,) if not isinstance(messages, (list, tuple)): messages = [messages] if not messages: continue - # Validate the messages against the keyword's specification - context = None - msgs = [] - invalid = False - # last_index is 1 based like the keyword spec - last_index = len(messages) - for index in spec: - if isinstance(index, tuple): - context = messages[index[0] - 1] - continue - if last_index < index: - # Not enough arguments - invalid = True - break - message = messages[index - 1] - if message is None: - invalid = True - break - msgs.append(message) - if invalid: - continue - - # keyword spec indexes are 1 based, therefore '-1' - if isinstance(spec[0], tuple): - # context-aware *gettext method - first_msg_index = spec[1] - 1 - else: - first_msg_index = spec[0] - 1 - if not messages[first_msg_index]: - # An empty string msgid isn't valid, emit a warning - 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) - if len(messages) == 1: - messages = messages[0] + specs = keywords[funcname] or None if funcname else None + # {None: x} may be collapsed into x for backwards compatibility. + if not isinstance(specs, dict): + specs = {None: specs} if strip_comment_tags: _strip_comment_tags(comments, comment_tags) - yield lineno, messages, comments, context + + # None matches all arities. + for arity in (None, len(messages)): + try: + spec = specs[arity] + except KeyError: + continue + if spec is None: + spec = (1,) + result = _match_messages_against_spec(lineno, messages, comments, fileobj, spec) + if result is not None: + yield result def extract_nothing( @@ -497,6 +519,11 @@ def extract_python( next_line = lambda: fileobj.readline().decode(encoding) tokens = generate_tokens(next_line) + + # Current prefix of a Python 3.12 (PEP 701) f-string, or None if we're not + # currently parsing one. + current_fstring_start = None + for tok, value, (lineno, _), _, _ in tokens: if call_stack == -1 and tok == NAME and value in ('def', 'class'): in_def = True @@ -558,6 +585,20 @@ def extract_python( val = _parse_python_string(value, encoding, future_flags) if val is not None: buf.append(val) + + # Python 3.12+, see https://peps.python.org/pep-0701/#new-tokens + elif tok == FSTRING_START: + current_fstring_start = value + elif tok == FSTRING_MIDDLE: + if current_fstring_start is not None: + current_fstring_start += value + elif tok == FSTRING_END: + if current_fstring_start is not None: + fstring = current_fstring_start + value + val = _parse_python_string(fstring, encoding, future_flags) + if val is not None: + buf.append(val) + elif tok == OP and value == ',': if buf: messages.append(''.join(buf)) @@ -578,6 +619,15 @@ def extract_python( elif tok == NAME and value in keywords: funcname = value + if (current_fstring_start is not None + and tok not in {FSTRING_START, FSTRING_MIDDLE} + ): + # In Python 3.12, tokens other than FSTRING_* mean the + # f-string is dynamic, so we don't wan't to extract it. + # And if it's FSTRING_END, we've already handled it above. + # Let's forget that we're in an f-string. + current_fstring_start = None + def _parse_python_string(value: str, encoding: str, future_flags: int) -> str | None: # Unwrap quotes in a safe manner, maintaining the string's encoding diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index af7e1d11d..0008a9b84 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -8,6 +8,8 @@ :license: BSD, see LICENSE for more details. """ +from __future__ import annotations + import datetime import fnmatch import logging @@ -727,11 +729,13 @@ class update_catalog(Command): 'don\'t update the catalog, just return the status. Return code 0 ' 'means nothing would change. Return code 1 means that the catalog ' 'would be updated'), + ('ignore-pot-creation-date=', None, + 'ignore changes to POT-Creation-Date when updating or checking'), ] boolean_options = [ 'omit-header', 'no-wrap', 'ignore-obsolete', 'init-missing', 'no-fuzzy-matching', 'previous', 'update-header-comment', - 'check', + 'check', 'ignore-pot-creation-date', ] def initialize_options(self): @@ -749,6 +753,7 @@ def initialize_options(self): self.update_header_comment = False self.previous = False self.check = False + self.ignore_pot_creation_date = False def finalize_options(self): if not self.input_file: @@ -837,7 +842,8 @@ def run(self): catalog.update( template, self.no_fuzzy_matching, - update_header_comment=self.update_header_comment + update_header_comment=self.update_header_comment, + update_creation_date=not self.ignore_pot_creation_date, ) tmpname = os.path.join(os.path.dirname(filename), @@ -1107,34 +1113,63 @@ def parse_mapping(fileobj, filename=None): return method_map, options_map +def _parse_spec(s: str) -> tuple[int | None, tuple[int|tuple[int, str], ...]]: + inds = [] + number = None + for x in s.split(','): + if x[-1] == 't': + number = int(x[:-1]) + elif x[-1] == 'c': + inds.append((int(x[:-1]), 'c')) + else: + inds.append(int(x)) + return number, tuple(inds) def parse_keywords(strings: Iterable[str] = ()): """Parse keywords specifications from the given list of strings. - >>> kw = sorted(parse_keywords(['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2']).items()) - >>> for keyword, indices in kw: - ... print((keyword, indices)) - ('_', None) - ('dgettext', (2,)) - ('dngettext', (2, 3)) - ('pgettext', ((1, 'c'), 2)) + >>> import pprint + >>> keywords = ['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2', + ... 'polymorphic:1', 'polymorphic:2,2t', 'polymorphic:3c,3t'] + >>> pprint.pprint(parse_keywords(keywords)) + {'_': None, + 'dgettext': (2,), + 'dngettext': (2, 3), + 'pgettext': ((1, 'c'), 2), + 'polymorphic': {None: (1,), 2: (2,), 3: ((3, 'c'),)}} + + The input keywords are in GNU Gettext style; see :doc:`cmdline` for details. + + The output is a dictionary mapping keyword names to a dictionary of specifications. + Keys in this dictionary are numbers of arguments, where ``None`` means that all numbers + of arguments are matched, and a number means only calls with that number of arguments + are matched (which happens when using the "t" specifier). However, as a special + case for backwards compatibility, if the dictionary of specifications would + be ``{None: x}``, i.e., there is only one specification and it matches all argument + counts, then it is collapsed into just ``x``. + + A specification is either a tuple or None. If a tuple, each element can be either a number + ``n``, meaning that the nth argument should be extracted as a message, or the tuple + ``(n, 'c')``, meaning that the nth argument should be extracted as context for the + messages. A ``None`` specification is equivalent to ``(1,)``, extracting the first + argument. """ keywords = {} for string in strings: if ':' in string: - funcname, indices = string.split(':') + funcname, spec_str = string.split(':') + number, spec = _parse_spec(spec_str) else: - funcname, indices = string, None - if funcname not in keywords: - if indices: - inds = [] - for x in indices.split(','): - if x[-1] == 'c': - inds.append((int(x[:-1]), 'c')) - else: - inds.append(int(x)) - indices = tuple(inds) - keywords[funcname] = indices + funcname = string + number = None + spec = None + keywords.setdefault(funcname, {})[number] = spec + + # For best backwards compatibility, collapse {None: x} into x. + for k, v in keywords.items(): + if set(v) == {None}: + keywords[k] = v[None] + return keywords diff --git a/babel/messages/jslexer.py b/babel/messages/jslexer.py index d3389d076..cf287e450 100644 --- a/babel/messages/jslexer.py +++ b/babel/messages/jslexer.py @@ -98,7 +98,7 @@ def unquote_string(string: str) -> str: assert string and string[0] == string[-1] and string[0] in '"\'`', \ 'string provided is not properly delimited' string = line_join_re.sub('\\1', string[1:-1]) - result = [] + result: list[str] = [] add = result.append pos = 0 diff --git a/babel/messages/plurals.py b/babel/messages/plurals.py index 86ec8008b..5341b8356 100644 --- a/babel/messages/plurals.py +++ b/babel/messages/plurals.py @@ -213,7 +213,7 @@ class _PluralTuple(tuple): The number of plurals used by the locale.""") plural_expr = property(itemgetter(1), doc=""" The plural expression used by the locale.""") - plural_forms = property(lambda x: 'nplurals=%s; plural=%s;' % x, doc=""" + plural_forms = property(lambda x: 'nplurals={}; plural={};'.format(*x), doc=""" The plural expression used by the catalog or locale.""") def __str__(self) -> str: diff --git a/babel/numbers.py b/babel/numbers.py index de0f419d4..e0df40cce 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -60,10 +60,8 @@ def list_currencies(locale: Locale | str | None = None) -> set[str]: """ # Get locale-scoped currencies. if locale: - currencies = Locale.parse(locale).currencies.keys() - else: - currencies = get_global('all_currencies') - return set(currencies) + return set(Locale.parse(locale).currencies) + return set(get_global('all_currencies')) def validate_currency(currency: str, locale: Locale | str | None = None) -> None: @@ -103,7 +101,7 @@ def normalize_currency(currency: str, locale: Locale | str | None = None) -> str if isinstance(currency, str): currency = currency.upper() if not is_currency(currency, locale): - return + return None return currency @@ -402,7 +400,7 @@ def format_number(number: float | decimal.Decimal | str, locale: Locale | str | """ - warnings.warn('Use babel.numbers.format_decimal() instead.', DeprecationWarning) + warnings.warn('Use babel.numbers.format_decimal() instead.', DeprecationWarning, stacklevel=2) return format_decimal(number, locale=locale) @@ -706,7 +704,7 @@ def _format_currency_long_name( # Step 5. if not format: - format = locale.decimal_formats[format] + format = locale.decimal_formats[None] pattern = parse_pattern(format) @@ -810,7 +808,7 @@ def format_percent( """ locale = Locale.parse(locale) if not format: - format = locale.percent_formats[format] + format = locale.percent_formats[None] pattern = parse_pattern(format) return pattern.apply( number, locale, decimal_quantization=decimal_quantization, group_separator=group_separator) @@ -849,7 +847,7 @@ def format_scientific( """ locale = Locale.parse(locale) if not format: - format = locale.scientific_formats[format] + format = locale.scientific_formats[None] pattern = parse_pattern(format) return pattern.apply( number, locale, decimal_quantization=decimal_quantization) @@ -1192,7 +1190,11 @@ def apply( # currency's if necessary. if force_frac: # TODO (3.x?): Remove this parameter - warnings.warn('The force_frac parameter to NumberPattern.apply() is deprecated.', DeprecationWarning) + warnings.warn( + 'The force_frac parameter to NumberPattern.apply() is deprecated.', + DeprecationWarning, + stacklevel=2, + ) frac_prec = force_frac elif currency and currency_digits: frac_prec = (get_currency_precision(currency), ) * 2 diff --git a/babel/plural.py b/babel/plural.py index c5c77161b..9d368481c 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -24,7 +24,7 @@ def extract_operands(source: float | decimal.Decimal) -> tuple[decimal.Decimal | int, int, int, int, int, int, Literal[0], Literal[0]]: """Extract operands from a decimal, a float or an int, according to `CLDR rules`_. - The result is a 8-tuple (n, i, v, w, f, t, c, e), where those symbols are as follows: + The result is an 8-tuple (n, i, v, w, f, t, c, e), where those symbols are as follows: ====== =============================================================== Symbol Value diff --git a/babel/support.py b/babel/support.py index d6ff73aa2..e0a76b2d3 100644 --- a/babel/support.py +++ b/babel/support.py @@ -17,7 +17,7 @@ import locale import os from collections.abc import Iterator -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Iterable from babel.core import Locale from babel.dates import format_date, format_datetime, format_time, format_timedelta @@ -393,8 +393,11 @@ def ldgettext(self, domain: str, message: str) -> str: domain. """ import warnings - warnings.warn('ldgettext() is deprecated, use dgettext() instead', - DeprecationWarning, 2) + warnings.warn( + 'ldgettext() is deprecated, use dgettext() instead', + DeprecationWarning, + stacklevel=2, + ) return self._domains.get(domain, self).lgettext(message) def udgettext(self, domain: str, message: str) -> str: @@ -416,8 +419,11 @@ def ldngettext(self, domain: str, singular: str, plural: str, num: int) -> str: domain. """ import warnings - warnings.warn('ldngettext() is deprecated, use dngettext() instead', - DeprecationWarning, 2) + warnings.warn( + 'ldngettext() is deprecated, use dngettext() instead', + DeprecationWarning, + stacklevel=2, + ) return self._domains.get(domain, self).lngettext(singular, plural, num) def udngettext(self, domain: str, singular: str, plural: str, num: int) -> str: @@ -458,8 +464,11 @@ def lpgettext(self, context: str, message: str) -> str | bytes | object: ``bind_textdomain_codeset()``. """ import warnings - warnings.warn('lpgettext() is deprecated, use pgettext() instead', - DeprecationWarning, 2) + warnings.warn( + 'lpgettext() is deprecated, use pgettext() instead', + DeprecationWarning, + stacklevel=2, + ) tmsg = self.pgettext(context, message) encoding = getattr(self, "_output_charset", None) or locale.getpreferredencoding() return tmsg.encode(encoding) if isinstance(tmsg, str) else tmsg @@ -493,8 +502,11 @@ def lnpgettext(self, context: str, singular: str, plural: str, num: int) -> str ``bind_textdomain_codeset()``. """ import warnings - warnings.warn('lnpgettext() is deprecated, use npgettext() instead', - DeprecationWarning, 2) + warnings.warn( + 'lnpgettext() is deprecated, use npgettext() instead', + DeprecationWarning, + stacklevel=2, + ) ctxt_msg_id = self.CONTEXT_ENCODING % (context, singular) try: tmsg = self._catalog[(ctxt_msg_id, self.plural(num))] @@ -615,7 +627,7 @@ def __init__(self, fp: gettext._TranslationsReader | None = None, domain: str | def load( cls, dirname: str | os.PathLike[str] | None = None, - locales: list[str] | tuple[str, ...] | str | None = None, + locales: Iterable[str | Locale] | str | Locale | None = None, domain: str | None = None, ) -> NullTranslations: """Load translations from the given directory. @@ -626,13 +638,9 @@ def load( strings) :param domain: the message domain (default: 'messages') """ - if locales is not None: - if not isinstance(locales, (list, tuple)): - locales = [locales] - locales = [str(locale) for locale in locales] if not domain: domain = cls.DEFAULT_DOMAIN - filename = gettext.find(domain, dirname, locales) + filename = gettext.find(domain, dirname, _locales_to_names(locales)) if not filename: return NullTranslations() with open(filename, 'rb') as fp: @@ -683,3 +691,21 @@ def merge(self, translations: Translations): self.files.extend(translations.files) return self + + +def _locales_to_names( + locales: Iterable[str | Locale] | str | Locale | None, +) -> list[str] | None: + """Normalize a `locales` argument to a list of locale names. + + :param locales: the list of locales in order of preference (items in + this list can be either `Locale` objects or locale + strings) + """ + if locales is None: + return None + if isinstance(locales, Locale): + return [str(locale)] + if isinstance(locales, str): + return [locales] + return [str(locale) for locale in locales] diff --git a/babel/units.py b/babel/units.py index 0c72ee97a..7b0e144de 100644 --- a/babel/units.py +++ b/babel/units.py @@ -50,7 +50,7 @@ def get_unit_name( def _find_unit_pattern(unit_id: str, locale: Locale | str | None = LC_NUMERIC) -> str | None: """ - Expand an unit into a qualified form. + Expand a unit into a qualified form. Known units can be found in the CLDR Unit Validity XML file: https://unicode.org/repos/cldr/tags/latest/common/validity/unit.xml @@ -72,10 +72,11 @@ def _find_unit_pattern(unit_id: str, locale: Locale | str | None = LC_NUMERIC) - for unit_pattern in sorted(unit_patterns, key=len): if unit_pattern.endswith(unit_id): return unit_pattern + return None def format_unit( - value: float | decimal.Decimal, + value: str | float | decimal.Decimal, measurement_unit: str, length: Literal['short', 'long', 'narrow'] = 'long', format: str | None = None, @@ -184,18 +185,18 @@ def _find_compound_unit( # units like "kilometer" or "hour" into actual units like "length-kilometer" and # "duration-hour". - numerator_unit = _find_unit_pattern(numerator_unit, locale=locale) - denominator_unit = _find_unit_pattern(denominator_unit, locale=locale) + resolved_numerator_unit = _find_unit_pattern(numerator_unit, locale=locale) + resolved_denominator_unit = _find_unit_pattern(denominator_unit, locale=locale) # If either was not found, we can't possibly build a suitable compound unit either. - if not (numerator_unit and denominator_unit): + if not (resolved_numerator_unit and resolved_denominator_unit): return None # Since compound units are named "speed-kilometer-per-hour", we'll have to slice off # the quantities (i.e. "length", "duration") from both qualified units. - bare_numerator_unit = numerator_unit.split("-", 1)[-1] - bare_denominator_unit = denominator_unit.split("-", 1)[-1] + bare_numerator_unit = resolved_numerator_unit.split("-", 1)[-1] + bare_denominator_unit = resolved_denominator_unit.split("-", 1)[-1] # Now we can try and rebuild a compound unit specifier, then qualify it: @@ -203,9 +204,9 @@ def _find_compound_unit( def format_compound_unit( - numerator_value: float | decimal.Decimal, + numerator_value: str | float | decimal.Decimal, numerator_unit: str | None = None, - denominator_value: float | decimal.Decimal = 1, + denominator_value: str | float | decimal.Decimal = 1, denominator_unit: str | None = None, length: Literal["short", "long", "narrow"] = "long", format: str | None = None, @@ -289,7 +290,11 @@ def format_compound_unit( denominator_value = "" formatted_denominator = format_unit( - denominator_value, denominator_unit, length=length, format=format, locale=locale + denominator_value, + measurement_unit=(denominator_unit or ""), + length=length, + format=format, + locale=locale, ).strip() else: # Bare denominator formatted_denominator = format_decimal(denominator_value, format=format, locale=locale) diff --git a/contrib/babel.js b/contrib/babel.js index 506efeb84..b1ad341d1 100644 --- a/contrib/babel.js +++ b/contrib/babel.js @@ -15,7 +15,7 @@ /** * A simple module that provides a gettext like translation interface. - * The catalog passed to load() must be a object conforming to this + * The catalog passed to load() must be an object conforming to this * interface:: * * { diff --git a/docs/cmdline.rst b/docs/cmdline.rst index 8d9742fbf..e1328fe8f 100644 --- a/docs/cmdline.rst +++ b/docs/cmdline.rst @@ -133,6 +133,45 @@ a collection of source files:: header comment for the catalog +The meaning of ``--keyword`` values is as follows: + +- Pass a simple identifier like ``_`` to extract the first (and only the first) + argument of all function calls to ``_``, + +- To extract other arguments than the first, add a colon and the argument + indices separated by commas. For example, the ``dngettext`` function + typically expects translatable strings as second and third arguments, + so you could pass ``dngettext:2,3``. + +- Some arguments should not be interpreted as translatable strings, but + context strings. For that, append "c" to the argument index. For example: + ``pgettext:1c,2``. + +- In C++ and Python, you may have functions that behave differently + depending on how many arguments they take. For this use case, you can + add an integer followed by "t" after the colon. In this case, the + keyword will only match a function invocation if it has the specified + total number of arguments. For example, if you have a function + ``foo`` that behaves as ``gettext`` (argument is a message) or + ``pgettext`` (arguments are a context and a message) depending on + whether it takes one or two arguments, you can pass + ``--keyword=foo:1,1t --keyword=foo:1c,2,2t``. + +The default keywords are equivalent to passing :: + + --keyword=_ + --keyword=gettext + --keyword=ngettext:1,2 + --keyword=ugettext + --keyword=ungettext:1,2 + --keyword=dgettext:2 + --keyword=dngettext:2,3 + --keyword=N_ + --keyword=pgettext:1c,2 + --keyword=npgettext:1c,2,3 + + + init ==== diff --git a/docs/conf.py b/docs/conf.py index 71718a1e0..90c452553 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,9 +51,9 @@ # built documents. # # The short X.Y version. -version = '2.12' +version = '2.13' # The full version, including alpha/beta/rc tags. -release = '2.12.1' +release = '2.13.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/dates.rst b/docs/dates.rst index 1827a9a20..0c2c17fc0 100644 --- a/docs/dates.rst +++ b/docs/dates.rst @@ -67,9 +67,9 @@ local time when returning dates to users. At that point the timezone the user has selected can usually be established and Babel can automatically rebase the time for you. -To get the current time use the :meth:`~datetime.datetime.utcnow` method -of the :class:`~datetime.datetime` object. It will return a naive -:class:`~datetime.datetime` object in UTC. +To get the current time use the :meth:`~datetime.datetime.now` method +of the :class:`~datetime.datetime` object, +passing :attr:`~datetime.timezone.utc` to it as the timezone. For more information about timezones see :ref:`timezone-support`. diff --git a/scripts/generate_authors.py b/scripts/generate_authors.py index 64c0af82e..a5443b91e 100644 --- a/scripts/generate_authors.py +++ b/scripts/generate_authors.py @@ -14,7 +14,7 @@ def get_sorted_authors_list(): def get_authors_file_content(): author_list = "\n".join(f"- {a}" for a in get_sorted_authors_list()) - return ''' + return f''' Babel is written and maintained by the Babel team and various contributors: {author_list} @@ -26,7 +26,7 @@ def get_authors_file_content(): In addition to the regular contributions Babel includes a fork of Lennart Regebro's tzlocal that originally was licensed under the CC0 license. The original copyright of that project is "Copyright 2013 by Lennart Regebro". -'''.format(author_list=author_list) +''' def write_authors_file(): diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 5a4a3a817..493787407 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -525,25 +525,25 @@ def parse_dates(data, tree, sup, regions, territory): if _should_skip_elem(elem): continue territories = elem.attrib['territories'].split() - if territory in territories or any([r in territories for r in regions]): + if territory in territories or any(r in territories for r in regions): week_data['min_days'] = int(elem.attrib['count']) for elem in supelem.findall('firstDay'): if _should_skip_elem(elem): continue territories = elem.attrib['territories'].split() - if territory in territories or any([r in territories for r in regions]): + if territory in territories or any(r in territories for r in regions): week_data['first_day'] = weekdays[elem.attrib['day']] for elem in supelem.findall('weekendStart'): if _should_skip_elem(elem): continue territories = elem.attrib['territories'].split() - if territory in territories or any([r in territories for r in regions]): + if territory in territories or any(r in territories for r in regions): week_data['weekend_start'] = weekdays[elem.attrib['day']] for elem in supelem.findall('weekendEnd'): if _should_skip_elem(elem): continue territories = elem.attrib['territories'].split() - if territory in territories or any([r in territories for r in regions]): + if territory in territories or any(r in territories for r in regions): week_data['weekend_end'] = weekdays[elem.attrib['day']] zone_formats = data.setdefault('zone_formats', {}) for elem in tree.findall('.//timeZoneNames/gmtFormat'): diff --git a/setup.py b/setup.py index dbc6c60c1..a013b7880 100755 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def run(self): author_email='armin.ronacher@active-4.com', maintainer='Aarni Koskela', maintainer_email='akx@iki.fi', - license='BSD', + license='BSD-3-Clause', url='https://babel.pocoo.org/', project_urls={ 'Source': 'https://github.com/python-babel/babel', @@ -52,6 +52,7 @@ def run(self): 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries :: Python Modules', @@ -67,6 +68,13 @@ def run(self): # Python 3.9 and later include zoneinfo which replaces pytz 'pytz>=2015.7; python_version<"3.9"', ], + extras_require={ + 'dev': [ + 'pytest>=6.0', + 'pytest-cov', + 'freezegun~=1.0', + ], + }, cmdclass={'import_cldr': import_cldr}, zip_safe=False, # Note when adding extractors: builtin extractors we also want to diff --git a/tests/messages/data/project/file1.py b/tests/messages/data/project/file1.py index 80ad00c66..460d94152 100644 --- a/tests/messages/data/project/file1.py +++ b/tests/messages/data/project/file1.py @@ -5,4 +5,4 @@ def foo(): # TRANSLATOR: This will be a translator coment, # that will include several lines - print _('bar') + print(_('bar')) diff --git a/tests/messages/data/project/file2.py b/tests/messages/data/project/file2.py index 9991ea89e..b6214932e 100644 --- a/tests/messages/data/project/file2.py +++ b/tests/messages/data/project/file2.py @@ -6,4 +6,4 @@ def foo(): # Note: This will have the TRANSLATOR: tag but shouldn't # be included on the extracted stuff - print ngettext('foobar', 'foobars', 1) + print(ngettext('foobar', 'foobars', 1)) diff --git a/tests/messages/data/project/ignored/this_wont_normally_be_here.py b/tests/messages/data/project/ignored/this_wont_normally_be_here.py index b96f54260..8ca499157 100644 --- a/tests/messages/data/project/ignored/this_wont_normally_be_here.py +++ b/tests/messages/data/project/ignored/this_wont_normally_be_here.py @@ -8,4 +8,4 @@ def foo(): # Note: This will have the TRANSLATOR: tag but shouldn't # be included on the extracted stuff - print ngettext('FooBar', 'FooBars', 1) + print(ngettext('FooBar', 'FooBars', 1)) diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index da8ad5577..4e133304f 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -273,6 +273,17 @@ def test_update_po_updates_pot_creation_date(self): localized_catalog.update(template) assert template.creation_date == localized_catalog.creation_date + def test_update_po_ignores_pot_creation_date(self): + template = catalog.Catalog() + localized_catalog = copy.deepcopy(template) + localized_catalog.locale = 'de_DE' + assert template.mime_headers != localized_catalog.mime_headers + assert template.creation_date == localized_catalog.creation_date + template.creation_date = datetime.datetime.now() - \ + datetime.timedelta(minutes=5) + localized_catalog.update(template, update_creation_date=False) + assert template.creation_date != localized_catalog.creation_date + def test_update_po_keeps_po_revision_date(self): template = catalog.Catalog() localized_catalog = copy.deepcopy(template) @@ -455,7 +466,7 @@ def test_catalog_update(): assert len(cat) == 3 msg1 = cat['green'] - msg1.string + assert not msg1.string assert msg1.locations == [('main.py', 99)] msg2 = cat['blue'] diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index 8e1e670ca..b28cb0da2 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -16,8 +16,8 @@ import sys import time import unittest -from datetime import datetime -from io import StringIO +from datetime import datetime, timedelta +from io import BytesIO, StringIO import pytest from freezegun import freeze_time @@ -25,7 +25,7 @@ from babel import __version__ as VERSION from babel.dates import format_datetime -from babel.messages import Catalog, frontend +from babel.messages import Catalog, extract, frontend from babel.messages.frontend import ( BaseError, CommandLineInterface, @@ -1179,6 +1179,60 @@ def test_update(self): catalog = read_po(infp) assert len(catalog) == 4 # Catalog was updated + def test_update_pot_creation_date(self): + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + po_file = os.path.join(i18n_dir, 'temp1.po') + self.cli.run(sys.argv + ['init', + '-l', 'fi', + '-o', po_file, + '-i', tmpl_file + ]) + with open(po_file) as infp: + catalog = read_po(infp) + assert len(catalog) == 3 + original_catalog_creation_date = catalog.creation_date + + # Update the template creation date + template.creation_date -= timedelta(minutes=3) + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + with open(po_file) as infp: + catalog = read_po(infp) + # We didn't ignore the creation date, so expect a diff + assert catalog.creation_date != original_catalog_creation_date + + # Reset the "original" + original_catalog_creation_date = catalog.creation_date + + # Update the template creation date again + # This time, pass the ignore flag and expect the times are different + template.creation_date -= timedelta(minutes=5) + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file, + '--ignore-pot-creation-date']) + + with open(po_file) as infp: + catalog = read_po(infp) + # We ignored creation date, so it should not have changed + assert catalog.creation_date == original_catalog_creation_date + def test_check(self): template = Catalog() template.add("1") @@ -1237,6 +1291,54 @@ def test_check(self): '-o', po_file, '-i', tmpl_file]) + def test_check_pot_creation_date(self): + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = os.path.join(i18n_dir, 'temp-template.pot') + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + po_file = os.path.join(i18n_dir, 'temp1.po') + self.cli.run(sys.argv + ['init', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file + ]) + + # Update the catalog file + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + # Run a check without introducing any changes to the template + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + # Run a check after changing the template creation date + template.creation_date = datetime.now() - timedelta(minutes=5) + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + # Should fail without --ignore-pot-creation-date flag + with pytest.raises(BaseError): + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + # Should pass with --ignore-pot-creation-date flag + self.cli.run(sys.argv + ['update', + '--check', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file, + '--ignore-pot-creation-date']) + def test_update_init_missing(self): template = Catalog() template.add("1") @@ -1320,6 +1422,35 @@ def test_parse_keywords(): } +def test_parse_keywords_with_t(): + kw = frontend.parse_keywords(['_:1', '_:2,2t', '_:2c,3,3t']) + + assert kw == { + '_': { + None: (1,), + 2: (2,), + 3: ((2, 'c'), 3), + } + } + +def test_extract_messages_with_t(): + content = rb""" +_("1 arg, arg 1") +_("2 args, arg 1", "2 args, arg 2") +_("3 args, arg 1", "3 args, arg 2", "3 args, arg 3") +_("4 args, arg 1", "4 args, arg 2", "4 args, arg 3", "4 args, arg 4") +""" + kw = frontend.parse_keywords(['_:1', '_:2,2t', '_:2c,3,3t']) + result = list(extract.extract("python", BytesIO(content), kw)) + expected = [(2, '1 arg, arg 1', [], None), + (3, '2 args, arg 1', [], None), + (3, '2 args, arg 2', [], None), + (4, '3 args, arg 1', [], None), + (4, '3 args, arg 3', [], '3 args, arg 2'), + (5, '4 args, arg 1', [], None)] + assert result == expected + + def configure_cli_command(cmdline): """ Helper to configure a command class, but not run it just yet. diff --git a/tests/messages/test_plurals.py b/tests/messages/test_plurals.py index 56a22b9c5..41438f58d 100644 --- a/tests/messages/test_plurals.py +++ b/tests/messages/test_plurals.py @@ -28,7 +28,7 @@ def test_get_plural_selection(locale, num_plurals, plural_expr): assert plurals.get_plural(locale) == (num_plurals, plural_expr) -def test_get_plural_accpets_strings(): +def test_get_plural_accepts_strings(): assert plurals.get_plural(locale='ga') == (5, '(n==1 ? 0 : n==2 ? 1 : n>=3 && n<=6 ? 2 : n>=7 && n<=10 ? 3 : 4)') diff --git a/tests/test_dates.py b/tests/test_dates.py index b94c710fd..f4f577397 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -17,7 +17,7 @@ import pytest from babel import Locale, dates -from babel.dates import NO_INHERITANCE_MARKER, _localize +from babel.dates import NO_INHERITANCE_MARKER, UTC, _localize from babel.util import FixedOffsetTimezone @@ -542,7 +542,7 @@ def test_get_timezone_name_time_pytz(timezone_getter, tzname, params, expected): def test_get_timezone_name_misc(timezone_getter): - localnow = datetime.utcnow().replace(tzinfo=timezone_getter('UTC')).astimezone(dates.LOCALTZ) + localnow = datetime.now(timezone_getter('UTC')).astimezone(dates.LOCALTZ) assert (dates.get_timezone_name(None, locale='en_US') == dates.get_timezone_name(localnow, locale='en_US')) @@ -601,12 +601,13 @@ def test_format_time(timezone_getter): custom = dates.format_time(t, "hh 'o''clock' a, zzzz", tzinfo=eastern, locale='en') assert custom == "09 o'clock AM, Eastern Daylight Time" - t = time(15, 30) - paris = dates.format_time(t, format='full', tzinfo=paris, locale='fr_FR') - assert paris == '15:30:00 heure normale d’Europe centrale' + with freezegun.freeze_time("2023-01-01"): + t = time(15, 30) + paris = dates.format_time(t, format='full', tzinfo=paris, locale='fr_FR') + assert paris == '15:30:00 heure normale d’Europe centrale' - us_east = dates.format_time(t, format='full', tzinfo=eastern, locale='en_US') - assert us_east == '3:30:00\u202fPM Eastern Standard Time' + us_east = dates.format_time(t, format='full', tzinfo=eastern, locale='en_US') + assert us_east == '3:30:00\u202fPM Eastern Standard Time' def test_format_skeleton(timezone_getter): @@ -702,7 +703,7 @@ def test_zh_TW_format(): def test_format_current_moment(): - frozen_instant = datetime.utcnow() + frozen_instant = datetime.now(UTC) with freezegun.freeze_time(time_to_freeze=frozen_instant): assert dates.format_datetime(locale="en_US") == dates.format_datetime(frozen_instant, locale="en_US") diff --git a/tests/test_support.py b/tests/test_support.py index 493d55151..92188a4cb 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -291,7 +291,7 @@ def raise_attribute_error(): proxy = support.LazyProxy(raise_attribute_error) with pytest.raises(AttributeError) as exception: - proxy.value + _ = proxy.value assert str(exception.value) == 'message' diff --git a/tox.ini b/tox.ini index 1156fecc6..c2d235fb5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,17 @@ [tox] envlist = - py{37,38,39,310,311} + py{37,38,39,310,311,312} pypy3 py{37,38}-pytz [testenv] +extras = + dev deps = - pytest>=6.0 - pytest-cov - freezegun==0.3.12 + # including setuptools here for CI; + # see https://github.com/python/cpython/issues/95299 + # see https://github.com/python-babel/babel/issues/1005#issuecomment-1728105742 + setuptools;python_version>="3.12" backports.zoneinfo;python_version<"3.9" tzdata;sys_platform == 'win32' pytz: pytz @@ -29,3 +32,4 @@ python = 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312