From: Aarni Koskela Date: Thu, 30 Jul 2026 10:17:11 +0000 (+0300) Subject: Add a benchmark suite + CodSpeed (#1300) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=daab719aaf1db607c03578b3c9374ec119143ced;p=thirdparty%2Fbabel.git Add a benchmark suite + CodSpeed (#1300) * Add pytest-benchmark/pytest-codspeed compatible benchmarks * Add CodSpeed CI step --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 630fcdff..05dbfaae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore index b890ef9b..31cf48ed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *~ .*cache .DS_Store +.benchmarks .coverage .idea .tox diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04f38512..85a15074 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 index 00000000..e69de29b diff --git a/tests/benchmarks/benchmark_core.py b/tests/benchmarks/benchmark_core.py new file mode 100644 index 00000000..b12a1434 --- /dev/null +++ b/tests/benchmarks/benchmark_core.py @@ -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 index 00000000..5c3d2b23 --- /dev/null +++ b/tests/benchmarks/benchmark_dates.py @@ -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 index 00000000..a2ea3b6c --- /dev/null +++ b/tests/benchmarks/benchmark_languages.py @@ -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 index 00000000..fb01a25f --- /dev/null +++ b/tests/benchmarks/benchmark_lists.py @@ -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 index 00000000..60af20fd --- /dev/null +++ b/tests/benchmarks/benchmark_messages.py @@ -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 index 00000000..2cb97211 --- /dev/null +++ b/tests/benchmarks/benchmark_numbers.py @@ -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 index 00000000..d9addae6 --- /dev/null +++ b/tests/benchmarks/benchmark_plural.py @@ -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 index 00000000..1e8eaa26 --- /dev/null +++ b/tests/benchmarks/benchmark_support.py @@ -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 index 00000000..0dbeea5b --- /dev/null +++ b/tests/benchmarks/benchmark_units.py @@ -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 index 00000000..3fc0b1d2 --- /dev/null +++ b/tests/benchmarks/conftest.py @@ -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 index 00000000..82faa85b --- /dev/null +++ b/tests/benchmarks/helpers.py @@ -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()