From 3ac8aaf2bd5d7b17b67a900e8aa1553b3b2bcd41 Mon Sep 17 00:00:00 2001 From: Fedor Borshev Date: Mon, 12 Oct 2026 21:19:11 +0300 Subject: [PATCH] Compile translations with polib instead of GNU gettext compilemessages is overridden in app to compile the catalogues in LOCALE_PATHS with polib, so neither the image nor CI needs gettext. polib keeps Plural-Forms verbatim and skips fuzzy messages, matching msgfmt; Babel was rejected because it drops Plural-Forms and falls back to the English plural rule. makemessages still needs gettext locally. The generated project's CI loses its apt step entirely: nothing else needed gettext or locales-all, and awalsh128/cache-apt-pkgs-action silently caches an empty archive when apt fails (upstream #166, #221). Ported from fandsdev/medical-backend#804. Co-Authored-By: Claude Opus 5.5 --- .../.github/workflows/ci.yml | 6 -- {{ cookiecutter.name }}/Dockerfile | 2 +- {{ cookiecutter.name }}/pyproject.toml | 2 + .../management/commands/compilemessages.py | 26 ++++++++ .../src/app/tests/test_compilemessages.py | 60 +++++++++++++++++++ {{ cookiecutter.name }}/uv.lock | 26 ++++++-- 6 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 {{ cookiecutter.name }}/src/app/management/commands/compilemessages.py create mode 100644 {{ cookiecutter.name }}/src/app/tests/test_compilemessages.py diff --git a/{{ cookiecutter.name }}/.github/workflows/ci.yml b/{{ cookiecutter.name }}/.github/workflows/ci.yml index ba04a51a..e184859c 100644 --- a/{{ cookiecutter.name }}/.github/workflows/ci.yml +++ b/{{ cookiecutter.name }}/.github/workflows/ci.yml @@ -80,12 +80,6 @@ jobs: id: setup-project uses: ./.github/actions/setup-project - - name: install locale stuff - uses: awalsh128/cache-apt-pkgs-action@v1 - with: - packages: locales-all gettext - version: 1 - - name: test env: DATABASE_URL: postgres://postgres:secret@localhost:5432/postgres diff --git a/{{ cookiecutter.name }}/Dockerfile b/{{ cookiecutter.name }}/Dockerfile index 64dbbc31..18b84778 100644 --- a/{{ cookiecutter.name }}/Dockerfile +++ b/{{ cookiecutter.name }}/Dockerfile @@ -45,7 +45,7 @@ ENV STATIC_ROOT=/code/static ENV UID=999 GID=999 RUN apt-get update \ - && apt-get --no-install-recommends install -y gettext locales-all tzdata git wait-for-it wget tini \ + && apt-get --no-install-recommends install -y locales-all tzdata git wait-for-it wget tini \ && rm -rf /var/lib/apt/lists/* RUN groupadd --system --gid=${GID} "web" \ diff --git a/{{ cookiecutter.name }}/pyproject.toml b/{{ cookiecutter.name }}/pyproject.toml index a0b93e78..4ce27b6c 100644 --- a/{{ cookiecutter.name }}/pyproject.toml +++ b/{{ cookiecutter.name }}/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "drf-spectacular[sidecar]>=0.30.0", "orjson>=3.11.9", "pillow>=12.3.0", + "polib>=1.2.0", "psycopg[binary]>=3.3.4", "redis>=8.0.1", "sentry-sdk>=2.66.0", @@ -48,6 +49,7 @@ dev = [ "toml-sort>=0.24.4", "types-freezegun>=1.1.10", "types-pillow>=10.2.0.20240822", + "types-polib>=1.2.0.20260518", ] [tool.uv] diff --git a/{{ cookiecutter.name }}/src/app/management/commands/compilemessages.py b/{{ cookiecutter.name }}/src/app/management/commands/compilemessages.py new file mode 100644 index 00000000..166bb227 --- /dev/null +++ b/{{ cookiecutter.name }}/src/app/management/commands/compilemessages.py @@ -0,0 +1,26 @@ +from pathlib import Path +from typing import Any + +import polib +from django.conf import settings +from django.core.management.base import BaseCommand, CommandParser + + +class Command(BaseCommand): + """Compile .po catalogues in LOCALE_PATHS with polib, so GNU gettext is not needed""" + + help = "Compiles .po files to .mo files for use with builtin gettext support." + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument("--locale", "-l", action="append", default=[], help="Locale(s) to process. Default is to process all.") + + def handle(self, *args: Any, **options: Any) -> None: + for locale_path in settings.LOCALE_PATHS: + for po_file in sorted(Path(locale_path).glob("*/LC_MESSAGES/*.po")): + if options["locale"] and po_file.parent.parent.name not in options["locale"]: + continue + + if options["verbosity"] > 0: + self.stdout.write(f"processing file {po_file.name} in {po_file.parent}") + + polib.pofile(str(po_file)).save_as_mofile(str(po_file.with_suffix(".mo"))) diff --git a/{{ cookiecutter.name }}/src/app/tests/test_compilemessages.py b/{{ cookiecutter.name }}/src/app/tests/test_compilemessages.py new file mode 100644 index 00000000..38dec95d --- /dev/null +++ b/{{ cookiecutter.name }}/src/app/tests/test_compilemessages.py @@ -0,0 +1,60 @@ +import gettext + +import pytest +from django.core.management import call_command + + +@pytest.fixture +def messages_dir(tmp_path, settings): + settings.LOCALE_PATHS = [tmp_path] + messages_dir = tmp_path / "ru" / "LC_MESSAGES" + messages_dir.mkdir(parents=True) + (messages_dir / "django.po").write_text( + r"""#, fuzzy +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +msgid "Active" +msgstr "Активна" + +msgid "day" +msgid_plural "days" +msgstr[0] "день" +msgstr[1] "дня" +msgstr[2] "дней" + +#, fuzzy +msgid "Paused" +msgstr "Приостановлена" +""" + ) + return messages_dir + + +@pytest.fixture +def translations(messages_dir): + call_command("compilemessages", verbosity=0) + + with (messages_dir / "django.mo").open("rb") as mo_file: + return gettext.GNUTranslations(mo_file) + + +def test_translates(translations): + assert translations.gettext("Active") == "Активна" + + +@pytest.mark.parametrize(("count", "expected"), [(1, "день"), (3, "дня"), (5, "дней"), (11, "дней"), (21, "день")]) +def test_keeps_plural_forms(translations, count, expected): + assert translations.ngettext("day", "days", count) == expected + + +def test_skips_fuzzy_messages(translations): + assert translations.gettext("Paused") == "Paused" + + +def test_skips_other_locales(messages_dir): + call_command("compilemessages", locale=["en"], verbosity=0) + + assert not (messages_dir / "django.mo").exists() diff --git a/{{ cookiecutter.name }}/uv.lock b/{{ cookiecutter.name }}/uv.lock index 450595f8..b3ff3347 100644 --- a/{{ cookiecutter.name }}/uv.lock +++ b/{{ cookiecutter.name }}/uv.lock @@ -234,7 +234,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, ] - [[package]] name = "click" version = "8.4.2" @@ -425,7 +424,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/01/afffed1e3c4540fb75bf550a18b6176a9f6371b5f3e52b69a28995b6480c/django_filter-26.1-py3-none-any.whl", hash = "sha256:7d98ef2899218e6242619b532cb1b95af14e09dfcf74844aecb550ad27b59ff2", size = 94069, upload-time = "2026-07-11T09:27:01.012Z" }, ] - [[package]] name = "django-ipware" version = "7.0.1" @@ -624,7 +622,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, ] - [[package]] name = "inflection" version = "0.5.1" @@ -939,6 +936,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polib" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1315,7 +1321,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] - [[package]] name = "rpds-py" version = "2026.6.3" @@ -1451,6 +1456,7 @@ dependencies = [ { name = "drf-spectacular", extra = ["sidecar"] }, { name = "orjson" }, { name = "pillow" }, + { name = "polib" }, { name = "psycopg", extra = ["binary"] }, { name = "redis" }, { name = "sentry-sdk" }, @@ -1480,6 +1486,7 @@ dev = [ { name = "toml-sort" }, { name = "types-freezegun" }, { name = "types-pillow" }, + { name = "types-polib" }, ] [package.metadata] @@ -1498,6 +1505,7 @@ requires-dist = [ { name = "drf-spectacular", extras = ["sidecar"], specifier = ">=0.30.0" }, { name = "orjson", specifier = ">=3.11.9" }, { name = "pillow", specifier = ">=12.3.0" }, + { name = "polib", specifier = ">=1.2.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "redis", specifier = ">=8.0.1" }, { name = "sentry-sdk", specifier = ">=2.66.0" }, @@ -1527,6 +1535,7 @@ dev = [ { name = "toml-sort", specifier = ">=0.24.4" }, { name = "types-freezegun", specifier = ">=1.1.10" }, { name = "types-pillow", specifier = ">=10.2.0.20240822" }, + { name = "types-polib", specifier = ">=1.2.0.20260518" }, ] [[package]] @@ -1613,6 +1622,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/23/e81a5354859831fcf54d488d33b80ba6133ea84f874a9c0ec40a4881e133/types_Pillow-10.2.0.20240822-py3-none-any.whl", hash = "sha256:d9dab025aba07aeb12fd50a6799d4eac52a9603488eca09d7662543983f16c5d", size = 54354, upload-time = "2024-08-22T02:32:46.664Z" }, ] +[[package]] +name = "types-polib" +version = "1.2.0.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/a0/bc3cfd4d6cb2e9798a28579dd4dd39675cde66a52dd9f5f158b0afc80420/types_polib-1.2.0.20260518.tar.gz", hash = "sha256:2b593a1c6eceb1c4d2ef41054d24ec17c5ee3ec4f2a29a26ece75d8f5715420f", size = 9285, upload-time = "2026-05-18T06:02:24.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/05/11cf19d219d4cdf53bbf8b3e0c9254d7a04d4b1a6df966124421e407138e/types_polib-1.2.0.20260518-py3-none-any.whl", hash = "sha256:1a6b9bd7eab3672a1c2e54c939b3fb720241e2b671e34a60cb82d2f791eba025", size = 8528, upload-time = "2026-05-18T06:02:22.737Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518"