]> git.ipfire.org Git - thirdparty/babel.git/commitdiff
Add a benchmark suite + CodSpeed (#1300)
authorAarni Koskela <akx@iki.fi>
Thu, 30 Jul 2026 10:17:11 +0000 (13:17 +0300)
committerGitHub <noreply@github.com>
Thu, 30 Jul 2026 10:17:11 +0000 (13:17 +0300)
* Add pytest-benchmark/pytest-codspeed compatible benchmarks

* Add CodSpeed CI step

15 files changed:
.github/workflows/ci.yml
.gitignore
.pre-commit-config.yaml
tests/benchmarks/__init__.py [new file with mode: 0644]
tests/benchmarks/benchmark_core.py [new file with mode: 0644]
tests/benchmarks/benchmark_dates.py [new file with mode: 0644]
tests/benchmarks/benchmark_languages.py [new file with mode: 0644]
tests/benchmarks/benchmark_lists.py [new file with mode: 0644]
tests/benchmarks/benchmark_messages.py [new file with mode: 0644]
tests/benchmarks/benchmark_numbers.py [new file with mode: 0644]
tests/benchmarks/benchmark_plural.py [new file with mode: 0644]
tests/benchmarks/benchmark_support.py [new file with mode: 0644]
tests/benchmarks/benchmark_units.py [new file with mode: 0644]
tests/benchmarks/conftest.py [new file with mode: 0644]
tests/benchmarks/helpers.py [new file with mode: 0644]

index 630fcdffaa85ef35f02d6f688a1ae39cc1408e28..05dbfaae881f56d1f2189fba66f86ca471429013 100644 (file)
@@ -97,6 +97,34 @@ jobs:
         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:
index b890ef9b0546fcca7a13459f3915cefc8a00a815..31cf48ed4cde94224310cd45bff095502fe00f14 100644 (file)
@@ -8,6 +8,7 @@
 *~
 .*cache
 .DS_Store
+.benchmarks
 .coverage
 .idea
 .tox
index 04f3851232356fea26b3e1021aac040c4736fe1c..85a1507446fdf7bb3f8b26e6f78c8d9a1f656a26 100644 (file)
@@ -19,6 +19,6 @@ repos:
         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
diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/benchmarks/benchmark_core.py b/tests/benchmarks/benchmark_core.py
new file mode 100644 (file)
index 0000000..b12a143
--- /dev/null
@@ -0,0 +1,56 @@
+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"
diff --git a/tests/benchmarks/benchmark_dates.py b/tests/benchmarks/benchmark_dates.py
new file mode 100644 (file)
index 0000000..5c3d2b2
--- /dev/null
@@ -0,0 +1,111 @@
+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"
+    )
diff --git a/tests/benchmarks/benchmark_languages.py b/tests/benchmarks/benchmark_languages.py
new file mode 100644 (file)
index 0000000..a2ea3b6
--- /dev/null
@@ -0,0 +1,15 @@
+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"))
diff --git a/tests/benchmarks/benchmark_lists.py b/tests/benchmarks/benchmark_lists.py
new file mode 100644 (file)
index 0000000..fb01a25
--- /dev/null
@@ -0,0 +1,33 @@
+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"
+    )
diff --git a/tests/benchmarks/benchmark_messages.py b/tests/benchmarks/benchmark_messages.py
new file mode 100644 (file)
index 0000000..60af20f
--- /dev/null
@@ -0,0 +1,94 @@
+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
diff --git a/tests/benchmarks/benchmark_numbers.py b/tests/benchmarks/benchmark_numbers.py
new file mode 100644 (file)
index 0000000..2cb9721
--- /dev/null
@@ -0,0 +1,85 @@
+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"]
diff --git a/tests/benchmarks/benchmark_plural.py b/tests/benchmarks/benchmark_plural.py
new file mode 100644 (file)
index 0000000..d9addae
--- /dev/null
@@ -0,0 +1,42 @@
+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
diff --git a/tests/benchmarks/benchmark_support.py b/tests/benchmarks/benchmark_support.py
new file mode 100644 (file)
index 0000000..1e8eaa2
--- /dev/null
@@ -0,0 +1,44 @@
+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"
diff --git a/tests/benchmarks/benchmark_units.py b/tests/benchmarks/benchmark_units.py
new file mode 100644 (file)
index 0000000..0dbeea5
--- /dev/null
@@ -0,0 +1,59 @@
+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"
diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py
new file mode 100644 (file)
index 0000000..3fc0b1d
--- /dev/null
@@ -0,0 +1,30 @@
+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")
diff --git a/tests/benchmarks/helpers.py b/tests/benchmarks/helpers.py
new file mode 100644 (file)
index 0000000..82faa85
--- /dev/null
@@ -0,0 +1,47 @@
+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()