diff --git a/.ci/appveyor.yml b/.ci/appveyor.yml deleted file mode 100644 index eb9ba8656..000000000 --- a/.ci/appveyor.yml +++ /dev/null @@ -1,38 +0,0 @@ -# From https://github.com/ogrisel/python-appveyor-demo/blob/master/appveyor.yml - -environment: - global: - # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the - # /E:ON and /V:ON options are not enabled in the batch script intepreter - # See: https://stackoverflow.com/a/13751649 - CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\.ci\\run_with_env.cmd" - - matrix: - - PYTHON: "C:\\Python35" - PYTHON_VERSION: "3.5.x" - PYTHON_ARCH: "32" - - - PYTHON: "C:\\Python35-x64" - PYTHON_VERSION: "3.5.x" - PYTHON_ARCH: "64" - -branches: # Only build official branches, PRs are built anyway. - only: - - master - - /release.*/ - -install: - - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - # Check that we have the expected version and architecture for Python - - "python --version" - - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" - # Build data files - - "pip install --upgrade pytest==4.3.1 pytest-cov==2.6.1 codecov freezegun==0.3.11" - - "pip install --editable ." - - "python setup.py import_cldr" - -build: false # Not a C# project, build stuff at the test step instead. - -test_script: - - "%CMD_IN_ENV% python -m pytest --cov=babel" - - "codecov" diff --git a/.ci/deploy.linux.sh b/.ci/deploy.linux.sh deleted file mode 100644 index 4d59382d7..000000000 --- a/.ci/deploy.linux.sh +++ /dev/null @@ -1,4 +0,0 @@ -set -x -set -e - -bash <(curl -s https://codecov.io/bash) diff --git a/.ci/deploy.osx.sh b/.ci/deploy.osx.sh deleted file mode 100644 index c44550eff..000000000 --- a/.ci/deploy.osx.sh +++ /dev/null @@ -1,4 +0,0 @@ -set -x -set -e - -echo "Due to a bug in codecov, coverage cannot be deployed for Mac builds." diff --git a/.ci/deps.linux.sh b/.ci/deps.linux.sh deleted file mode 100644 index 13cc9e1ef..000000000 --- a/.ci/deps.linux.sh +++ /dev/null @@ -1,4 +0,0 @@ -set -x -set -e - -echo "No dependencies to install for linux." diff --git a/.ci/deps.osx.sh b/.ci/deps.osx.sh deleted file mode 100644 index b52a84f6d..000000000 --- a/.ci/deps.osx.sh +++ /dev/null @@ -1,11 +0,0 @@ -set -e -set -x - -# Install packages with brew -brew update >/dev/null -brew outdated pyenv || brew upgrade --quiet pyenv - -# Install required python version for this build -pyenv install -ks $PYTHON_VERSION -pyenv global $PYTHON_VERSION -python --version diff --git a/.ci/run_with_env.cmd b/.ci/run_with_env.cmd deleted file mode 100644 index 0f5b8e097..000000000 --- a/.ci/run_with_env.cmd +++ /dev/null @@ -1,47 +0,0 @@ -:: To build extensions for 64 bit Python 3, we need to configure environment -:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: -:: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) -:: -:: To build extensions for 64 bit Python 2, we need to configure environment -:: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: -:: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) -:: -:: 32 bit builds do not require specific environment configurations. -:: -:: Note: this script needs to be run with the /E:ON and /V:ON flags for the -:: cmd interpreter, at least for (SDK v7.0) -:: -:: More details at: -:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows -:: https://stackoverflow.com/a/13751649 -:: -:: Author: Olivier Grisel -:: License: CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ -@ECHO OFF - -SET COMMAND_TO_RUN=%* -SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows - -SET MAJOR_PYTHON_VERSION="%PYTHON_VERSION:~0,1%" -IF %MAJOR_PYTHON_VERSION% == "2" ( - SET WINDOWS_SDK_VERSION="v7.0" -) ELSE IF %MAJOR_PYTHON_VERSION% == "3" ( - SET WINDOWS_SDK_VERSION="v7.1" -) ELSE ( - ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%" - EXIT 1 -) - -IF "%PYTHON_ARCH%"=="64" ( - ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture - SET DISTUTILS_USE_SDK=1 - SET MSSdk=1 - "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% - "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release - ECHO Executing: %COMMAND_TO_RUN% - call %COMMAND_TO_RUN% || EXIT 1 -) ELSE ( - ECHO Using default MSVC build environment for 32 bit architecture - ECHO Executing: %COMMAND_TO_RUN% - call %COMMAND_TO_RUN% || EXIT 1 -) diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..a3d8ae65e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[report] +exclude_lines = + NotImplemented + pragma: no cover + warnings.warn \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..e9c411862 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-18.04, windows-2019, macos-10.15] + python-version: [3.6, 3.7, 3.8, 3.9, pypy3] + exclude: + - os: windows-2019 + python-version: pypy3 + # TODO: Remove this; see: + # https://github.com/actions/setup-python/issues/151 + # https://github.com/tox-dev/tox/issues/1704 + # https://foss.heptapod.net/pypy/pypy/-/issues/3331 + env: + BABEL_CLDR_NO_DOWNLOAD_PROGRESS: "1" + BABEL_CLDR_QUIET: "1" + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install tox tox-gh-actions==2.1.0 + - name: Run test via Tox + run: tox --skip-missing-interpreters + - uses: codecov/codecov-action@v1 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 174913952..000000000 --- a/.travis.yml +++ /dev/null @@ -1,58 +0,0 @@ -dist: xenial -language: python - -# Use travis docker infrastructure for greater speed -sudo: false - -cache: - directories: - - cldr - - "$HOME/.cache/pip" - - "$HOME/.pyenv" - -matrix: - include: - - os: linux - python: 2.7 - - os: linux - python: 2.7 - env: - - CDECIMAL=m3-cdecimal - - os: linux - dist: trusty - python: pypy - - os: linux - dist: trusty - python: pypy3 - - os: linux - python: 3.4 - - os: linux - python: 3.5 - env: - - PYTHON_TEST_FLAGS=-bb - - os: linux - python: 3.6 - - os: linux - python: 3.7 - - os: linux - python: 3.8-dev - -install: - - bash .ci/deps.${TRAVIS_OS_NAME}.sh - - pip install --upgrade pip - - pip install --upgrade $CDECIMAL pytest==4.3.1 pytest-cov==2.6.1 freezegun==0.3.11 - - pip install --editable . - -script: - - make test-cov - - bash .ci/deploy.${TRAVIS_OS_NAME}.sh - -notifications: - email: false - irc: - channels: - - "chat.freenode.net#pocoo" - on_success: change - on_failure: always - use_notice: true - skip_join: true diff --git a/AUTHORS b/AUTHORS index 6374cd650..9cf8f4e7d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -25,12 +25,13 @@ Babel is written and maintained by the Babel team and various contributors: - Sachin Paliwal - Alex Willmer - Daniel Neuhäuser +- Miro Hrončok - Cédric Krier - Luke Plant - Jennifer Wang - Lukas Balaga - sudheesh001 -- Miro Hrončok +- Niklas Hambüchen - Changaco - Xavier Fernandez - KO. Mattsson @@ -45,6 +46,17 @@ Babel is written and maintained by the Babel team and various contributors: - Leonardo Pistone - Jun Omae - Hyunjun Kim +- Alessio Bogon +- Nikiforov Konstantin +- Abdullah Javed Nesar +- Brad Martin +- Tyler Kennedy +- CyanNani123 +- sebleblanc +- He Chen +- Steve (Gadget) Barnes +- Romuald Brunet +- Mario Frasca - BT-sschmid - Alberto Mardegan - mondeja diff --git a/CHANGES b/CHANGES index 3462117fc..e3c54bfc8 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,81 @@ Babel Changelog =============== +Version 2.9.1 +------------- + +Bugfixes +~~~~~~~~ + +* The internal locale-data loading functions now validate the name of the locale file to be loaded and only + allow files within Babel's data directory. Thank you to Chris Lyne of Tenable, Inc. for discovering the issue! + +Version 2.9.0 +------------- + +Upcoming version support changes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* This version, Babel 2.9, is the last version of Babel to support Python 2.7, Python 3.4, and Python 3.5. + +Improvements +~~~~~~~~~~~~ + +* CLDR: Use CLDR 37 – Aarni Koskela (#734) +* Dates: Handle ZoneInfo objects in get_timezone_location, get_timezone_name - Alessio Bogon (#741) +* Numbers: Add group_separator feature in number formatting - Abdullah Javed Nesar (#726) + +Bugfixes +~~~~~~~~ + +* Dates: Correct default Format().timedelta format to 'long' to mute deprecation warnings – Aarni Koskela +* Import: Simplify iteration code in "import_cldr.py" – Felix Schwarz +* Import: Stop using deprecated ElementTree methods "getchildren()" and "getiterator()" – Felix Schwarz +* Messages: Fix unicode printing error on Python 2 without TTY. – Niklas Hambüchen +* Messages: Introduce invariant that _invalid_pofile() takes unicode line. – Niklas Hambüchen +* Tests: fix tests when using Python 3.9 – Felix Schwarz +* Tests: Remove deprecated 'sudo: false' from Travis configuration – Jon Dufresne +* Tests: Support Py.test 6.x – Aarni Koskela +* Utilities: LazyProxy: Handle AttributeError in specified func – Nikiforov Konstantin (#724) +* Utilities: Replace usage of parser.suite with ast.parse – Miro Hrončok + +Documentation +~~~~~~~~~~~~~ + +* Update parse_number comments – Brad Martin (#708) +* Add __iter__ to Catalog documentation – @CyanNani123 + +Version 2.8.1 +------------- + +This is solely a patch release to make running tests on Py.test 6+ possible. + +Bugfixes +~~~~~~~~ + +* Support Py.test 6 - Aarni Koskela (#747, #750, #752) + +Version 2.8.0 +------------- + +Improvements +~~~~~~~~~~~~ + +* CLDR: Upgrade to CLDR 36.0 - Aarni Koskela (#679) +* Messages: Don't even open files with the "ignore" extraction method - @sebleblanc (#678) + +Bugfixes +~~~~~~~~ + +* Numbers: Fix formatting very small decimals when quantization is disabled - Lev Lybin, @miluChen (#662) +* Messages: Attempt to sort all messages – Mario Frasca (#651, #606) + +Docs +~~~~ + +* Add years to changelog - Romuald Brunet +* Note that installation requires pytz - Steve (Gadget) Barnes + Version 2.7.0 ------------- @@ -188,7 +263,7 @@ Internal improvements Version 2.3.4 ------------- -(Bugfix release, released on April 22th) +(Bugfix release, released on April 22th 2016) Bugfixes ~~~~~~~~ @@ -199,7 +274,7 @@ Bugfixes Version 2.3.3 ------------- -(Bugfix release, released on April 12th) +(Bugfix release, released on April 12th 2016) Bugfixes ~~~~~~~~ @@ -209,7 +284,7 @@ Bugfixes Version 2.3.2 ------------- -(Bugfix release, released on April 9th) +(Bugfix release, released on April 9th 2016) Bugfixes ~~~~~~~~ @@ -219,12 +294,12 @@ Bugfixes Version 2.3.1 ------------- -(Bugfix release because of deployment problems, released on April 8th) +(Bugfix release because of deployment problems, released on April 8th 2016) Version 2.3 ----------- -(Feature release, released on April 8th) +(Feature release, released on April 8th 2016) Internal improvements ~~~~~~~~~~~~~~~~~~~~~ diff --git a/LICENSE b/LICENSE index 10722cc18..693e1a187 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013-2019 by the Babel Team, see AUTHORS for more information. +Copyright (c) 2013-2021 by the Babel Team, see AUTHORS for more information. All rights reserved. diff --git a/babel/__init__.py b/babel/__init__.py index 1132e6f37..3e20e4bd1 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -13,7 +13,7 @@ access to various locale display names, localized number and date formatting, etc. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ @@ -21,4 +21,4 @@ negotiate_locale, parse_locale, get_locale_identifier -__version__ = '2.7.0' +__version__ = '2.9.1' diff --git a/babel/core.py b/babel/core.py index a80807a61..a323a7295 100644 --- a/babel/core.py +++ b/babel/core.py @@ -5,7 +5,7 @@ Core locale representation and locale data access. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ diff --git a/babel/dates.py b/babel/dates.py index f1bd66faf..75e8f3501 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -12,7 +12,7 @@ * ``LC_ALL``, and * ``LANG`` - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ @@ -76,6 +76,21 @@ def _get_dt_and_tzinfo(dt_or_tzinfo): return dt, tzinfo +def _get_tz_name(dt_or_tzinfo): + """ + Get the timezone name out of a time, datetime, or tzinfo object. + + :rtype: str + """ + dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo) + if hasattr(tzinfo, 'zone'): # pytz object + return tzinfo.zone + elif hasattr(tzinfo, 'key') and tzinfo.key is not None: # ZoneInfo object + return tzinfo.key + else: + return tzinfo.tzname(dt or datetime.utcnow()) + + def _get_datetime(instant): """ Get a datetime out of an "instant" (date, time, datetime, number). @@ -500,13 +515,9 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME, return_city=False): :return: the localized timezone name using location format """ - dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo) locale = Locale.parse(locale) - if hasattr(tzinfo, 'zone'): - zone = tzinfo.zone - else: - zone = tzinfo.tzname(dt or datetime.utcnow()) + zone = _get_tz_name(dt_or_tzinfo) # Get the canonical time-zone code zone = get_global('zone_aliases').get(zone, zone) @@ -619,10 +630,7 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo) locale = Locale.parse(locale) - if hasattr(tzinfo, 'zone'): - zone = tzinfo.zone - else: - zone = tzinfo.tzname(dt) + zone = _get_tz_name(dt_or_tzinfo) if zone_variant is None: if dt is None: diff --git a/babel/lists.py b/babel/lists.py index ab5a24c40..8368b27a6 100644 --- a/babel/lists.py +++ b/babel/lists.py @@ -11,7 +11,7 @@ * ``LC_ALL``, and * ``LANG`` - :copyright: (c) 2015-2019 by the Babel Team. + :copyright: (c) 2015-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ diff --git a/babel/localedata.py b/babel/localedata.py index e012abbf2..438afb643 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -8,11 +8,13 @@ :note: The `Locale` class, which uses this module under the hood, provides a more convenient interface for accessing the locale data. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ import os +import re +import sys import threading from itertools import chain @@ -22,6 +24,7 @@ _cache = {} _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): @@ -38,6 +41,22 @@ def normalize_locale(name): return locale_id +def resolve_locale_filename(name): + """ + Resolve a locale identifier to a `.dat` path on disk. + """ + + # Clean up any possible relative paths. + name = os.path.basename(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) + + # Build the path. + return os.path.join(_dirname, '%s.dat' % name) + + def exists(name): """Check whether locale data is available for the given locale. @@ -49,7 +68,7 @@ def exists(name): return False if name in _cache: return True - file_found = os.path.exists(os.path.join(_dirname, '%s.dat' % name)) + file_found = os.path.exists(resolve_locale_filename(name)) return True if file_found else bool(normalize_locale(name)) @@ -102,6 +121,7 @@ def load(name, merge_inherited=True): :raise `IOError`: if no locale data file is found for the given locale identifer, or one of the locales it inherits from """ + name = os.path.basename(name) _cache_lock.acquire() try: data = _cache.get(name) @@ -119,7 +139,7 @@ def load(name, merge_inherited=True): else: parent = '_'.join(parts[:-1]) data = load(parent).copy() - filename = os.path.join(_dirname, '%s.dat' % name) + filename = resolve_locale_filename(name) with open(filename, 'rb') as fileobj: if name != 'root' and merge_inherited: merge(data, pickle.load(fileobj)) diff --git a/babel/localtime/__init__.py b/babel/localtime/__init__.py index aefd8a3e7..bd3954951 100644 --- a/babel/localtime/__init__.py +++ b/babel/localtime/__init__.py @@ -6,7 +6,7 @@ Babel specific fork of tzlocal to determine the local timezone of the system. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ diff --git a/babel/messages/__init__.py b/babel/messages/__init__.py index 5b69675f3..7d2587f63 100644 --- a/babel/messages/__init__.py +++ b/babel/messages/__init__.py @@ -5,7 +5,7 @@ Support for ``gettext`` message catalogs. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 2fcb461a8..a19a3e6d8 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -5,7 +5,7 @@ Data structures for message catalogs. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ @@ -807,7 +807,7 @@ def _merge(message, oldkey, newkey): if key in messages: _merge(message, key, key) else: - if no_fuzzy_matching is False: + if not no_fuzzy_matching: # do some fuzzy matching with difflib if isinstance(key, tuple): matchkey = key[0] # just the msgid, no context diff --git a/babel/messages/checkers.py b/babel/messages/checkers.py index 8c1effaf5..cba911d72 100644 --- a/babel/messages/checkers.py +++ b/babel/messages/checkers.py @@ -7,7 +7,7 @@ :since: version 0.9 - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ diff --git a/babel/messages/extract.py b/babel/messages/extract.py index db429b2ea..64497762c 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -13,7 +13,7 @@ The main entry points into the extraction functionality are the functions `extract_from_dir` and `extract_from_file`. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ @@ -236,9 +236,12 @@ def extract_from_file(method, filename, keywords=DEFAULT_KEYWORDS, :returns: list of tuples of the form ``(lineno, message, comments, context)`` :rtype: list[tuple[int, str|tuple[str], list[str], str|None] """ + if method == 'ignore': + return [] + with open(filename, 'rb') as fileobj: - return list(extract(method, fileobj, keywords, comment_tags, options, - strip_comment_tags)) + return list(extract(method, fileobj, keywords, comment_tags, + options, strip_comment_tags)) def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 475605549..c5eb1dea9 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -5,7 +5,7 @@ Frontends for the message extraction functionality. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ from __future__ import print_function diff --git a/babel/messages/jslexer.py b/babel/messages/jslexer.py index ace0b47e0..c57b1213f 100644 --- a/babel/messages/jslexer.py +++ b/babel/messages/jslexer.py @@ -6,7 +6,7 @@ A simple JavaScript 1.5 lexer which is used for the JavaScript extractor. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ from collections import namedtuple diff --git a/babel/messages/mofile.py b/babel/messages/mofile.py index dfd923d23..8d3cfc905 100644 --- a/babel/messages/mofile.py +++ b/babel/messages/mofile.py @@ -5,7 +5,7 @@ Writing of files in the ``gettext`` MO (machine object) format. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ diff --git a/babel/messages/plurals.py b/babel/messages/plurals.py index 81234580d..91ba9e1b1 100644 --- a/babel/messages/plurals.py +++ b/babel/messages/plurals.py @@ -5,7 +5,7 @@ Plural form definitions. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index bbcf7f76c..be33b831d 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -6,7 +6,7 @@ Reading and writing of files in the ``gettext`` PO (portable object) format. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ @@ -178,7 +178,7 @@ def _add_message(self): string = ['' for _ in range(self.catalog.num_plurals)] for idx, translation in self.translations: if idx >= self.catalog.num_plurals: - self._invalid_pofile("", self.offset, "msg has more translations than num_plurals of catalog") + self._invalid_pofile(u"", self.offset, "msg has more translations than num_plurals of catalog") continue string[idx] = translation.denormalize() string = tuple(string) @@ -319,10 +319,14 @@ def parse(self, fileobj): self._add_message() def _invalid_pofile(self, line, lineno, msg): + assert isinstance(line, text_type) if self.abort_invalid: raise PoFileError(msg, self.catalog, line, lineno) print("WARNING:", msg) - print(u"WARNING: Problem on line {0}: {1}".format(lineno + 1, line)) + # `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))) def read_po(fileobj, locale=None, domain=None, ignore_obsolete=False, charset=None, abort_invalid=False): @@ -582,11 +586,13 @@ def _write_message(message, prefix=''): if not no_location: locs = [] - # Attempt to sort the locations. If we can't do that, for instance - # because there are mixed integers and Nones or whatnot (see issue #606) - # then give up, but also don't just crash. + # sort locations by filename and lineno. + # if there's no as lineno, use `-1`. + # 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/babel/numbers.py b/babel/numbers.py index 6888c9cb4..0fcc07e15 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -12,7 +12,7 @@ * ``LC_ALL``, and * ``LANG`` - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ # TODO: @@ -373,7 +373,7 @@ def get_decimal_quantum(precision): def format_decimal( - number, format=None, locale=LC_NUMERIC, decimal_quantization=True): + number, format=None, locale=LC_NUMERIC, decimal_quantization=True, group_separator=True): u"""Return the given decimal number formatted for a specific locale. >>> format_decimal(1.2345, locale='en_US') @@ -401,19 +401,25 @@ def format_decimal( u'1.235' >>> format_decimal(1.2346, locale='en_US', decimal_quantization=False) u'1.2346' + >>> format_decimal(12345.67, locale='fr_CA', group_separator=False) + u'12345,67' + >>> format_decimal(12345.67, locale='en_US', group_separator=True) + u'12,345.67' :param number: the number to format :param format: :param locale: the `Locale` object or locale identifier :param decimal_quantization: Truncate and round high-precision numbers to the format pattern. Defaults to `True`. + :param group_separator: Boolean to switch group separator on/off in a locale's + number format. """ locale = Locale.parse(locale) if not format: format = locale.decimal_formats.get(format) pattern = parse_pattern(format) return pattern.apply( - number, locale, decimal_quantization=decimal_quantization) + number, locale, decimal_quantization=decimal_quantization, group_separator=group_separator) class UnknownCurrencyFormatError(KeyError): @@ -422,7 +428,7 @@ class UnknownCurrencyFormatError(KeyError): def format_currency( number, currency, format=None, locale=LC_NUMERIC, currency_digits=True, - format_type='standard', decimal_quantization=True): + format_type='standard', decimal_quantization=True, group_separator=True): u"""Return formatted currency value. >>> format_currency(1099.98, 'USD', locale='en_US') @@ -472,6 +478,12 @@ def format_currency( ... UnknownCurrencyFormatError: "'unknown' is not a known currency format type" + >>> format_currency(101299.98, 'USD', locale='en_US', group_separator=False) + u'$101299.98' + + >>> format_currency(101299.98, 'USD', locale='en_US', group_separator=True) + u'$101,299.98' + You can also pass format_type='name' to use long display names. The order of the number and currency name, along with the correct localized plural form of the currency name, is chosen according to locale: @@ -500,12 +512,14 @@ def format_currency( :param format_type: the currency format type to use :param decimal_quantization: Truncate and round high-precision numbers to the format pattern. Defaults to `True`. + :param group_separator: Boolean to switch group separator on/off in a locale's + number format. """ if format_type == 'name': return _format_currency_long_name(number, currency, format=format, locale=locale, currency_digits=currency_digits, - decimal_quantization=decimal_quantization) + decimal_quantization=decimal_quantization, group_separator=group_separator) locale = Locale.parse(locale) if format: pattern = parse_pattern(format) @@ -518,12 +532,12 @@ def format_currency( return pattern.apply( number, locale, currency=currency, currency_digits=currency_digits, - decimal_quantization=decimal_quantization) + decimal_quantization=decimal_quantization, group_separator=group_separator) def _format_currency_long_name( number, currency, format=None, locale=LC_NUMERIC, currency_digits=True, - format_type='standard', decimal_quantization=True): + format_type='standard', decimal_quantization=True, group_separator=True): # Algorithm described here: # https://www.unicode.org/reports/tr35/tr35-numbers.html#Currencies locale = Locale.parse(locale) @@ -552,13 +566,13 @@ def _format_currency_long_name( number_part = pattern.apply( number, locale, currency=currency, currency_digits=currency_digits, - decimal_quantization=decimal_quantization) + decimal_quantization=decimal_quantization, group_separator=group_separator) return unit_pattern.format(number_part, display_name) def format_percent( - number, format=None, locale=LC_NUMERIC, decimal_quantization=True): + number, format=None, locale=LC_NUMERIC, decimal_quantization=True, group_separator=True): """Return formatted percent value for a specific locale. >>> format_percent(0.34, locale='en_US') @@ -582,18 +596,26 @@ def format_percent( >>> format_percent(23.9876, locale='en_US', decimal_quantization=False) u'2,398.76%' + >>> format_percent(229291.1234, locale='pt_BR', group_separator=False) + u'22929112%' + + >>> format_percent(229291.1234, locale='pt_BR', group_separator=True) + u'22.929.112%' + :param number: the percent number to format :param format: :param locale: the `Locale` object or locale identifier :param decimal_quantization: Truncate and round high-precision numbers to the format pattern. Defaults to `True`. + :param group_separator: Boolean to switch group separator on/off in a locale's + number format. """ locale = Locale.parse(locale) if not format: format = locale.percent_formats.get(format) pattern = parse_pattern(format) return pattern.apply( - number, locale, decimal_quantization=decimal_quantization) + number, locale, decimal_quantization=decimal_quantization, group_separator=group_separator) def format_scientific( @@ -913,6 +935,7 @@ def apply( currency_digits=True, decimal_quantization=True, force_frac=None, + group_separator=True, ): """Renders into a string a number following the defined pattern. @@ -952,8 +975,8 @@ def apply( if self.exp_prec: value, exp, exp_sign = self.scientific_notation_elements(value, locale) - # Adjust the precision of the fractionnal part and force it to the - # currency's if neccessary. + # Adjust the precision of the fractional part and force it to the + # currency's if necessary. if force_frac: # TODO (3.x?): Remove this parameter warnings.warn('The force_frac parameter to NumberPattern.apply() is deprecated.', DeprecationWarning) @@ -975,7 +998,7 @@ def apply( # Render scientific notation. if self.exp_prec: number = ''.join([ - self._quantize_value(value, locale, frac_prec), + self._quantize_value(value, locale, frac_prec, group_separator), get_exponential_symbol(locale), exp_sign, self._format_int( @@ -993,7 +1016,7 @@ def apply( # A normal number pattern. else: - number = self._quantize_value(value, locale, frac_prec) + number = self._quantize_value(value, locale, frac_prec, group_separator) retval = ''.join([ self.prefix[is_negative], @@ -1060,13 +1083,14 @@ def _format_int(self, value, min, max, locale): gsize = self.grouping[1] return value + ret - def _quantize_value(self, value, locale, frac_prec): + def _quantize_value(self, value, locale, frac_prec, group_separator): quantum = get_decimal_quantum(frac_prec[1]) rounded = value.quantize(quantum) - a, sep, b = str(rounded).partition(".") - number = (self._format_int(a, self.int_prec[0], - self.int_prec[1], locale) + - self._format_frac(b or '0', locale, frac_prec)) + a, sep, b = "{:f}".format(rounded).partition(".") + integer_part = a + if group_separator: + integer_part = self._format_int(a, self.int_prec[0], self.int_prec[1], locale) + number = integer_part + self._format_frac(b or '0', locale, frac_prec) return number def _format_frac(self, value, locale, force_frac=None): diff --git a/babel/plural.py b/babel/plural.py index 1e2b2734b..e705e9b8d 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -5,7 +5,7 @@ CLDR Plural support. See UTS #35. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ import re diff --git a/babel/support.py b/babel/support.py index efe41d562..4be9ed37f 100644 --- a/babel/support.py +++ b/babel/support.py @@ -8,7 +8,7 @@ .. note: the code in this module is not used by Babel itself - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ @@ -79,7 +79,7 @@ def time(self, time=None, format='medium'): return format_time(time, format, tzinfo=self.tzinfo, locale=self.locale) def timedelta(self, delta, granularity='second', threshold=.85, - format='medium', add_direction=False): + format='long', add_direction=False): """Return a time delta according to the rules of the given locale. >>> from datetime import timedelta @@ -165,7 +165,7 @@ class LazyProxy(object): Hello, universe! Hello, world! """ - __slots__ = ['_func', '_args', '_kwargs', '_value', '_is_cache_enabled'] + __slots__ = ['_func', '_args', '_kwargs', '_value', '_is_cache_enabled', '_attribute_error'] def __init__(self, func, *args, **kwargs): is_cache_enabled = kwargs.pop('enable_cache', True) @@ -175,11 +175,17 @@ def __init__(self, func, *args, **kwargs): object.__setattr__(self, '_kwargs', kwargs) object.__setattr__(self, '_is_cache_enabled', is_cache_enabled) object.__setattr__(self, '_value', None) + object.__setattr__(self, '_attribute_error', None) @property def value(self): if self._value is None: - value = self._func(*self._args, **self._kwargs) + try: + value = self._func(*self._args, **self._kwargs) + except AttributeError as error: + object.__setattr__(self, '_attribute_error', error) + raise + if not self._is_cache_enabled: return value object.__setattr__(self, '_value', value) @@ -249,6 +255,8 @@ def __delattr__(self, name): delattr(self.value, name) def __getattr__(self, name): + if self._attribute_error is not None: + raise self._attribute_error return getattr(self.value, name) def __setattr__(self, name, value): diff --git a/babel/units.py b/babel/units.py index e58bf81c2..07637358c 100644 --- a/babel/units.py +++ b/babel/units.py @@ -75,8 +75,10 @@ def format_unit(value, measurement_unit, length='long', format=None, locale=LC_N u'12 metri' >>> format_unit(15.5, 'length-mile', locale='fi_FI') u'15,5 mailia' - >>> format_unit(1200, 'pressure-inch-hg', locale='nb') - u'1\\xa0200 tommer kvikks\\xf8lv' + >>> format_unit(1200, 'pressure-millimeter-ofhg', locale='nb') + u'1\\xa0200 millimeter kvikks\\xf8lv' + >>> format_unit(270, 'ton', locale='en') + u'270 tons' Number formats may be overridden with the ``format`` parameter. @@ -88,12 +90,12 @@ def format_unit(value, measurement_unit, length='long', format=None, locale=LC_N >>> format_unit(1, 'length-meter', locale='ro_RO') u'1 metru' - >>> format_unit(0, 'length-picometer', locale='cy') - u'0 picometr' - >>> format_unit(2, 'length-picometer', locale='cy') - u'2 bicometr' - >>> format_unit(3, 'length-picometer', locale='cy') - u'3 phicometr' + >>> format_unit(0, 'length-mile', locale='cy') + u'0 mi' + >>> format_unit(1, 'length-mile', locale='cy') + u'1 filltir' + >>> format_unit(3, 'length-mile', locale='cy') + u'3 milltir' >>> format_unit(15, 'length-horse', locale='fi') Traceback (most recent call last): @@ -271,6 +273,7 @@ def format_compound_unit( else: # Bare denominator formatted_denominator = format_decimal(denominator_value, format=format, locale=locale) - per_pattern = locale._data["compound_unit_patterns"].get("per", {}).get(length, "{0}/{1}") + # TODO: this doesn't support "compound_variations" (or "prefix"), and will fall back to the "x/y" representation + per_pattern = locale._data["compound_unit_patterns"].get("per", {}).get(length, {}).get("compound", "{0}/{1}") return per_pattern.format(formatted_numerator, formatted_denominator) diff --git a/babel/util.py b/babel/util.py index 73a90516f..a8fbac1d9 100644 --- a/babel/util.py +++ b/babel/util.py @@ -5,7 +5,7 @@ Various utility classes and functions. - :copyright: (c) 2013-2019 by the Babel Team. + :copyright: (c) 2013-2021 by the Babel Team. :license: BSD, see LICENSE for more details. """ @@ -68,8 +68,8 @@ def parse_encoding(fp): m = PYTHON_MAGIC_COMMENT_re.match(line1) if not m: try: - import parser - parser.suite(line1.decode('latin-1')) + import ast + ast.parse(line1.decode('latin-1')) except (ImportError, SyntaxError, UnicodeEncodeError): # Either it's a real syntax error, in which case the source is # not valid python source, or line2 is a continuation of line1, diff --git a/conftest.py b/conftest.py index 32bd1362a..bd9f2d32d 100644 --- a/conftest.py +++ b/conftest.py @@ -8,4 +8,7 @@ def pytest_collect_file(path, parent): if babel_path.common(path) == babel_path: if path.ext == ".py": + # TODO: remove check when dropping support for old Pytest + if hasattr(DoctestModule, "from_parent"): + return DoctestModule.from_parent(parent, fspath=path) return DoctestModule(path, parent) diff --git a/docs/api/messages/catalog.rst b/docs/api/messages/catalog.rst index 8a905bcd9..8cb6375e3 100644 --- a/docs/api/messages/catalog.rst +++ b/docs/api/messages/catalog.rst @@ -12,6 +12,7 @@ Catalogs .. autoclass:: Catalog :members: + :special-members: __iter__ Messages -------- diff --git a/docs/conf.py b/docs/conf.py index 6eed08fe6..962792fbd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -44,16 +44,16 @@ # General information about the project. project = u'Babel' -copyright = u'2019, The Babel Team' +copyright = u'2021, The Babel Team' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '2.7' +version = '2.9' # The full version, including alpha/beta/rc tags. -release = '2.7.0' +release = '2.9.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/installation.rst b/docs/installation.rst index ce778b04c..c1b7ab9fe 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -79,15 +79,16 @@ Get the git checkout in a new virtualenv and run in development mode:: New python executable in venv/bin/python Installing distribute............done. $ . venv/bin/activate + $ pip install pytz $ python setup.py import_cldr $ pip install --editable . ... Finished processing dependencies for Babel -Make sure to not forget about the ``import_cldr`` step because otherwise -you will be missing the locale data. This custom command will download -the most appropriate CLDR release from the official website and convert it -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. +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``. This will pull also in the dependencies and activate the git head as the current version inside the virtualenv. Then all you have to do is run diff --git a/docs/license.rst b/docs/license.rst index a619b5746..7c93ab426 100644 --- a/docs/license.rst +++ b/docs/license.rst @@ -19,7 +19,7 @@ Authors General License Definitions --------------------------- -The following section contains the full license texts for Flask and the +The following section contains the full license texts for Babel and the documentation. - "AUTHORS" hereby refers to all the authors listed in the diff --git a/docs/numbers.rst b/docs/numbers.rst index df834eaf8..058d79e18 100644 --- a/docs/numbers.rst +++ b/docs/numbers.rst @@ -160,4 +160,21 @@ Examples: ... NumberFormatError: '2,109,998' is not a valid decimal number -.. note:: Number parsing is not properly implemented yet +Note: as of version 2.8.0, the ``parse_number`` function has limited +functionality. It can remove group symbols of certain locales from numeric +strings, but may behave unexpectedly until its logic handles more encoding +issues and other special cases. + +Examples: + +.. code-block:: pycon + + >>> parse_number('1,099', locale='en_US') + 1099 + >>> parse_number('1.099.024', locale='de') + 1099024 + >>> parse_number('123' + u'\xa0' + '4567', locale='ru') + 1234567 + >>> parse_number('123 4567', locale='ru') + ... + NumberFormatError: '123 4567' is not a valid number diff --git a/scripts/download_import_cldr.py b/scripts/download_import_cldr.py index f118c6900..805772a16 100755 --- a/scripts/download_import_cldr.py +++ b/scripts/download_import_cldr.py @@ -13,9 +13,9 @@ from urllib import urlretrieve -URL = 'https://unicode.org/Public/cldr/35.1/core.zip' -FILENAME = 'cldr-core-35.1.zip' -FILESUM = 'e2ede8cb8f9c29157e281ee9e696ce540a72c598841bed595a406b710eea87b0' +URL = 'http://unicode.org/Public/cldr/37/core.zip' +FILENAME = 'cldr-core-37.zip' +FILESUM = 'ba93f5ba256a61a6f8253397c6c4b1a9b9e77531f013cc7ffa7977b5f7e4da57' BLKSIZE = 131072 @@ -75,12 +75,13 @@ def main(): cldr_path = os.path.join(repo, 'cldr', os.path.splitext(FILENAME)[0]) zip_path = os.path.join(cldr_dl_path, FILENAME) changed = False + show_progress = (False if os.environ.get("BABEL_CLDR_NO_DOWNLOAD_PROGRESS") else sys.stdout.isatty()) while not is_good_file(zip_path): log('Downloading \'%s\'', FILENAME) if os.path.isfile(zip_path): os.remove(zip_path) - urlretrieve(URL, zip_path, reporthook) + urlretrieve(URL, zip_path, (reporthook if show_progress else None)) changed = True print() common_path = os.path.join(cldr_path, 'common') diff --git a/scripts/dump_data.py b/scripts/dump_data.py index e452248d7..ac295b2d7 100755 --- a/scripts/dump_data.py +++ b/scripts/dump_data.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/scripts/dump_global.py b/scripts/dump_global.py index 8db55f2dc..c9e1d3008 100755 --- a/scripts/dump_global.py +++ b/scripts/dump_global.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 4188055a6..7876d5208 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which @@ -17,6 +17,7 @@ import os import re import sys +import logging try: from xml.etree import cElementTree as ElementTree @@ -62,23 +63,16 @@ def _text(elem): 'timeFormats': 'time_formats' } - -def log(message, *args): - if args: - message = message % args - sys.stderr.write(message + '\r\n') - sys.stderr.flush() - - -def error(message, *args): - log('ERROR: %s' % message, *args) +log = logging.getLogger("import_cldr") def need_conversion(dst_filename, data_dict, source_filename): with open(source_filename, 'rb') as f: blob = f.read(4096) - version = int(re.search(b'version number="\\$Revision: (\\d+)', - blob).group(1)) + version_match = re.search(b'version number="\\$Revision: (\\d+)', blob) + if not version_match: # CLDR 36.0 was shipped without proper revision numbers + return True + version = int(version_match.group(1)) data_dict['_version'] = version if not os.path.isfile(dst_filename): @@ -180,10 +174,19 @@ def main(): '-j', '--json', dest='dump_json', action='store_true', default=False, help='also export debugging JSON dumps of locale data' ) + parser.add_option( + '-q', '--quiet', dest='quiet', action='store_true', default=bool(os.environ.get('BABEL_CLDR_QUIET')), + help='quiesce info/warning messages', + ) options, args = parser.parse_args() if len(args) != 1: parser.error('incorrect number of arguments') + + logging.basicConfig( + level=(logging.ERROR if options.quiet else logging.INFO), + ) + return process_data( srcdir=args[0], destdir=BABEL_PACKAGE_ROOT, @@ -381,8 +384,10 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False): territory = '001' # world regions = territory_containment.get(territory, []) - log('Processing %s (Language = %s; Territory = %s)', - filename, language, territory) + log.info( + 'Processing %s (Language = %s; Territory = %s)', + filename, language, territory, + ) locale_id = '_'.join(filter(None, [ language, @@ -390,6 +395,7 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False): ])) data['locale_id'] = locale_id + data['unsupported_number_systems'] = set() if locale_id in plural_rules: data['plural_form'] = plural_rules[locale_id] @@ -430,6 +436,13 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False): parse_character_order(data, tree) parse_measurement_systems(data, tree) + 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, + )) + write_datafile(data_filename, data, dump_json=dump_json) @@ -438,21 +451,14 @@ def _should_skip_number_elem(data, elem): Figure out whether the numbering-containing element `elem` is in a currently non-supported (i.e. currently non-Latin) numbering system. - If it is, a warning is raised. - - :param data: The root data element, for formatting the warning. + :param data: The root data element, for stashing the warning. :param elem: Element with `numberSystem` key :return: Boolean """ number_system = elem.get('numberSystem', 'latn') if number_system != 'latn': - log('%s: Unsupported number system "%s" in <%s numberSystem="%s">' % ( - data['locale_id'], - number_system, - elem.tag, - number_system, - )) + data['unsupported_number_systems'].add(number_system) return True return False @@ -596,7 +602,7 @@ def parse_calendar_months(data, calendar): for width in ctxt.findall('monthWidth'): width_type = width.attrib['type'] widths = ctxts.setdefault(width_type, {}) - for elem in width.getiterator(): + for elem in width: if elem.tag == 'month': _import_type_text(widths, elem, int(elem.attrib['type'])) elif elem.tag == 'alias': @@ -614,7 +620,7 @@ def parse_calendar_days(data, calendar): for width in ctxt.findall('dayWidth'): width_type = width.attrib['type'] widths = ctxts.setdefault(width_type, {}) - for elem in width.getiterator(): + for elem in width: if elem.tag == 'day': _import_type_text(widths, elem, weekdays[elem.attrib['type']]) elif elem.tag == 'alias': @@ -632,7 +638,7 @@ def parse_calendar_quarters(data, calendar): for width in ctxt.findall('quarterWidth'): width_type = width.attrib['type'] widths = ctxts.setdefault(width_type, {}) - for elem in width.getiterator(): + for elem in width: if elem.tag == 'quarter': _import_type_text(widths, elem, int(elem.attrib['type'])) elif elem.tag == 'alias': @@ -647,7 +653,7 @@ def parse_calendar_eras(data, calendar): for width in calendar.findall('eras/*'): width_type = NAME_MAP[width.tag] widths = eras.setdefault(width_type, {}) - for elem in width.getiterator(): + for elem in width: if elem.tag == 'era': _import_type_text(widths, elem, type=int(elem.attrib.get('type'))) elif elem.tag == 'alias': @@ -674,7 +680,7 @@ def parse_calendar_periods(data, calendar): def parse_calendar_date_formats(data, calendar): date_formats = data.setdefault('date_formats', {}) for format in calendar.findall('dateFormats'): - for elem in format.getiterator(): + for elem in format: if elem.tag == 'dateFormatLength': type = elem.attrib.get('type') if _should_skip_elem(elem, type, date_formats): @@ -684,7 +690,7 @@ def parse_calendar_date_formats(data, calendar): text_type(elem.findtext('dateFormat/pattern')) ) except ValueError as e: - error(e) + log.error(e) elif elem.tag == 'alias': date_formats = Alias(_translate_alias( ['date_formats'], elem.attrib['path']) @@ -694,7 +700,7 @@ def parse_calendar_date_formats(data, calendar): def parse_calendar_time_formats(data, calendar): time_formats = data.setdefault('time_formats', {}) for format in calendar.findall('timeFormats'): - for elem in format.getiterator(): + for elem in format: if elem.tag == 'timeFormatLength': type = elem.attrib.get('type') if _should_skip_elem(elem, type, time_formats): @@ -704,7 +710,7 @@ def parse_calendar_time_formats(data, calendar): text_type(elem.findtext('timeFormat/pattern')) ) except ValueError as e: - error(e) + log.error(e) elif elem.tag == 'alias': time_formats = Alias(_translate_alias( ['time_formats'], elem.attrib['path']) @@ -715,7 +721,7 @@ def parse_calendar_datetime_skeletons(data, calendar): datetime_formats = data.setdefault('datetime_formats', {}) datetime_skeletons = data.setdefault('datetime_skeletons', {}) for format in calendar.findall('dateTimeFormats'): - for elem in format.getiterator(): + for elem in format: if elem.tag == 'dateTimeFormatLength': type = elem.attrib.get('type') if _should_skip_elem(elem, type, datetime_formats): @@ -723,7 +729,7 @@ def parse_calendar_datetime_skeletons(data, calendar): try: datetime_formats[type] = text_type(elem.findtext('dateTimeFormat/pattern')) except ValueError as e: - error(e) + log.error(e) elif elem.tag == 'alias': datetime_formats = Alias(_translate_alias( ['datetime_formats'], elem.attrib['path']) @@ -851,9 +857,23 @@ def parse_unit_patterns(data, tree): for unit in elem.findall('compoundUnit'): unit_type = unit.attrib['type'] - compound_patterns.setdefault(unit_type, {})[unit_length_type] = ( - _text(unit.find('compoundUnitPattern')) - ) + compound_unit_info = {} + compound_variations = {} + for child in unit: + if child.tag == "unitPrefixPattern": + compound_unit_info['prefix'] = _text(child) + elif child.tag == "compoundUnitPattern": + compound_variations[None] = _text(child) + elif child.tag == "compoundUnitPattern1": + compound_variations[child.attrib.get('count')] = _text(child) + if compound_variations: + compound_variation_values = set(compound_variations.values()) + if len(compound_variation_values) == 1: + # shortcut: if all compound variations are the same, only store one + compound_unit_info['compound'] = next(iter(compound_variation_values)) + else: + compound_unit_info['compound_variations'] = compound_variations + compound_patterns.setdefault(unit_type, {})[unit_length_type] = compound_unit_info def parse_date_fields(data, tree): @@ -878,7 +898,7 @@ def parse_interval_formats(data, tree): interval_formats[None] = elem.text elif elem.tag == "intervalFormatItem": skel_data = interval_formats.setdefault(elem.attrib["id"], {}) - for item_sub in elem.getchildren(): + for item_sub in elem: if item_sub.tag == "greatestDifference": skel_data[item_sub.attrib["id"]] = split_interval_pattern(item_sub.text) else: @@ -901,7 +921,7 @@ def parse_currency_formats(data, tree): type = '%s:%s' % (type, curr_length_type) if _should_skip_elem(elem, type, currency_formats): continue - for child in elem.getiterator(): + for child in elem.iter(): if child.tag == 'alias': currency_formats[type] = Alias( _translate_alias(['currency_formats', elem.attrib['type']], diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index f31dca310..661999648 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/tests/messages/test_checkers.py b/tests/messages/test_checkers.py index ec845001e..49abb51b0 100644 --- a/tests/messages/test_checkers.py +++ b/tests/messages/test_checkers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py index 2f41ddc2c..ac7f0a642 100644 --- a/tests/messages/test_extract.py +++ b/tests/messages/test_extract.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index ad3ea0df3..70580215e 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/tests/messages/test_mofile.py b/tests/messages/test_mofile.py index b1851f297..fb672a80c 100644 --- a/tests/messages/test_mofile.py +++ b/tests/messages/test_mofile.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/tests/messages/test_plurals.py b/tests/messages/test_plurals.py index bdca8f6a8..5e490f374 100644 --- a/tests/messages/test_plurals.py +++ b/tests/messages/test_plurals.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/tests/messages/test_pofile.py b/tests/messages/test_pofile.py index e77fa6e02..be1172a88 100644 --- a/tests/messages/test_pofile.py +++ b/tests/messages/test_pofile.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which @@ -480,7 +480,7 @@ def test_abort_invalid_po_file(self): def test_invalid_pofile_with_abort_flag(self): parser = pofile.PoFileParser(None, abort_invalid=True) lineno = 10 - line = 'Algo esta mal' + line = u'Algo esta mal' msg = 'invalid file' with self.assertRaises(pofile.PoFileError) as e: parser._invalid_pofile(line, lineno, msg) diff --git a/tests/test_core.py b/tests/test_core.py index c146aae9c..558322e00 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/tests/test_dates.py b/tests/test_dates.py index 5be0d16a1..44efa7fbc 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which @@ -15,6 +15,7 @@ from datetime import date, datetime, time, timedelta import unittest +import freezegun import pytest import pytz from pytz import timezone @@ -24,6 +25,23 @@ from babel.util import FixedOffsetTimezone +@pytest.fixture(params=["pytz.timezone", "zoneinfo.ZoneInfo"]) +def timezone_getter(request): + if request.param == "pytz.timezone": + return timezone + elif request.param == "zoneinfo.ZoneInfo": + try: + import zoneinfo + except ImportError: + try: + from backports import zoneinfo + except ImportError: + pytest.skip("zoneinfo not available") + return zoneinfo.ZoneInfo + else: + raise NotImplementedError + + class DateTimeFormatTestCase(unittest.TestCase): def test_quarter_format(self): @@ -583,8 +601,8 @@ def test_get_timezone_gmt(): assert dates.get_timezone_gmt(dt, 'long', locale='fr_FR') == u'UTC-07:00' -def test_get_timezone_location(): - tz = timezone('America/St_Johns') +def test_get_timezone_location(timezone_getter): + tz = timezone_getter('America/St_Johns') assert (dates.get_timezone_location(tz, locale='de_DE') == u"Kanada (St. John\u2019s) Zeit") assert (dates.get_timezone_location(tz, locale='en') == @@ -592,51 +610,83 @@ def test_get_timezone_location(): assert (dates.get_timezone_location(tz, locale='en', return_city=True) == u'St. John’s') - tz = timezone('America/Mexico_City') + tz = timezone_getter('America/Mexico_City') assert (dates.get_timezone_location(tz, locale='de_DE') == u'Mexiko (Mexiko-Stadt) Zeit') - tz = timezone('Europe/Berlin') + tz = timezone_getter('Europe/Berlin') assert (dates.get_timezone_location(tz, locale='de_DE') == u'Deutschland (Berlin) Zeit') -def test_get_timezone_name(): - dt = time(15, 30, tzinfo=timezone('America/Los_Angeles')) - assert (dates.get_timezone_name(dt, locale='en_US') == - u'Pacific Standard Time') - assert (dates.get_timezone_name(dt, locale='en_US', return_zone=True) == - u'America/Los_Angeles') - assert dates.get_timezone_name(dt, width='short', locale='en_US') == u'PST' - - tz = timezone('America/Los_Angeles') - assert dates.get_timezone_name(tz, locale='en_US') == u'Pacific Time' - assert dates.get_timezone_name(tz, 'short', locale='en_US') == u'PT' - - tz = timezone('Europe/Berlin') - assert (dates.get_timezone_name(tz, locale='de_DE') == - u'Mitteleurop\xe4ische Zeit') - assert (dates.get_timezone_name(tz, locale='pt_BR') == - u'Hor\xe1rio da Europa Central') - - tz = timezone('America/St_Johns') - assert dates.get_timezone_name(tz, locale='de_DE') == u'Neufundland-Zeit' - - tz = timezone('America/Los_Angeles') - assert dates.get_timezone_name(tz, locale='en', width='short', - zone_variant='generic') == u'PT' - assert dates.get_timezone_name(tz, locale='en', width='short', - zone_variant='standard') == u'PST' - assert dates.get_timezone_name(tz, locale='en', width='short', - zone_variant='daylight') == u'PDT' - assert dates.get_timezone_name(tz, locale='en', width='long', - zone_variant='generic') == u'Pacific Time' - assert dates.get_timezone_name(tz, locale='en', width='long', - zone_variant='standard') == u'Pacific Standard Time' - assert dates.get_timezone_name(tz, locale='en', width='long', - zone_variant='daylight') == u'Pacific Daylight Time' - - localnow = datetime.utcnow().replace(tzinfo=timezone('UTC')).astimezone(dates.LOCALTZ) +@pytest.mark.parametrize( + "tzname, params, expected", + [ + ("America/Los_Angeles", {"locale": "en_US"}, u"Pacific Time"), + ("America/Los_Angeles", {"width": "short", "locale": "en_US"}, u"PT"), + ("Europe/Berlin", {"locale": "de_DE"}, u"Mitteleurop\xe4ische Zeit"), + ("Europe/Berlin", {"locale": "pt_BR"}, u"Hor\xe1rio da Europa Central"), + ("America/St_Johns", {"locale": "de_DE"}, u"Neufundland-Zeit"), + ( + "America/Los_Angeles", + {"locale": "en", "width": "short", "zone_variant": "generic"}, + u"PT", + ), + ( + "America/Los_Angeles", + {"locale": "en", "width": "short", "zone_variant": "standard"}, + u"PST", + ), + ( + "America/Los_Angeles", + {"locale": "en", "width": "short", "zone_variant": "daylight"}, + u"PDT", + ), + ( + "America/Los_Angeles", + {"locale": "en", "width": "long", "zone_variant": "generic"}, + u"Pacific Time", + ), + ( + "America/Los_Angeles", + {"locale": "en", "width": "long", "zone_variant": "standard"}, + u"Pacific Standard Time", + ), + ( + "America/Los_Angeles", + {"locale": "en", "width": "long", "zone_variant": "daylight"}, + u"Pacific Daylight Time", + ), + ("Europe/Berlin", {"locale": "en_US"}, u"Central European Time"), + ], +) +def test_get_timezone_name_tzinfo(timezone_getter, tzname, params, expected): + tz = timezone_getter(tzname) + assert dates.get_timezone_name(tz, **params) == expected + + +@pytest.mark.parametrize("timezone_getter", ["pytz.timezone"], indirect=True) +@pytest.mark.parametrize( + "tzname, params, expected", + [ + ("America/Los_Angeles", {"locale": "en_US"}, u"Pacific Standard Time"), + ( + "America/Los_Angeles", + {"locale": "en_US", "return_zone": True}, + u"America/Los_Angeles", + ), + ("America/Los_Angeles", {"width": "short", "locale": "en_US"}, u"PST"), + ], +) +def test_get_timezone_name_time_pytz(timezone_getter, tzname, params, expected): + """pytz (by design) can't determine if the time is in DST or not, + so it will always return Standard time""" + dt = time(15, 30, tzinfo=timezone_getter(tzname)) + assert dates.get_timezone_name(dt, **params) == expected + + +def test_get_timezone_name_misc(timezone_getter): + localnow = datetime.utcnow().replace(tzinfo=timezone_getter('UTC')).astimezone(dates.LOCALTZ) assert (dates.get_timezone_name(None, locale='en_US') == dates.get_timezone_name(localnow, locale='en_US')) @@ -760,19 +810,10 @@ def test_zh_TW_format(): assert dates.format_time(datetime(2016, 4, 8, 12, 34, 56), locale='zh_TW') == u'\u4e0b\u534812:34:56' -def test_format_current_moment(monkeypatch): - import datetime as datetime_module +def test_format_current_moment(): frozen_instant = datetime.utcnow() - - class frozen_datetime(datetime): - - @classmethod - def utcnow(cls): - return frozen_instant - - # Freeze time! Well, some of it anyway. - monkeypatch.setattr(datetime_module, "datetime", frozen_datetime) - assert dates.format_datetime(locale="en_US") == dates.format_datetime(frozen_instant, locale="en_US") + with freezegun.freeze_time(time_to_freeze=frozen_instant): + assert dates.format_datetime(locale="en_US") == dates.format_datetime(frozen_instant, locale="en_US") @pytest.mark.all_locales diff --git a/tests/test_localedata.py b/tests/test_localedata.py index dbacba0d5..735678f80 100644 --- a/tests/test_localedata.py +++ b/tests/test_localedata.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which @@ -11,11 +11,17 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at http://babel.edgewall.org/log/. +import os +import pickle +import sys +import tempfile import unittest import random from operator import methodcaller -from babel import localedata +import pytest + +from babel import localedata, Locale, UnknownLocaleError class MergeResolveTestCase(unittest.TestCase): @@ -131,3 +137,34 @@ def listdir_spy(*args): localedata.locale_identifiers.cache = None assert localedata.locale_identifiers() assert len(listdir_calls) == 2 + + +def test_locale_name_cleanup(): + """ + Test that locale identifiers are cleaned up to avoid directory traversal. + """ + no_exist_name = os.path.join(tempfile.gettempdir(), "babel%d.dat" % random.randint(1, 99999)) + with open(no_exist_name, "wb") as f: + pickle.dump({}, f) + + try: + name = os.path.splitext(os.path.relpath(no_exist_name, localedata._dirname))[0] + except ValueError: + if sys.platform == "win32": + pytest.skip("unable to form relpath") + raise + + assert not localedata.exists(name) + with pytest.raises(IOError): + localedata.load(name) + with pytest.raises(UnknownLocaleError): + Locale(name) + + +@pytest.mark.skipif(sys.platform != "win32", reason="windows-only test") +def test_reserved_locale_names(): + for name in ("con", "aux", "nul", "prn", "com8", "lpt5"): + with pytest.raises(ValueError): + localedata.load(name) + with pytest.raises(ValueError): + Locale(name) diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 6e26fe900..11e61d37d 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which @@ -153,6 +153,36 @@ def test_formatting_of_very_small_decimals(self): fmt = numbers.format_decimal(number, format="@@@", locale='en_US') self.assertEqual('0.000000700', fmt) + def test_group_separator(self): + self.assertEqual('29567.12', numbers.format_decimal(29567.12, + locale='en_US', group_separator=False)) + self.assertEqual('29567,12', numbers.format_decimal(29567.12, + locale='fr_CA', group_separator=False)) + self.assertEqual('29567,12', numbers.format_decimal(29567.12, + locale='pt_BR', group_separator=False)) + self.assertEqual(u'$1099.98', numbers.format_currency(1099.98, 'USD', + locale='en_US', group_separator=False)) + self.assertEqual(u'101299,98\xa0€', numbers.format_currency(101299.98, 'EUR', + locale='fr_CA', group_separator=False)) + self.assertEqual('101299.98 euros', numbers.format_currency(101299.98, 'EUR', + locale='en_US', group_separator=False, format_type='name')) + self.assertEqual(u'25123412\xa0%', numbers.format_percent(251234.1234, locale='sv_SE', group_separator=False)) + + self.assertEqual(u'29,567.12', numbers.format_decimal(29567.12, + locale='en_US', group_separator=True)) + self.assertEqual(u'29\u202f567,12', numbers.format_decimal(29567.12, + locale='fr_CA', group_separator=True)) + self.assertEqual(u'29.567,12', numbers.format_decimal(29567.12, + locale='pt_BR', group_separator=True)) + self.assertEqual(u'$1,099.98', numbers.format_currency(1099.98, 'USD', + locale='en_US', group_separator=True)) + self.assertEqual(u'101\u202f299,98\xa0\u20ac', numbers.format_currency(101299.98, 'EUR', + locale='fr_CA', group_separator=True)) + self.assertEqual(u'101,299.98 euros', numbers.format_currency(101299.98, 'EUR', + locale='en_US', group_separator=True, + format_type='name')) + self.assertEqual(u'25\xa0123\xa0412\xa0%', numbers.format_percent(251234.1234, locale='sv_SE', group_separator=True)) + class NumberParsingTestCase(unittest.TestCase): @@ -690,3 +720,7 @@ def test_parse_decimal_nbsp_heuristics(): n = decimal.Decimal("12345.123") assert numbers.parse_decimal("12 345.123", locale="fi") == n assert numbers.parse_decimal(numbers.format_decimal(n, locale="fi"), locale="fi") == n + + +def test_very_small_decimal_no_quantization(): + assert numbers.format_decimal(decimal.Decimal('1E-7'), locale='en', decimal_quantization=False) == '0.0000001' diff --git a/tests/test_plural.py b/tests/test_plural.py index c54d07a57..bea8115ce 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which diff --git a/tests/test_support.py b/tests/test_support.py index b4dd823cd..a683591dc 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which @@ -17,6 +17,7 @@ import tempfile import unittest import pytest +import sys from datetime import date, datetime, timedelta from babel import support @@ -26,6 +27,7 @@ get_arg_spec = (inspect.getargspec if PY2 else inspect.getfullargspec) +SKIP_LGETTEXT = sys.version_info >= (3, 8) @pytest.mark.usefixtures("os_environ") class TranslationsTestCase(unittest.TestCase): @@ -76,6 +78,7 @@ def test_upgettext(self): self.assertEqualTypeToo(u'VohCTX', self.translations.upgettext('foo', 'foo')) + @pytest.mark.skipif(SKIP_LGETTEXT, reason="lgettext is deprecated") def test_lpgettext(self): self.assertEqualTypeToo(b'Voh', self.translations.lgettext('foo')) self.assertEqualTypeToo(b'VohCTX', self.translations.lpgettext('foo', @@ -105,6 +108,7 @@ def test_unpgettext(self): self.translations.unpgettext('foo', 'foo1', 'foos1', 2)) + @pytest.mark.skipif(SKIP_LGETTEXT, reason="lgettext is deprecated") def test_lnpgettext(self): self.assertEqualTypeToo(b'Voh1', self.translations.lngettext('foo1', 'foos1', 1)) @@ -129,6 +133,7 @@ def test_dupgettext(self): self.assertEqualTypeToo( u'VohCTXD', self.translations.dupgettext('messages1', 'foo', 'foo')) + @pytest.mark.skipif(SKIP_LGETTEXT, reason="lgettext is deprecated") def test_ldpgettext(self): self.assertEqualTypeToo( b'VohD', self.translations.ldgettext('messages1', 'foo')) @@ -159,6 +164,7 @@ def test_dunpgettext(self): u'VohsCTXD1', self.translations.dunpgettext('messages1', 'foo', 'foo1', 'foos1', 2)) + @pytest.mark.skipif(SKIP_LGETTEXT, reason="lgettext is deprecated") def test_ldnpgettext(self): self.assertEqualTypeToo( b'VohD1', self.translations.ldngettext('messages1', 'foo1', 'foos1', 1)) @@ -197,7 +203,11 @@ def setUp(self): self.null_translations = support.NullTranslations(fp=fp) def method_names(self): - return [name for name in dir(self.translations) if 'gettext' in name] + names = [name for name in dir(self.translations) if 'gettext' in name] + if SKIP_LGETTEXT: + # Remove deprecated l*gettext functions + names = [name for name in names if not name.startswith('l')] + return names def test_same_methods(self): for name in self.method_names(): @@ -279,6 +289,17 @@ def first(xs): self.assertEqual(2, proxy.value) self.assertEqual(1, proxy_deepcopy.value) + def test_handle_attribute_error(self): + + def raise_attribute_error(): + raise AttributeError('message') + + proxy = support.LazyProxy(raise_attribute_error) + with pytest.raises(AttributeError) as exception: + proxy.value + + self.assertEqual('message', str(exception.value)) + def test_format_date(): fmt = support.Format('en_US') diff --git a/tests/test_util.py b/tests/test_util.py index a6a4450cf..b29278e00 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2007-2011 Edgewall Software, 2013-2019 the Babel team +# Copyright (C) 2007-2011 Edgewall Software, 2013-2021 the Babel team # All rights reserved. # # This software is licensed as described in the file LICENSE, which @@ -11,6 +11,7 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at http://babel.edgewall.org/log/. +import __future__ import unittest import pytest @@ -20,6 +21,12 @@ from babel.util import parse_future_flags +class _FF: + division = __future__.division.compiler_flag + print_function = __future__.print_function.compiler_flag + with_statement = __future__.with_statement.compiler_flag + unicode_literals = __future__.unicode_literals.compiler_flag + def test_distinct(): assert list(util.distinct([1, 2, 1, 3, 4, 4])) == [1, 2, 3, 4] assert list(util.distinct('foobar')) == ['f', 'o', 'b', 'a', 'r'] @@ -70,25 +77,25 @@ def test_parse_encoding_non_ascii(): from __future__ import print_function, division, with_statement, unicode_literals -''', 0x10000 | 0x2000 | 0x8000 | 0x20000), +''', _FF.print_function | _FF.division | _FF.with_statement | _FF.unicode_literals), (''' from __future__ import print_function, division print('hello') -''', 0x10000 | 0x2000), +''', _FF.print_function | _FF.division), (''' from __future__ import print_function, division, unknown,,,,, print 'hello' -''', 0x10000 | 0x2000), +''', _FF.print_function | _FF.division), (''' from __future__ import ( print_function, division) -''', 0x10000 | 0x2000), +''', _FF.print_function | _FF.division), (''' from __future__ import \\ print_function, \\ division -''', 0x10000 | 0x2000), +''', _FF.print_function | _FF.division), ]) def test_parse_future(source, result): fp = BytesIO(source.encode('latin-1')) diff --git a/tox.ini b/tox.ini index b3f8041f4..14b450ff8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,25 @@ [tox] -envlist = py27, pypy, py34, py35, py36, py37, pypy3, py27-cdecimal +envlist = + py{36,37,38,39} + pypy3 [testenv] deps = - pytest==4.3.1 - pytest-cov==2.6.1 - cdecimal: m3-cdecimal - freezegun==0.3.11 + pytest + pytest-cov + freezegun==0.3.12 + backports.zoneinfo;python_version<"3.9" + tzdata;sys_platform == 'win32' whitelist_externals = make -commands = make clean-cldr test -passenv = PYTHON_TEST_FLAGS +commands = make clean-cldr test-cov +passenv = + BABEL_* + PYTHON_* -[pep8] -ignore = E501,E731,W503 - -[flake8] -ignore = E501,E731,W503 +[gh-actions] +python = + pypy3: pypy3 + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39