flags: ${{ matrix.os }}-${{ matrix.python-version }}
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
+ benchmark:
+ name: benchmark
+ permissions:
+ contents: read
+ runs-on: "ubuntu-24.04"
+ env:
+ BABEL_CLDR_NO_DOWNLOAD_PROGRESS: "1"
+ BABEL_CLDR_QUIET: "1"
+ PIP_DISABLE_PIP_VERSION_CHECK: "1"
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: cldr
+ key: cldr-${{ hashFiles('scripts/*cldr*') }}
+ - name: Set up Python 3.14
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ python-version: "3.14"
+ activate-environment: 'true'
+ - run: uv pip install -e .[dev] pytest-codspeed
+ - run: make import-cldr
+ - uses: CodSpeedHQ/action@88472375d0a4572cf70a9f1fe3a4e0ab8da1b924 # v5.0.1
+ with:
+ mode: simulation
+ run: uv run pytest --codspeed tests/benchmarks/benchmark*py
build:
name: build
permissions:
*~
.*cache
.DS_Store
+.benchmarks
.coverage
.idea
.tox
exclude: (tests/messages/data/)
- id: name-tests-test
args: [ '--django' ]
- exclude: (tests/messages/data/|.*(consts|utils).py)
+ exclude: (tests/benchmarks|tests/messages/data/|.*(consts|utils).py)
- id: requirements-txt-fixer
- id: trailing-whitespace
--- /dev/null
+from __future__ import annotations
+
+from babel import Locale
+from babel.core import (
+ get_global,
+ get_locale_identifier,
+ negotiate_locale,
+ parse_locale,
+)
+
+
+def test_locale_parse_language(benchmark, fi_locale):
+ # The most common shape: a plain language tag, or an already-parsed Locale.
+ assert benchmark(lambda: Locale.parse(fi_locale)).language == "fi"
+
+
+def test_locale_parse_full_tag(benchmark):
+ assert str(benchmark(lambda: Locale.parse("zh-Hans-CN", sep="-"))) == "zh_Hans_CN"
+
+
+def test_locale_parse_with_variant(benchmark):
+ assert str(benchmark(lambda: Locale.parse("en_US_POSIX"))) == "en_US_POSIX"
+
+
+def test_locale_parse_likely_subtags(benchmark):
+ # zh_TW only exists via likely subtag resolution, i.e. the slow path.
+ assert str(benchmark(lambda: Locale.parse("zh_TW"))) == "zh_Hant_TW"
+
+
+def test_locale_construct(benchmark):
+ assert str(benchmark(lambda: Locale("en", "US"))) == "en_US"
+
+
+def test_negotiate_locale(benchmark):
+ preferred = ["fi_FI", "en-US", "de"]
+ available = ["en", "de", "fi"]
+ assert benchmark(lambda: negotiate_locale(preferred, available)) == "fi"
+
+
+def test_parse_locale(benchmark):
+ assert benchmark(lambda: parse_locale("en_US.UTF-8")) == ("en", "US", None, None)
+
+
+def test_get_locale_identifier(benchmark):
+ parts = ("zh", "CN", "Hans", None)
+ assert benchmark(lambda: get_locale_identifier(parts)) == "zh_Hans_CN"
+
+
+def test_get_global(benchmark):
+ assert benchmark(lambda: get_global("zone_territories")["Europe/Helsinki"]) == "FI"
+
+
+def test_locale_get_display_name(benchmark):
+ locale = Locale.parse("fi")
+ en = Locale.parse("en")
+ assert benchmark(lambda: locale.get_display_name(en)) == "Finnish"
--- /dev/null
+from __future__ import annotations
+
+import datetime
+
+from babel import Locale
+from babel.dates import (
+ _cached_parse_pattern,
+ format_date,
+ format_datetime,
+ format_interval,
+ format_skeleton,
+ format_time,
+ format_timedelta,
+ get_timezone_name,
+ parse_date,
+ parse_pattern,
+)
+
+
+def test_format_date_standalone_month(benchmark, fi_locale):
+ d = datetime.date(2025, 10, 15)
+ assert benchmark(lambda: format_date(d, "LLLL", fi_locale)) == "lokakuu"
+
+
+def test_locale_deep_data_read(benchmark):
+ # Repeated alias-resolving deep reads through a long-lived Locale.
+ locale = Locale.parse("fi")
+ assert locale.months["stand-alone"]["wide"][10] == "lokakuu"
+ benchmark(lambda: locale.months["stand-alone"]["wide"][10])
+
+
+def test_format_datetime_medium(benchmark, helsinki_tz, fi_locale):
+ dt = datetime.datetime(2025, 10, 15, 13, 45, 30)
+ assert benchmark(lambda: format_datetime(dt, locale=fi_locale)) == "15.10.2025 13.45.30"
+
+
+def test_format_time_with_tzinfo(benchmark, fi_locale, helsinki_tz):
+ # Aware datetime, converted into another zone on the way out.
+ dt = datetime.datetime(2025, 10, 15, 13, 45, 30, tzinfo=datetime.timezone.utc)
+ assert (
+ benchmark(lambda: format_time(dt, locale=fi_locale, tzinfo=helsinki_tz)) == "16.45.30"
+ )
+
+
+def test_format_timedelta_long(benchmark, fi_locale):
+ delta = datetime.timedelta(days=3, hours=5)
+ expected = "3 päivää"
+ assert format_timedelta(delta, locale=fi_locale) == expected
+ assert benchmark(lambda: format_timedelta(delta, locale=fi_locale)) == expected
+
+
+def test_format_timedelta_short_hours(benchmark, fi_locale):
+ delta = datetime.timedelta(days=3, hours=5)
+ assert (
+ benchmark(
+ lambda: format_timedelta(
+ delta,
+ granularity="hour",
+ format="short",
+ locale=fi_locale,
+ ),
+ )
+ == "3 pv"
+ )
+
+
+def test_format_skeleton(benchmark, fi_locale):
+ dt = datetime.datetime(2025, 10, 15, 13, 45, 30)
+ assert benchmark(lambda: format_skeleton("yMMMd", dt, locale=fi_locale)) == "15.10.2025"
+
+
+def test_format_skeleton_fuzzy(benchmark, fi_locale):
+ # "EyMMMd" is not in the locale's skeleton table, so match_skeleton() has to run.
+ dt = datetime.datetime(2025, 10, 15, 13, 45, 30)
+ assert (
+ benchmark(lambda: format_skeleton("EyMMMd", dt, fuzzy=True, locale=fi_locale))
+ == "ke 15.10.2025"
+ )
+
+
+def test_format_interval_same_day(benchmark, fi_locale):
+ start = datetime.datetime(2025, 10, 15, 9, 0)
+ end = datetime.datetime(2025, 10, 15, 17, 30)
+ assert (
+ benchmark(lambda: format_interval(start, end, "Hm", locale=fi_locale)) == "9.00–17.30"
+ )
+
+
+def test_parse_pattern_cached(benchmark):
+ assert benchmark(lambda: parse_pattern("MMM d, yyyy").format) == "%(MMM)s %(d)s, %(yyyy)s"
+
+
+def test_parse_pattern_uncached(benchmark):
+ # Bypass the lru_cache to measure actual tokenization + parsing.
+ parse = _cached_parse_pattern.__wrapped__
+ assert benchmark(lambda: parse("MMM d, yyyy").format) == "%(MMM)s %(d)s, %(yyyy)s"
+
+
+def test_parse_date_short(benchmark, fi_locale):
+ assert benchmark(lambda: parse_date("15.10.2025", locale=fi_locale)) == datetime.date(
+ 2025,
+ 10,
+ 15,
+ )
+
+
+def test_get_timezone_name(benchmark, fi_locale, helsinki_tz):
+ assert (
+ benchmark(lambda: get_timezone_name(helsinki_tz, locale=fi_locale))
+ == "Itä-Euroopan aika"
+ )
--- /dev/null
+from __future__ import annotations
+
+from babel.languages import get_official_languages, get_territory_language_info
+
+
+def test_get_official_languages(benchmark):
+ benchmark(lambda: get_official_languages("CH"))
+
+
+def test_get_official_languages_regional(benchmark):
+ benchmark(lambda: get_official_languages("CH", regional=True, de_facto=True))
+
+
+def test_get_territory_language_info(benchmark):
+ benchmark(lambda: get_territory_language_info("CH"))
--- /dev/null
+from __future__ import annotations
+
+from babel.lists import format_list
+
+TWO = ["omena", "peruna"]
+FIVE = ["omena", "peruna", "aplari", "kurpitsa", "porkkana"]
+
+
+def test_format_list_two(benchmark, fi_locale):
+ # Two items hit the special-cased "2" pattern.
+ assert benchmark(lambda: format_list(TWO, locale=fi_locale)) == "omena ja peruna"
+
+
+def test_format_list_five(benchmark, fi_locale):
+ assert (
+ benchmark(lambda: format_list(FIVE, locale=fi_locale))
+ == "omena, peruna, aplari, kurpitsa ja porkkana"
+ )
+
+
+def test_format_list_or(benchmark, fi_locale):
+ assert (
+ benchmark(lambda: format_list(FIVE, "or", locale=fi_locale))
+ == "omena, peruna, aplari, kurpitsa tai porkkana"
+ )
+
+
+def test_format_list_style_fallback(benchmark, fi_locale):
+ # fi has no "standard-short" list patterns, so this falls back to "standard".
+ assert (
+ benchmark(lambda: format_list(FIVE, "standard-short", locale=fi_locale))
+ == "omena, peruna, aplari, kurpitsa ja porkkana"
+ )
--- /dev/null
+from __future__ import annotations
+
+import io
+
+import pytest
+
+from babel.messages import Catalog
+from babel.messages.extract import DEFAULT_KEYWORDS, extract, extract_python
+from babel.messages.mofile import read_mo
+from babel.messages.pofile import read_po
+from tests.benchmarks.helpers import build_catalog, dump_mo, dump_po
+
+MESSAGE_COUNT = 100
+
+
+@pytest.fixture()
+def catalog() -> Catalog:
+ return build_catalog(MESSAGE_COUNT)
+
+
+@pytest.fixture()
+def po_source(catalog) -> str:
+ return dump_po(catalog)
+
+
+@pytest.fixture()
+def mo_bytes(catalog) -> bytes:
+ return dump_mo(catalog)
+
+
+_VIEW_TEMPLATE = '''
+def view_{i}(request, count):
+ title = _("Page title {i}")
+ body = gettext("Body text for page {i}")
+ # NOTE: shown next to the item counter
+ footer = ngettext("%(num)d item", "%(num)d items", count) % {{"num": count}}
+ return render(request, title=title, body=body, footer=footer)
+'''
+
+PYTHON_SOURCE = (
+ "from gettext import gettext, ngettext\n\n_ = gettext\n"
+ + "".join(_VIEW_TEMPLATE.format(i=i) for i in range(7))
+).encode("utf-8")
+
+
+def test_catalog_add(benchmark):
+ # Constructed inside the callable, since adding messages mutates the catalog.
+ assert benchmark(lambda: len(build_catalog(MESSAGE_COUNT))) == MESSAGE_COUNT
+
+
+def test_catalog_iter(benchmark, catalog):
+ # Iteration also yields the header message, hence the + 1.
+ assert benchmark(lambda: len(list(catalog))) == MESSAGE_COUNT + 1
+
+
+def test_catalog_get(benchmark, catalog):
+ # A plural ID exercises the tuple branch of the key lookup.
+ assert benchmark(lambda: catalog.get(("5 apple", "5 apples")).string) == (
+ "5 omena",
+ "5 omenaa",
+ )
+
+
+def test_read_po(benchmark, po_source):
+ benchmark(lambda: read_po(io.StringIO(po_source), locale="fi"))
+
+
+def test_write_po(benchmark, catalog):
+ benchmark(lambda: dump_po(catalog))
+
+
+def test_write_mo(benchmark, catalog):
+ benchmark(lambda: dump_mo(catalog))
+
+
+def test_read_mo(benchmark, mo_bytes):
+ # Fuzzy messages are not written to the MO, so this catalog is smaller.
+ catalog = benchmark(lambda: read_mo(io.BytesIO(mo_bytes)))
+ assert catalog.get("Message number 42").string == "Viesti numero 42"
+
+
+def test_extract_python(benchmark):
+ def run():
+ return list(extract_python(io.BytesIO(PYTHON_SOURCE), DEFAULT_KEYWORDS, ["NOTE:"], {}))
+
+ assert len(benchmark(run)) == 21
+
+
+def test_extract_method_python(benchmark):
+ # Same work as above, plus the extraction method lookup/dispatch in extract().
+ def run():
+ return list(extract("python", io.BytesIO(PYTHON_SOURCE), DEFAULT_KEYWORDS, ["NOTE:"]))
+
+ assert len(benchmark(run)) == 21
--- /dev/null
+from __future__ import annotations
+
+from decimal import Decimal
+
+from babel.numbers import (
+ format_compact_currency,
+ format_compact_decimal,
+ format_currency,
+ format_decimal,
+ format_percent,
+ format_scientific,
+ get_currency_name,
+ get_territory_currencies,
+ parse_decimal,
+)
+
+
+def test_format_decimal(benchmark, fi_locale):
+ number = Decimal("1234567.891")
+ assert benchmark(lambda: format_decimal(number, locale=fi_locale)) == "1\xa0234\xa0567,891"
+
+
+def test_format_currency(benchmark, fi_locale):
+ number = Decimal("1234.5")
+ assert (
+ benchmark(lambda: format_currency(number, "EUR", locale=fi_locale))
+ == "1\xa0234,50\xa0€"
+ )
+
+
+def test_format_currency_name(benchmark):
+ # The "name" format type additionally does a plural-form currency name lookup.
+ number = Decimal("1234.5")
+ assert (
+ benchmark(lambda: format_currency(number, "EUR", locale="fi", format_type="name"))
+ == "1\xa0234,50 euroa"
+ )
+
+
+def test_format_compact_decimal(benchmark):
+ assert (
+ benchmark(
+ lambda: format_compact_decimal(
+ 1234567,
+ format_type="long",
+ locale="fi",
+ fraction_digits=2,
+ ),
+ )
+ == "1,23 miljoonaa"
+ )
+
+
+def test_format_compact_currency(benchmark):
+ assert (
+ benchmark(
+ lambda: format_compact_currency(123456789, "EUR", locale="fi", fraction_digits=1),
+ )
+ == "123,5\xa0milj.\xa0€"
+ )
+
+
+def test_format_percent(benchmark):
+ number = Decimal("0.3456")
+ assert benchmark(lambda: format_percent(number, locale="fi")) == "35\xa0%"
+
+
+def test_format_scientific(benchmark):
+ number = Decimal("1234567.891")
+ assert benchmark(lambda: format_scientific(number, locale="fi")) == "1,234567891E6"
+
+
+def test_parse_decimal(benchmark, fi_locale):
+ string = "1\xa0234\xa0567,891"
+ assert benchmark(lambda: parse_decimal(string, locale=fi_locale, strict=True)) == Decimal(
+ "1234567.891",
+ )
+
+
+def test_get_currency_name(benchmark):
+ assert benchmark(lambda: get_currency_name("EUR", count=2, locale="fi")) == "euroa"
+
+
+def test_get_territory_currencies(benchmark):
+ assert benchmark(lambda: get_territory_currencies("FI")) == ["EUR"]
--- /dev/null
+from __future__ import annotations
+
+import pytest
+
+from babel import Locale
+from babel.plural import PluralRule, to_gettext
+
+# A synthetic ruleset, comparable in complexity to real CLDR rule sets.
+RULES = {
+ "one": "v in 0 and i mod 10 in 1..2 and i mod 100 not in 11..12",
+ "few": "v in 0 and i mod 10 in 3..6",
+ "many": "v in 0 and i mod 10 in 7..9 or v in 0 and i mod 100 in 11..12",
+}
+
+
+@pytest.fixture
+def plural_rule() -> PluralRule:
+ return PluralRule(RULES)
+
+
+def test_plural_rule_parse(benchmark):
+ assert benchmark(lambda: PluralRule(RULES).tags) == frozenset({"one", "few", "many"})
+
+
+def test_plural_rule_call_int(benchmark, plural_rule: PluralRule):
+ assert benchmark(lambda: plural_rule(21)) == "one"
+
+
+def test_plural_rule_call_float(benchmark, plural_rule):
+ # Floats go through the Decimal path in extract_operands.
+ assert benchmark(lambda: plural_rule(2.5)) == "other"
+
+
+def test_locale_plural_form(benchmark):
+ locale = Locale.parse("fi")
+ assert benchmark(lambda: locale.plural_form(1)) == "one"
+
+
+def test_to_gettext(benchmark):
+ expected = to_gettext(RULES)
+ assert expected.startswith("nplurals=4; plural=(")
+ assert benchmark(lambda: to_gettext(RULES)) == expected
--- /dev/null
+from __future__ import annotations
+
+import datetime
+import io
+
+import pytest
+
+from babel.support import Format, LazyProxy, Translations
+from tests.benchmarks.helpers import build_catalog, dump_mo
+
+
+@pytest.fixture()
+def translations() -> Translations:
+ return Translations(fp=io.BytesIO(dump_mo(build_catalog(20))))
+
+
+def _greeting(name: str) -> str:
+ return f"Hei, {name}!"
+
+
+def test_format_decimal(benchmark):
+ fmt = Format("fi")
+ assert benchmark(lambda: fmt.decimal(1234567.891)) == "1\xa0234\xa0567,891"
+
+
+def test_format_date(benchmark):
+ fmt = Format("fi")
+ d = datetime.date(2025, 10, 15)
+ assert benchmark(lambda: fmt.date(d, "long")) == "15. lokakuuta 2025"
+
+
+def test_lazy_proxy(benchmark):
+ # Cache disabled so every access re-evaluates the wrapped function.
+ proxy = LazyProxy(_greeting, "maailma", enable_cache=False)
+ assert benchmark(lambda: f"{proxy} {proxy.upper()}") == "Hei, maailma! HEI, MAAILMA!"
+
+
+def test_translations_gettext(benchmark, translations: Translations):
+ # ugettext is an alias of gettext on Translations, so it measures the same path.
+ assert benchmark(lambda: translations.gettext("Message number 19")) == "Viesti numero 19"
+
+
+def test_translations_ngettext(benchmark, translations: Translations):
+ assert benchmark(lambda: translations.ngettext("10 apple", "10 apples", 3)) == "10 omenaa"
--- /dev/null
+from __future__ import annotations
+
+from babel.units import format_compound_unit, format_unit, get_unit_name
+
+
+def test_format_unit_long(benchmark, fi_locale):
+ assert (
+ benchmark(lambda: format_unit(15.5, "length-mile", locale=fi_locale)) == "15,5 mailia"
+ )
+
+
+def test_format_unit_short(benchmark, fi_locale):
+ assert (
+ benchmark(lambda: format_unit(15.5, "length-mile", "short", locale=fi_locale))
+ == "15,5 mi"
+ )
+
+
+def test_format_unit_length_fallback(benchmark):
+ # et's "long" duration-month only has a "per" pattern, so this falls back to "short".
+ assert benchmark(lambda: format_unit(1, "duration-month", "long", locale="et")) == "1 kuu"
+
+
+def test_format_compound_unit_predefined(benchmark, fi_locale):
+ # Resolves to the predefined speed-kilometer-per-hour pattern.
+ expected = "150 kilometriä tunnissa"
+ assert (
+ benchmark(
+ lambda: format_compound_unit(
+ 150,
+ "kilometer",
+ denominator_unit="hour",
+ locale=fi_locale,
+ ),
+ )
+ == expected
+ )
+
+
+def test_format_compound_unit_constructed(benchmark, fi_locale):
+ # No predefined compound unit; both sides get formatted and joined with the "per" pattern.
+ expected = "32,5 am. tonnia/15 tuntia"
+ assert (
+ benchmark(
+ lambda: format_compound_unit(
+ 32.5,
+ "ton",
+ 15,
+ denominator_unit="hour",
+ locale=fi_locale,
+ ),
+ )
+ == expected
+ )
+
+
+def test_get_unit_name(benchmark, fi_locale):
+ # An unqualified unit id, so the pattern table is scanned to qualify it.
+ assert benchmark(lambda: get_unit_name("radian", locale=fi_locale)) == "radiaanit"
--- /dev/null
+from __future__ import annotations
+
+from importlib.util import find_spec
+from typing import TYPE_CHECKING
+
+import pytest
+
+if TYPE_CHECKING:
+ import zoneinfo
+
+ import babel
+
+if not (find_spec("pytest_benchmark") or find_spec("pytest_codspeed")):
+ pytest.skip("pytest-benchmark or pytest-codspeed required", allow_module_level=True)
+
+
+@pytest.fixture(scope="function", params=["fresh", "cached"])
+def fi_locale(request) -> str | babel.Locale:
+ from babel import Locale
+
+ if request.param == "fresh":
+ return "fi"
+ return Locale.parse("fi") # Share the object in the test
+
+
+@pytest.fixture(scope="session")
+def helsinki_tz() -> zoneinfo.ZoneInfo:
+ import zoneinfo
+
+ return zoneinfo.ZoneInfo("Europe/Helsinki")
--- /dev/null
+from __future__ import annotations
+
+import datetime
+import io
+
+from babel.messages import Catalog
+from babel.messages.mofile import write_mo
+from babel.messages.pofile import write_po
+
+
+def build_catalog(message_count: int) -> Catalog:
+ # Fixed dates keep the serialized headers (and thus PO/MO bytes) stable.
+ catalog = Catalog(
+ locale="fi",
+ creation_date=datetime.datetime(2025, 10, 15, 12, 0, 0),
+ revision_date=datetime.datetime(2025, 10, 16, 12, 0, 0),
+ )
+ for i in range(message_count):
+ locations = [(f"module_{i % 7}.py", i * 3 + 1)]
+ if i % 5 == 0:
+ catalog.add(
+ (f"{i} apple", f"{i} apples"),
+ (f"{i} omena", f"{i} omenaa"),
+ locations=locations,
+ auto_comments=[f"Auto comment for message {i}"],
+ )
+ else:
+ catalog.add(
+ f"Message number {i}",
+ f"Viesti numero {i}",
+ locations=locations,
+ flags=["fuzzy"] if i % 11 == 0 else (),
+ user_comments=[f"Translator note {i}"] if i % 3 == 0 else (),
+ )
+ return catalog
+
+
+def dump_po(catalog: Catalog) -> str:
+ buf = io.BytesIO()
+ write_po(buf, catalog)
+ return buf.getvalue().decode("utf-8")
+
+
+def dump_mo(catalog: Catalog) -> bytes:
+ buf = io.BytesIO()
+ write_mo(buf, catalog)
+ return buf.getvalue()