diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6cc34b65a..a5ed97f22 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,17 +25,17 @@ jobs:
strategy:
matrix:
os:
- - "ubuntu-22.04"
+ - "ubuntu-24.04"
- "windows-2022"
- - "macos-12"
+ - "macos-14"
python-version:
- "3.8"
- "3.9"
- "3.10"
- "3.11"
- "3.12"
+ - "3.13"
- "pypy3.10"
- - "3.13-dev"
env:
BABEL_CLDR_NO_DOWNLOAD_PROGRESS: "1"
BABEL_CLDR_QUIET: "1"
@@ -61,20 +61,20 @@ jobs:
env:
COVERAGE_XML_PATH: ${{ runner.temp }}
BABEL_TOX_EXTRA_DEPS: pytest-github-actions-annotate-failures
- - uses: codecov/codecov-action@v4
+ - uses: codecov/codecov-action@v5
with:
directory: ${{ runner.temp }}
flags: ${{ matrix.os }}-${{ matrix.python-version }}
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
build:
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-24.04
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
- python-version: "3.12"
+ python-version: "3.13"
cache: "pip"
cache-dependency-path: "**/setup.py"
- run: pip install build -e .
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index d43cc7794..6a86ee871 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,12 +1,12 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.5.1
+ rev: v0.9.1
hooks:
- id: ruff
args:
- --fix
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.6.0
+ rev: v5.0.0
hooks:
- id: check-added-large-files
- id: check-docstring-first
diff --git a/AUTHORS b/AUTHORS
index cdceb1229..89353ae0f 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -12,6 +12,7 @@ Babel is written and maintained by the Babel team and various contributors:
- Philip Jenvey
- benselme
- Isaac Jurado
+- Tomas R.
- Tobias Bieniek
- Erick Wilder
- Jonah Lawrence
@@ -20,15 +21,13 @@ Babel is written and maintained by the Babel team and various contributors:
- Kevin Deldycke
- Ville Skyttä
- Jon Dufresne
+- Hugo van Kemenade
- Jun Omae
-- Hugo
- Heungsub Lee
-- Tomas R
- Jakob Schnitzer
- Sachin Paliwal
- Alex Willmer
- Daniel Neuhäuser
-- Hugo van Kemenade
- Miro Hrončok
- Cédric Krier
- Luke Plant
@@ -50,6 +49,13 @@ Babel is written and maintained by the Babel team and various contributors:
- Arturas Moskvinas
- Leonardo Pistone
- Hyunjun Kim
+- wandrew004
+- James McKinney
+- Tomáš Hrnčiar
+- Gabe Sherman
+- mattdiaz007
+- Dylan Kiss
+- Daniel Roschka
- buhtz
- Bohdan Malomuzh
- Leonid
diff --git a/CHANGES.rst b/CHANGES.rst
index f33221fc9..dcd7aa28e 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,58 @@
Babel Changelog
===============
+Version 2.17.0
+--------------
+
+Happy 2025! This release is being made from FOSDEM 2025, in Brussels, Belgium.
+
+Thank you to all contributors, new and old,
+and here's to another great year of internationalization and localization!
+
+Features
+~~~~~~~~
+
+* CLDR: Babel now uses CLDR 46, by @tomasr8 in :gh:`1145`
+* Dates: Allow specifying an explicit format in parse_date/parse_time by @tomasr8 in :gh:`1131`
+* Dates: More alternate characters are now supported by `format_skeleton`. By @tomasr8 in :gh:`1122`
+* Dates: Support short and narrow formats for format_timedelta when using `add_direction`, by @akx in :gh:`1163`
+* Messages: .po files now enclose white spaces in filenames like GNU gettext does. By @Dunedan in :gh:`1105`, and @tomasr8 in :gh:`1120`
+* Messages: Initial support for `Message.python_brace_format`, by @tomasr8 in :gh:`1169`
+* Numbers: LC_MONETARY is now preferred when formatting currencies, by @akx in :gh:`1173`
+
+Bugfixes
+~~~~~~~~
+
+* Dates: Make seconds optional in `parse_time` time formats by @tomasr8 in :gh:`1141`
+* Dates: Replace `str.index` with `str.find` by @tomasr8 in :gh:`1130`
+* Dates: Strip extra leading slashes in `/etc/localtime` by @akx in :gh:`1165`
+* Dates: Week numbering and formatting of dates with week numbers was repaired by @jun66j5 in :gh:`1179`
+* General: Improve handling for `locale=None` by @akx in :gh:`1164`
+* General: Remove redundant assignment in `Catalog.__setitem__` by @tomasr8 in :gh:`1167`
+* Messages: Fix extracted lineno with nested calls, by @dylankiss in :gh:`1126`
+* Messages: Fix of list index out of range when translations is empty, by @gabe-sherman in :gh:`1135`
+* Messages: Fix the way obsolete messages are stored by @tomasr8 in :gh:`1132`
+* Messages: Simplify `read_mo` logic regarding `catalog.charset` by @tomasr8 in :gh:`1148`
+* Messages: Use the first matching method & options, rather than first matching method & last options, by @jpmckinney in :gh:`1121`
+
+Deprecation and compatibility
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+* Dates: Fix deprecation warnings for `datetime.utcnow()` by @tomasr8 in :gh:`1119`
+* Docs: Adjust docs/conf.py to add compatibility with sphinx 8 by @hrnciar in :gh:`1155`
+* General: Import `Literal` from the typing module by @tomasr8 in :gh:`1175`
+* General: Replace `OrderedDict` with just `dict` by @tomasr8 in :gh:`1149`
+* Messages: Mark `wraptext` deprecated; use `TextWrapper` directly in `write_po` by @akx in :gh:`1140`
+
+Infrastructure
+~~~~~~~~~~~~~~
+
+* Add tzdata as dev dependency and sync with tox.ini by @wandrew004 in :gh:`1159`
+* Duplicate test code was deleted by @mattdiaz007 in :gh:`1138`
+* Increase test coverage of the `python_format` checker by @tomasr8 in :gh:`1176`
+* Small cleanups by @akx in :gh:`1160`, :gh:`1166`, :gh:`1170` and :gh:`1172`
+* Update CI to use python 3.13 and Ubuntu 24.04 by @tomasr8 in :gh:`1153`
+
Version 2.16.0
--------------
diff --git a/LICENSE b/LICENSE
index 83b6e513d..6ddae98eb 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2013-2024 by the Babel Team, see AUTHORS for more information.
+Copyright (c) 2013-2025 by the Babel Team, see AUTHORS for more information.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
diff --git a/README.rst b/README.rst
index c708f9da6..290223ccc 100644
--- a/README.rst
+++ b/README.rst
@@ -9,7 +9,7 @@ Details can be found in the HTML files in the ``docs`` folder.
For more information please visit the Babel web site:
-http://babel.pocoo.org/
+https://babel.pocoo.org/
Join the chat at https://gitter.im/python-babel/babel
diff --git a/babel/__init__.py b/babel/__init__.py
index 9a1ef4bab..7b2774558 100644
--- a/babel/__init__.py
+++ b/babel/__init__.py
@@ -12,7 +12,7 @@
access to various locale display names, localized number and date
formatting, etc.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
@@ -25,11 +25,12 @@
parse_locale,
)
-__version__ = '2.16.0'
+__version__ = '2.17.0'
__all__ = [
'Locale',
'UnknownLocaleError',
+ '__version__',
'default_locale',
'get_locale_identifier',
'negotiate_locale',
diff --git a/babel/core.py b/babel/core.py
index a2e1e1de5..5762bbe36 100644
--- a/babel/core.py
+++ b/babel/core.py
@@ -4,7 +4,7 @@
Core locale representation and locale data access.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
@@ -13,16 +13,23 @@
import os
import pickle
from collections.abc import Iterable, Mapping
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Literal
from babel import localedata
from babel.plural import PluralRule
-__all__ = ['UnknownLocaleError', 'Locale', 'default_locale', 'negotiate_locale',
- 'parse_locale']
+__all__ = [
+ 'Locale',
+ 'UnknownLocaleError',
+ 'default_locale',
+ 'get_global',
+ 'get_locale_identifier',
+ 'negotiate_locale',
+ 'parse_locale',
+]
if TYPE_CHECKING:
- from typing_extensions import Literal, TypeAlias
+ from typing_extensions import TypeAlias
_GLOBAL_KEY: TypeAlias = Literal[
"all_currencies",
@@ -259,6 +266,7 @@ def negotiate(
:param preferred: the list of locale identifiers preferred by the user
:param available: the list of locale identifiers available
:param aliases: a dictionary of aliases for locale identifiers
+ :param sep: separator for parsing; e.g. Windows tends to use '-' instead of '_'.
"""
identifier = negotiate_locale(preferred, available, sep=sep,
aliases=aliases)
@@ -269,7 +277,7 @@ def negotiate(
@classmethod
def parse(
cls,
- identifier: str | Locale | None,
+ identifier: Locale | str | None,
sep: str = '_',
resolve_likely_subtags: bool = True,
) -> Locale:
@@ -286,8 +294,8 @@ def parse(
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:
+ or an empty string, e.g. because a default locale identifier
+ could not be determined, a `TypeError` is raised:
>>> Locale.parse(None)
Traceback (most recent call last):
@@ -325,10 +333,23 @@ def parse(
:raise `UnknownLocaleError`: if no locale data is available for the
requested locale
:raise `TypeError`: if the identifier is not a string or a `Locale`
+ :raise `ValueError`: if the identifier is not a valid string
"""
if isinstance(identifier, Locale):
return identifier
- elif not isinstance(identifier, str):
+
+ if not identifier:
+ msg = (
+ f"Empty locale identifier value: {identifier!r}\n\n"
+ f"If you didn't explicitly pass an empty value to a Babel function, "
+ f"this could be caused by there being no suitable locale environment "
+ f"variables for the API you tried to use.",
+ )
+ if isinstance(identifier, str):
+ raise ValueError(msg) # `parse_locale` would raise a ValueError, so let's do that here
+ raise TypeError(msg)
+
+ if not isinstance(identifier, str):
raise TypeError(f"Unexpected value for identifier: {identifier!r}")
parts = parse_locale(identifier, sep=sep)
@@ -562,7 +583,7 @@ def languages(self) -> localedata.LocaleDataDict:
>>> Locale('de', 'DE').languages['ja']
u'Japanisch'
- See `ISO 639 `_ for
+ See `ISO 639 `_ for
more information.
"""
return self._data['languages']
@@ -574,7 +595,7 @@ def scripts(self) -> localedata.LocaleDataDict:
>>> Locale('en', 'US').scripts['Hira']
u'Hiragana'
- See `ISO 15924 `_
+ See `ISO 15924 `_
for more information.
"""
return self._data['scripts']
@@ -586,7 +607,7 @@ def territories(self) -> localedata.LocaleDataDict:
>>> Locale('es', 'CO').territories['DE']
u'Alemania'
- See `ISO 3166 `_
+ See `ISO 3166 `_
for more information.
"""
return self._data['territories']
@@ -1068,7 +1089,10 @@ def unit_display_names(self) -> localedata.LocaleDataDict:
return self._data['unit_display_names']
-def default_locale(category: str | None = None, aliases: Mapping[str, str] = LOCALE_ALIASES) -> str | None:
+def default_locale(
+ category: str | tuple[str, ...] | list[str] | None = None,
+ aliases: Mapping[str, str] = LOCALE_ALIASES,
+) -> str | None:
"""Returns the system default locale for a given category, based on
environment variables.
@@ -1092,11 +1116,22 @@ def default_locale(category: str | None = None, aliases: Mapping[str, str] = LOC
- ``LC_CTYPE``
- ``LANG``
- :param category: one of the ``LC_XXX`` environment variable names
+ :param category: one or more of the ``LC_XXX`` environment variable names
:param aliases: a dictionary of aliases for locale identifiers
"""
- varnames = (category, 'LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG')
- for name in filter(None, varnames):
+
+ varnames = ('LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG')
+ if category:
+ if isinstance(category, str):
+ varnames = (category, *varnames)
+ elif isinstance(category, (list, tuple)):
+ varnames = (*category, *varnames)
+ else:
+ raise TypeError(f"Invalid type for category: {category!r}")
+
+ for name in varnames:
+ if not name:
+ continue
locale = os.getenv(name)
if locale:
if name == 'LANGUAGE' and ':' in locale:
@@ -1235,6 +1270,8 @@ def parse_locale(
:raise `ValueError`: if the string does not appear to be a valid locale
identifier
"""
+ if not identifier:
+ raise ValueError("empty locale identifier")
identifier, _, modifier = identifier.partition('@')
if '.' in identifier:
# this is probably the charset/encoding, which we don't care about
diff --git a/babel/dates.py b/babel/dates.py
index 3373e0639..355a9236e 100644
--- a/babel/dates.py
+++ b/babel/dates.py
@@ -11,16 +11,17 @@
* ``LC_ALL``, and
* ``LANG``
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
+import math
import re
import warnings
from functools import lru_cache
-from typing import TYPE_CHECKING, SupportsInt
+from typing import TYPE_CHECKING, Literal, SupportsInt
try:
import pytz
@@ -36,7 +37,7 @@
from babel.localedata import LocaleDataDict
if TYPE_CHECKING:
- from typing_extensions import Literal, TypeAlias
+ from typing_extensions import TypeAlias
_Instant: TypeAlias = datetime.date | datetime.time | float | None
_PredefinedTimeFormat: TypeAlias = Literal['full', 'long', 'medium', 'short']
_Context: TypeAlias = Literal['format', 'stand-alone']
@@ -251,8 +252,11 @@ def get_timezone(zone: str | datetime.tzinfo | None = None) -> datetime.tzinfo:
raise LookupError(f"Unknown timezone {zone}") from exc
-def get_period_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
- context: _Context = 'stand-alone', locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
+def get_period_names(
+ width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
+ context: _Context = 'stand-alone',
+ locale: Locale | str | None = None,
+) -> LocaleDataDict:
"""Return the names for day periods (AM/PM) used by the locale.
>>> get_period_names(locale='en_US')['am']
@@ -260,13 +264,16 @@ def get_period_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
:param width: the width to use, one of "abbreviated", "narrow", or "wide"
:param context: the context, either "format" or "stand-alone"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
"""
- return Locale.parse(locale).day_periods[context][width]
+ return Locale.parse(locale or LC_TIME).day_periods[context][width]
-def get_day_names(width: Literal['abbreviated', 'narrow', 'short', 'wide'] = 'wide',
- context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
+def get_day_names(
+ width: Literal['abbreviated', 'narrow', 'short', 'wide'] = 'wide',
+ context: _Context = 'format',
+ locale: Locale | str | None = None,
+) -> LocaleDataDict:
"""Return the day names used by the locale for the specified format.
>>> get_day_names('wide', locale='en_US')[1]
@@ -280,13 +287,16 @@ def get_day_names(width: Literal['abbreviated', 'narrow', 'short', 'wide'] = 'wi
:param width: the width to use, one of "wide", "abbreviated", "short" or "narrow"
:param context: the context, either "format" or "stand-alone"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
"""
- return Locale.parse(locale).days[context][width]
+ return Locale.parse(locale or LC_TIME).days[context][width]
-def get_month_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
- context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
+def get_month_names(
+ width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
+ context: _Context = 'format',
+ locale: Locale | str | None = None,
+) -> LocaleDataDict:
"""Return the month names used by the locale for the specified format.
>>> get_month_names('wide', locale='en_US')[1]
@@ -298,13 +308,16 @@ def get_month_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
:param width: the width to use, one of "wide", "abbreviated", or "narrow"
:param context: the context, either "format" or "stand-alone"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
"""
- return Locale.parse(locale).months[context][width]
+ return Locale.parse(locale or LC_TIME).months[context][width]
-def get_quarter_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
- context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
+def get_quarter_names(
+ width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
+ context: _Context = 'format',
+ locale: Locale | str | None = None,
+) -> LocaleDataDict:
"""Return the quarter names used by the locale for the specified format.
>>> get_quarter_names('wide', locale='en_US')[1]
@@ -316,13 +329,15 @@ def get_quarter_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
:param width: the width to use, one of "wide", "abbreviated", or "narrow"
:param context: the context, either "format" or "stand-alone"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
"""
- return Locale.parse(locale).quarters[context][width]
+ return Locale.parse(locale or LC_TIME).quarters[context][width]
-def get_era_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
- locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
+def get_era_names(
+ width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
+ locale: Locale | str | None = None,
+) -> LocaleDataDict:
"""Return the era names used by the locale for the specified format.
>>> get_era_names('wide', locale='en_US')[1]
@@ -331,12 +346,15 @@ def get_era_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
u'n. Chr.'
:param width: the width to use, either "wide", "abbreviated", or "narrow"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
"""
- return Locale.parse(locale).eras[width]
+ return Locale.parse(locale or LC_TIME).eras[width]
-def get_date_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern:
+def get_date_format(
+ format: _PredefinedTimeFormat = 'medium',
+ locale: Locale | str | None = None,
+) -> DateTimePattern:
"""Return the date formatting patterns used by the locale for the specified
format.
@@ -347,12 +365,15 @@ def get_date_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | s
:param format: the format to use, one of "full", "long", "medium", or
"short"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
"""
- return Locale.parse(locale).date_formats[format]
+ return Locale.parse(locale or LC_TIME).date_formats[format]
-def get_datetime_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern:
+def get_datetime_format(
+ format: _PredefinedTimeFormat = 'medium',
+ locale: Locale | str | None = None,
+) -> DateTimePattern:
"""Return the datetime formatting patterns used by the locale for the
specified format.
@@ -361,15 +382,18 @@ def get_datetime_format(format: _PredefinedTimeFormat = 'medium', locale: Locale
:param format: the format to use, one of "full", "long", "medium", or
"short"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
"""
- patterns = Locale.parse(locale).datetime_formats
+ patterns = Locale.parse(locale or LC_TIME).datetime_formats
if format not in patterns:
format = None
return patterns[format]
-def get_time_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern:
+def get_time_format(
+ format: _PredefinedTimeFormat = 'medium',
+ locale: Locale | str | None = None,
+) -> DateTimePattern:
"""Return the time formatting patterns used by the locale for the specified
format.
@@ -380,15 +404,15 @@ def get_time_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | s
:param format: the format to use, one of "full", "long", "medium", or
"short"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
"""
- return Locale.parse(locale).time_formats[format]
+ return Locale.parse(locale or LC_TIME).time_formats[format]
def get_timezone_gmt(
datetime: _Instant = None,
width: Literal['long', 'short', 'iso8601', 'iso8601_short'] = 'long',
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
return_z: bool = False,
) -> str:
"""Return the timezone associated with the given `datetime` object formatted
@@ -422,12 +446,12 @@ def get_timezone_gmt(
:param datetime: the ``datetime`` object; if `None`, the current date and
time in UTC is used
:param width: either "long" or "short" or "iso8601" or "iso8601_short"
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
:param return_z: True or False; Function returns indicator "Z"
when local time offset is 0
"""
datetime = _ensure_datetime_tzinfo(_get_datetime(datetime))
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
offset = datetime.tzinfo.utcoffset(datetime)
seconds = offset.days * 24 * 60 * 60 + offset.seconds
@@ -447,7 +471,7 @@ def get_timezone_gmt(
def get_timezone_location(
dt_or_tzinfo: _DtOrTzinfo = None,
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
return_city: bool = False,
) -> str:
"""Return a representation of the given timezone using "location format".
@@ -478,13 +502,13 @@ def get_timezone_location(
:param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines
the timezone; if `None`, the current date and time in
UTC is assumed
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
:param return_city: True or False, if True then return exemplar city (location)
for the time zone
:return: the localized timezone name using location format
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
zone = _get_tz_name(dt_or_tzinfo)
@@ -529,7 +553,7 @@ def get_timezone_name(
dt_or_tzinfo: _DtOrTzinfo = None,
width: Literal['long', 'short'] = 'long',
uncommon: bool = False,
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
zone_variant: Literal['generic', 'daylight', 'standard'] | None = None,
return_zone: bool = False,
) -> str:
@@ -599,12 +623,12 @@ def get_timezone_name(
``'generic'`` variation is assumed. The following
values are valid: ``'generic'``, ``'daylight'`` and
``'standard'``.
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
:param return_zone: True or False. If true then function
returns long time zone ID
"""
dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo)
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
zone = _get_tz_name(dt_or_tzinfo)
@@ -650,7 +674,7 @@ def get_timezone_name(
def format_date(
date: datetime.date | None = None,
format: _PredefinedTimeFormat | str = 'medium',
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
) -> str:
"""Return a date formatted according to the given pattern.
@@ -671,14 +695,14 @@ def format_date(
date is used
:param format: one of "full", "long", "medium", or "short", or a custom
date/time pattern
- :param locale: a `Locale` object or a locale identifier
+ :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
"""
if date is None:
date = datetime.date.today()
elif isinstance(date, datetime.datetime):
date = date.date()
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
if format in ('full', 'long', 'medium', 'short'):
format = get_date_format(format, locale=locale)
pattern = parse_pattern(format)
@@ -689,7 +713,7 @@ def format_datetime(
datetime: _Instant = None,
format: _PredefinedTimeFormat | str = 'medium',
tzinfo: datetime.tzinfo | None = None,
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
) -> str:
r"""Return a date formatted according to the given pattern.
@@ -712,11 +736,11 @@ def format_datetime(
:param format: one of "full", "long", "medium", or "short", or a custom
date/time pattern
:param tzinfo: the timezone to apply to the time for display
- :param locale: a `Locale` object or a locale identifier
+ :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
"""
datetime = _ensure_datetime_tzinfo(_get_datetime(datetime), tzinfo)
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
if format in ('full', 'long', 'medium', 'short'):
return get_datetime_format(format, locale=locale) \
.replace("'", "") \
@@ -730,7 +754,8 @@ def format_datetime(
def format_time(
time: datetime.time | datetime.datetime | float | None = None,
format: _PredefinedTimeFormat | str = 'medium',
- tzinfo: datetime.tzinfo | None = None, locale: Locale | str | None = LC_TIME,
+ tzinfo: datetime.tzinfo | None = None,
+ locale: Locale | str | None = None,
) -> str:
r"""Return a time formatted according to the given pattern.
@@ -785,7 +810,7 @@ def format_time(
:param format: one of "full", "long", "medium", or "short", or a custom
date/time pattern
:param tzinfo: the time-zone to apply to the time for display
- :param locale: a `Locale` object or a locale identifier
+ :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
"""
# get reference date for if we need to find the right timezone variant
@@ -794,7 +819,7 @@ def format_time(
time = _get_time(time, tzinfo)
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
if format in ('full', 'long', 'medium', 'short'):
format = get_time_format(format, locale=locale)
return parse_pattern(format).apply(time, locale, reference_date=ref_date)
@@ -805,7 +830,7 @@ def format_skeleton(
datetime: _Instant = None,
tzinfo: datetime.tzinfo | None = None,
fuzzy: bool = True,
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
) -> str:
r"""Return a time and/or date formatted according to the given pattern.
@@ -841,9 +866,9 @@ def format_skeleton(
:param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
close enough to it. If there is no close match, a `KeyError`
is thrown.
- :param locale: a `Locale` object or a locale identifier
+ :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
if fuzzy and skeleton not in locale.datetime_skeletons:
skeleton = match_skeleton(skeleton, locale.datetime_skeletons)
format = locale.datetime_skeletons[skeleton]
@@ -867,7 +892,7 @@ def format_timedelta(
threshold: float = .85,
add_direction: bool = False,
format: Literal['narrow', 'short', 'medium', 'long'] = 'long',
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
) -> str:
"""Return a time delta according to the rules of the given locale.
@@ -922,7 +947,7 @@ def format_timedelta(
:param format: the format, can be "narrow", "short" or "long". (
"medium" is deprecated, currently converted to "long" to
maintain compatibility)
- :param locale: a `Locale` object or a locale identifier
+ :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
"""
if format not in ('narrow', 'short', 'medium', 'long'):
raise TypeError('Format must be one of "narrow", "short" or "long"')
@@ -938,17 +963,21 @@ def format_timedelta(
seconds = int((delta.days * 86400) + delta.seconds)
else:
seconds = delta
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
+ date_fields = locale._data["date_fields"]
+ unit_patterns = locale._data["unit_patterns"]
def _iter_patterns(a_unit):
if add_direction:
- unit_rel_patterns = locale._data['date_fields'][a_unit]
+ # Try to find the length variant version first ("year-narrow")
+ # before falling back to the default.
+ unit_rel_patterns = (date_fields.get(f"{a_unit}-{format}") or date_fields[a_unit])
if seconds >= 0:
yield unit_rel_patterns['future']
else:
yield unit_rel_patterns['past']
a_unit = f"duration-{a_unit}"
- unit_pats = locale._data['unit_patterns'].get(a_unit, {})
+ unit_pats = unit_patterns.get(a_unit, {})
yield unit_pats.get(format)
# We do not support `` tags at all while ingesting CLDR data,
# so these aliases specified in `root.xml` are hard-coded here:
@@ -983,7 +1012,7 @@ def _format_fallback_interval(
end: _Instant,
skeleton: str | None,
tzinfo: datetime.tzinfo | None,
- locale: Locale | str | None = LC_TIME,
+ locale: Locale,
) -> str:
if skeleton in locale.datetime_skeletons: # Use the given skeleton
format = lambda dt: format_skeleton(skeleton, dt, tzinfo, locale=locale)
@@ -1013,7 +1042,7 @@ def format_interval(
skeleton: str | None = None,
tzinfo: datetime.tzinfo | None = None,
fuzzy: bool = True,
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
) -> str:
"""
Format an interval between two instants according to the locale's rules.
@@ -1053,10 +1082,10 @@ def format_interval(
:param tzinfo: tzinfo to use (if none is already attached)
:param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
close enough to it.
- :param locale: A locale object or identifier.
+ :param locale: A locale object or identifier. Defaults to the system time locale.
:return: Formatted interval
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
# NB: The quote comments below are from the algorithm description in
# https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
@@ -1116,7 +1145,7 @@ def get_period_id(
time: _Instant,
tzinfo: datetime.tzinfo | None = None,
type: Literal['selection'] | None = None,
- locale: Locale | str | None = LC_TIME,
+ locale: Locale | str | None = None,
) -> str:
"""
Get the day period ID for a given time.
@@ -1138,12 +1167,12 @@ def get_period_id(
:param type: The period type to use. Either "selection" or None.
The selection type is used for selecting among phrases such as
“Your email arrived yesterday evening” or “Your email arrived last night”.
- :param locale: the `Locale` object, or a locale string
+ :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
:return: period ID. Something is always returned -- even if it's just "am" or "pm".
"""
time = _get_time(time, tzinfo)
seconds_past_midnight = int(time.hour * 60 * 60 + time.minute * 60 + time.second)
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_TIME)
# The LDML rules state that the rules may not overlap, so iterating in arbitrary
# order should be alright, though `at` periods should be preferred.
@@ -1194,14 +1223,21 @@ class ParseError(ValueError):
def parse_date(
string: str,
- locale: Locale | str | None = LC_TIME,
- format: _PredefinedTimeFormat = 'medium',
+ locale: Locale | str | None = None,
+ format: _PredefinedTimeFormat | str = 'medium',
) -> datetime.date:
"""Parse a date from a string.
- This function first tries to interpret the string as ISO-8601
- date format, then uses the date format for the locale as a hint to
- determine the order in which the date fields appear in the string.
+ If an explicit format is provided, it is used to parse the date.
+
+ >>> parse_date('01.04.2004', format='dd.MM.yyyy')
+ datetime.date(2004, 4, 1)
+
+ If no format is given, or if it is one of "full", "long", "medium",
+ or "short", the function first tries to interpret the string as
+ ISO-8601 date format and then uses the date format for the locale
+ as a hint to determine the order in which the date fields appear in
+ the string.
>>> parse_date('4/1/04', locale='en_US')
datetime.date(2004, 4, 1)
@@ -1211,28 +1247,38 @@ def parse_date(
datetime.date(2004, 4, 1)
>>> parse_date('2004-04-01', locale='de_DE')
datetime.date(2004, 4, 1)
+ >>> parse_date('01.04.04', locale='de_DE', format='short')
+ datetime.date(2004, 4, 1)
:param string: the string containing the date
:param locale: a `Locale` object or a locale identifier
- :param format: the format to use (see ``get_date_format``)
+ :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
+ :param format: the format to use, either an explicit date format,
+ or one of "full", "long", "medium", or "short"
+ (see ``get_time_format``)
"""
numbers = re.findall(r'(\d+)', string)
if not numbers:
raise ParseError("No numbers were found in input")
+ use_predefined_format = format in ('full', 'long', 'medium', 'short')
# we try ISO-8601 format first, meaning similar to formats
# extended YYYY-MM-DD or basic YYYYMMDD
iso_alike = re.match(r'^(\d{4})-?([01]\d)-?([0-3]\d)$',
string, flags=re.ASCII) # allow only ASCII digits
- if iso_alike:
+ if iso_alike and use_predefined_format:
try:
return datetime.date(*map(int, iso_alike.groups()))
except ValueError:
pass # a locale format might fit better, so let's continue
- format_str = get_date_format(format=format, locale=locale).pattern.lower()
+ if use_predefined_format:
+ fmt = get_date_format(format=format, locale=locale)
+ else:
+ fmt = parse_pattern(format)
+ format_str = fmt.pattern.lower()
year_idx = format_str.index('y')
- month_idx = format_str.index('m')
+ month_idx = format_str.find('m')
if month_idx < 0:
month_idx = format_str.index('l')
day_idx = format_str.index('d')
@@ -1254,20 +1300,27 @@ def parse_date(
def parse_time(
string: str,
- locale: Locale | str | None = LC_TIME,
- format: _PredefinedTimeFormat = 'medium',
+ locale: Locale | str | None = None,
+ format: _PredefinedTimeFormat | str = 'medium',
) -> datetime.time:
"""Parse a time from a string.
This function uses the time format for the locale as a hint to determine
the order in which the time fields appear in the string.
+ If an explicit format is provided, the function will use it to parse
+ the time instead.
+
>>> parse_time('15:30:00', locale='en_US')
datetime.time(15, 30)
+ >>> parse_time('15:30:00', format='H:mm:ss')
+ datetime.time(15, 30)
:param string: the string containing the time
- :param locale: a `Locale` object or a locale identifier
- :param format: the format to use (see ``get_time_format``)
+ :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
+ :param format: the format to use, either an explicit time format,
+ or one of "full", "long", "medium", or "short"
+ (see ``get_time_format``)
:return: the parsed time
:rtype: `time`
"""
@@ -1276,12 +1329,18 @@ def parse_time(
raise ParseError("No numbers were found in input")
# TODO: try ISO format first?
- format_str = get_time_format(format=format, locale=locale).pattern.lower()
- hour_idx = format_str.index('h')
+ if format in ('full', 'long', 'medium', 'short'):
+ fmt = get_time_format(format=format, locale=locale)
+ else:
+ fmt = parse_pattern(format)
+ format_str = fmt.pattern.lower()
+ hour_idx = format_str.find('h')
if hour_idx < 0:
hour_idx = format_str.index('k')
min_idx = format_str.index('m')
- sec_idx = format_str.index('s')
+ # format might not contain seconds
+ if (sec_idx := format_str.find('s')) < 0:
+ sec_idx = math.inf
indexes = sorted([(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')])
indexes = {item[1]: idx for idx, item in enumerate(indexes)}
@@ -1423,7 +1482,11 @@ def format_era(self, char: str, num: int) -> str:
def format_year(self, char: str, num: int) -> str:
value = self.value.year
if char.isupper():
- value = self.value.isocalendar()[0]
+ month = self.value.month
+ if month == 1 and self.value.day < 7 and self.get_week_of_year() >= 52:
+ value -= 1
+ elif month == 12 and self.value.day > 25 and self.get_week_of_year() <= 2:
+ value += 1
year = self.format(value, num)
if num == 2:
year = year[-2:]
@@ -1446,18 +1509,10 @@ def format_month(self, char: str, num: int) -> str:
def format_week(self, char: str, num: int) -> str:
if char.islower(): # week of year
- day_of_year = self.get_day_of_year()
- week = self.get_week_number(day_of_year)
- if week == 0:
- date = self.value - datetime.timedelta(days=day_of_year)
- week = self.get_week_number(self.get_day_of_year(date),
- date.weekday())
+ week = self.get_week_of_year()
return self.format(week, num)
else: # week of month
- week = self.get_week_number(self.value.day)
- if week == 0:
- date = self.value - datetime.timedelta(days=self.value.day)
- week = self.get_week_number(date.day, date.weekday())
+ week = self.get_week_of_month()
return str(week)
def format_weekday(self, char: str = 'E', num: int = 4) -> str:
@@ -1618,6 +1673,25 @@ def get_day_of_year(self, date: datetime.date | None = None) -> int:
date = self.value
return (date - date.replace(month=1, day=1)).days + 1
+ def get_week_of_year(self) -> int:
+ """Return the week of the year."""
+ day_of_year = self.get_day_of_year(self.value)
+ week = self.get_week_number(day_of_year)
+ if week == 0:
+ date = datetime.date(self.value.year - 1, 12, 31)
+ week = self.get_week_number(self.get_day_of_year(date),
+ date.weekday())
+ elif week > 52:
+ weekday = datetime.date(self.value.year + 1, 1, 1).weekday()
+ if self.get_week_number(1, weekday) == 1 and \
+ 32 - (weekday - self.locale.first_week_day) % 7 <= self.value.day:
+ week = 1
+ return week
+
+ def get_week_of_month(self) -> int:
+ """Return the week of the month."""
+ return self.get_week_number(self.value.day)
+
def get_week_number(self, day_of_period: int, day_of_week: int | None = None) -> int:
"""Return the number of the week of a day within a period. This may be
the week number in a year or the week number in a month.
@@ -1644,20 +1718,8 @@ def get_week_number(self, day_of_period: int, day_of_week: int | None = None) ->
if first_day < 0:
first_day += 7
week_number = (day_of_period + first_day - 1) // 7
-
if 7 - first_day >= self.locale.min_week_days:
week_number += 1
-
- if self.locale.first_week_day == 0:
- # Correct the weeknumber in case of iso-calendar usage (first_week_day=0).
- # If the weeknumber exceeds the maximum number of weeks for the given year
- # we must count from zero.For example the above calculation gives week 53
- # for 2018-12-31. By iso-calender definition 2018 has a max of 52
- # weeks, thus the weeknumber must be 53-52=1.
- max_weeks = datetime.date(year=self.value.year, day=28, month=12).isocalendar()[1]
- if week_number > max_weeks:
- week_number -= max_weeks
-
return week_number
@@ -1876,6 +1938,10 @@ def match_skeleton(skeleton: str, options: Iterable[str], allow_different_fields
:type skeleton: str
:param options: An iterable of other skeletons to match against
:type options: Iterable[str]
+ :param allow_different_fields: Whether to allow a match that uses different fields
+ than the skeleton requested.
+ :type allow_different_fields: bool
+
:return: The closest skeleton match, or if no match was found, None.
:rtype: str|None
"""
@@ -1883,13 +1949,21 @@ def match_skeleton(skeleton: str, options: Iterable[str], allow_different_fields
# TODO: maybe implement pattern expansion?
# Based on the implementation in
- # http://source.icu-project.org/repos/icu/icu4j/trunk/main/classes/core/src/com/ibm/icu/text/DateIntervalInfo.java
+ # https://github.com/unicode-org/icu/blob/main/icu4j/main/core/src/main/java/com/ibm/icu/text/DateIntervalInfo.java
# Filter out falsy values and sort for stability; when `interval_formats` is passed in, there may be a None key.
options = sorted(option for option in options if option)
if 'z' in skeleton and not any('z' in option for option in options):
skeleton = skeleton.replace('z', 'v')
+ if 'k' in skeleton and not any('k' in option for option in options):
+ skeleton = skeleton.replace('k', 'H')
+ if 'K' in skeleton and not any('K' in option for option in options):
+ skeleton = skeleton.replace('K', 'h')
+ if 'a' in skeleton and not any('a' in option for option in options):
+ skeleton = skeleton.replace('a', '')
+ if 'b' in skeleton and not any('b' in option for option in options):
+ skeleton = skeleton.replace('b', '')
get_input_field_width = dict(t[1] for t in tokenize_pattern(skeleton) if t[0] == "field").get
best_skeleton = None
diff --git a/babel/lists.py b/babel/lists.py
index 6c34cb099..353171c71 100644
--- a/babel/lists.py
+++ b/babel/lists.py
@@ -10,26 +10,35 @@
* ``LC_ALL``, and
* ``LANG``
- :copyright: (c) 2015-2024 by the Babel Team.
+ :copyright: (c) 2015-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
+import warnings
from collections.abc import Sequence
-from typing import TYPE_CHECKING
+from typing import Literal
from babel.core import Locale, default_locale
-if TYPE_CHECKING:
- from typing_extensions import Literal
+_DEFAULT_LOCALE = default_locale() # TODO(3.0): Remove this.
-DEFAULT_LOCALE = default_locale()
+
+def __getattr__(name):
+ if name == "DEFAULT_LOCALE":
+ warnings.warn(
+ "The babel.lists.DEFAULT_LOCALE constant is deprecated and will be removed.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return _DEFAULT_LOCALE
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def format_list(
lst: Sequence[str],
style: Literal['standard', 'standard-short', 'or', 'or-short', 'unit', 'unit-short', 'unit-narrow'] = 'standard',
- locale: Locale | str | None = DEFAULT_LOCALE,
+ locale: Locale | str | None = None,
) -> str:
"""
Format the items in `lst` as a list.
@@ -74,9 +83,9 @@ def format_list(
:param lst: a sequence of items to format in to a list
:param style: the style to format the list with. See above for description.
- :param locale: the locale
+ :param locale: the locale. Defaults to the system locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or _DEFAULT_LOCALE)
if not lst:
return ''
if len(lst) == 1:
diff --git a/babel/locale-data/LICENSE.unicode b/babel/locale-data/LICENSE.unicode
index a82da8263..861b74f3c 100644
--- a/babel/locale-data/LICENSE.unicode
+++ b/babel/locale-data/LICENSE.unicode
@@ -2,7 +2,7 @@ UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
-Copyright © 2004-2024 Unicode, Inc.
+Copyright © 2004-2025 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
diff --git a/babel/localedata.py b/babel/localedata.py
index 2aabfd18f..59f1db09e 100644
--- a/babel/localedata.py
+++ b/babel/localedata.py
@@ -7,7 +7,7 @@
:note: The `Locale` class, which uses this module under the hood, provides a
more convenient interface for accessing the locale data.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
diff --git a/babel/localtime/__init__.py b/babel/localtime/__init__.py
index 1066c9511..854c07496 100644
--- a/babel/localtime/__init__.py
+++ b/babel/localtime/__init__.py
@@ -5,7 +5,7 @@
Babel specific fork of tzlocal to determine the local timezone
of the system.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
diff --git a/babel/localtime/_fallback.py b/babel/localtime/_fallback.py
index 7c99f488c..fab6867c3 100644
--- a/babel/localtime/_fallback.py
+++ b/babel/localtime/_fallback.py
@@ -4,7 +4,7 @@
Emulated fallback local timezone when all else fails.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
diff --git a/babel/localtime/_unix.py b/babel/localtime/_unix.py
index eb81beb61..782a7d246 100644
--- a/babel/localtime/_unix.py
+++ b/babel/localtime/_unix.py
@@ -45,7 +45,13 @@ def _get_localzone(_root: str = '/') -> datetime.tzinfo:
else:
pos = link_dst.find('/zoneinfo/')
if pos >= 0:
- zone_name = link_dst[pos + 10:]
+ # On occasion, the `/etc/localtime` symlink has a double slash, e.g.
+ # "/usr/share/zoneinfo//UTC", which would make `zoneinfo.ZoneInfo`
+ # complain (no absolute paths allowed), and we'd end up returning
+ # `None` (as a fix for #1092).
+ # Instead, let's just "fix" the double slash symlink by stripping
+ # leading slashes before passing the assumed zone name forward.
+ zone_name = link_dst[pos + 10:].lstrip("/")
tzinfo = _get_tzinfo(zone_name)
if tzinfo is not None:
return tzinfo
diff --git a/babel/messages/__init__.py b/babel/messages/__init__.py
index 5a81b910f..ca83faa97 100644
--- a/babel/messages/__init__.py
+++ b/babel/messages/__init__.py
@@ -4,7 +4,7 @@
Support for ``gettext`` message catalogs.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py
index ecf6f91d4..f84a5bd1b 100644
--- a/babel/messages/catalog.py
+++ b/babel/messages/catalog.py
@@ -4,19 +4,19 @@
Data structures for message catalogs.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
import datetime
import re
-from collections import OrderedDict
from collections.abc import Iterable, Iterator
from copy import copy
from difflib import SequenceMatcher
from email import message_from_string
from heapq import nlargest
+from string import Formatter
from typing import TYPE_CHECKING
from babel import __version__ as VERSION
@@ -30,7 +30,14 @@
_MessageID: TypeAlias = str | tuple[str, ...] | list[str]
-__all__ = ['Message', 'Catalog', 'TranslationError']
+__all__ = [
+ 'DEFAULT_HEADER',
+ 'PYTHON_FORMAT',
+ 'Catalog',
+ 'Message',
+ 'TranslationError',
+]
+
def get_close_matches(word, possibilities, n=3, cutoff=0.6):
"""A modified version of ``difflib.get_close_matches``.
@@ -43,7 +50,7 @@ def get_close_matches(word, possibilities, n=3, cutoff=0.6):
if not 0.0 <= cutoff <= 1.0: # pragma: no cover
raise ValueError(f"cutoff must be in [0.0, 1.0]: {cutoff!r}")
result = []
- s = SequenceMatcher(autojunk=False) # only line changed from difflib.py
+ s = SequenceMatcher(autojunk=False) # only line changed from difflib.py
s.set_seq2(word)
for x in possibilities:
s.set_seq1(x)
@@ -70,6 +77,25 @@ def get_close_matches(word, possibilities, n=3, cutoff=0.6):
''', re.VERBOSE)
+def _has_python_brace_format(string: str) -> bool:
+ if "{" not in string:
+ return False
+ fmt = Formatter()
+ try:
+ # `fmt.parse` returns 3-or-4-tuples of the form
+ # `(literal_text, field_name, format_spec, conversion)`;
+ # if `field_name` is set, this smells like brace format
+ field_name_seen = False
+ for t in fmt.parse(string):
+ if t[1] is not None:
+ field_name_seen = True
+ # We cannot break here, as we need to consume the whole string
+ # to ensure that it is a valid format string.
+ except ValueError:
+ return False
+ return field_name_seen
+
+
def _parse_datetime_header(value: str) -> datetime.datetime:
match = re.match(r'^(?P.*?)(?P[+-]\d{4})?$', value)
@@ -141,6 +167,10 @@ def __init__(
self.flags.add('python-format')
else:
self.flags.discard('python-format')
+ if id and self.python_brace_format:
+ self.flags.add('python-brace-format')
+ else:
+ self.flags.discard('python-brace-format')
self.auto_comments = list(distinct(auto_comments))
self.user_comments = list(distinct(user_comments))
if isinstance(previous_id, str):
@@ -253,6 +283,21 @@ def python_format(self) -> bool:
ids = [ids]
return any(PYTHON_FORMAT.search(id) for id in ids)
+ @property
+ def python_brace_format(self) -> bool:
+ """Whether the message contains Python f-string parameters.
+
+ >>> Message('Hello, {name}!').python_brace_format
+ True
+ >>> Message(('One apple', '{count} apples')).python_brace_format
+ True
+
+ :type: `bool`"""
+ ids = self.id
+ if not isinstance(ids, (list, tuple)):
+ ids = [ids]
+ return any(_has_python_brace_format(id) for id in ids)
+
class TranslationError(Exception):
"""Exception thrown by translation checkers when invalid message
@@ -275,12 +320,20 @@ def parse_separated_header(value: str) -> dict[str, str]:
return dict(m.get_params())
+def _force_text(s: str | bytes, encoding: str = 'utf-8', errors: str = 'strict') -> str:
+ if isinstance(s, str):
+ return s
+ if isinstance(s, bytes):
+ return s.decode(encoding, errors)
+ return str(s)
+
+
class Catalog:
"""Representation of a message catalog."""
def __init__(
self,
- locale: str | Locale | None = None,
+ locale: Locale | str | None = None,
domain: str | None = None,
header_comment: str | None = DEFAULT_HEADER,
project: str | None = None,
@@ -317,7 +370,7 @@ def __init__(
self.domain = domain
self.locale = locale
self._header_comment = header_comment
- self._messages: OrderedDict[str | tuple[str, str], Message] = OrderedDict()
+ self._messages: dict[str | tuple[str, str], Message] = {}
self.project = project or 'PROJECT'
self.version = version or 'VERSION'
@@ -344,7 +397,7 @@ def __init__(
self.fuzzy = fuzzy
# Dictionary of obsolete messages
- self.obsolete: OrderedDict[str | tuple[str, str], Message] = OrderedDict()
+ self.obsolete: dict[str | tuple[str, str], Message] = {}
self._num_plurals = None
self._plural_expr = None
@@ -429,46 +482,39 @@ def _set_header_comment(self, string: str | None) -> None:
""")
def _get_mime_headers(self) -> list[tuple[str, str]]:
- headers: list[tuple[str, str]] = []
- headers.append(("Project-Id-Version", f"{self.project} {self.version}"))
- headers.append(('Report-Msgid-Bugs-To', self.msgid_bugs_address))
- headers.append(('POT-Creation-Date',
- format_datetime(self.creation_date, 'yyyy-MM-dd HH:mmZ',
- locale='en')))
if isinstance(self.revision_date, (datetime.datetime, datetime.time, int, float)):
- headers.append(('PO-Revision-Date',
- format_datetime(self.revision_date,
- 'yyyy-MM-dd HH:mmZ', locale='en')))
+ revision_date = format_datetime(self.revision_date, 'yyyy-MM-dd HH:mmZ', locale='en')
else:
- headers.append(('PO-Revision-Date', self.revision_date))
- headers.append(('Last-Translator', self.last_translator))
+ revision_date = self.revision_date
+
+ language_team = self.language_team
+ if self.locale_identifier and 'LANGUAGE' in language_team:
+ language_team = language_team.replace('LANGUAGE', str(self.locale_identifier))
+
+ headers: list[tuple[str, str]] = [
+ ("Project-Id-Version", f"{self.project} {self.version}"),
+ ('Report-Msgid-Bugs-To', self.msgid_bugs_address),
+ ('POT-Creation-Date', format_datetime(self.creation_date, 'yyyy-MM-dd HH:mmZ', locale='en')),
+ ('PO-Revision-Date', revision_date),
+ ('Last-Translator', self.last_translator),
+ ]
if self.locale_identifier:
headers.append(('Language', str(self.locale_identifier)))
- if self.locale_identifier and ('LANGUAGE' in self.language_team):
- headers.append(('Language-Team',
- self.language_team.replace('LANGUAGE',
- str(self.locale_identifier))))
- else:
- headers.append(('Language-Team', self.language_team))
+ headers.append(('Language-Team', language_team))
if self.locale is not None:
headers.append(('Plural-Forms', self.plural_forms))
- headers.append(('MIME-Version', '1.0'))
- headers.append(("Content-Type", f"text/plain; charset={self.charset}"))
- headers.append(('Content-Transfer-Encoding', '8bit'))
- headers.append(("Generated-By", f"Babel {VERSION}\n"))
+ headers += [
+ ('MIME-Version', '1.0'),
+ ("Content-Type", f"text/plain; charset={self.charset}"),
+ ('Content-Transfer-Encoding', '8bit'),
+ ("Generated-By", f"Babel {VERSION}\n"),
+ ]
return headers
- def _force_text(self, s: str | bytes, encoding: str = 'utf-8', errors: str = 'strict') -> str:
- if isinstance(s, str):
- return s
- if isinstance(s, bytes):
- return s.decode(encoding, errors)
- return str(s)
-
def _set_mime_headers(self, headers: Iterable[tuple[str, str]]) -> None:
for name, value in headers:
- name = self._force_text(name.lower(), encoding=self.charset)
- value = self._force_text(value, encoding=self.charset)
+ name = _force_text(name.lower(), encoding=self.charset)
+ value = _force_text(value, encoding=self.charset)
if name == 'project-id-version':
parts = value.split(' ')
self.project = ' '.join(parts[:-1])
@@ -680,7 +726,6 @@ def __setitem__(self, id: _MessageID, message: Message) -> None:
current.user_comments = list(distinct(current.user_comments +
message.user_comments))
current.flags |= message.flags
- message = current
elif id == '':
# special treatment for the header message
self.mime_headers = message_from_string(message.string).items()
@@ -826,10 +871,13 @@ def update(
:param template: the reference catalog, usually read from a POT file
:param no_fuzzy_matching: whether to use fuzzy matching of message IDs
+ :param update_header_comment: whether to copy the header comment from the template
+ :param keep_user_comments: whether to keep user comments from the old catalog
+ :param update_creation_date: whether to copy the creation date from the template
"""
messages = self._messages
remaining = messages.copy()
- self._messages = OrderedDict()
+ self._messages = {}
# Prepare for fuzzy matching
fuzzy_candidates = {}
diff --git a/babel/messages/checkers.py b/babel/messages/checkers.py
index 2889b4e6c..df7c3ca73 100644
--- a/babel/messages/checkers.py
+++ b/babel/messages/checkers.py
@@ -6,7 +6,7 @@
:since: version 0.9
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
@@ -65,9 +65,6 @@ def _validate_format(format: str, alternative: str) -> None:
arguments are not interchangeable as `alternative` may contain less
placeholders if `format` uses named placeholders.
- The behavior of this function is undefined if the string does not use
- string formatting.
-
If the string formatting of `alternative` is compatible to `format` the
function returns `None`, otherwise a `TranslationError` is raised.
@@ -121,6 +118,9 @@ def _check_positional(results: list[tuple[str, str]]) -> bool:
a, b = map(_parse, (format, alternative))
+ if not a:
+ return
+
# now check if both strings are positional or named
a_positional, b_positional = map(_check_positional, (a, b))
if a_positional and not b_positional and not b:
diff --git a/babel/messages/extract.py b/babel/messages/extract.py
index 8d4bbeaf8..7f4230f61 100644
--- a/babel/messages/extract.py
+++ b/babel/messages/extract.py
@@ -12,7 +12,7 @@
The main entry points into the extraction functionality are the functions
`extract_from_dir` and `extract_from_file`.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
@@ -33,8 +33,8 @@
from functools import lru_cache
from os.path import relpath
from textwrap import dedent
-from tokenize import COMMENT, NAME, OP, STRING, generate_tokens
-from typing import TYPE_CHECKING, Any
+from tokenize import COMMENT, NAME, NL, OP, STRING, generate_tokens
+from typing import TYPE_CHECKING, Any, TypedDict
from babel.messages._compat import find_entrypoints
from babel.util import parse_encoding, parse_future_flags, pathmatch
@@ -43,7 +43,7 @@
from typing import IO, Final, Protocol
from _typeshed import SupportsItems, SupportsRead, SupportsReadline
- from typing_extensions import TypeAlias, TypedDict
+ from typing_extensions import TypeAlias
class _PyOptions(TypedDict, total=False):
encoding: str
@@ -276,6 +276,7 @@ def check_and_call_extract_file(
for opattern, odict in options_map.items():
if pathmatch(opattern, filename):
options = odict
+ break
if callback:
callback(filename, method, options)
for message_tuple in extract_from_file(
@@ -324,15 +325,20 @@ 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], ...]):
+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')
+ if isinstance(index, tuple): # (n, 'c')
context = messages[index[0] - 1]
continue
if last_index < index:
@@ -420,7 +426,6 @@ def extract(
:returns: iterable of tuples of the form ``(lineno, message, comments, context)``
:rtype: Iterable[tuple[int, str|tuple[str], list[str], str|None]
"""
- func = None
if callable(method):
func = method
elif ':' in method or '.' in method:
@@ -530,7 +535,6 @@ def extract_python(
in_def = False
continue
if funcname:
- message_lineno = lineno
call_stack += 1
elif in_def and tok == OP and value == ':':
# End of a class definition without parens
@@ -580,11 +584,15 @@ def extract_python(
elif tok == STRING:
val = _parse_python_string(value, encoding, future_flags)
if val is not None:
+ if not message_lineno:
+ message_lineno = lineno
buf.append(val)
# Python 3.12+, see https://peps.python.org/pep-0701/#new-tokens
elif tok == FSTRING_START:
current_fstring_start = value
+ if not message_lineno:
+ message_lineno = lineno
elif tok == FSTRING_MIDDLE:
if current_fstring_start is not None:
current_fstring_start += value
@@ -608,6 +616,9 @@ def extract_python(
# for the comment to still be a valid one
old_lineno, old_comment = translator_comments.pop()
translator_comments.append((old_lineno + 1, old_comment))
+
+ elif tok != NL and not message_lineno:
+ message_lineno = lineno
elif call_stack > 0 and tok == OP and value == ')':
call_stack -= 1
elif funcname and call_stack == -1:
@@ -615,9 +626,7 @@ 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}
- ):
+ 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.
diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py
index 7a9ce385f..29e5a2aa2 100644
--- a/babel/messages/frontend.py
+++ b/babel/messages/frontend.py
@@ -4,7 +4,7 @@
Frontends for the message extraction functionality.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
@@ -20,7 +20,6 @@
import sys
import tempfile
import warnings
-from collections import OrderedDict
from configparser import RawConfigParser
from io import StringIO
from typing import BinaryIO, Iterable, Literal
@@ -962,7 +961,7 @@ def _configure_command(self, cmdname, argv):
usage=self.usage % (cmdname, ''),
description=self.commands[cmdname],
)
- as_args = getattr(cmdclass, "as_args", ())
+ as_args: str | None = getattr(cmdclass, "as_args", None)
for long, short, help in cmdclass.user_options:
name = long.strip("=")
default = getattr(cmdinst, name.replace("-", "_"))
@@ -1013,13 +1012,13 @@ def parse_mapping_cfg(fileobj, filename=None):
:param fileobj: a readable file-like object containing the configuration
text to parse
+ :param filename: the name of the file being parsed, for error messages
"""
extractors = {}
method_map = []
options_map = {}
parser = RawConfigParser()
- parser._sections = OrderedDict(parser._sections) # We need ordered sections
parser.read_file(fileobj, filename)
for section in parser.sections():
diff --git a/babel/messages/jslexer.py b/babel/messages/jslexer.py
index 31f0582e3..5fc4956fd 100644
--- a/babel/messages/jslexer.py
+++ b/babel/messages/jslexer.py
@@ -5,7 +5,7 @@
A simple JavaScript 1.5 lexer which is used for the JavaScript
extractor.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
@@ -162,6 +162,7 @@ def tokenize(source: str, jsx: bool = True, dotted: bool = True, template_string
"""
Tokenize JavaScript/JSX source. Returns a generator of tokens.
+ :param source: The JavaScript source to tokenize.
:param jsx: Enable (limited) JSX parsing.
:param dotted: Read dotted names as single name token.
:param template_string: Support ES6 template strings
diff --git a/babel/messages/mofile.py b/babel/messages/mofile.py
index 0291b07cc..3c9fefc4a 100644
--- a/babel/messages/mofile.py
+++ b/babel/messages/mofile.py
@@ -4,7 +4,7 @@
Writing of files in the ``gettext`` MO (machine object) format.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
@@ -89,13 +89,11 @@ def read_mo(fileobj: SupportsRead[bytes]) -> Catalog:
if b'\x00' in msg: # plural forms
msg = msg.split(b'\x00')
tmsg = tmsg.split(b'\x00')
- if catalog.charset:
- msg = [x.decode(catalog.charset) for x in msg]
- tmsg = [x.decode(catalog.charset) for x in tmsg]
+ msg = [x.decode(catalog.charset) for x in msg]
+ tmsg = [x.decode(catalog.charset) for x in tmsg]
else:
- if catalog.charset:
- msg = msg.decode(catalog.charset)
- tmsg = tmsg.decode(catalog.charset)
+ msg = msg.decode(catalog.charset)
+ tmsg = tmsg.decode(catalog.charset)
catalog[msg] = Message(msg, tmsg, context=ctxt)
# advance to next entry in the seek tables
diff --git a/babel/messages/plurals.py b/babel/messages/plurals.py
index 01c38f4cd..da336a7ba 100644
--- a/babel/messages/plurals.py
+++ b/babel/messages/plurals.py
@@ -4,13 +4,11 @@
Plural form definitions.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
-from operator import itemgetter
-
from babel.core import Locale, default_locale
# XXX: remove this file, duplication with babel.plural
@@ -209,22 +207,33 @@ class _PluralTuple(tuple):
"""A tuple with plural information."""
__slots__ = ()
- num_plurals = property(itemgetter(0), doc="""
- 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={}; plural={};'.format(*x), doc="""
- The plural expression used by the catalog or locale.""")
+
+ @property
+ def num_plurals(self) -> int:
+ """The number of plurals used by the locale."""
+ return self[0]
+
+ @property
+ def plural_expr(self) -> str:
+ """The plural expression used by the locale."""
+ return self[1]
+
+ @property
+ def plural_forms(self) -> str:
+ """The plural expression used by the catalog or locale."""
+ return f'nplurals={self[0]}; plural={self[1]};'
def __str__(self) -> str:
return self.plural_forms
-def get_plural(locale: str | None = LC_CTYPE) -> _PluralTuple:
+def get_plural(locale: Locale | str | None = None) -> _PluralTuple:
"""A tuple with the information catalogs need to perform proper
pluralization. The first item of the tuple is the number of plural
forms, the second the plural expression.
+ :param locale: the `Locale` object or locale identifier. Defaults to the system character type locale.
+
>>> get_plural(locale='en')
(2, '(n != 1)')
>>> get_plural(locale='ga')
@@ -246,7 +255,7 @@ def get_plural(locale: str | None = LC_CTYPE) -> _PluralTuple:
>>> str(tup)
'nplurals=1; plural=0;'
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_CTYPE)
try:
tup = PLURALS[str(locale)]
except KeyError:
diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py
index 89a924255..2bb0c7741 100644
--- a/babel/messages/pofile.py
+++ b/babel/messages/pofile.py
@@ -5,7 +5,7 @@
Reading and writing of files in the ``gettext`` PO (portable object)
format.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
@@ -13,17 +13,16 @@
import os
import re
from collections.abc import Iterable
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
from babel.core import Locale
from babel.messages.catalog import Catalog, Message
-from babel.util import _cmp, wraptext
+from babel.util import TextWrapper, _cmp
if TYPE_CHECKING:
from typing import IO, AnyStr
from _typeshed import SupportsWrite
- from typing_extensions import Literal
def unescape(string: str) -> str:
@@ -80,6 +79,50 @@ def denormalize(string: str) -> str:
return unescape(string)
+def _extract_locations(line: str) -> list[str]:
+ """Extract locations from location comments.
+
+ Locations are extracted while properly handling First Strong
+ Isolate (U+2068) and Pop Directional Isolate (U+2069), used by
+ gettext to enclose filenames with spaces and tabs in their names.
+ """
+ if "\u2068" not in line and "\u2069" not in line:
+ return line.lstrip().split()
+
+ locations = []
+ location = ""
+ in_filename = False
+ for c in line:
+ if c == "\u2068":
+ if in_filename:
+ raise ValueError("location comment contains more First Strong Isolate "
+ "characters, than Pop Directional Isolate characters")
+ in_filename = True
+ continue
+ elif c == "\u2069":
+ if not in_filename:
+ raise ValueError("location comment contains more Pop Directional Isolate "
+ "characters, than First Strong Isolate characters")
+ in_filename = False
+ continue
+ elif c == " ":
+ if in_filename:
+ location += c
+ elif location:
+ locations.append(location)
+ location = ""
+ else:
+ location += c
+ else:
+ if location:
+ if in_filename:
+ raise ValueError("location comment contains more First Strong Isolate "
+ "characters, than Pop Directional Isolate characters")
+ locations.append(location)
+
+ return locations
+
+
class PoFileError(Exception):
"""Exception thrown by PoParser when an invalid po file is encountered."""
@@ -195,7 +238,7 @@ def _add_message(self) -> None:
context=msgctxt)
if self.obsolete:
if not self.ignore_obsolete:
- self.catalog.obsolete[msgid] = message
+ self.catalog.obsolete[self.catalog._key_for(msgid, msgctxt)] = message
else:
self.catalog[msgid] = message
self.counter += 1
@@ -203,6 +246,9 @@ def _add_message(self) -> None:
def _finish_current_message(self) -> None:
if self.messages:
+ if not self.translations:
+ self._invalid_pofile("", self.offset, f"missing msgstr for msgid '{self.messages[0].denormalize()}'")
+ self.translations.append([0, _NormalizedString("")])
self._add_message()
def _process_message_line(self, lineno, line, obsolete=False) -> None:
@@ -269,7 +315,7 @@ def _process_comment(self, line) -> None:
self._finish_current_message()
if line[1:].startswith(':'):
- for location in line[2:].lstrip().split():
+ for location in _extract_locations(line[2:]):
pos = location.rfind(':')
if pos >= 0:
try:
@@ -307,7 +353,10 @@ def parse(self, fileobj: IO[AnyStr] | Iterable[AnyStr]) -> None:
if line[1:].startswith('~'):
self._process_message_line(lineno, line[2:].lstrip(), obsolete=True)
else:
- self._process_comment(line)
+ try:
+ self._process_comment(line)
+ except ValueError as exc:
+ self._invalid_pofile(line, lineno, str(exc))
else:
self._process_message_line(lineno, line)
@@ -330,7 +379,7 @@ def _invalid_pofile(self, line, lineno, msg) -> None:
def read_po(
fileobj: IO[AnyStr] | Iterable[AnyStr],
- locale: str | Locale | None = None,
+ locale: Locale | str | None = None,
domain: str | None = None,
ignore_obsolete: bool = False,
charset: str | None = None,
@@ -474,6 +523,23 @@ def normalize(string: str, prefix: str = '', width: int = 76) -> str:
return '""\n' + '\n'.join([(prefix + escape(line)) for line in lines])
+def _enclose_filename_if_necessary(filename: str) -> str:
+ """Enclose filenames which include white spaces or tabs.
+
+ Do the same as gettext and enclose filenames which contain white
+ spaces or tabs with First Strong Isolate (U+2068) and Pop
+ Directional Isolate (U+2069).
+ """
+ if " " not in filename and "\t" not in filename:
+ return filename
+
+ if not filename.startswith("\u2068"):
+ filename = "\u2068" + filename
+ if not filename.endswith("\u2069"):
+ filename += "\u2069"
+ return filename
+
+
def write_po(
fileobj: SupportsWrite[bytes],
catalog: Catalog,
@@ -570,8 +636,11 @@ def generate_po(
# provide the same behaviour
comment_width = width if width and width > 0 else 76
+ comment_wrapper = TextWrapper(width=comment_width, break_long_words=False)
+ header_wrapper = TextWrapper(width=width, subsequent_indent="# ", break_long_words=False)
+
def _format_comment(comment, prefix=''):
- for line in wraptext(comment, comment_width):
+ for line in comment_wrapper.wrap(comment):
yield f"#{prefix} {line.strip()}\n"
def _format_message(message, prefix=''):
@@ -601,8 +670,7 @@ def _format_message(message, prefix=''):
if width and width > 0:
lines = []
for line in comment_header.splitlines():
- lines += wraptext(line, width=width,
- subsequent_indent='# ')
+ lines += header_wrapper.wrap(line)
comment_header = '\n'.join(lines)
yield f"{comment_header}\n"
@@ -626,6 +694,7 @@ def _format_message(message, prefix=''):
for filename, lineno in locations:
location = filename.replace(os.sep, '/')
+ location = _enclose_filename_if_necessary(location)
if lineno and include_lineno:
location = f"{location}:{lineno:d}"
if location not in locs:
diff --git a/babel/numbers.py b/babel/numbers.py
index 624e8d61e..2737a7076 100644
--- a/babel/numbers.py
+++ b/babel/numbers.py
@@ -7,11 +7,12 @@
The default locale for the functions in this module is determined by the
following environment variables, in that order:
- * ``LC_NUMERIC``,
+ * ``LC_MONETARY`` for currency related functions,
+ * ``LC_NUMERIC``, and
* ``LC_ALL``, and
* ``LANG``
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
# TODO:
@@ -23,14 +24,12 @@
import decimal
import re
import warnings
-from typing import TYPE_CHECKING, Any, cast, overload
+from typing import Any, Literal, cast, overload
from babel.core import Locale, default_locale, get_global
from babel.localedata import LocaleDataDict
-if TYPE_CHECKING:
- from typing_extensions import Literal
-
+LC_MONETARY = default_locale(('LC_MONETARY', 'LC_NUMERIC'))
LC_NUMERIC = default_locale('LC_NUMERIC')
@@ -108,7 +107,7 @@ def normalize_currency(currency: str, locale: Locale | str | None = None) -> str
def get_currency_name(
currency: str,
count: float | decimal.Decimal | None = None,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
) -> str:
"""Return the name used by the locale for the specified currency.
@@ -121,8 +120,9 @@ def get_currency_name(
:param count: the optional count. If provided the currency name
will be pluralized to that number if possible.
:param locale: the `Locale` object or locale identifier.
+ Defaults to the system currency locale or numeric locale.
"""
- loc = Locale.parse(locale)
+ loc = Locale.parse(locale or LC_MONETARY)
if count is not None:
try:
plural_form = loc.plural_form(count)
@@ -138,7 +138,7 @@ def get_currency_name(
return loc.currencies.get(currency, currency)
-def get_currency_symbol(currency: str, locale: Locale | str | None = LC_NUMERIC) -> str:
+def get_currency_symbol(currency: str, locale: Locale | str | None = None) -> str:
"""Return the symbol used by the locale for the specified currency.
>>> get_currency_symbol('USD', locale='en_US')
@@ -146,8 +146,9 @@ def get_currency_symbol(currency: str, locale: Locale | str | None = LC_NUMERIC)
:param currency: the currency code.
:param locale: the `Locale` object or locale identifier.
+ Defaults to the system currency locale or numeric locale.
"""
- return Locale.parse(locale).currency_symbols.get(currency, currency)
+ return Locale.parse(locale or LC_MONETARY).currency_symbols.get(currency, currency)
def get_currency_precision(currency: str) -> int:
@@ -165,9 +166,9 @@ def get_currency_precision(currency: str) -> int:
def get_currency_unit_pattern(
- currency: str,
+ currency: str, # TODO: unused?!
count: float | decimal.Decimal | None = None,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
) -> str:
"""
Return the unit pattern used for long display of a currency value
@@ -185,8 +186,9 @@ def get_currency_unit_pattern(
:param count: the optional count. If provided the unit
pattern for that number will be returned.
:param locale: the `Locale` object or locale identifier.
+ Defaults to the system currency locale or numeric locale.
"""
- loc = Locale.parse(locale)
+ loc = Locale.parse(locale or LC_MONETARY)
if count is not None:
plural_form = loc.plural_form(count)
try:
@@ -325,16 +327,15 @@ def _get_numbering_system(locale: Locale, numbering_system: Literal["default"] |
def _get_number_symbols(
- locale: Locale | str | None,
+ locale: Locale,
*,
numbering_system: Literal["default"] | str = "latn",
) -> LocaleDataDict:
- parsed_locale = Locale.parse(locale)
- numbering_system = _get_numbering_system(parsed_locale, numbering_system)
+ numbering_system = _get_numbering_system(locale, numbering_system)
try:
- return parsed_locale.number_symbols[numbering_system]
+ return locale.number_symbols[numbering_system]
except KeyError as error:
- raise UnsupportedNumberingSystemError(f"Unknown numbering system {numbering_system} for Locale {parsed_locale}.") from error
+ raise UnsupportedNumberingSystemError(f"Unknown numbering system {numbering_system} for Locale {locale}.") from error
class UnsupportedNumberingSystemError(Exception):
@@ -343,7 +344,7 @@ class UnsupportedNumberingSystemError(Exception):
def get_decimal_symbol(
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -356,16 +357,17 @@ def get_decimal_symbol(
>>> get_decimal_symbol('ar_EG', numbering_system='latn')
u'.'
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for fetching the symbol. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
+ locale = Locale.parse(locale or LC_NUMERIC)
return _get_number_symbols(locale, numbering_system=numbering_system).get('decimal', '.')
def get_plus_sign_symbol(
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -378,16 +380,17 @@ def get_plus_sign_symbol(
>>> get_plus_sign_symbol('ar_EG', numbering_system='latn')
u'\u200e+'
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for fetching the symbol. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: if the numbering system is not supported by the locale.
"""
+ locale = Locale.parse(locale or LC_NUMERIC)
return _get_number_symbols(locale, numbering_system=numbering_system).get('plusSign', '+')
def get_minus_sign_symbol(
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -400,16 +403,17 @@ def get_minus_sign_symbol(
>>> get_minus_sign_symbol('ar_EG', numbering_system='latn')
u'\u200e-'
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for fetching the symbol. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: if the numbering system is not supported by the locale.
"""
+ locale = Locale.parse(locale or LC_NUMERIC)
return _get_number_symbols(locale, numbering_system=numbering_system).get('minusSign', '-')
def get_exponential_symbol(
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -422,16 +426,17 @@ def get_exponential_symbol(
>>> get_exponential_symbol('ar_EG', numbering_system='latn')
u'E'
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for fetching the symbol. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: if the numbering system is not supported by the locale.
"""
+ locale = Locale.parse(locale or LC_NUMERIC)
return _get_number_symbols(locale, numbering_system=numbering_system).get('exponential', 'E')
def get_group_symbol(
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -444,16 +449,17 @@ def get_group_symbol(
>>> get_group_symbol('ar_EG', numbering_system='latn')
u','
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for fetching the symbol. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: if the numbering system is not supported by the locale.
"""
+ locale = Locale.parse(locale or LC_NUMERIC)
return _get_number_symbols(locale, numbering_system=numbering_system).get('group', ',')
def get_infinity_symbol(
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -466,15 +472,16 @@ def get_infinity_symbol(
>>> get_infinity_symbol('ar_EG', numbering_system='latn')
u'∞'
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for fetching the symbol. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: if the numbering system is not supported by the locale.
"""
+ locale = Locale.parse(locale or LC_NUMERIC)
return _get_number_symbols(locale, numbering_system=numbering_system).get('infinity', '∞')
-def format_number(number: float | decimal.Decimal | str, locale: Locale | str | None = LC_NUMERIC) -> str:
+def format_number(number: float | decimal.Decimal | str, locale: Locale | str | None = None) -> str:
"""Return the given number formatted for a specific locale.
>>> format_number(1099, locale='en_US') # doctest: +SKIP
@@ -487,7 +494,7 @@ def format_number(number: float | decimal.Decimal | str, locale: Locale | str |
Use babel.numbers.format_decimal() instead.
:param number: the number to format
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
"""
@@ -518,7 +525,7 @@ def get_decimal_quantum(precision: int | decimal.Decimal) -> decimal.Decimal:
def format_decimal(
number: float | decimal.Decimal | str,
format: str | NumberPattern | None = None,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
decimal_quantization: bool = True,
group_separator: bool = True,
*,
@@ -562,7 +569,7 @@ def format_decimal(
:param number: the number to format
:param format:
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
: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
@@ -571,7 +578,7 @@ def format_decimal(
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
if format is None:
format = locale.decimal_formats[format]
pattern = parse_pattern(format)
@@ -583,7 +590,7 @@ def format_compact_decimal(
number: float | decimal.Decimal | str,
*,
format_type: Literal["short", "long"] = "short",
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
fraction_digits: int = 0,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -606,13 +613,13 @@ def format_compact_decimal(
:param number: the number to format
:param format_type: Compact format to use ("short" or "long")
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param fraction_digits: Number of digits after the decimal point to use. Defaults to `0`.
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
compact_format = locale.compact_decimal_formats[format_type]
number, format = _get_compact_format(number, compact_format, locale, fraction_digits)
# Did not find a format, fall back.
@@ -670,7 +677,7 @@ def format_currency(
number: float | decimal.Decimal | str,
currency: str,
format: str | NumberPattern | None = None,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
currency_digits: bool = True,
format_type: Literal["name", "standard", "accounting"] = "standard",
decimal_quantization: bool = True,
@@ -758,7 +765,8 @@ def format_currency(
:param number: the number to format
:param currency: the currency code
:param format: the format string to use
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier.
+ Defaults to the system currency locale or numeric locale.
:param currency_digits: use the currency's natural number of decimal digits
:param format_type: the currency format type to use
:param decimal_quantization: Truncate and round high-precision numbers to
@@ -769,12 +777,20 @@ def format_currency(
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
+ locale = Locale.parse(locale or LC_MONETARY)
+
if format_type == 'name':
- return _format_currency_long_name(number, currency, format=format,
- locale=locale, currency_digits=currency_digits,
- decimal_quantization=decimal_quantization, group_separator=group_separator,
- numbering_system=numbering_system)
- locale = Locale.parse(locale)
+ return _format_currency_long_name(
+ number,
+ currency,
+ locale=locale,
+ format=format,
+ currency_digits=currency_digits,
+ decimal_quantization=decimal_quantization,
+ group_separator=group_separator,
+ numbering_system=numbering_system,
+ )
+
if format:
pattern = parse_pattern(format)
else:
@@ -791,18 +807,17 @@ def format_currency(
def _format_currency_long_name(
number: float | decimal.Decimal | str,
currency: str,
- format: str | NumberPattern | None = None,
- locale: Locale | str | None = LC_NUMERIC,
- currency_digits: bool = True,
- format_type: Literal["name", "standard", "accounting"] = "standard",
- decimal_quantization: bool = True,
- group_separator: bool = True,
*,
- numbering_system: Literal["default"] | str = "latn",
+ locale: Locale,
+ format: str | NumberPattern | None,
+ currency_digits: bool,
+ decimal_quantization: bool,
+ group_separator: bool,
+ numbering_system: Literal["default"] | str,
) -> str:
# Algorithm described here:
# https://www.unicode.org/reports/tr35/tr35-numbers.html#Currencies
- locale = Locale.parse(locale)
+
# Step 1.
# There are no examples of items with explicit count (0 or 1) in current
# locale data. So there is no point implementing that.
@@ -835,7 +850,7 @@ def format_compact_currency(
currency: str,
*,
format_type: Literal["short"] = "short",
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
fraction_digits: int = 0,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -851,13 +866,14 @@ def format_compact_currency(
:param number: the number to format
:param currency: the currency code
:param format_type: the compact format type to use. Defaults to "short".
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier.
+ Defaults to the system currency locale or numeric locale.
:param fraction_digits: Number of digits after the decimal point to use. Defaults to `0`.
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_MONETARY)
try:
compact_format = locale.compact_currency_formats[format_type]
except KeyError as error:
@@ -885,7 +901,7 @@ def format_compact_currency(
def format_percent(
number: float | decimal.Decimal | str,
format: str | NumberPattern | None = None,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
decimal_quantization: bool = True,
group_separator: bool = True,
*,
@@ -924,7 +940,7 @@ def format_percent(
:param number: the percent number to format
:param format:
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
: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
@@ -933,7 +949,7 @@ def format_percent(
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
if not format:
format = locale.percent_formats[None]
pattern = parse_pattern(format)
@@ -944,12 +960,12 @@ def format_percent(
def format_scientific(
- number: float | decimal.Decimal | str,
- format: str | NumberPattern | None = None,
- locale: Locale | str | None = LC_NUMERIC,
- decimal_quantization: bool = True,
- *,
- numbering_system: Literal["default"] | str = "latn",
+ number: float | decimal.Decimal | str,
+ format: str | NumberPattern | None = None,
+ locale: Locale | str | None = None,
+ decimal_quantization: bool = True,
+ *,
+ numbering_system: Literal["default"] | str = "latn",
) -> str:
"""Return value formatted in scientific notation for a specific locale.
@@ -974,14 +990,14 @@ def format_scientific(
:param number: the number to format
:param format:
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param decimal_quantization: Truncate and round high-precision numbers to
the format pattern. Defaults to `True`.
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
if not format:
format = locale.scientific_formats[None]
pattern = parse_pattern(format)
@@ -1009,7 +1025,7 @@ def __init__(self, message: str, suggestions: list[str] | None = None) -> None:
def parse_number(
string: str,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> int:
@@ -1028,7 +1044,7 @@ def parse_number(
NumberFormatError: '1.099,98' is not a valid number
:param string: the string to parse
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:return: the parsed number
@@ -1053,7 +1069,7 @@ def parse_number(
def parse_decimal(
string: str,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
strict: bool = False,
*,
numbering_system: Literal["default"] | str = "latn",
@@ -1090,7 +1106,7 @@ def parse_decimal(
NumberFormatError: '0.00' is not a properly formatted decimal number. Did you mean '0'?
:param string: the string to parse
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param strict: controls whether numbers formatted in a weird way are
accepted or rejected
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
@@ -1099,7 +1115,7 @@ def parse_decimal(
decimal number
:raise UnsupportedNumberingSystemError: if the numbering system is not supported by the locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
group_symbol = get_group_symbol(locale, numbering_system=numbering_system)
decimal_symbol = get_decimal_symbol(locale, numbering_system=numbering_system)
@@ -1392,6 +1408,7 @@ def apply(
:type decimal_quantization: bool
:param force_frac: DEPRECATED - a forced override for `self.frac_prec`
for a single formatting invocation.
+ :param group_separator: Whether to use the locale's number group separator.
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:return: Formatted decimal string.
diff --git a/babel/plural.py b/babel/plural.py
index 5c828d601..085209e9d 100644
--- a/babel/plural.py
+++ b/babel/plural.py
@@ -4,7 +4,7 @@
CLDR Plural support. See UTS #35.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
@@ -12,10 +12,7 @@
import decimal
import re
from collections.abc import Iterable, Mapping
-from typing import TYPE_CHECKING, Any, Callable
-
-if TYPE_CHECKING:
- from typing_extensions import Literal
+from typing import Any, Callable, Literal
_plural_tags = ('zero', 'one', 'two', 'few', 'many', 'other')
_fallback_tag = 'other'
@@ -296,7 +293,7 @@ def within_range_list(num: float | decimal.Decimal, range_list: Iterable[Iterabl
>>> within_range_list(10.5, [(1, 4), (20, 30)])
False
"""
- return any(num >= min_ and num <= max_ for min_, max_ in range_list)
+ return any(min_ <= num <= max_ for min_, max_ in range_list)
def cldr_modulo(a: float, b: float) -> float:
diff --git a/babel/support.py b/babel/support.py
index 7dcda5c63..b600bfe27 100644
--- a/babel/support.py
+++ b/babel/support.py
@@ -7,17 +7,16 @@
.. note: the code in this module is not used by Babel itself
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
-import decimal
import gettext
import locale
import os
from collections.abc import Iterator
-from typing import TYPE_CHECKING, Any, Callable, Iterable
+from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal
from babel.core import Locale
from babel.dates import format_date, format_datetime, format_time, format_timedelta
@@ -31,7 +30,8 @@
)
if TYPE_CHECKING:
- from typing_extensions import Literal
+ import datetime as _datetime
+ from decimal import Decimal
from babel.dates import _PredefinedTimeFormat
@@ -52,7 +52,7 @@ class Format:
def __init__(
self,
locale: Locale | str,
- tzinfo: datetime.tzinfo | None = None,
+ tzinfo: _datetime.tzinfo | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> None:
@@ -69,7 +69,7 @@ def __init__(
def date(
self,
- date: datetime.date | None = None,
+ date: _datetime.date | None = None,
format: _PredefinedTimeFormat | str = 'medium',
) -> str:
"""Return a date formatted according to the given pattern.
@@ -83,7 +83,7 @@ def date(
def datetime(
self,
- datetime: datetime.date | None = None,
+ datetime: _datetime.date | None = None,
format: _PredefinedTimeFormat | str = 'medium',
) -> str:
"""Return a date and time formatted according to the given pattern.
@@ -98,7 +98,7 @@ def datetime(
def time(
self,
- time: datetime.time | datetime.datetime | None = None,
+ time: _datetime.time | _datetime.datetime | None = None,
format: _PredefinedTimeFormat | str = 'medium',
) -> str:
"""Return a time formatted according to the given pattern.
@@ -113,7 +113,7 @@ def time(
def timedelta(
self,
- delta: datetime.timedelta | int,
+ delta: _datetime.timedelta | int,
granularity: Literal["year", "month", "week", "day", "hour", "minute", "second"] = "second",
threshold: float = 0.85,
format: Literal["narrow", "short", "medium", "long"] = "long",
@@ -131,7 +131,7 @@ def timedelta(
format=format, add_direction=add_direction,
locale=self.locale)
- def number(self, number: float | decimal.Decimal | str) -> str:
+ def number(self, number: float | Decimal | str) -> str:
"""Return an integer number formatted for the locale.
>>> fmt = Format('en_US')
@@ -140,7 +140,7 @@ def number(self, number: float | decimal.Decimal | str) -> str:
"""
return format_decimal(number, locale=self.locale, numbering_system=self.numbering_system)
- def decimal(self, number: float | decimal.Decimal | str, format: str | None = None) -> str:
+ def decimal(self, number: float | Decimal | str, format: str | None = None) -> str:
"""Return a decimal number formatted for the locale.
>>> fmt = Format('en_US')
@@ -151,7 +151,7 @@ def decimal(self, number: float | decimal.Decimal | str, format: str | None = No
def compact_decimal(
self,
- number: float | decimal.Decimal | str,
+ number: float | Decimal | str,
format_type: Literal['short', 'long'] = 'short',
fraction_digits: int = 0,
) -> str:
@@ -171,14 +171,14 @@ def compact_decimal(
numbering_system=self.numbering_system,
)
- def currency(self, number: float | decimal.Decimal | str, currency: str) -> str:
+ def currency(self, number: float | Decimal | str, currency: str) -> str:
"""Return a number in the given currency formatted for the locale.
"""
return format_currency(number, currency, locale=self.locale, numbering_system=self.numbering_system)
def compact_currency(
self,
- number: float | decimal.Decimal | str,
+ number: float | Decimal | str,
currency: str,
format_type: Literal['short'] = 'short',
fraction_digits: int = 0,
@@ -192,7 +192,7 @@ def compact_currency(
return format_compact_currency(number, currency, format_type=format_type, fraction_digits=fraction_digits,
locale=self.locale, numbering_system=self.numbering_system)
- def percent(self, number: float | decimal.Decimal | str, format: str | None = None) -> str:
+ def percent(self, number: float | Decimal | str, format: str | None = None) -> str:
"""Return a number formatted as percentage for the locale.
>>> fmt = Format('en_US')
@@ -201,7 +201,7 @@ def percent(self, number: float | decimal.Decimal | str, format: str | None = No
"""
return format_percent(number, format, locale=self.locale, numbering_system=self.numbering_system)
- def scientific(self, number: float | decimal.Decimal | str) -> str:
+ def scientific(self, number: float | Decimal | str) -> str:
"""Return a number formatted using scientific notation for the locale.
"""
return format_scientific(number, locale=self.locale, numbering_system=self.numbering_system)
@@ -389,7 +389,7 @@ def __init__(self, fp: gettext._TranslationsReader | None = None) -> None:
# is parsed (fp != None). Ensure that they are always present because
# some *gettext methods (including '.gettext()') rely on the attributes.
self._catalog: dict[tuple[str, Any] | str, str] = {}
- self.plural: Callable[[float | decimal.Decimal], int] = lambda n: int(n != 1)
+ self.plural: Callable[[float | Decimal], int] = lambda n: int(n != 1)
super().__init__(fp=fp)
self.files = list(filter(None, [getattr(fp, 'name', None)]))
self.domain = self.DEFAULT_DOMAIN
@@ -642,7 +642,7 @@ def __init__(self, fp: gettext._TranslationsReader | None = None, domain: str |
def load(
cls,
dirname: str | os.PathLike[str] | None = None,
- locales: Iterable[str | Locale] | str | Locale | None = None,
+ locales: Iterable[str | Locale] | Locale | str | None = None,
domain: str | None = None,
) -> NullTranslations:
"""Load translations from the given directory.
@@ -709,7 +709,7 @@ def merge(self, translations: Translations):
def _locales_to_names(
- locales: Iterable[str | Locale] | str | Locale | None,
+ locales: Iterable[str | Locale] | Locale | str | None,
) -> list[str] | None:
"""Normalize a `locales` argument to a list of locale names.
diff --git a/babel/units.py b/babel/units.py
index 8dae6947e..86ac2abc9 100644
--- a/babel/units.py
+++ b/babel/units.py
@@ -1,14 +1,11 @@
from __future__ import annotations
import decimal
-from typing import TYPE_CHECKING
+from typing import Literal
from babel.core import Locale
from babel.numbers import LC_NUMERIC, format_decimal
-if TYPE_CHECKING:
- from typing_extensions import Literal
-
class UnknownUnitError(ValueError):
def __init__(self, unit: str, locale: Locale) -> None:
@@ -18,7 +15,7 @@ def __init__(self, unit: str, locale: Locale) -> None:
def get_unit_name(
measurement_unit: str,
length: Literal['short', 'long', 'narrow'] = 'long',
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
) -> str | None:
"""
Get the display name for a measurement unit in the given locale.
@@ -38,17 +35,17 @@ def get_unit_name(
https://unicode.org/repos/cldr/tags/latest/common/validity/unit.xml
:param length: "short", "long" or "narrow"
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:return: The unit display name, or None.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
unit = _find_unit_pattern(measurement_unit, locale=locale)
if not unit:
raise UnknownUnitError(unit=measurement_unit, locale=locale)
return locale.unit_display_names.get(unit, {}).get(length)
-def _find_unit_pattern(unit_id: str, locale: Locale | str | None = LC_NUMERIC) -> str | None:
+def _find_unit_pattern(unit_id: str, locale: Locale | str | None = None) -> str | None:
"""
Expand a unit into a qualified form.
@@ -65,8 +62,8 @@ def _find_unit_pattern(unit_id: str, locale: Locale | str | None = LC_NUMERIC) -
:param unit_id: the code of a measurement unit.
:return: A key to the `unit_patterns` mapping, or None.
"""
- locale = Locale.parse(locale)
- unit_patterns = locale._data["unit_patterns"]
+ locale = Locale.parse(locale or LC_NUMERIC)
+ unit_patterns: dict[str, str] = locale._data["unit_patterns"]
if unit_id in unit_patterns:
return unit_id
for unit_pattern in sorted(unit_patterns, key=len):
@@ -80,7 +77,7 @@ def format_unit(
measurement_unit: str,
length: Literal['short', 'long', 'narrow'] = 'long',
format: str | None = None,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> str:
@@ -130,12 +127,12 @@ def format_unit(
https://unicode.org/repos/cldr/tags/latest/common/validity/unit.xml
:param length: "short", "long" or "narrow"
:param format: An optional format, as accepted by `format_decimal`.
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
q_unit = _find_unit_pattern(measurement_unit, locale=locale)
if not q_unit:
@@ -161,7 +158,7 @@ def format_unit(
def _find_compound_unit(
numerator_unit: str,
denominator_unit: str,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
) -> str | None:
"""
Find a predefined compound unit pattern.
@@ -182,11 +179,11 @@ def _find_compound_unit(
:param numerator_unit: The numerator unit's identifier
:param denominator_unit: The denominator unit's identifier
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:return: A key to the `unit_patterns` mapping, or None.
:rtype: str|None
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
# Qualify the numerator and denominator units. This will turn possibly partial
# units like "kilometer" or "hour" into actual units like "length-kilometer" and
@@ -217,7 +214,7 @@ def format_compound_unit(
denominator_unit: str | None = None,
length: Literal["short", "long", "narrow"] = "long",
format: str | None = None,
- locale: Locale | str | None = LC_NUMERIC,
+ locale: Locale | str | None = None,
*,
numbering_system: Literal["default"] | str = "latn",
) -> str | None:
@@ -265,13 +262,13 @@ def format_compound_unit(
:param denominator_unit: The denominator unit. See `format_unit`.
:param length: The formatting length. "short", "long" or "narrow"
:param format: An optional format, as accepted by `format_decimal`.
- :param locale: the `Locale` object or locale identifier
+ :param locale: the `Locale` object or locale identifier. Defaults to the system numeric locale.
:param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".
The special value "default" will use the default numbering system of the locale.
:return: A formatted compound value.
:raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.
"""
- locale = Locale.parse(locale)
+ locale = Locale.parse(locale or LC_NUMERIC)
# Look for a specific compound unit first...
diff --git a/babel/util.py b/babel/util.py
index 605695b1f..d113982ee 100644
--- a/babel/util.py
+++ b/babel/util.py
@@ -4,17 +4,17 @@
Various utility classes and functions.
- :copyright: (c) 2013-2024 by the Babel Team.
+ :copyright: (c) 2013-2025 by the Babel Team.
:license: BSD, see LICENSE for more details.
"""
from __future__ import annotations
import codecs
-import collections
import datetime
import os
import re
import textwrap
+import warnings
from collections.abc import Generator, Iterable
from typing import IO, Any, TypeVar
@@ -205,10 +205,31 @@ class TextWrapper(textwrap.TextWrapper):
r'(?<=[\w\!\"\'\&\.\,\?])-{2,}(?=\w))', # em-dash
)
+ # e.g. '\u2068foo bar.py\u2069:42'
+ _enclosed_filename_re = re.compile(r'(\u2068[^\u2068]+?\u2069(?::-?\d+)?)')
+
+ def _split(self, text):
+ """Splits the text into indivisible chunks while ensuring that file names
+ containing spaces are not broken up.
+ """
+ enclosed_filename_start = '\u2068'
+ if enclosed_filename_start not in text:
+ # There are no file names which contain spaces, fallback to the default implementation
+ return super()._split(text)
+
+ chunks = []
+ for chunk in re.split(self._enclosed_filename_re, text):
+ if chunk.startswith(enclosed_filename_start):
+ chunks.append(chunk)
+ else:
+ chunks.extend(super()._split(chunk))
+ return [c for c in chunks if c]
+
def wraptext(text: str, width: int = 70, initial_indent: str = '', subsequent_indent: str = '') -> list[str]:
"""Simple wrapper around the ``textwrap.wrap`` function in the standard
- library. This version does not wrap lines on hyphens in words.
+ library. This version does not wrap lines on hyphens in words. It also
+ does not wrap PO file locations containing spaces.
:param text: the text to wrap
:param width: the maximum line width
@@ -217,6 +238,12 @@ def wraptext(text: str, width: int = 70, initial_indent: str = '', subsequent_in
:param subsequent_indent: string that will be prepended to all lines save
the first of wrapped output
"""
+ warnings.warn(
+ "`babel.util.wraptext` is deprecated and will be removed in a future version of Babel. "
+ "If you need this functionality, use the `babel.util.TextWrapper` class directly.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
wrapper = TextWrapper(width=width, initial_indent=initial_indent,
subsequent_indent=subsequent_indent,
break_long_words=False)
@@ -224,7 +251,7 @@ def wraptext(text: str, width: int = 70, initial_indent: str = '', subsequent_in
# TODO (Babel 3.x): Remove this re-export
-odict = collections.OrderedDict
+odict = dict
class FixedOffsetTimezone(datetime.tzinfo):
diff --git a/contrib/babel.js b/contrib/babel.js
index b1ad341d1..0cd4666a2 100644
--- a/contrib/babel.js
+++ b/contrib/babel.js
@@ -6,11 +6,11 @@
*
* This software is licensed as described in the file COPYING, which
* you should have received as part of this distribution. The terms
- * are also available at http://babel.edgewall.org/wiki/License.
+ * are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
*
* This software consists of voluntary contributions made by many
* individuals. For the exact contribution history, see the revision
- * history and logs, available at http://babel.edgewall.org/log/.
+ * history and logs, available at https://github.com/python-babel/babel/commits/master/.
*/
/**
diff --git a/docs/_templates/sidebar-links.html b/docs/_templates/sidebar-links.html
index 71d11b850..e71dec487 100644
--- a/docs/_templates/sidebar-links.html
+++ b/docs/_templates/sidebar-links.html
@@ -3,12 +3,12 @@ Other Formats
You can download the documentation in other formats as well:
Useful Links
- - Babel Website
+ - Babel Website
- Babel @ PyPI
- Babel @ github
- Issue Tracker
diff --git a/docs/_themes/babel/layout.html b/docs/_themes/babel/layout.html
index 1fd054eae..fd2f0205e 100644
--- a/docs/_themes/babel/layout.html
+++ b/docs/_themes/babel/layout.html
@@ -19,7 +19,7 @@
{%- block footer %}
{% if pagename == 'index' %}
diff --git a/docs/_themes/babel/static/babel.css_t b/docs/_themes/babel/static/babel.css_t
index bd1a1cdfe..04b115822 100644
--- a/docs/_themes/babel/static/babel.css_t
+++ b/docs/_themes/babel/static/babel.css_t
@@ -15,7 +15,7 @@
@import url("basic.css");
-@import url(http://fonts.googleapis.com/css?family=Bree+Serif);
+@import url(://fonts.googleapis.com/css?family=Bree+Serif);
/* -- page layout ----------------------------------------------------------- */
diff --git a/docs/conf.py b/docs/conf.py
index 08ff9a056..b22b78c4f 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -44,16 +44,16 @@
# General information about the project.
project = 'Babel'
-copyright = '2024, The Babel Team'
+copyright = '2025, 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.16'
+version = '2.17'
# The full version, including alpha/beta/rc tags.
-release = '2.16.0'
+release = '2.17.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@@ -252,7 +252,7 @@
#texinfo_show_urls = 'footnote'
intersphinx_mapping = {
- 'https://docs.python.org/3/': None,
+ 'python': ('https://docs.python.org/3/', None),
}
extlinks = {
diff --git a/docs/messages.rst b/docs/messages.rst
index 3ac035607..0f57eb117 100644
--- a/docs/messages.rst
+++ b/docs/messages.rst
@@ -80,7 +80,7 @@ languages (for whatever reason). Therefore the way in which messages are
extracted from source files can not only depend on the file extension, but
needs to be controllable in a precise manner.
-.. _`Jinja2`: http://jinja.pocoo.org/
+.. _`Jinja2`: https://jinja.pocoo.org/
.. _`Genshi`: https://genshi.edgewall.org/
Babel accepts a configuration file to specify this mapping of files to
@@ -184,12 +184,12 @@ example above.)
Referencing Extraction Methods
------------------------------
-To be able to use short extraction method names such as “genshi”, you need to
-have `pkg_resources`_ installed, and the package implementing that extraction
-method needs to have been installed with its meta data (the `egg-info`_).
+Generally, packages publish Babel extractors as Python entry points,
+and so you can use a short name such as “genshi” to refer to them.
-If this is not possible for some reason, you need to map the short names to
-fully qualified function names in an extract section in the mapping
+If the package implementing an extraction method has not been installed in a
+way that has kept its entry point mapping intact, you need to map the short
+names to fully qualified function names in an extract section in the mapping
configuration. For example:
.. code-block:: ini
@@ -202,12 +202,9 @@ configuration. For example:
[custom: **.ctm]
some_option = foo
-Note that the builtin extraction methods ``python`` and ``ignore`` are available
-by default, even if `pkg_resources`_ is not installed. You should never need to
-explicitly define them in the ``[extractors]`` section.
-
-.. _`egg-info`: http://peak.telecommunity.com/DevCenter/PythonEggs
-.. _`pkg_resources`: http://peak.telecommunity.com/DevCenter/PkgResources
+Note that the builtin extraction methods ``python`` and ``ignore`` are always
+available, and you should never need to explicitly define them in the
+``[extractors]`` section.
--------------------------
@@ -240,10 +237,9 @@ need to implement a function that complies with the following interface:
is the job of the extractor implementation to do the decoding to
``unicode`` objects.
-Next, you should register that function as an entry point. This requires your
-``setup.py`` script to use `setuptools`_, and your package to be installed with
-the necessary metadata. If that's taken care of, add something like the
-following to your ``setup.py`` script:
+Next, you should register that function as an `entry point`_.
+If using ``setup.py``, add something like the following to your ``setup.py``
+script:
.. code-block:: python
@@ -254,6 +250,13 @@ following to your ``setup.py`` script:
xxx = your.package:extract_xxx
""",
+If using a ``pyproject.toml`` file, add something like the following:
+
+.. code-block:: toml
+
+ [project.entry-points."babel.extractors"]
+ xxx = "your.package:extract_xxx"
+
That is, add your extraction method to the entry point group
``babel.extractors``, where the name of the entry point is the name that people
will use to reference the extraction method, and the value being the module and
@@ -265,7 +268,7 @@ extraction.
extraction function directly. But whenever possible, the entry point
should be declared to make configuration more convenient.
-.. _`setuptools`: http://peak.telecommunity.com/DevCenter/setuptools
+.. _`setuptools`: https://setuptools.pypa.io/en/latest/setuptools.html
-------------------
diff --git a/pyproject.toml b/pyproject.toml
index ba0e4771d..e68b6d5d1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,24 +4,28 @@ extend-exclude = [
"tests/messages/data",
]
+[tool.ruff.format]
+quote-style = "preserve"
+
[tool.ruff.lint]
select = [
"B",
"C",
- "COM",
+ "COM", # Trailing commas
"E",
"F",
- "I",
- "SIM300",
- "UP",
+ "I", # import sorting
+ "SIM300", # Yoda conditions
+ "UP", # upgrades
+ "RUF022", # unsorted __all__
]
ignore = [
- "C901", # Complexity
- "E501", # Line length
- "E731", # Do not assign a lambda expression (we use them on purpose)
- "E741", # Ambiguous variable name
- "UP012", # "utf-8" is on purpose
- "UP031", # A bunch of places where % formatting is better
+ "C901", # Complexity
+ "E501", # Line length
+ "E731", # Do not assign a lambda expression (we use them on purpose)
+ "E741", # Ambiguous variable name
+ "UP012", # "utf-8" is on purpose
+ "UP031", # A bunch of places where % formatting is better
]
[tool.ruff.lint.per-file-ignores]
diff --git a/scripts/download_import_cldr.py b/scripts/download_import_cldr.py
index cf670ed98..9fb0ab580 100755
--- a/scripts/download_import_cldr.py
+++ b/scripts/download_import_cldr.py
@@ -9,10 +9,10 @@
import zipfile
from urllib.request import urlretrieve
-URL = 'https://unicode.org/Public/cldr/45/cldr-common-45.0.zip'
-FILENAME = 'cldr-common-45.0.zip'
+URL = 'https://unicode.org/Public/cldr/46/cldr-common-46.0.zip'
+FILENAME = 'cldr-common-46.0.zip'
# Via https://unicode.org/Public/cldr/45/hashes/SHASUM512.txt
-FILESUM = '638123882bd29911fc9492ec152926572fec48eb6c1f5dd706aee3e59cad8be4963a334bb7a09a645dbedc3356f60ef7ac2ef7ab4ccf2c8926b547782175603c'
+FILESUM = '316d644b79a4976d4da57d59ca57c689b339908fe61bb49110bfe1a9269c94144cb27322a0ea080398e6dc4c54a16752fd1ca837e14c054b3a6806b1ef9d3ec3'
BLKSIZE = 131072
diff --git a/scripts/dump_data.py b/scripts/dump_data.py
index 639c14d43..f054dfab8 100755
--- a/scripts/dump_data.py
+++ b/scripts/dump_data.py
@@ -1,15 +1,15 @@
#!/usr/bin/env python
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
from optparse import OptionParser
from pprint import pprint
diff --git a/scripts/dump_global.py b/scripts/dump_global.py
index 8b8294014..54f8de1a9 100755
--- a/scripts/dump_global.py
+++ b/scripts/dump_global.py
@@ -1,22 +1,21 @@
#!/usr/bin/env python
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import os
+import pickle
import sys
from pprint import pprint
-import cPickle as pickle
-
import babel
dirname = os.path.join(os.path.dirname(babel.__file__))
diff --git a/scripts/generate_authors.py b/scripts/generate_authors.py
index a5443b91e..cd18f640a 100644
--- a/scripts/generate_authors.py
+++ b/scripts/generate_authors.py
@@ -1,13 +1,27 @@
import os
+import re
from collections import Counter
from subprocess import check_output
root_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))
+aliases = {
+ re.compile("Jun Omae"): "Jun Omae",
+ re.compile(r"^Hugo$"): "Hugo van Kemenade",
+ re.compile(r"^Tomas R([.])?"): "Tomas R.",
+}
+
+
+def map_alias(name):
+ for pattern, alias in aliases.items():
+ if pattern.match(name):
+ return alias
+ return name
+
def get_sorted_authors_list():
authors = check_output(['git', 'log', '--format=%aN'], cwd=root_path).decode('UTF-8')
- counts = Counter(authors.splitlines())
+ counts = Counter(map_alias(name) for name in authors.splitlines())
return [author for (author, count) in counts.most_common()]
diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py
index e8cc03106..bcd5898e6 100755
--- a/scripts/import_cldr.py
+++ b/scripts/import_cldr.py
@@ -1,15 +1,15 @@
#!/usr/bin/env python
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import collections
import logging
@@ -389,12 +389,10 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False):
if elem is not None:
language = elem.attrib['type']
- territory = None
+ territory = '001' # world
elem = tree.find('.//identity/territory')
if elem is not None:
territory = elem.attrib['type']
- else:
- territory = '001' # world
regions = territory_containment.get(territory, [])
log.info(
diff --git a/setup.py b/setup.py
index 23936a75d..c8cdb9d0a 100755
--- a/setup.py
+++ b/setup.py
@@ -69,9 +69,14 @@ def run(self):
],
extras_require={
'dev': [
- 'pytest>=6.0',
- 'pytest-cov',
+ "tzdata;sys_platform == 'win32'",
+ 'backports.zoneinfo; python_version<"3.9"',
'freezegun~=1.0',
+ 'jinja2>=3.0',
+ 'pytest-cov',
+ 'pytest>=6.0',
+ 'pytz',
+ 'setuptools',
],
},
cmdclass={'import_cldr': import_cldr},
diff --git a/tests/conftest.py b/tests/conftest.py
index 67e3ce921..dab67a9a3 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,3 @@
-import os
-
import pytest
try:
@@ -16,13 +14,6 @@
pytz = None
-@pytest.fixture
-def os_environ(monkeypatch):
- mock_environ = dict(os.environ)
- monkeypatch.setattr(os, 'environ', mock_environ)
- return mock_environ
-
-
def pytest_generate_tests(metafunc):
if hasattr(metafunc.function, "pytestmark"):
for mark in metafunc.function.pytestmark:
diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py
index 2df85debb..692931ea2 100644
--- a/tests/messages/test_catalog.py
+++ b/tests/messages/test_catalog.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import copy
import datetime
@@ -39,6 +39,24 @@ def test_python_format(self):
assert catalog.PYTHON_FORMAT.search('foo %(name)*.*f')
assert catalog.PYTHON_FORMAT.search('foo %()s')
+ def test_python_brace_format(self):
+ assert not catalog._has_python_brace_format('')
+ assert not catalog._has_python_brace_format('foo')
+ assert not catalog._has_python_brace_format('{')
+ assert not catalog._has_python_brace_format('}')
+ assert not catalog._has_python_brace_format('{} {')
+ assert not catalog._has_python_brace_format('{{}}')
+ assert catalog._has_python_brace_format('{}')
+ assert catalog._has_python_brace_format('foo {name}')
+ assert catalog._has_python_brace_format('foo {name!s}')
+ assert catalog._has_python_brace_format('foo {name!r}')
+ assert catalog._has_python_brace_format('foo {name!a}')
+ assert catalog._has_python_brace_format('foo {name!r:10}')
+ assert catalog._has_python_brace_format('foo {name!r:10.2}')
+ assert catalog._has_python_brace_format('foo {name!r:10.2f}')
+ assert catalog._has_python_brace_format('foo {name!r:10.2f} {name!r:10.2f}')
+ assert catalog._has_python_brace_format('foo {name!r:10.2f=}')
+
def test_translator_comments(self):
mess = catalog.Message('foo', user_comments=['Comment About `foo`'])
assert mess.user_comments == ['Comment About `foo`']
@@ -342,10 +360,19 @@ def test_message_pluralizable():
def test_message_python_format():
+ assert not catalog.Message('foo').python_format
+ assert not catalog.Message(('foo', 'foo')).python_format
assert catalog.Message('foo %(name)s bar').python_format
assert catalog.Message(('foo %(name)s', 'foo %(name)s')).python_format
+def test_message_python_brace_format():
+ assert not catalog.Message('foo').python_brace_format
+ assert not catalog.Message(('foo', 'foo')).python_brace_format
+ assert catalog.Message('foo {name} bar').python_brace_format
+ assert catalog.Message(('foo {name}', 'foo {name}')).python_brace_format
+
+
def test_catalog():
cat = catalog.Catalog(project='Foobar', version='1.0',
copyright_holder='Foo Company')
@@ -414,6 +441,17 @@ def test_catalog_mime_headers_set_locale():
]
+def test_catalog_mime_headers_type_coercion():
+ """
+ Test that mime headers' keys and values are coerced to strings
+ """
+ cat = catalog.Catalog(locale='de_DE', project='Foobar', version='1.0')
+ # This is a strange interface in that it doesn't actually overwrite all
+ # of the MIME headers, but just sets the ones that are passed in (and known).
+ cat.mime_headers = {b'REPORT-MSGID-BUGS-TO': 8}.items()
+ assert dict(cat.mime_headers)['Report-Msgid-Bugs-To'] == '8'
+
+
def test_catalog_num_plurals():
assert catalog.Catalog(locale='en').num_plurals == 2
assert catalog.Catalog(locale='ga').num_plurals == 5
diff --git a/tests/messages/test_checkers.py b/tests/messages/test_checkers.py
index b2dc9dc4e..bba8f145a 100644
--- a/tests/messages/test_checkers.py
+++ b/tests/messages/test_checkers.py
@@ -1,23 +1,27 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import unittest
from datetime import datetime
from io import BytesIO
+import pytest
+
from babel import __version__ as VERSION
from babel.core import Locale, UnknownLocaleError
from babel.dates import format_datetime
-from babel.messages import checkers
+from babel.messages import Message, checkers
+from babel.messages.catalog import TranslationError
+from babel.messages.checkers import _validate_format, python_format
from babel.messages.plurals import PLURALS
from babel.messages.pofile import read_po
from babel.util import LOCALTZ
@@ -325,3 +329,59 @@ def test_6_num_plurals_checkers(self):
catalog = read_po(BytesIO(po_file), _locale)
message = catalog['foobar']
checkers.num_plurals(catalog, message)
+
+
+class TestPythonFormat:
+ @pytest.mark.parametrize(('msgid', 'msgstr'), [
+ ('foo %s', 'foo'),
+ (('foo %s', 'bar'), ('foo', 'bar')),
+ (('foo', 'bar %s'), ('foo', 'bar')),
+ (('foo %s', 'bar'), ('foo')),
+ ])
+ def test_python_format_invalid(self, msgid, msgstr):
+ msg = Message(msgid, msgstr)
+ with pytest.raises(TranslationError):
+ python_format(None, msg)
+
+ @pytest.mark.parametrize(('msgid', 'msgstr'), [
+ ('foo', 'foo'),
+ ('foo', 'foo %s'),
+ (('foo %s', 'bar %d'), ('foo %s', 'bar %d')),
+ (('foo %s', 'bar %d'), ('foo %s', 'bar %d', 'baz')),
+ (('foo', 'bar %s'), ('foo')),
+ ])
+ def test_python_format_valid(self, msgid, msgstr):
+ msg = Message(msgid, msgstr)
+ python_format(None, msg)
+
+ @pytest.mark.parametrize(('msgid', 'msgstr', 'error'), [
+ ('%s %(foo)s', '%s %(foo)s', 'format string mixes positional and named placeholders'),
+ ('foo %s', 'foo', 'placeholders are incompatible'),
+ ('%s', '%(foo)s', 'the format strings are of different kinds'),
+ ('%s', '%s %d', 'positional format placeholders are unbalanced'),
+ ('%s', '%d', "incompatible format for placeholder 1: 's' and 'd' are not compatible"),
+ ('%s %s %d', '%s %s %s', "incompatible format for placeholder 3: 'd' and 's' are not compatible"),
+ ('%(foo)s', '%(bar)s', "unknown named placeholder 'bar'"),
+ ('%(foo)s', '%(bar)d', "unknown named placeholder 'bar'"),
+ ('%(foo)s', '%(foo)d', "incompatible format for placeholder 'foo': 'd' and 's' are not compatible"),
+ ])
+ def test__validate_format_invalid(self, msgid, msgstr, error):
+ with pytest.raises(TranslationError, match=error):
+ _validate_format(msgid, msgstr)
+
+ @pytest.mark.parametrize(('msgid', 'msgstr'), [
+ ('foo', 'foo'),
+ ('foo', 'foo %s'),
+ ('%s foo', 'foo %s'),
+ ('%i', '%d'),
+ ('%d', '%u'),
+ ('%x', '%X'),
+ ('%f', '%F'),
+ ('%F', '%g'),
+ ('%g', '%G'),
+ ('%(foo)s', 'foo'),
+ ('%(foo)s', '%(foo)s %(foo)s'),
+ ('%(bar)s foo %(n)d', '%(n)d foo %(bar)s'),
+ ])
+ def test__validate_format_valid(self, msgid, msgstr):
+ _validate_format(msgid, msgstr)
diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py
index 7d3a05aa7..d5ac3b2ca 100644
--- a/tests/messages/test_extract.py
+++ b/tests/messages/test_extract.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import codecs
import sys
@@ -34,6 +34,11 @@ def test_nested_calls(self):
msg8 = gettext('Rabbit')
msg9 = dgettext('wiki', model.addPage())
msg10 = dngettext(getDomain(), 'Page', 'Pages', 3)
+msg11 = ngettext(
+ "bunny",
+ "bunnies",
+ len(bunnies)
+)
""")
messages = list(extract.extract_python(buf,
extract.DEFAULT_KEYWORDS.keys(),
@@ -49,6 +54,7 @@ def test_nested_calls(self):
(8, 'gettext', 'Rabbit', []),
(9, 'dgettext', ('wiki', None), []),
(10, 'dngettext', (None, 'Page', 'Pages', None), []),
+ (12, 'ngettext', ('bunny', 'bunnies', None), []),
]
def test_extract_default_encoding_ascii(self):
@@ -97,10 +103,10 @@ def test_comments_with_calls_that_spawn_multiple_lines(self):
messages = list(extract.extract_python(buf, ('ngettext', '_'), ['NOTE:'],
{'strip_comment_tags': False}))
- assert messages[0] == (3, 'ngettext', ('Catalog deleted.', 'Catalogs deleted.', None), ['NOTE: This Comment SHOULD Be Extracted'])
+ assert messages[0] == (2, 'ngettext', ('Catalog deleted.', 'Catalogs deleted.', None), ['NOTE: This Comment SHOULD Be Extracted'])
assert messages[1] == (6, '_', 'Locale deleted.', ['NOTE: This Comment SHOULD Be Extracted'])
assert messages[2] == (10, 'ngettext', ('Foo deleted.', 'Foos deleted.', None), ['NOTE: This Comment SHOULD Be Extracted'])
- assert messages[3] == (15, 'ngettext', ('Bar deleted.', 'Bars deleted.', None), ['NOTE: This Comment SHOULD Be Extracted', 'NOTE: And This One Too'])
+ assert messages[3] == (14, 'ngettext', ('Bar deleted.', 'Bars deleted.', None), ['NOTE: This Comment SHOULD Be Extracted', 'NOTE: And This One Too'])
def test_declarations(self):
buf = BytesIO(b"""\
diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py
index 7a6b08c44..c83948d28 100644
--- a/tests/messages/test_frontend.py
+++ b/tests/messages/test_frontend.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import logging
import os
import re
@@ -45,6 +45,7 @@
project_dir,
this_dir,
)
+from tests.messages.utils import CUSTOM_EXTRACTOR_COOKIE
def _po_file(locale):
@@ -1392,7 +1393,11 @@ def test_update_init_missing(self):
mapping_cfg = """
[extractors]
-custom = mypackage.module:myfunc
+custom = tests.messages.utils:custom_extractor
+
+# Special extractor for a given Python file
+[custom: special.py]
+treat = delicious
# Python source files
[python: **.py]
@@ -1411,7 +1416,13 @@ def test_update_init_missing(self):
mapping_toml = """
[extractors]
-custom = "mypackage.module:myfunc"
+custom = "tests.messages.utils:custom_extractor"
+
+# Special extractor for a given Python file
+[[mappings]]
+method = "custom"
+pattern = "special.py"
+treat = "delightful"
# Python source files
[[mappings]]
@@ -1470,18 +1481,17 @@ def test_parse_mapping(data: str, parser, preprocess, is_toml):
buf = StringIO(data)
method_map, options_map = parser(buf)
- assert len(method_map) == 4
+ assert len(method_map) == 5
- assert method_map[0] == ('**.py', 'python')
+ assert method_map[1] == ('**.py', 'python')
assert options_map['**.py'] == {}
- assert method_map[1] == ('**/templates/**.html', 'genshi')
+ assert method_map[2] == ('**/templates/**.html', 'genshi')
assert options_map['**/templates/**.html']['include_attrs'] == ''
- assert method_map[2] == ('**/templates/**.txt', 'genshi')
+ assert method_map[3] == ('**/templates/**.txt', 'genshi')
assert (options_map['**/templates/**.txt']['template_class']
== 'genshi.template:TextTemplate')
assert options_map['**/templates/**.txt']['encoding'] == 'latin-1'
-
- assert method_map[3] == ('**/custom/*.*', 'mypackage.module:myfunc')
+ assert method_map[4] == ('**/custom/*.*', 'tests.messages.utils:custom_extractor')
assert options_map['**/custom/*.*'] == {}
@@ -1663,3 +1673,29 @@ def test_extract_header_comment(monkeypatch, tmp_path):
cmdinst.run()
pot_content = pot_file.read_text()
assert 'Boing' in pot_content
+
+
+@pytest.mark.parametrize("mapping_format", ("toml", "cfg"))
+def test_pr_1121(tmp_path, monkeypatch, caplog, mapping_format):
+ """
+ Test that extraction uses the first matching method and options,
+ instead of the first matching method and last matching options.
+
+ Without the fix in PR #1121, this test would fail,
+ since the `custom_extractor` isn't passed a delicious treat via
+ the configuration.
+ """
+ if mapping_format == "cfg":
+ mapping_file = (tmp_path / "mapping.cfg")
+ mapping_file.write_text(mapping_cfg)
+ else:
+ mapping_file = (tmp_path / "mapping.toml")
+ mapping_file.write_text(mapping_toml)
+ (tmp_path / "special.py").write_text("# this file is special")
+ pot_path = (tmp_path / "output.pot")
+ monkeypatch.chdir(tmp_path)
+ cmdinst = configure_cli_command(f"extract . -o {shlex.quote(str(pot_path))} --mapping {shlex.quote(mapping_file.name)}")
+ assert isinstance(cmdinst, ExtractMessages)
+ cmdinst.run()
+ # If the custom extractor didn't run, we wouldn't see the cookie in there.
+ assert CUSTOM_EXTRACTOR_COOKIE in pot_path.read_text()
diff --git a/tests/messages/test_mofile.py b/tests/messages/test_mofile.py
index 7a699041d..8d1a89eb0 100644
--- a/tests/messages/test_mofile.py
+++ b/tests/messages/test_mofile.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import os
import unittest
diff --git a/tests/messages/test_plurals.py b/tests/messages/test_plurals.py
index e9f8e80fd..cd5a2fbaf 100644
--- a/tests/messages/test_plurals.py
+++ b/tests/messages/test_plurals.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import pytest
from babel import Locale
diff --git a/tests/messages/test_pofile.py b/tests/messages/test_pofile.py
index d1a3e2d11..2bcc3df8d 100644
--- a/tests/messages/test_pofile.py
+++ b/tests/messages/test_pofile.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import unittest
from datetime import datetime
@@ -19,6 +19,7 @@
from babel.core import Locale
from babel.messages import pofile
from babel.messages.catalog import Catalog, Message
+from babel.messages.pofile import _enclose_filename_if_necessary, _extract_locations
from babel.util import FixedOffsetTimezone
@@ -298,10 +299,62 @@ def test_obsolete_message_with_context(self):
catalog = pofile.read_po(buf)
assert len(catalog) == 2
assert len(catalog.obsolete) == 1
- message = catalog.obsolete["foo"]
+ message = catalog.obsolete[("foo", "other")]
assert message.context == 'other'
assert message.string == 'Voh'
+ def test_obsolete_messages_with_context(self):
+ buf = StringIO('''
+# This is an obsolete message
+#~ msgctxt "apple"
+#~ msgid "foo"
+#~ msgstr "Foo"
+
+# This is an obsolete message with the same id but different context
+#~ msgctxt "orange"
+#~ msgid "foo"
+#~ msgstr "Bar"
+''')
+ catalog = pofile.read_po(buf)
+ assert len(catalog) == 0
+ assert len(catalog.obsolete) == 2
+ assert 'foo' not in catalog.obsolete
+
+ apple_msg = catalog.obsolete[('foo', 'apple')]
+ assert apple_msg.id == 'foo'
+ assert apple_msg.string == 'Foo'
+ assert apple_msg.user_comments == ['This is an obsolete message']
+
+ orange_msg = catalog.obsolete[('foo', 'orange')]
+ assert orange_msg.id == 'foo'
+ assert orange_msg.string == 'Bar'
+ assert orange_msg.user_comments == ['This is an obsolete message with the same id but different context']
+
+ def test_obsolete_messages_roundtrip(self):
+ buf = StringIO('''\
+# This message is not obsolete
+#: main.py:1
+msgid "bar"
+msgstr "Bahr"
+
+# This is an obsolete message
+#~ msgid "foo"
+#~ msgstr "Voh"
+
+# This is an obsolete message
+#~ msgctxt "apple"
+#~ msgid "foo"
+#~ msgstr "Foo"
+
+# This is an obsolete message with the same id but different context
+#~ msgctxt "orange"
+#~ msgid "foo"
+#~ msgstr "Bar"
+
+''')
+ generated_po_file = ''.join(pofile.generate_po(pofile.read_po(buf), omit_header=True))
+ assert buf.getvalue() == generated_po_file
+
def test_multiline_context(self):
buf = StringIO('''
msgctxt "a really long "
@@ -393,7 +446,7 @@ def test_obsolete_plural_with_square_brackets(self):
assert len(catalog) == 0
assert len(catalog.obsolete) == 1
assert catalog.num_plurals == 2
- message = catalog.obsolete[('foo', 'foos')]
+ message = catalog.obsolete['foo']
assert len(message.string) == 2
assert message.string[0] == 'Voh [text]'
assert message.string[1] == 'Vohs [text]'
@@ -438,6 +491,19 @@ def test_missing_plural_in_the_middle(self):
assert message.string[1] == ''
assert message.string[2] == 'Vohs [text]'
+ def test_with_location(self):
+ buf = StringIO('''\
+#: main.py:1 \u2068filename with whitespace.py\u2069:123
+msgid "foo"
+msgstr "bar"
+''')
+ catalog = pofile.read_po(buf, locale='de_DE')
+ assert len(catalog) == 1
+ message = catalog['foo']
+ assert message.string == 'bar'
+ assert message.locations == [("main.py", 1), ("filename with whitespace.py", 123)]
+
+
def test_abort_invalid_po_file(self):
invalid_po = '''
msgctxt ""
@@ -841,6 +907,73 @@ def test_no_include_lineno(self):
msgid "foo"
msgstr ""'''
+ def test_white_space_in_location(self):
+ catalog = Catalog()
+ catalog.add('foo', locations=[('main.py', 1)])
+ catalog.add('foo', locations=[('utils b.py', 3)])
+ buf = BytesIO()
+ pofile.write_po(buf, catalog, omit_header=True, include_lineno=True)
+ assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3
+msgid "foo"
+msgstr ""'''
+
+ def test_white_space_in_location_already_enclosed(self):
+ catalog = Catalog()
+ catalog.add('foo', locations=[('main.py', 1)])
+ catalog.add('foo', locations=[('\u2068utils b.py\u2069', 3)])
+ buf = BytesIO()
+ pofile.write_po(buf, catalog, omit_header=True, include_lineno=True)
+ assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3
+msgid "foo"
+msgstr ""'''
+
+ def test_tab_in_location(self):
+ catalog = Catalog()
+ catalog.add('foo', locations=[('main.py', 1)])
+ catalog.add('foo', locations=[('utils\tb.py', 3)])
+ buf = BytesIO()
+ pofile.write_po(buf, catalog, omit_header=True, include_lineno=True)
+ assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3
+msgid "foo"
+msgstr ""'''
+
+ def test_tab_in_location_already_enclosed(self):
+ catalog = Catalog()
+ catalog.add('foo', locations=[('main.py', 1)])
+ catalog.add('foo', locations=[('\u2068utils\tb.py\u2069', 3)])
+ buf = BytesIO()
+ pofile.write_po(buf, catalog, omit_header=True, include_lineno=True)
+ assert buf.getvalue().strip() == b'''#: main.py:1 \xe2\x81\xa8utils b.py\xe2\x81\xa9:3
+msgid "foo"
+msgstr ""'''
+
+
+ def test_wrap_with_enclosed_file_locations(self):
+ # Ensure that file names containing white space are not wrapped regardless of the --width parameter
+ catalog = Catalog()
+ catalog.add('foo', locations=[('\u2068test utils.py\u2069', 1)])
+ catalog.add('foo', locations=[('\u2068test utils.py\u2069', 3)])
+ buf = BytesIO()
+ pofile.write_po(buf, catalog, omit_header=True, include_lineno=True, width=1)
+ assert buf.getvalue().strip() == b'''#: \xe2\x81\xa8test utils.py\xe2\x81\xa9:1
+#: \xe2\x81\xa8test utils.py\xe2\x81\xa9:3
+msgid "foo"
+msgstr ""'''
+
+
+class RoundtripPoTestCase(unittest.TestCase):
+
+ def test_enclosed_filenames_in_location_comment(self):
+ catalog = Catalog()
+ catalog.add("foo", lineno=2, locations=[("main 1.py", 1)], string="")
+ catalog.add("bar", lineno=6, locations=[("other.py", 2)], string="")
+ catalog.add("baz", lineno=10, locations=[("main 1.py", 3), ("other.py", 4)], string="")
+ buf = BytesIO()
+ pofile.write_po(buf, catalog, omit_header=True, include_lineno=True)
+ buf.seek(0)
+ catalog2 = pofile.read_po(buf)
+ assert True is catalog.is_identical(catalog2)
+
class PofileFunctionsTestCase(unittest.TestCase):
@@ -864,6 +997,51 @@ def test_denormalize_on_msgstr_without_empty_first_line(self):
assert expected_denormalized == pofile.denormalize(f'""\n{msgstr}')
+@pytest.mark.parametrize(("line", "locations"), [
+ ("\u2068file1.po\u2069", ["file1.po"]),
+ ("file1.po \u2068file 2.po\u2069 file3.po", ["file1.po", "file 2.po", "file3.po"]),
+ ("file1.po:1 \u2068file 2.po\u2069:2 file3.po:3", ["file1.po:1", "file 2.po:2", "file3.po:3"]),
+ ("\u2068file1.po\u2069:1 \u2068file\t2.po\u2069:2 file3.po:3",
+ ["file1.po:1", "file\t2.po:2", "file3.po:3"]),
+ ("file1.po file2.po", ["file1.po", "file2.po"]),
+ ("file1.po \u2068\u2069 file2.po", ["file1.po", "file2.po"]),
+])
+def test_extract_locations_valid_location_comment(line, locations):
+ assert locations == _extract_locations(line)
+
+
+@pytest.mark.parametrize(("line",), [
+ ("\u2068file 1.po",),
+ ("file 1.po\u2069",),
+ ("\u2069file 1.po\u2068",),
+ ("\u2068file 1.po:1 \u2068file 2.po\u2069:2",),
+ ("\u2068file 1.po\u2069:1 file 2.po\u2069:2",),
+])
+def test_extract_locations_invalid_location_comment(line):
+ with pytest.raises(ValueError):
+ _extract_locations(line)
+
+
+@pytest.mark.parametrize(("filename",), [
+ ("file.po",),
+ ("file_a.po",),
+ ("file-a.po",),
+ ("file\n.po",),
+ ("\u2068file.po\u2069",),
+ ("\u2068file a.po\u2069",),
+])
+def test_enclose_filename_if_necessary_no_change(filename):
+ assert filename == _enclose_filename_if_necessary(filename)
+
+
+@pytest.mark.parametrize(("filename",), [
+ ("file a.po",),
+ ("file\ta.po",),
+])
+def test_enclose_filename_if_necessary_enclosed(filename):
+ assert "\u2068" + filename + "\u2069" == _enclose_filename_if_necessary(filename)
+
+
def test_unknown_language_roundtrip():
buf = StringIO(r'''
msgid ""
@@ -902,3 +1080,19 @@ def test_issue_1087():
"Language: \n"
''')
assert pofile.read_po(buf).locale is None
+
+
+@pytest.mark.parametrize("case", ['msgid "foo"', 'msgid "foo"\nmsgid_plural "foos"'])
+@pytest.mark.parametrize("abort_invalid", [False, True])
+def test_issue_1134(case: str, abort_invalid: bool):
+ buf = StringIO(case)
+
+ if abort_invalid:
+ # Catalog not created, aborted with PoFileError
+ with pytest.raises(pofile.PoFileError, match="missing msgstr for msgid 'foo' on 0"):
+ pofile.read_po(buf, abort_invalid=True)
+ else:
+ # Catalog is created with warning, no abort
+ output = pofile.read_po(buf)
+ assert len(output) == 1
+ assert output["foo"].string in ((''), ('', ''))
diff --git a/tests/messages/test_setuptools_frontend.py b/tests/messages/test_setuptools_frontend.py
index f3686a8b7..a623efd29 100644
--- a/tests/messages/test_setuptools_frontend.py
+++ b/tests/messages/test_setuptools_frontend.py
@@ -56,12 +56,11 @@ def test_setuptools_commands(tmp_path, monkeypatch):
shutil.copytree(data_dir, dest)
monkeypatch.chdir(dest)
- env = os.environ.copy()
# When in Tox, we need to hack things a bit so as not to have the
# sub-interpreter `sys.executable` use the tox virtualenv's Babel
# installation, so the locale data is where we expect it to be.
- if "BABEL_TOX_INI_DIR" in env:
- env["PYTHONPATH"] = env["BABEL_TOX_INI_DIR"]
+ if "BABEL_TOX_INI_DIR" in os.environ:
+ monkeypatch.setenv("PYTHONPATH", os.environ["BABEL_TOX_INI_DIR"])
# Initialize an empty catalog
subprocess.check_call([
@@ -71,7 +70,7 @@ def test_setuptools_commands(tmp_path, monkeypatch):
"-i", os.devnull,
"-l", "fi",
"-d", "inited",
- ], env=env)
+ ])
po_file = Path("inited/fi/LC_MESSAGES/messages.po")
orig_po_data = po_file.read_text()
subprocess.check_call([
@@ -79,7 +78,7 @@ def test_setuptools_commands(tmp_path, monkeypatch):
"setup.py",
"extract_messages",
"-o", "extracted.pot",
- ], env=env)
+ ])
pot_file = Path("extracted.pot")
pot_data = pot_file.read_text()
assert "FooBar, TM" in pot_data # should be read from setup.cfg
@@ -90,7 +89,7 @@ def test_setuptools_commands(tmp_path, monkeypatch):
"update_catalog",
"-i", "extracted.pot",
"-d", "inited",
- ], env=env)
+ ])
new_po_data = po_file.read_text()
assert new_po_data != orig_po_data # check we updated the file
subprocess.check_call([
@@ -98,5 +97,5 @@ def test_setuptools_commands(tmp_path, monkeypatch):
"setup.py",
"compile_catalog",
"-d", "inited",
- ], env=env)
+ ])
assert po_file.with_suffix(".mo").exists()
diff --git a/tests/messages/utils.py b/tests/messages/utils.py
new file mode 100644
index 000000000..d0797a337
--- /dev/null
+++ b/tests/messages/utils.py
@@ -0,0 +1,7 @@
+CUSTOM_EXTRACTOR_COOKIE = "custom extractor was here"
+
+
+def custom_extractor(fileobj, keywords, comment_tags, options):
+ if "treat" not in options:
+ raise RuntimeError(f"The custom extractor refuses to run without a delicious treat; got {options!r}")
+ return [(1, next(iter(keywords)), (CUSTOM_EXTRACTOR_COOKIE,), [])]
diff --git a/tests/test_core.py b/tests/test_core.py
index 57f1a89c6..aaf95a1c2 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import pytest
@@ -42,15 +42,15 @@ def test_locale_comparison():
assert fi_FI != bad_en_US
-def test_can_return_default_locale(os_environ):
- os_environ['LC_MESSAGES'] = 'fr_FR.UTF-8'
+def test_can_return_default_locale(monkeypatch):
+ monkeypatch.setenv('LC_MESSAGES', 'fr_FR.UTF-8')
assert Locale('fr', 'FR') == Locale.default('LC_MESSAGES')
-def test_ignore_invalid_locales_in_lc_ctype(os_environ):
+def test_ignore_invalid_locales_in_lc_ctype(monkeypatch):
# This is a regression test specifically for a bad LC_CTYPE setting on
# MacOS X 10.6 (#200)
- os_environ['LC_CTYPE'] = 'UTF-8'
+ monkeypatch.setenv('LC_CTYPE', 'UTF-8')
# must not throw an exception
default_locale('LC_CTYPE')
@@ -76,10 +76,10 @@ def test_attributes(self):
assert locale.language == 'en'
assert locale.territory == 'US'
- def test_default(self, os_environ):
+ def test_default(self, monkeypatch):
for name in ['LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES']:
- os_environ[name] = ''
- os_environ['LANG'] = 'fr_FR.UTF-8'
+ monkeypatch.setenv(name, '')
+ monkeypatch.setenv('LANG', 'fr_FR.UTF-8')
default = Locale.default('LC_MESSAGES')
assert (default.language, default.territory) == ('fr', 'FR')
@@ -264,20 +264,36 @@ def test_plural_form_property(self):
assert Locale('ru').plural_form(100) == 'many'
-def test_default_locale(os_environ):
- for name in ['LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES']:
- os_environ[name] = ''
- os_environ['LANG'] = 'fr_FR.UTF-8'
+def test_default_locale(monkeypatch):
+ for name in ['LANGUAGE', 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES']:
+ monkeypatch.setenv(name, '')
+ monkeypatch.setenv('LANG', 'fr_FR.UTF-8')
assert default_locale('LC_MESSAGES') == 'fr_FR'
-
- os_environ['LC_MESSAGES'] = 'POSIX'
+ monkeypatch.setenv('LC_MESSAGES', 'POSIX')
assert default_locale('LC_MESSAGES') == 'en_US_POSIX'
for value in ['C', 'C.UTF-8', 'POSIX']:
- os_environ['LANGUAGE'] = value
+ monkeypatch.setenv('LANGUAGE', value)
assert default_locale() == 'en_US_POSIX'
+def test_default_locale_multiple_args(monkeypatch):
+ for name in ['LANGUAGE', 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_MESSAGES', 'LC_MONETARY', 'LC_NUMERIC']:
+ monkeypatch.setenv(name, '')
+ assert default_locale(["", 0, None]) is None
+ monkeypatch.setenv('LANG', 'en_US')
+ assert default_locale(('LC_MONETARY', 'LC_NUMERIC')) == 'en_US' # No LC_MONETARY or LC_NUMERIC set
+ monkeypatch.setenv('LC_NUMERIC', 'fr_FR.UTF-8')
+ assert default_locale(('LC_MONETARY', 'LC_NUMERIC')) == 'fr_FR' # LC_NUMERIC set
+ monkeypatch.setenv('LC_MONETARY', 'fi_FI.UTF-8')
+ assert default_locale(('LC_MONETARY', 'LC_NUMERIC')) == 'fi_FI' # LC_MONETARY set, it takes precedence
+
+
+def test_default_locale_bad_arg():
+ with pytest.raises(TypeError):
+ default_locale(42)
+
+
def test_negotiate_locale():
assert (core.negotiate_locale(['de_DE', 'en_US'], ['de_DE', 'de_AT']) ==
'de_DE')
@@ -296,10 +312,8 @@ def test_parse_locale():
assert core.parse_locale('zh_Hans_CN') == ('zh', 'CN', 'Hans', None)
assert core.parse_locale('zh-CN', sep='-') == ('zh', 'CN', None, None)
- with pytest.raises(ValueError) as excinfo:
+ with pytest.raises(ValueError, match="'not_a_LOCALE_String' is not a valid locale identifier"):
core.parse_locale('not_a_LOCALE_String')
- assert (excinfo.value.args[0] ==
- "'not_a_LOCALE_String' is not a valid locale identifier")
assert core.parse_locale('it_IT@euro') == ('it', 'IT', None, None, 'euro')
assert core.parse_locale('it_IT@something') == ('it', 'IT', None, None, 'something')
@@ -308,6 +322,9 @@ def test_parse_locale():
assert (core.parse_locale('de_DE.iso885915@euro') ==
('de', 'DE', None, None, 'euro'))
+ with pytest.raises(ValueError, match="empty"):
+ core.parse_locale("")
+
@pytest.mark.parametrize('filename', [
'babel/global.dat',
@@ -362,3 +379,25 @@ def test_issue_1112():
Locale.parse('de_DE').territories['TR'] ==
'Türkei'
)
+
+
+def test_language_alt_official_not_used():
+ # If there exists an official and customary language name, the customary
+ # name should be used.
+ #
+ # For example, here 'Muscogee' should be used instead of 'Mvskoke':
+ # Muscogee
+ # Mvskoke
+
+ locale = Locale('mus')
+ assert locale.get_display_name() == 'Mvskoke'
+ assert locale.get_display_name(Locale('en')) == 'Muscogee'
+
+
+def test_locale_parse_empty():
+ with pytest.raises(ValueError, match="Empty"):
+ Locale.parse("")
+ with pytest.raises(TypeError, match="Empty"):
+ Locale.parse(None)
+ with pytest.raises(TypeError, match="Empty"):
+ Locale.parse(False) # weird...!
diff --git a/tests/test_date_intervals.py b/tests/test_date_intervals.py
index 55992b595..c0532c9d2 100644
--- a/tests/test_date_intervals.py
+++ b/tests/test_date_intervals.py
@@ -9,7 +9,7 @@
def test_format_interval_same_instant_1():
- assert dates.format_interval(TEST_DT, TEST_DT, "yMMMd", fuzzy=False, locale="fi") == "8. tammik. 2016"
+ assert dates.format_interval(TEST_DT, TEST_DT, "yMMMd", fuzzy=False, locale="fi") == "8.1.2016"
def test_format_interval_same_instant_2():
diff --git a/tests/test_dates.py b/tests/test_dates.py
index 0e0c97d89..e47521e4d 100644
--- a/tests/test_dates.py
+++ b/tests/test_dates.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import calendar
from datetime import date, datetime, time, timedelta
@@ -17,7 +17,7 @@
import pytest
from babel import Locale, dates
-from babel.dates import NO_INHERITANCE_MARKER, UTC, _localize
+from babel.dates import NO_INHERITANCE_MARKER, UTC, _localize, parse_pattern
from babel.util import FixedOffsetTimezone
@@ -619,6 +619,19 @@ def test_format_skeleton(timezone_getter):
assert (dates.format_skeleton('EHm', dt, tzinfo=timezone_getter('Asia/Bangkok'), locale='th') == 'อา. 22:30 น.')
+@pytest.mark.parametrize(('skeleton', 'expected'), [
+ ('Hmz', 'Hmv'),
+ ('kmv', 'Hmv'),
+ ('Kmv', 'hmv'),
+ ('Hma', 'Hm'),
+ ('Hmb', 'Hm'),
+ ('zkKab', 'vHh'),
+])
+def test_match_skeleton_alternate_characters(skeleton, expected):
+ # https://github.com/unicode-org/icu/blob/5e22f0076ec9b55056cd8a84e9ef370632f44174/icu4j/main/core/src/main/java/com/ibm/icu/text/DateIntervalInfo.java#L1090-L1102
+ assert dates.match_skeleton(skeleton, (expected,)) == expected
+
+
def test_format_timedelta():
assert (dates.format_timedelta(timedelta(weeks=12), locale='en_US')
== '3 months')
@@ -643,6 +656,13 @@ def test_parse_date():
assert dates.parse_date('2004-04-01', locale='sv_SE', format='short') == date(2004, 4, 1)
+def test_parse_date_custom_format():
+ assert dates.parse_date('1.4.2024', format='dd.mm.yyyy') == date(2024, 4, 1)
+ assert dates.parse_date('2024.4.1', format='yyyy.mm.dd') == date(2024, 4, 1)
+ # Dates that look like ISO 8601 should use the custom format as well:
+ assert dates.parse_date('2024-04-01', format='yyyy.dd.mm') == date(2024, 1, 4)
+
+
@pytest.mark.parametrize('input, expected', [
# base case, fully qualified time
('15:30:00', time(15, 30)),
@@ -666,6 +686,37 @@ def test_parse_time(input, expected):
assert dates.parse_time(input, locale='en_US') == expected
+def test_parse_time_no_seconds_in_format():
+ # parse time using a time format which does not include seconds
+ locale = 'cs_CZ'
+ fmt = 'short'
+ assert dates.get_time_format(format=fmt, locale=locale).pattern == 'H:mm'
+ assert dates.parse_time('9:30', locale=locale, format=fmt) == time(9, 30)
+
+
+def test_parse_time_alternate_characters(monkeypatch):
+ # 'K' can be used as an alternative to 'H'
+ def get_time_format(*args, **kwargs):
+ return parse_pattern('KK:mm:ss')
+ monkeypatch.setattr(dates, 'get_time_format', get_time_format)
+
+ assert dates.parse_time('9:30') == time(9, 30)
+
+
+def test_parse_date_alternate_characters(monkeypatch):
+ # 'l' can be used as an alternative to 'm'
+ def get_date_format(*args, **kwargs):
+ return parse_pattern('yyyy-ll-dd')
+ monkeypatch.setattr(dates, 'get_time_format', get_date_format)
+
+ assert dates.parse_date('2024-10-20') == date(2024, 10, 20)
+
+
+def test_parse_time_custom_format():
+ assert dates.parse_time('15:30:00', format='HH:mm:ss') == time(15, 30)
+ assert dates.parse_time('00:30:15', format='ss:mm:HH') == time(15, 30)
+
+
@pytest.mark.parametrize('case', ['', 'a', 'aaa'])
@pytest.mark.parametrize('func', [dates.parse_date, dates.parse_time])
def test_parse_errors(case, func):
@@ -734,6 +785,365 @@ def test_russian_week_numbering():
assert dates.format_date(v, format='YYYY-ww', locale='de_DE') == '2016-52'
+def test_week_numbering_isocalendar():
+ locale = Locale.parse('de_DE')
+ assert locale.first_week_day == 0
+ assert locale.min_week_days == 4
+
+ def week_number(value):
+ return dates.format_date(value, format="YYYY-'W'ww-e", locale=locale)
+
+ for year in range(1991, 2010):
+ for delta in range(-9, 9):
+ value = date(year, 1, 1) + timedelta(days=delta)
+ expected = '%04d-W%02d-%d' % value.isocalendar()
+ assert week_number(value) == expected
+
+def test_week_numbering_monday_mindays_4():
+ locale = Locale.parse('de_DE')
+ assert locale.first_week_day == 0
+ assert locale.min_week_days == 4
+
+ def week_number(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format="YYYY-'W'ww-e", locale=locale)
+
+ def week_of_month(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format='W', locale=locale)
+
+ assert week_number('2003-01-01') == '2003-W01-3'
+ assert week_number('2003-01-06') == '2003-W02-1'
+ assert week_number('2003-12-28') == '2003-W52-7'
+ assert week_number('2003-12-29') == '2004-W01-1'
+ assert week_number('2003-12-31') == '2004-W01-3'
+ assert week_number('2004-01-01') == '2004-W01-4'
+ assert week_number('2004-01-05') == '2004-W02-1'
+ assert week_number('2004-12-26') == '2004-W52-7'
+ assert week_number('2004-12-27') == '2004-W53-1'
+ assert week_number('2004-12-31') == '2004-W53-5'
+ assert week_number('2005-01-01') == '2004-W53-6'
+ assert week_number('2005-01-03') == '2005-W01-1'
+ assert week_number('2005-01-10') == '2005-W02-1'
+ assert week_number('2005-12-31') == '2005-W52-6'
+ assert week_number('2006-01-01') == '2005-W52-7'
+ assert week_number('2006-01-02') == '2006-W01-1'
+ assert week_number('2006-01-09') == '2006-W02-1'
+ assert week_number('2006-12-31') == '2006-W52-7'
+ assert week_number('2007-01-01') == '2007-W01-1'
+ assert week_number('2007-12-30') == '2007-W52-7'
+ assert week_number('2007-12-31') == '2008-W01-1'
+ assert week_number('2008-01-01') == '2008-W01-2'
+ assert week_number('2008-01-07') == '2008-W02-1'
+ assert week_number('2008-12-28') == '2008-W52-7'
+ assert week_number('2008-12-29') == '2009-W01-1'
+ assert week_number('2008-12-31') == '2009-W01-3'
+ assert week_number('2009-01-01') == '2009-W01-4'
+ assert week_number('2009-01-05') == '2009-W02-1'
+ assert week_number('2009-12-27') == '2009-W52-7'
+ assert week_number('2009-12-28') == '2009-W53-1'
+ assert week_number('2009-12-31') == '2009-W53-4'
+ assert week_number('2010-01-01') == '2009-W53-5'
+ assert week_number('2010-01-03') == '2009-W53-7'
+ assert week_number('2010-01-04') == '2010-W01-1'
+
+ assert week_of_month('2003-01-01') == '1' # Wed
+ assert week_of_month('2003-02-28') == '4'
+ assert week_of_month('2003-05-01') == '1' # Thu
+ assert week_of_month('2003-08-01') == '0' # Fri
+ assert week_of_month('2003-12-31') == '5'
+ assert week_of_month('2004-01-01') == '1' # Thu
+ assert week_of_month('2004-02-29') == '4'
+
+
+def test_week_numbering_monday_mindays_1():
+ locale = Locale.parse('tr_TR')
+ assert locale.first_week_day == 0
+ assert locale.min_week_days == 1
+
+ def week_number(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format="YYYY-'W'ww-e", locale=locale)
+
+ def week_of_month(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format='W', locale=locale)
+
+ assert week_number('2003-01-01') == '2003-W01-3'
+ assert week_number('2003-01-06') == '2003-W02-1'
+ assert week_number('2003-12-28') == '2003-W52-7'
+ assert week_number('2003-12-29') == '2004-W01-1'
+ assert week_number('2003-12-31') == '2004-W01-3'
+ assert week_number('2004-01-01') == '2004-W01-4'
+ assert week_number('2004-01-05') == '2004-W02-1'
+ assert week_number('2004-12-26') == '2004-W52-7'
+ assert week_number('2004-12-27') == '2005-W01-1'
+ assert week_number('2004-12-31') == '2005-W01-5'
+ assert week_number('2005-01-01') == '2005-W01-6'
+ assert week_number('2005-01-03') == '2005-W02-1'
+ assert week_number('2005-12-25') == '2005-W52-7'
+ assert week_number('2005-12-26') == '2006-W01-1'
+ assert week_number('2005-12-31') == '2006-W01-6'
+ assert week_number('2006-01-01') == '2006-W01-7'
+ assert week_number('2006-01-02') == '2006-W02-1'
+ assert week_number('2006-12-31') == '2006-W53-7'
+ assert week_number('2007-01-01') == '2007-W01-1'
+ assert week_number('2007-01-08') == '2007-W02-1'
+ assert week_number('2007-12-30') == '2007-W52-7'
+ assert week_number('2007-12-31') == '2008-W01-1'
+ assert week_number('2008-01-01') == '2008-W01-2'
+ assert week_number('2008-01-07') == '2008-W02-1'
+ assert week_number('2008-12-28') == '2008-W52-7'
+ assert week_number('2008-12-29') == '2009-W01-1'
+ assert week_number('2008-12-31') == '2009-W01-3'
+ assert week_number('2009-01-01') == '2009-W01-4'
+ assert week_number('2009-01-05') == '2009-W02-1'
+ assert week_number('2009-12-27') == '2009-W52-7'
+ assert week_number('2009-12-28') == '2010-W01-1'
+ assert week_number('2009-12-31') == '2010-W01-4'
+ assert week_number('2010-01-01') == '2010-W01-5'
+ assert week_number('2010-01-03') == '2010-W01-7'
+ assert week_number('2010-01-04') == '2010-W02-1'
+
+ assert week_of_month('2003-01-01') == '1' # Wed
+ assert week_of_month('2003-02-28') == '5'
+ assert week_of_month('2003-06-01') == '1' # Sun
+ assert week_of_month('2003-12-31') == '5'
+ assert week_of_month('2004-01-01') == '1' # Thu
+ assert week_of_month('2004-02-29') == '5'
+
+
+def test_week_numbering_sunday_mindays_1():
+ locale = Locale.parse('en_US')
+ assert locale.first_week_day == 6
+ assert locale.min_week_days == 1
+
+ def week_number(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format="YYYY-'W'ww-e", locale=locale)
+
+ def week_of_month(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format='W', locale=locale)
+
+ assert week_number('2003-01-01') == '2003-W01-4'
+ assert week_number('2003-01-05') == '2003-W02-1'
+ assert week_number('2003-12-27') == '2003-W52-7'
+ assert week_number('2003-12-28') == '2004-W01-1'
+ assert week_number('2003-12-31') == '2004-W01-4'
+ assert week_number('2004-01-01') == '2004-W01-5'
+ assert week_number('2004-01-04') == '2004-W02-1'
+ assert week_number('2004-12-25') == '2004-W52-7'
+ assert week_number('2004-12-26') == '2005-W01-1'
+ assert week_number('2004-12-31') == '2005-W01-6'
+ assert week_number('2005-01-01') == '2005-W01-7'
+ assert week_number('2005-01-02') == '2005-W02-1'
+ assert week_number('2005-12-24') == '2005-W52-7'
+ assert week_number('2005-12-25') == '2005-W53-1'
+ assert week_number('2005-12-31') == '2005-W53-7'
+ assert week_number('2006-01-01') == '2006-W01-1'
+ assert week_number('2006-01-08') == '2006-W02-1'
+ assert week_number('2006-12-30') == '2006-W52-7'
+ assert week_number('2006-12-31') == '2007-W01-1'
+ assert week_number('2007-01-01') == '2007-W01-2'
+ assert week_number('2007-01-07') == '2007-W02-1'
+ assert week_number('2007-12-29') == '2007-W52-7'
+ assert week_number('2007-12-30') == '2008-W01-1'
+ assert week_number('2007-12-31') == '2008-W01-2'
+ assert week_number('2008-01-01') == '2008-W01-3'
+ assert week_number('2008-01-06') == '2008-W02-1'
+ assert week_number('2008-12-27') == '2008-W52-7'
+ assert week_number('2008-12-28') == '2009-W01-1'
+ assert week_number('2008-12-31') == '2009-W01-4'
+ assert week_number('2009-01-01') == '2009-W01-5'
+ assert week_number('2009-01-04') == '2009-W02-1'
+ assert week_number('2009-12-26') == '2009-W52-7'
+ assert week_number('2009-12-27') == '2010-W01-1'
+ assert week_number('2009-12-31') == '2010-W01-5'
+ assert week_number('2010-01-01') == '2010-W01-6'
+ assert week_number('2010-01-03') == '2010-W02-1'
+
+ assert week_of_month('2003-01-01') == '1' # Wed
+ assert week_of_month('2003-02-01') == '1' # Sat
+ assert week_of_month('2003-02-28') == '5'
+ assert week_of_month('2003-12-31') == '5'
+ assert week_of_month('2004-01-01') == '1' # Thu
+ assert week_of_month('2004-02-29') == '5'
+
+
+def test_week_numbering_sunday_mindays_4():
+ locale = Locale.parse('pt_PT')
+ assert locale.first_week_day == 6
+ assert locale.min_week_days == 4
+
+ def week_number(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format="YYYY-'W'ww-e", locale=locale)
+
+ def week_of_month(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format='W', locale=locale)
+
+ assert week_number('2003-01-01') == '2003-W01-4'
+ assert week_number('2003-01-05') == '2003-W02-1'
+ assert week_number('2003-12-27') == '2003-W52-7'
+ assert week_number('2003-12-28') == '2003-W53-1'
+ assert week_number('2003-12-31') == '2003-W53-4'
+ assert week_number('2004-01-01') == '2003-W53-5'
+ assert week_number('2004-01-04') == '2004-W01-1'
+ assert week_number('2004-12-25') == '2004-W51-7'
+ assert week_number('2004-12-26') == '2004-W52-1'
+ assert week_number('2004-12-31') == '2004-W52-6'
+ assert week_number('2005-01-01') == '2004-W52-7'
+ assert week_number('2005-01-02') == '2005-W01-1'
+ assert week_number('2005-12-24') == '2005-W51-7'
+ assert week_number('2005-12-25') == '2005-W52-1'
+ assert week_number('2005-12-31') == '2005-W52-7'
+ assert week_number('2006-01-01') == '2006-W01-1'
+ assert week_number('2006-01-08') == '2006-W02-1'
+ assert week_number('2006-12-30') == '2006-W52-7'
+ assert week_number('2006-12-31') == '2007-W01-1'
+ assert week_number('2007-01-01') == '2007-W01-2'
+ assert week_number('2007-01-07') == '2007-W02-1'
+ assert week_number('2007-12-29') == '2007-W52-7'
+ assert week_number('2007-12-30') == '2008-W01-1'
+ assert week_number('2007-12-31') == '2008-W01-2'
+ assert week_number('2008-01-01') == '2008-W01-3'
+ assert week_number('2008-01-06') == '2008-W02-1'
+ assert week_number('2008-12-27') == '2008-W52-7'
+ assert week_number('2008-12-28') == '2008-W53-1'
+ assert week_number('2008-12-31') == '2008-W53-4'
+ assert week_number('2009-01-01') == '2008-W53-5'
+ assert week_number('2009-01-04') == '2009-W01-1'
+ assert week_number('2009-12-26') == '2009-W51-7'
+ assert week_number('2009-12-27') == '2009-W52-1'
+ assert week_number('2009-12-31') == '2009-W52-5'
+ assert week_number('2010-01-01') == '2009-W52-6'
+ assert week_number('2010-01-03') == '2010-W01-1'
+
+ assert week_of_month('2003-01-01') == '1' # Wed
+ assert week_of_month('2003-02-28') == '4'
+ assert week_of_month('2003-05-01') == '0' # Thu
+ assert week_of_month('2003-12-31') == '5'
+ assert week_of_month('2004-01-01') == '0' # Thu
+ assert week_of_month('2004-02-29') == '5'
+
+
+def test_week_numbering_friday_mindays_1():
+ locale = Locale.parse('dv_MV')
+ assert locale.first_week_day == 4
+ assert locale.min_week_days == 1
+
+ def week_number(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format="YYYY-'W'ww-e", locale=locale)
+
+ def week_of_month(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format='W', locale=locale)
+
+ assert week_number('2003-01-01') == '2003-W01-6'
+ assert week_number('2003-01-03') == '2003-W02-1'
+ assert week_number('2003-12-25') == '2003-W52-7'
+ assert week_number('2003-12-26') == '2004-W01-1'
+ assert week_number('2003-12-31') == '2004-W01-6'
+ assert week_number('2004-01-01') == '2004-W01-7'
+ assert week_number('2004-01-02') == '2004-W02-1'
+ assert week_number('2004-12-23') == '2004-W52-7'
+ assert week_number('2004-12-24') == '2004-W53-1'
+ assert week_number('2004-12-30') == '2004-W53-7'
+ assert week_number('2004-12-31') == '2005-W01-1'
+ assert week_number('2005-01-01') == '2005-W01-2'
+ assert week_number('2005-01-07') == '2005-W02-1'
+ assert week_number('2005-12-29') == '2005-W52-7'
+ assert week_number('2005-12-30') == '2006-W01-1'
+ assert week_number('2005-12-31') == '2006-W01-2'
+ assert week_number('2006-01-01') == '2006-W01-3'
+ assert week_number('2006-01-06') == '2006-W02-1'
+ assert week_number('2006-12-28') == '2006-W52-7'
+ assert week_number('2006-12-29') == '2007-W01-1'
+ assert week_number('2006-12-31') == '2007-W01-3'
+ assert week_number('2007-01-01') == '2007-W01-4'
+ assert week_number('2007-01-05') == '2007-W02-1'
+ assert week_number('2007-12-27') == '2007-W52-7'
+ assert week_number('2007-12-28') == '2008-W01-1'
+ assert week_number('2007-12-31') == '2008-W01-4'
+ assert week_number('2008-01-01') == '2008-W01-5'
+ assert week_number('2008-01-04') == '2008-W02-1'
+ assert week_number('2008-12-25') == '2008-W52-7'
+ assert week_number('2008-12-26') == '2009-W01-1'
+ assert week_number('2008-12-31') == '2009-W01-6'
+ assert week_number('2009-01-01') == '2009-W01-7'
+ assert week_number('2009-01-02') == '2009-W02-1'
+ assert week_number('2009-12-24') == '2009-W52-7'
+ assert week_number('2009-12-25') == '2009-W53-1'
+ assert week_number('2009-12-31') == '2009-W53-7'
+ assert week_number('2010-01-01') == '2010-W01-1'
+ assert week_number('2010-01-08') == '2010-W02-1'
+
+ assert week_of_month('2003-01-01') == '1' # Wed
+ assert week_of_month('2003-02-28') == '5'
+ assert week_of_month('2003-03-01') == '1' # Sun
+ assert week_of_month('2003-12-31') == '5'
+ assert week_of_month('2004-01-01') == '1' # Thu
+ assert week_of_month('2004-02-29') == '5'
+
+
+def test_week_numbering_saturday_mindays_1():
+ locale = Locale.parse('fr_DZ')
+ assert locale.first_week_day == 5
+ assert locale.min_week_days == 1
+
+ def week_number(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format="YYYY-'W'ww-e", locale=locale)
+
+ def week_of_month(value):
+ value = date.fromisoformat(value)
+ return dates.format_date(value, format='W', locale=locale)
+
+ assert week_number('2003-01-01') == '2003-W01-5'
+ assert week_number('2003-01-04') == '2003-W02-1'
+ assert week_number('2003-12-26') == '2003-W52-7'
+ assert week_number('2003-12-27') == '2004-W01-1'
+ assert week_number('2003-12-31') == '2004-W01-5'
+ assert week_number('2004-01-01') == '2004-W01-6'
+ assert week_number('2004-01-03') == '2004-W02-1'
+ assert week_number('2004-12-24') == '2004-W52-7'
+ assert week_number('2004-12-31') == '2004-W53-7'
+ assert week_number('2005-01-01') == '2005-W01-1'
+ assert week_number('2005-01-08') == '2005-W02-1'
+ assert week_number('2005-12-30') == '2005-W52-7'
+ assert week_number('2005-12-31') == '2006-W01-1'
+ assert week_number('2006-01-01') == '2006-W01-2'
+ assert week_number('2006-01-07') == '2006-W02-1'
+ assert week_number('2006-12-29') == '2006-W52-7'
+ assert week_number('2006-12-31') == '2007-W01-2'
+ assert week_number('2007-01-01') == '2007-W01-3'
+ assert week_number('2007-01-06') == '2007-W02-1'
+ assert week_number('2007-12-28') == '2007-W52-7'
+ assert week_number('2007-12-31') == '2008-W01-3'
+ assert week_number('2008-01-01') == '2008-W01-4'
+ assert week_number('2008-01-05') == '2008-W02-1'
+ assert week_number('2008-12-26') == '2008-W52-7'
+ assert week_number('2008-12-27') == '2009-W01-1'
+ assert week_number('2008-12-31') == '2009-W01-5'
+ assert week_number('2009-01-01') == '2009-W01-6'
+ assert week_number('2009-01-03') == '2009-W02-1'
+ assert week_number('2009-12-25') == '2009-W52-7'
+ assert week_number('2009-12-26') == '2010-W01-1'
+ assert week_number('2009-12-31') == '2010-W01-6'
+ assert week_number('2010-01-01') == '2010-W01-7'
+ assert week_number('2010-01-02') == '2010-W02-1'
+
+ assert week_of_month('2003-01-01') == '1' # Wed
+ assert week_of_month('2003-02-28') == '4'
+ assert week_of_month('2003-08-01') == '1' # Fri
+ assert week_of_month('2003-12-31') == '5'
+ assert week_of_month('2004-01-01') == '1' # Thu
+ assert week_of_month('2004-02-29') == '5'
+
+
def test_en_gb_first_weekday():
assert Locale.parse('en').first_week_day == 0 # Monday in general
assert Locale.parse('en_US').first_week_day == 6 # Sunday in the US
@@ -754,5 +1164,26 @@ def test_issue_892():
def test_issue_1089():
- assert dates.format_datetime(datetime.utcnow(), locale="ja_JP@mod")
- assert dates.format_datetime(datetime.utcnow(), locale=Locale.parse("ja_JP@mod"))
+ assert dates.format_datetime(datetime.now(), locale="ja_JP@mod")
+ assert dates.format_datetime(datetime.now(), locale=Locale.parse("ja_JP@mod"))
+
+
+@pytest.mark.parametrize(('locale', 'format', 'negative', 'expected'), [
+ ('en_US', 'long', False, 'in 3 hours'),
+ ('en_US', 'long', True, '3 hours ago'),
+ ('en_US', 'narrow', False, 'in 3h'),
+ ('en_US', 'narrow', True, '3h ago'),
+ ('en_US', 'short', False, 'in 3 hr.'),
+ ('en_US', 'short', True, '3 hr. ago'),
+ ('fi_FI', 'long', False, '3 tunnin päästä'),
+ ('fi_FI', 'long', True, '3 tuntia sitten'),
+ ('fi_FI', 'short', False, '3 t päästä'),
+ ('fi_FI', 'short', True, '3 t sitten'),
+ ('sv_SE', 'long', False, 'om 3 timmar'),
+ ('sv_SE', 'long', True, 'för 3 timmar sedan'),
+ ('sv_SE', 'short', False, 'om 3 tim'),
+ ('sv_SE', 'short', True, 'för 3 tim sedan'),
+])
+def test_issue_1162(locale, format, negative, expected):
+ delta = timedelta(seconds=10800) * (-1 if negative else +1)
+ assert dates.format_timedelta(delta, add_direction=True, format=format, locale=locale) == expected
diff --git a/tests/test_lists.py b/tests/test_lists.py
index 46ca10d02..ef522f223 100644
--- a/tests/test_lists.py
+++ b/tests/test_lists.py
@@ -30,3 +30,8 @@ def test_issue_1098():
# Translation verified using Google Translate. It would add more spacing, but the glyphs are correct.
"1英尺5英寸"
)
+
+
+def test_lists_default_locale_deprecation():
+ with pytest.warns(DeprecationWarning):
+ _ = lists.DEFAULT_LOCALE
diff --git a/tests/test_localedata.py b/tests/test_localedata.py
index 721b91fba..6911cbdcf 100644
--- a/tests/test_localedata.py
+++ b/tests/test_localedata.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import os
import pickle
diff --git a/tests/test_localtime.py b/tests/test_localtime.py
index 723ffa0b0..a7eaba8f7 100644
--- a/tests/test_localtime.py
+++ b/tests/test_localtime.py
@@ -1,4 +1,6 @@
+import os
import sys
+from unittest.mock import Mock
import pytest
@@ -27,3 +29,16 @@ def test_issue_1092_with_pytz(monkeypatch):
monkeypatch.setenv("TZ", "/UTC") # Malformed timezone name.
with pytest.raises(LookupError):
get_localzone()
+
+
+@pytest.mark.skipif(
+ sys.platform == "win32",
+ reason="Issue 990 is not applicable on Windows",
+)
+def test_issue_990(monkeypatch):
+ monkeypatch.setenv("TZ", "")
+ fake_readlink = Mock(return_value="/usr/share/zoneinfo////UTC") # Double slash, oops!
+ monkeypatch.setattr(os, "readlink", fake_readlink)
+ from babel.localtime._unix import _get_localzone
+ assert _get_localzone() is not None
+ fake_readlink.assert_called_with("/etc/localtime")
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index a96bdbeba..e9c216620 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import decimal
import unittest
@@ -52,7 +52,6 @@ def test_default_rounding(self):
"""
assert numbers.format_decimal(5.5, '0', locale='sv') == '6'
assert numbers.format_decimal(6.5, '0', locale='sv') == '6'
- assert numbers.format_decimal(6.5, '0', locale='sv') == '6'
assert numbers.format_decimal(1.2325, locale='sv') == '1,232'
assert numbers.format_decimal(1.2335, locale='sv') == '1,234'
@@ -189,7 +188,8 @@ class NumberParsingTestCase(unittest.TestCase):
def test_can_parse_decimals(self):
assert decimal.Decimal('1099.98') == numbers.parse_decimal('1,099.98', locale='en_US')
assert decimal.Decimal('1099.98') == numbers.parse_decimal('1.099,98', locale='de')
- assert decimal.Decimal('1099.98') == numbers.parse_decimal('1٬099٫98', locale='ar', numbering_system="default")
+ assert decimal.Decimal('1099.98') == numbers.parse_decimal('1,099.98', locale='ar', numbering_system="default")
+ assert decimal.Decimal('1099.98') == numbers.parse_decimal('1٬099٫98', locale='ar_EG', numbering_system="default")
with pytest.raises(numbers.NumberFormatError):
numbers.parse_decimal('2,109,998', locale='de')
with pytest.raises(numbers.UnsupportedNumberingSystemError):
@@ -244,21 +244,19 @@ def test_list_currencies():
assert isinstance(list_currencies(locale='fr'), set)
assert list_currencies('fr').issuperset(['BAD', 'BAM', 'KRO'])
- with pytest.raises(ValueError) as excinfo:
+ with pytest.raises(ValueError, match="expected only letters, got 'yo!'"):
list_currencies('yo!')
- assert excinfo.value.args[0] == "expected only letters, got 'yo!'"
assert list_currencies(locale='pa_Arab') == {'PKR', 'INR', 'EUR'}
- assert len(list_currencies()) == 306
+ assert len(list_currencies()) == 307
def test_validate_currency():
validate_currency('EUR')
- with pytest.raises(UnknownCurrencyError) as excinfo:
+ with pytest.raises(UnknownCurrencyError, match="Unknown currency 'FUU'."):
validate_currency('FUU')
- assert excinfo.value.args[0] == "Unknown currency 'FUU'."
def test_is_currency():
@@ -301,7 +299,7 @@ def test_get_currency_precision():
def test_get_currency_unit_pattern():
assert get_currency_unit_pattern('USD', locale='en_US') == '{0} {1}'
- assert get_currency_unit_pattern('USD', locale='es_GT') == '{1} {0}'
+ assert get_currency_unit_pattern('USD', locale='sw') == '{1} {0}'
# 'ro' locale various pattern according to count
assert get_currency_unit_pattern('USD', locale='ro', count=1) == '{0} {1}'
@@ -422,6 +420,7 @@ def test_format_decimal():
with pytest.raises(numbers.UnsupportedNumberingSystemError):
numbers.format_decimal(12345.5, locale='en_US', numbering_system="unknown")
+
@pytest.mark.parametrize('input_value, expected_value', [
('10000', '10,000'),
('1', '1'),
@@ -501,10 +500,8 @@ def test_format_currency_format_type():
format_type="accounting")
== '$0.00')
- with pytest.raises(numbers.UnknownCurrencyFormatError) as excinfo:
- numbers.format_currency(1099.98, 'USD', locale='en_US',
- format_type='unknown')
- assert excinfo.value.args[0] == "'unknown' is not a known currency format type"
+ with pytest.raises(numbers.UnknownCurrencyFormatError, match="'unknown' is not a known currency format type"):
+ numbers.format_currency(1099.98, 'USD', locale='en_US', format_type='unknown')
assert (numbers.format_currency(1099.98, 'JPY', locale='en_US')
== '\xa51,100')
@@ -599,7 +596,7 @@ def test_format_currency_long_display_name():
== '1.00 dola ya Marekani')
# This tests unicode chars:
assert (numbers.format_currency(1099.98, 'USD', locale='es_GT', format_type='name')
- == 'dólares estadounidenses 1,099.98')
+ == '1,099.98 dólares estadounidenses')
# Test for completely unknown currency, should fallback to currency code
assert (numbers.format_currency(1099.98, 'XAB', locale='en_US', format_type='name')
== '1,099.98 XAB')
@@ -744,13 +741,13 @@ def test_parse_number():
assert numbers.parse_number('1.099', locale='de_DE') == 1099
assert numbers.parse_number('1٬099', locale='ar_EG', numbering_system="default") == 1099
- with pytest.raises(numbers.NumberFormatError) as excinfo:
+ with pytest.raises(numbers.NumberFormatError, match="'1.099,98' is not a valid number"):
numbers.parse_number('1.099,98', locale='de')
- assert excinfo.value.args[0] == "'1.099,98' is not a valid number"
with pytest.raises(numbers.UnsupportedNumberingSystemError):
numbers.parse_number('1.099,98', locale='en', numbering_system="unsupported")
+
@pytest.mark.parametrize('string', [
'1 099',
'1\xa0099',
@@ -765,9 +762,8 @@ def test_parse_decimal():
== decimal.Decimal('1099.98'))
assert numbers.parse_decimal('1.099,98', locale='de') == decimal.Decimal('1099.98')
- with pytest.raises(numbers.NumberFormatError) as excinfo:
+ with pytest.raises(numbers.NumberFormatError, match="'2,109,998' is not a valid decimal number"):
numbers.parse_decimal('2,109,998', locale='de')
- assert excinfo.value.args[0] == "'2,109,998' is not a valid decimal number"
@pytest.mark.parametrize('string', [
@@ -858,3 +854,31 @@ def test_single_quotes_in_pattern():
assert numbers.format_decimal(123, "'$'''0", locale='en') == "$'123"
assert numbers.format_decimal(12, "'#'0 o''clock", locale='en') == "#12 o'clock"
+
+
+def test_format_currency_with_none_locale_with_default(monkeypatch):
+ """Test that the default locale is used when locale is None."""
+ monkeypatch.setattr(numbers, "LC_MONETARY", "fi_FI")
+ monkeypatch.setattr(numbers, "LC_NUMERIC", None)
+ assert numbers.format_currency(0, "USD", locale=None) == "0,00\xa0$"
+
+
+def test_format_currency_with_none_locale(monkeypatch):
+ """Test that the API raises the "Empty locale identifier" error when locale is None, and the default is too."""
+ monkeypatch.setattr(numbers, "LC_MONETARY", None) # Pretend we couldn't find any locale when importing the module
+ with pytest.raises(TypeError, match="Empty"):
+ numbers.format_currency(0, "USD", locale=None)
+
+
+def test_format_decimal_with_none_locale_with_default(monkeypatch):
+ """Test that the default locale is used when locale is None."""
+ monkeypatch.setattr(numbers, "LC_NUMERIC", "fi_FI")
+ monkeypatch.setattr(numbers, "LC_MONETARY", None)
+ assert numbers.format_decimal("1.23", locale=None) == "1,23"
+
+
+def test_format_decimal_with_none_locale(monkeypatch):
+ """Test that the API raises the "Empty locale identifier" error when locale is None, and the default is too."""
+ monkeypatch.setattr(numbers, "LC_NUMERIC", None) # Pretend we couldn't find any locale when importing the module
+ with pytest.raises(TypeError, match="Empty"):
+ numbers.format_decimal(0, locale=None)
diff --git a/tests/test_plural.py b/tests/test_plural.py
index b10ccf5ad..83f881b23 100644
--- a/tests/test_plural.py
+++ b/tests/test_plural.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import decimal
import unittest
diff --git a/tests/test_support.py b/tests/test_support.py
deleted file mode 100644
index 366c29412..000000000
--- a/tests/test_support.py
+++ /dev/null
@@ -1,395 +0,0 @@
-#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
-# All rights reserved.
-#
-# This software is licensed as described in the file LICENSE, which
-# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
-#
-# This software consists of voluntary contributions made by many
-# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
-
-import datetime
-import inspect
-import os
-import shutil
-import sys
-import tempfile
-import unittest
-from decimal import Decimal
-from io import BytesIO
-
-import pytest
-
-from babel import support
-from babel.messages import Catalog
-from babel.messages.mofile import write_mo
-
-SKIP_LGETTEXT = sys.version_info >= (3, 8)
-
-
-@pytest.mark.usefixtures("os_environ")
-class TranslationsTestCase(unittest.TestCase):
-
- def setUp(self):
- # Use a locale which won't fail to run the tests
- os.environ['LANG'] = 'en_US.UTF-8'
- messages1 = [
- ('foo', {'string': 'Voh'}),
- ('foo', {'string': 'VohCTX', 'context': 'foo'}),
- (('foo1', 'foos1'), {'string': ('Voh1', 'Vohs1')}),
- (('foo1', 'foos1'), {'string': ('VohCTX1', 'VohsCTX1'), 'context': 'foo'}),
- ]
- messages2 = [
- ('foo', {'string': 'VohD'}),
- ('foo', {'string': 'VohCTXD', 'context': 'foo'}),
- (('foo1', 'foos1'), {'string': ('VohD1', 'VohsD1')}),
- (('foo1', 'foos1'), {'string': ('VohCTXD1', 'VohsCTXD1'), 'context': 'foo'}),
- ]
- catalog1 = Catalog(locale='en_GB', domain='messages')
- catalog2 = Catalog(locale='en_GB', domain='messages1')
- for ids, kwargs in messages1:
- catalog1.add(ids, **kwargs)
- for ids, kwargs in messages2:
- catalog2.add(ids, **kwargs)
- catalog1_fp = BytesIO()
- catalog2_fp = BytesIO()
- write_mo(catalog1_fp, catalog1)
- catalog1_fp.seek(0)
- write_mo(catalog2_fp, catalog2)
- catalog2_fp.seek(0)
- translations1 = support.Translations(catalog1_fp)
- translations2 = support.Translations(catalog2_fp, domain='messages1')
- self.translations = translations1.add(translations2, merge=False)
-
- def assertEqualTypeToo(self, expected, result):
- assert expected == result
- assert type(expected) == type(result), f"instance types do not match: {type(expected)!r}!={type(result)!r}"
-
- def test_pgettext(self):
- self.assertEqualTypeToo('Voh', self.translations.gettext('foo'))
- self.assertEqualTypeToo('VohCTX', self.translations.pgettext('foo',
- 'foo'))
- self.assertEqualTypeToo('VohCTX1', self.translations.pgettext('foo',
- 'foo1'))
-
- def test_pgettext_fallback(self):
- fallback = self.translations._fallback
- self.translations._fallback = support.NullTranslations()
- assert self.translations.pgettext('foo', 'bar') == 'bar'
- self.translations._fallback = fallback
-
- def test_upgettext(self):
- self.assertEqualTypeToo('Voh', self.translations.ugettext('foo'))
- self.assertEqualTypeToo('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',
- 'foo'))
-
- def test_npgettext(self):
- self.assertEqualTypeToo('Voh1',
- self.translations.ngettext('foo1', 'foos1', 1))
- self.assertEqualTypeToo('Vohs1',
- self.translations.ngettext('foo1', 'foos1', 2))
- self.assertEqualTypeToo('VohCTX1',
- self.translations.npgettext('foo', 'foo1',
- 'foos1', 1))
- self.assertEqualTypeToo('VohsCTX1',
- self.translations.npgettext('foo', 'foo1',
- 'foos1', 2))
-
- def test_unpgettext(self):
- self.assertEqualTypeToo('Voh1',
- self.translations.ungettext('foo1', 'foos1', 1))
- self.assertEqualTypeToo('Vohs1',
- self.translations.ungettext('foo1', 'foos1', 2))
- self.assertEqualTypeToo('VohCTX1',
- self.translations.unpgettext('foo', 'foo1',
- 'foos1', 1))
- self.assertEqualTypeToo('VohsCTX1',
- 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))
- self.assertEqualTypeToo(b'Vohs1',
- self.translations.lngettext('foo1', 'foos1', 2))
- self.assertEqualTypeToo(b'VohCTX1',
- self.translations.lnpgettext('foo', 'foo1',
- 'foos1', 1))
- self.assertEqualTypeToo(b'VohsCTX1',
- self.translations.lnpgettext('foo', 'foo1',
- 'foos1', 2))
-
- def test_dpgettext(self):
- self.assertEqualTypeToo(
- 'VohD', self.translations.dgettext('messages1', 'foo'))
- self.assertEqualTypeToo(
- 'VohCTXD', self.translations.dpgettext('messages1', 'foo', 'foo'))
-
- def test_dupgettext(self):
- self.assertEqualTypeToo(
- 'VohD', self.translations.dugettext('messages1', 'foo'))
- self.assertEqualTypeToo(
- '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'))
- self.assertEqualTypeToo(
- b'VohCTXD', self.translations.ldpgettext('messages1', 'foo', 'foo'))
-
- def test_dnpgettext(self):
- self.assertEqualTypeToo(
- 'VohD1', self.translations.dngettext('messages1', 'foo1', 'foos1', 1))
- self.assertEqualTypeToo(
- 'VohsD1', self.translations.dngettext('messages1', 'foo1', 'foos1', 2))
- self.assertEqualTypeToo(
- 'VohCTXD1', self.translations.dnpgettext('messages1', 'foo', 'foo1',
- 'foos1', 1))
- self.assertEqualTypeToo(
- 'VohsCTXD1', self.translations.dnpgettext('messages1', 'foo', 'foo1',
- 'foos1', 2))
-
- def test_dunpgettext(self):
- self.assertEqualTypeToo(
- 'VohD1', self.translations.dungettext('messages1', 'foo1', 'foos1', 1))
- self.assertEqualTypeToo(
- 'VohsD1', self.translations.dungettext('messages1', 'foo1', 'foos1', 2))
- self.assertEqualTypeToo(
- 'VohCTXD1', self.translations.dunpgettext('messages1', 'foo', 'foo1',
- 'foos1', 1))
- self.assertEqualTypeToo(
- '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))
- self.assertEqualTypeToo(
- b'VohsD1', self.translations.ldngettext('messages1', 'foo1', 'foos1', 2))
- self.assertEqualTypeToo(
- b'VohCTXD1', self.translations.ldnpgettext('messages1', 'foo', 'foo1',
- 'foos1', 1))
- self.assertEqualTypeToo(
- b'VohsCTXD1', self.translations.ldnpgettext('messages1', 'foo', 'foo1',
- 'foos1', 2))
-
- def test_load(self):
- tempdir = tempfile.mkdtemp()
- try:
- messages_dir = os.path.join(tempdir, 'fr', 'LC_MESSAGES')
- os.makedirs(messages_dir)
- catalog = Catalog(locale='fr', domain='messages')
- catalog.add('foo', 'bar')
- with open(os.path.join(messages_dir, 'messages.mo'), 'wb') as f:
- write_mo(f, catalog)
-
- translations = support.Translations.load(tempdir, locales=('fr',), domain='messages')
- assert translations.gettext('foo') == 'bar'
- finally:
- shutil.rmtree(tempdir)
-
-
-class NullTranslationsTestCase(unittest.TestCase):
-
- def setUp(self):
- fp = BytesIO()
- write_mo(fp, Catalog(locale='de'))
- fp.seek(0)
- self.translations = support.Translations(fp=fp)
- self.null_translations = support.NullTranslations(fp=fp)
-
- def method_names(self):
- 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():
- if not hasattr(self.null_translations, name):
- self.fail(f"NullTranslations does not provide method {name!r}")
-
- def test_method_signature_compatibility(self):
- for name in self.method_names():
- translations_method = getattr(self.translations, name)
- null_method = getattr(self.null_translations, name)
- assert inspect.getfullargspec(translations_method) == inspect.getfullargspec(null_method)
-
- def test_same_return_values(self):
- data = {
- 'message': 'foo', 'domain': 'domain', 'context': 'tests',
- 'singular': 'bar', 'plural': 'baz', 'num': 1,
- 'msgid1': 'bar', 'msgid2': 'baz', 'n': 1,
- }
- for name in self.method_names():
- method = getattr(self.translations, name)
- null_method = getattr(self.null_translations, name)
- signature = inspect.getfullargspec(method)
- parameter_names = [name for name in signature.args if name != 'self']
- values = [data[name] for name in parameter_names]
- assert method(*values) == null_method(*values)
-
-
-class LazyProxyTestCase(unittest.TestCase):
-
- def test_proxy_caches_result_of_function_call(self):
- self.counter = 0
-
- def add_one():
- self.counter += 1
- return self.counter
- proxy = support.LazyProxy(add_one)
- assert proxy.value == 1
- assert proxy.value == 1
-
- def test_can_disable_proxy_cache(self):
- self.counter = 0
-
- def add_one():
- self.counter += 1
- return self.counter
- proxy = support.LazyProxy(add_one, enable_cache=False)
- assert proxy.value == 1
- assert proxy.value == 2
-
- def test_can_copy_proxy(self):
- from copy import copy
-
- numbers = [1, 2]
-
- def first(xs):
- return xs[0]
-
- proxy = support.LazyProxy(first, numbers)
- proxy_copy = copy(proxy)
-
- numbers.pop(0)
- assert proxy.value == 2
- assert proxy_copy.value == 2
-
- def test_can_deepcopy_proxy(self):
- from copy import deepcopy
- numbers = [1, 2]
-
- def first(xs):
- return xs[0]
-
- proxy = support.LazyProxy(first, numbers)
- proxy_deepcopy = deepcopy(proxy)
-
- numbers.pop(0)
- assert proxy.value == 2
- assert proxy_deepcopy.value == 1
-
- 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
-
- assert str(exception.value) == 'message'
-
-
-class TestFormat:
- def test_format_datetime(self, timezone_getter):
- when = datetime.datetime(2007, 4, 1, 15, 30)
- fmt = support.Format('en_US', tzinfo=timezone_getter('US/Eastern'))
- assert fmt.datetime(when) == 'Apr 1, 2007, 11:30:00\u202fAM'
-
- def test_format_time(self, timezone_getter):
- when = datetime.datetime(2007, 4, 1, 15, 30)
- fmt = support.Format('en_US', tzinfo=timezone_getter('US/Eastern'))
- assert fmt.time(when) == '11:30:00\u202fAM'
-
- def test_format_number(self):
- assert support.Format('en_US').number(1234) == '1,234'
- assert support.Format('ar_EG', numbering_system="default").number(1234) == '1٬234'
-
- def test_format_decimal(self):
- assert support.Format('en_US').decimal(1234.5) == '1,234.5'
- assert support.Format('en_US').decimal(Decimal("1234.5")) == '1,234.5'
- assert support.Format('ar_EG', numbering_system="default").decimal(1234.5) == '1٬234٫5'
- assert support.Format('ar_EG', numbering_system="default").decimal(Decimal("1234.5")) == '1٬234٫5'
-
- def test_format_compact_decimal(self):
- assert support.Format('en_US').compact_decimal(1234) == '1K'
- assert support.Format('ar_EG', numbering_system="default").compact_decimal(
- 1234, fraction_digits=1) == '1٫2\xa0ألف'
- assert support.Format('ar_EG', numbering_system="default").compact_decimal(
- Decimal("1234"), fraction_digits=1) == '1٫2\xa0ألف'
-
- def test_format_currency(self):
- assert support.Format('en_US').currency(1099.98, 'USD') == '$1,099.98'
- assert support.Format('en_US').currency(Decimal("1099.98"), 'USD') == '$1,099.98'
- assert support.Format('ar_EG', numbering_system="default").currency(
- 1099.98, 'EGP') == '\u200f1٬099٫98\xa0ج.م.\u200f'
-
- def test_format_compact_currency(self):
- assert support.Format('en_US').compact_currency(1099.98, 'USD') == '$1K'
- assert support.Format('en_US').compact_currency(Decimal("1099.98"), 'USD') == '$1K'
- assert support.Format('ar_EG', numbering_system="default").compact_currency(
- 1099.98, 'EGP') == '1\xa0ألف\xa0ج.م.\u200f'
-
- def test_format_percent(self):
- assert support.Format('en_US').percent(0.34) == '34%'
- assert support.Format('en_US').percent(Decimal("0.34")) == '34%'
- assert support.Format('ar_EG', numbering_system="default").percent(134.5) == '13٬450%'
-
- def test_format_scientific(self):
- assert support.Format('en_US').scientific(10000) == '1E4'
- assert support.Format('en_US').scientific(Decimal("10000")) == '1E4'
- assert support.Format('ar_EG', numbering_system="default").scientific(10000) == '1أس4'
-
-
-def test_lazy_proxy():
- def greeting(name='world'):
- return f"Hello, {name}!"
-
- lazy_greeting = support.LazyProxy(greeting, name='Joe')
- assert str(lazy_greeting) == "Hello, Joe!"
- assert ' ' + lazy_greeting == ' Hello, Joe!'
- assert '(%s)' % lazy_greeting == '(Hello, Joe!)'
- assert f"[{lazy_greeting}]" == "[Hello, Joe!]"
-
- greetings = sorted([
- support.LazyProxy(greeting, 'world'),
- support.LazyProxy(greeting, 'Joe'),
- support.LazyProxy(greeting, 'universe'),
- ])
- assert [str(g) for g in greetings] == [
- "Hello, Joe!",
- "Hello, universe!",
- "Hello, world!",
- ]
-
-
-def test_catalog_merge_files():
- # Refs issues #92, #162
- t1 = support.Translations()
- assert t1.files == []
- t1._catalog["foo"] = "bar"
- fp = BytesIO()
- write_mo(fp, Catalog())
- fp.seek(0)
- fp.name = "pro.mo"
- t2 = support.Translations(fp)
- assert t2.files == ["pro.mo"]
- t2._catalog["bar"] = "quux"
- t1.merge(t2)
- assert t1.files == ["pro.mo"]
- assert set(t1._catalog.keys()) == {'', 'foo', 'bar'}
diff --git a/tests/test_support_format.py b/tests/test_support_format.py
new file mode 100644
index 000000000..fdfac844b
--- /dev/null
+++ b/tests/test_support_format.py
@@ -0,0 +1,68 @@
+import datetime
+from decimal import Decimal
+
+import pytest
+
+from babel import support
+
+
+@pytest.fixture
+def ar_eg_format() -> support.Format:
+ return support.Format('ar_EG', numbering_system="default")
+
+
+@pytest.fixture
+def en_us_format(timezone_getter) -> support.Format:
+ return support.Format('en_US', tzinfo=timezone_getter('US/Eastern'))
+
+
+def test_format_datetime(en_us_format):
+ when = datetime.datetime(2007, 4, 1, 15, 30)
+ assert en_us_format.datetime(when) == 'Apr 1, 2007, 11:30:00\u202fAM'
+
+
+def test_format_time(en_us_format):
+ when = datetime.datetime(2007, 4, 1, 15, 30)
+ assert en_us_format.time(when) == '11:30:00\u202fAM'
+
+
+def test_format_number(ar_eg_format, en_us_format):
+ assert en_us_format.number(1234) == '1,234'
+ assert ar_eg_format.number(1234) == '1٬234'
+
+
+def test_format_decimal(ar_eg_format, en_us_format):
+ assert en_us_format.decimal(1234.5) == '1,234.5'
+ assert en_us_format.decimal(Decimal("1234.5")) == '1,234.5'
+ assert ar_eg_format.decimal(1234.5) == '1٬234٫5'
+ assert ar_eg_format.decimal(Decimal("1234.5")) == '1٬234٫5'
+
+
+def test_format_compact_decimal(ar_eg_format, en_us_format):
+ assert en_us_format.compact_decimal(1234) == '1K'
+ assert ar_eg_format.compact_decimal(1234, fraction_digits=1) == '1٫2\xa0ألف'
+ assert ar_eg_format.compact_decimal(Decimal("1234"), fraction_digits=1) == '1٫2\xa0ألف'
+
+
+def test_format_currency(ar_eg_format, en_us_format):
+ assert en_us_format.currency(1099.98, 'USD') == '$1,099.98'
+ assert en_us_format.currency(Decimal("1099.98"), 'USD') == '$1,099.98'
+ assert ar_eg_format.currency(1099.98, 'EGP') == '\u200f1٬099٫98\xa0ج.م.\u200f'
+
+
+def test_format_compact_currency(ar_eg_format, en_us_format):
+ assert en_us_format.compact_currency(1099.98, 'USD') == '$1K'
+ assert en_us_format.compact_currency(Decimal("1099.98"), 'USD') == '$1K'
+ assert ar_eg_format.compact_currency(1099.98, 'EGP') == '1\xa0ألف\xa0ج.م.\u200f'
+
+
+def test_format_percent(ar_eg_format, en_us_format):
+ assert en_us_format.percent(0.34) == '34%'
+ assert en_us_format.percent(Decimal("0.34")) == '34%'
+ assert ar_eg_format.percent(134.5) == '13٬450%'
+
+
+def test_format_scientific(ar_eg_format, en_us_format):
+ assert en_us_format.scientific(10000) == '1E4'
+ assert en_us_format.scientific(Decimal("10000")) == '1E4'
+ assert ar_eg_format.scientific(10000) == '1أس4'
diff --git a/tests/test_support_lazy_proxy.py b/tests/test_support_lazy_proxy.py
new file mode 100644
index 000000000..8445f71fd
--- /dev/null
+++ b/tests/test_support_lazy_proxy.py
@@ -0,0 +1,80 @@
+import copy
+
+import pytest
+
+from babel import support
+
+
+def test_proxy_caches_result_of_function_call():
+ counter = 0
+
+ def add_one():
+ nonlocal counter
+ counter += 1
+ return counter
+
+ proxy = support.LazyProxy(add_one)
+ assert proxy.value == 1
+ assert proxy.value == 1
+
+
+def test_can_disable_proxy_cache():
+ counter = 0
+
+ def add_one():
+ nonlocal counter
+ counter += 1
+ return counter
+
+ proxy = support.LazyProxy(add_one, enable_cache=False)
+ assert proxy.value == 1
+ assert proxy.value == 2
+
+
+@pytest.mark.parametrize(("copier", "expected_copy_value"), [
+ (copy.copy, 2),
+ (copy.deepcopy, 1),
+])
+def test_can_copy_proxy(copier, expected_copy_value):
+ numbers = [1, 2]
+
+ def first(xs):
+ return xs[0]
+
+ proxy = support.LazyProxy(first, numbers)
+ proxy_copy = copier(proxy)
+
+ numbers.pop(0)
+ assert proxy.value == 2
+ assert proxy_copy.value == expected_copy_value
+
+
+def test_handle_attribute_error():
+ def raise_attribute_error():
+ raise AttributeError('message')
+
+ proxy = support.LazyProxy(raise_attribute_error)
+ with pytest.raises(AttributeError, match='message'):
+ _ = proxy.value
+
+
+def test_lazy_proxy():
+ def greeting(name='world'):
+ return f"Hello, {name}!"
+
+ lazy_greeting = support.LazyProxy(greeting, name='Joe')
+ assert str(lazy_greeting) == "Hello, Joe!"
+ assert ' ' + lazy_greeting == ' Hello, Joe!'
+ assert '(%s)' % lazy_greeting == '(Hello, Joe!)'
+ assert f"[{lazy_greeting}]" == "[Hello, Joe!]"
+
+ greetings = sorted([
+ support.LazyProxy(greeting, 'world'),
+ support.LazyProxy(greeting, 'Joe'),
+ support.LazyProxy(greeting, 'universe'),
+ ])
+ assert [str(g) for g in greetings] == [
+ "Hello, Joe!",
+ "Hello, universe!",
+ "Hello, world!",
+ ]
diff --git a/tests/test_support_translations.py b/tests/test_support_translations.py
new file mode 100644
index 000000000..7e6dc59f9
--- /dev/null
+++ b/tests/test_support_translations.py
@@ -0,0 +1,235 @@
+import inspect
+import io
+import os
+import shutil
+import sys
+import tempfile
+
+import pytest
+
+from babel import support
+from babel.messages import Catalog
+from babel.messages.mofile import write_mo
+
+SKIP_LGETTEXT = sys.version_info >= (3, 8)
+
+messages1 = [
+ ('foo', {'string': 'Voh'}),
+ ('foo', {'string': 'VohCTX', 'context': 'foo'}),
+ (('foo1', 'foos1'), {'string': ('Voh1', 'Vohs1')}),
+ (('foo1', 'foos1'), {'string': ('VohCTX1', 'VohsCTX1'), 'context': 'foo'}),
+]
+
+messages2 = [
+ ('foo', {'string': 'VohD'}),
+ ('foo', {'string': 'VohCTXD', 'context': 'foo'}),
+ (('foo1', 'foos1'), {'string': ('VohD1', 'VohsD1')}),
+ (('foo1', 'foos1'), {'string': ('VohCTXD1', 'VohsCTXD1'), 'context': 'foo'}),
+]
+
+
+@pytest.fixture(autouse=True)
+def use_en_us_locale(monkeypatch):
+ # Use a locale which won't fail to run the tests
+ monkeypatch.setenv('LANG', 'en_US.UTF-8')
+
+
+@pytest.fixture()
+def translations() -> support.Translations:
+ catalog1 = Catalog(locale='en_GB', domain='messages')
+ catalog2 = Catalog(locale='en_GB', domain='messages1')
+ for ids, kwargs in messages1:
+ catalog1.add(ids, **kwargs)
+ for ids, kwargs in messages2:
+ catalog2.add(ids, **kwargs)
+ catalog1_fp = io.BytesIO()
+ catalog2_fp = io.BytesIO()
+ write_mo(catalog1_fp, catalog1)
+ catalog1_fp.seek(0)
+ write_mo(catalog2_fp, catalog2)
+ catalog2_fp.seek(0)
+ translations1 = support.Translations(catalog1_fp)
+ translations2 = support.Translations(catalog2_fp, domain='messages1')
+ return translations1.add(translations2, merge=False)
+
+
+@pytest.fixture(scope='module')
+def empty_translations() -> support.Translations:
+ fp = io.BytesIO()
+ write_mo(fp, Catalog(locale='de'))
+ fp.seek(0)
+ return support.Translations(fp=fp)
+
+
+@pytest.fixture(scope='module')
+def null_translations() -> support.NullTranslations:
+ fp = io.BytesIO()
+ write_mo(fp, Catalog(locale='de'))
+ fp.seek(0)
+ return support.NullTranslations(fp=fp)
+
+
+def assert_equal_type_too(expected, result) -> None:
+ assert expected == result
+ assert type(expected) is type(result), (
+ f'instance types do not match: {type(expected)!r}!={type(result)!r}'
+ )
+
+
+def test_pgettext(translations):
+ assert_equal_type_too('Voh', translations.gettext('foo'))
+ assert_equal_type_too('VohCTX', translations.pgettext('foo', 'foo'))
+ assert_equal_type_too('VohCTX1', translations.pgettext('foo', 'foo1'))
+
+
+def test_pgettext_fallback(translations):
+ fallback = translations._fallback
+ translations._fallback = support.NullTranslations()
+ assert translations.pgettext('foo', 'bar') == 'bar'
+ translations._fallback = fallback
+
+
+def test_upgettext(translations):
+ assert_equal_type_too('Voh', translations.ugettext('foo'))
+ assert_equal_type_too('VohCTX', translations.upgettext('foo', 'foo'))
+
+
+@pytest.mark.skipif(SKIP_LGETTEXT, reason='lgettext is deprecated')
+def test_lpgettext(translations):
+ assert_equal_type_too(b'Voh', translations.lgettext('foo'))
+ assert_equal_type_too(b'VohCTX', translations.lpgettext('foo', 'foo'))
+
+
+def test_npgettext(translations):
+ assert_equal_type_too('Voh1', translations.ngettext('foo1', 'foos1', 1))
+ assert_equal_type_too('Vohs1', translations.ngettext('foo1', 'foos1', 2))
+ assert_equal_type_too('VohCTX1', translations.npgettext('foo', 'foo1', 'foos1', 1))
+ assert_equal_type_too('VohsCTX1', translations.npgettext('foo', 'foo1', 'foos1', 2))
+
+
+def test_unpgettext(translations):
+ assert_equal_type_too('Voh1', translations.ungettext('foo1', 'foos1', 1))
+ assert_equal_type_too('Vohs1', translations.ungettext('foo1', 'foos1', 2))
+ assert_equal_type_too('VohCTX1', translations.unpgettext('foo', 'foo1', 'foos1', 1))
+ assert_equal_type_too('VohsCTX1', translations.unpgettext('foo', 'foo1', 'foos1', 2))
+
+
+@pytest.mark.skipif(SKIP_LGETTEXT, reason='lgettext is deprecated')
+def test_lnpgettext(translations):
+ assert_equal_type_too(b'Voh1', translations.lngettext('foo1', 'foos1', 1))
+ assert_equal_type_too(b'Vohs1', translations.lngettext('foo1', 'foos1', 2))
+ assert_equal_type_too(b'VohCTX1', translations.lnpgettext('foo', 'foo1', 'foos1', 1))
+ assert_equal_type_too(b'VohsCTX1', translations.lnpgettext('foo', 'foo1', 'foos1', 2))
+
+
+def test_dpgettext(translations):
+ assert_equal_type_too('VohD', translations.dgettext('messages1', 'foo'))
+ assert_equal_type_too('VohCTXD', translations.dpgettext('messages1', 'foo', 'foo'))
+
+
+def test_dupgettext(translations):
+ assert_equal_type_too('VohD', translations.dugettext('messages1', 'foo'))
+ assert_equal_type_too('VohCTXD', translations.dupgettext('messages1', 'foo', 'foo'))
+
+
+@pytest.mark.skipif(SKIP_LGETTEXT, reason='lgettext is deprecated')
+def test_ldpgettext(translations):
+ assert_equal_type_too(b'VohD', translations.ldgettext('messages1', 'foo'))
+ assert_equal_type_too(b'VohCTXD', translations.ldpgettext('messages1', 'foo', 'foo'))
+
+
+def test_dnpgettext(translations):
+ assert_equal_type_too('VohD1', translations.dngettext('messages1', 'foo1', 'foos1', 1))
+ assert_equal_type_too('VohsD1', translations.dngettext('messages1', 'foo1', 'foos1', 2))
+ assert_equal_type_too('VohCTXD1', translations.dnpgettext('messages1', 'foo', 'foo1', 'foos1', 1))
+ assert_equal_type_too('VohsCTXD1', translations.dnpgettext('messages1', 'foo', 'foo1', 'foos1', 2))
+
+
+def test_dunpgettext(translations):
+ assert_equal_type_too('VohD1', translations.dungettext('messages1', 'foo1', 'foos1', 1))
+ assert_equal_type_too('VohsD1', translations.dungettext('messages1', 'foo1', 'foos1', 2))
+ assert_equal_type_too('VohCTXD1', translations.dunpgettext('messages1', 'foo', 'foo1', 'foos1', 1))
+ assert_equal_type_too('VohsCTXD1', translations.dunpgettext('messages1', 'foo', 'foo1', 'foos1', 2))
+
+
+@pytest.mark.skipif(SKIP_LGETTEXT, reason='lgettext is deprecated')
+def test_ldnpgettext(translations):
+ assert_equal_type_too(b'VohD1', translations.ldngettext('messages1', 'foo1', 'foos1', 1))
+ assert_equal_type_too(b'VohsD1', translations.ldngettext('messages1', 'foo1', 'foos1', 2))
+ assert_equal_type_too(b'VohCTXD1', translations.ldnpgettext('messages1', 'foo', 'foo1', 'foos1', 1))
+ assert_equal_type_too(b'VohsCTXD1', translations.ldnpgettext('messages1', 'foo', 'foo1', 'foos1', 2))
+
+
+def test_load(translations):
+ tempdir = tempfile.mkdtemp()
+ try:
+ messages_dir = os.path.join(tempdir, 'fr', 'LC_MESSAGES')
+ os.makedirs(messages_dir)
+ catalog = Catalog(locale='fr', domain='messages')
+ catalog.add('foo', 'bar')
+ with open(os.path.join(messages_dir, 'messages.mo'), 'wb') as f:
+ write_mo(f, catalog)
+
+ translations = support.Translations.load(tempdir, locales=('fr',), domain='messages')
+ assert translations.gettext('foo') == 'bar'
+ finally:
+ shutil.rmtree(tempdir)
+
+
+def get_gettext_method_names(obj):
+ names = [name for name in dir(obj) 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_null_translations_have_same_methods(empty_translations, null_translations):
+ for name in get_gettext_method_names(empty_translations):
+ assert hasattr(null_translations, name), f'NullTranslations does not provide method {name!r}'
+
+
+def test_null_translations_method_signature_compatibility(empty_translations, null_translations):
+ for name in get_gettext_method_names(empty_translations):
+ assert (
+ inspect.getfullargspec(getattr(empty_translations, name)) ==
+ inspect.getfullargspec(getattr(null_translations, name))
+ )
+
+
+def test_null_translations_same_return_values(empty_translations, null_translations):
+ data = {
+ 'message': 'foo',
+ 'domain': 'domain',
+ 'context': 'tests',
+ 'singular': 'bar',
+ 'plural': 'baz',
+ 'num': 1,
+ 'msgid1': 'bar',
+ 'msgid2': 'baz',
+ 'n': 1,
+ }
+ for name in get_gettext_method_names(empty_translations):
+ method = getattr(empty_translations, name)
+ null_method = getattr(null_translations, name)
+ signature = inspect.getfullargspec(method)
+ parameter_names = [name for name in signature.args if name != 'self']
+ values = [data[name] for name in parameter_names]
+ assert method(*values) == null_method(*values)
+
+
+def test_catalog_merge_files():
+ # Refs issues #92, #162
+ t1 = support.Translations()
+ assert t1.files == []
+ t1._catalog['foo'] = 'bar'
+ fp = io.BytesIO()
+ write_mo(fp, Catalog())
+ fp.seek(0)
+ fp.name = 'pro.mo'
+ t2 = support.Translations(fp)
+ assert t2.files == ['pro.mo']
+ t2._catalog['bar'] = 'quux'
+ t1.merge(t2)
+ assert t1.files == ['pro.mo']
+ assert set(t1._catalog.keys()) == {'', 'foo', 'bar'}
diff --git a/tests/test_units.py b/tests/test_units.py
new file mode 100644
index 000000000..7c6ad6b4d
--- /dev/null
+++ b/tests/test_units.py
@@ -0,0 +1,19 @@
+import pytest
+
+from babel.units import format_unit
+
+
+# New units in CLDR 46
+@pytest.mark.parametrize(('unit', 'count', 'expected'), [
+ ('speed-light-speed', 1, '1 světlo'),
+ ('speed-light-speed', 2, '2 světla'),
+ ('speed-light-speed', 5, '5 světel'),
+ ('concentr-portion-per-1e9', 1, '1 částice na miliardu'),
+ ('concentr-portion-per-1e9', 2, '2 částice na miliardu'),
+ ('concentr-portion-per-1e9', 5, '5 částic na miliardu'),
+ ('duration-night', 1, '1 noc'),
+ ('duration-night', 2, '2 noci'),
+ ('duration-night', 5, '5 nocí'),
+])
+def test_new_cldr46_units(unit, count, expected):
+ assert format_unit(count, unit, locale='cs_CZ') == expected
diff --git a/tests/test_util.py b/tests/test_util.py
index c661894c8..1b464e079 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -1,14 +1,14 @@
#
-# Copyright (C) 2007-2011 Edgewall Software, 2013-2024 the Babel team
+# Copyright (C) 2007-2011 Edgewall Software, 2013-2025 the Babel team
# All rights reserved.
#
# This software is licensed as described in the file LICENSE, which
# you should have received as part of this distribution. The terms
-# are also available at http://babel.edgewall.org/wiki/License.
+# are also available at https://github.com/python-babel/babel/blob/master/LICENSE.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://babel.edgewall.org/log/.
+# history and logs, available at https://github.com/python-babel/babel/commits/master/.
import __future__
diff --git a/tox.ini b/tox.ini
index 70e5e7ed2..8aaa8a3e3 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,22 +1,16 @@
[tox]
isolated_build = true
envlist =
- py{38,39,310,311,312}
+ py{38,39,310,311,312,313}
pypy3
py{38}-pytz
- py{311,312}-setuptools
- py312-jinja
+ py{311,312,313}-setuptools
+ py{312,313}-jinja
[testenv]
extras =
dev
-deps =
- {env:BABEL_TOX_EXTRA_DEPS:}
- backports.zoneinfo;python_version<"3.9"
- tzdata;sys_platform == 'win32'
- pytz: pytz
- setuptools: setuptools
- jinja: jinja2>=3.0
+deps = {env:BABEL_TOX_EXTRA_DEPS:}
allowlist_externals = make
commands = make clean-cldr test
setenv =
@@ -37,3 +31,4 @@ python =
3.10: py310
3.11: py311
3.12: py312
+ 3.13: py313