#36980: TranslationCatalog.__getitem__ gives wrong priority to non-plural
translations when plural forms mismatch
-------------------------------------+-------------------------------------
     Reporter:  UHHHHHHHHHHHHHH      |                    Owner:  (none)
         Type:  Bug                  |                   Status:  closed
    Component:                       |                  Version:  5.1
  Internationalization               |               Resolution:
     Severity:  Normal               |  worksforme
     Keywords:  i18n translation     |             Triage Stage:
  plural TranslationCatalog          |  Unreviewed
    Has patch:  0                    |      Needs documentation:  0
  Needs tests:  0                    |  Patch needs improvement:  0
Easy pickings:  0                    |                    UI/UX:  0
-------------------------------------+-------------------------------------
Comment (by UHHHHHHHHHHHHHH):

 The reproduction depends on whether the `Plural-Forms` headers actually
 mismatch. The bug only triggers when they do.

 '''What allauth ships (nl):'''
 {{{
 "Plural-Forms: nplurals=2; plural=n != 1;\n"
 }}}

 '''What Django's own nl translations use:'''
 {{{
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 }}}

 These are functionally identical, but `gettext.c2py()` produces functions
 with different `__code__` objects:

 {{{#!python
 >>> from gettext import c2py
 >>> c2py('(n != 1)').__code__ == c2py('n != 1').__code__
 False
 }}}

 If your allauth version's `Plural-Forms` happens to match Django's (with
 parentheses), everything merges into a single catalog and `LOCALE_PATHS`
 correctly wins. That's likely why you couldn't reproduce.

 '''Here's the exact loading trace on Django 5.1 with allauth 65.15.0:'''

 1. Django's own nl loaded → `catalogs = [{django}(P1)]` where P1 = `(n !=
 1)`
 2. allauth loaded → `P2 = n != 1`, iterates catalogs, `P2.__code__ !=
 P1.__code__` → '''prepended''' → `catalogs = [{allauth}(P2),
 {django}(P1)]`
 3. LOCALE_PATHS loaded → `P1 = (n != 1)`, iterates catalogs: doesn't match
 P2 at position 0, '''matches P1 at position 1''' → merged into Django's
 catalog → `catalogs = [{allauth}(P2), {django+project}(P1)]`

 `__getitem__()` iterates in order → allauth at position 0 wins for all
 shared msgids.

 '''Minimal reproducer (4 files):'''

 `requirements.txt`:
 {{{
 django>=5.1,<5.2
 django-allauth>=65.0
 }}}

 `settings.py`:
 {{{#!python
 import os

 BASE_DIR = os.path.dirname(os.path.abspath(__file__))

 SECRET_KEY = "not-secret"
 USE_I18N = True
 LANGUAGE_CODE = "nl"

 LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")]

 INSTALLED_APPS = [
     "allauth",
 ]
 }}}

 `locale/nl/LC_MESSAGES/django.po` (compile with `msgfmt -o django.mo
 django.po`):
 {{{
 # Project translations — should have highest priority via LOCALE_PATHS
 msgid ""
 msgstr ""
 "Language: nl\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"

 msgid "Incorrect password."
 msgstr "Wachtwoord onjuist (PROJECT OVERRIDE)."
 }}}

 `reproduce.py`:
 {{{#!python
 import os
 import django.conf

 django.conf.settings.configure(
     USE_I18N=True,
     LANGUAGE_CODE="nl",
     LOCALE_PATHS=[os.path.join(os.path.dirname(__file__), "locale")],
     INSTALLED_APPS=["allauth"],
 )

 import django
 django.setup()

 from django.utils.translation import activate, gettext
 from django.utils.translation.trans_real import _translations

 activate("nl")

 trans = _translations.get("nl")
 if trans:
     print(f"Number of catalogs: {len(trans._catalog._catalogs)}")
     for i, (cat, plural) in enumerate(zip(trans._catalog._catalogs,
 trans._catalog._plurals)):
         print(f"\n  Catalog {i}: {len(cat)} entries")
         if "Incorrect password." in cat:
             print(f"    'Incorrect password.' -> {cat['Incorrect
 password.']!r}")

 result = gettext("Incorrect password.")
 expected = "Wachtwoord onjuist (PROJECT OVERRIDE)."

 print(f"\ngettext('Incorrect password.') = {result!r}")

 if result == expected:
     print("\nOK: LOCALE_PATHS translation wins (correct behavior)")
 else:
     print(f"\nBUG: Expected LOCALE_PATHS: {expected!r}")
     print(f"     Got allauth's version: {result!r}")
 }}}

 '''Output:'''
 {{{
 Number of catalogs: 2

   Catalog 0: 377 entries
     'Incorrect password.' -> 'Ongeldig wachtwoord.'

   Catalog 1: 365 entries
     'Incorrect password.' -> 'Wachtwoord onjuist (PROJECT OVERRIDE).'

 gettext('Incorrect password.') = 'Ongeldig wachtwoord.'

 BUG: Expected LOCALE_PATHS: 'Wachtwoord onjuist (PROJECT OVERRIDE).'
      Got allauth's version: 'Ongeldig wachtwoord.'
 }}}

 '''Side note:''' In Django 5.2, `update()` was changed to only compare
 with `_plurals[0]` (the top catalog) instead of iterating all catalogs.
 This accidentally masks this specific scenario — LOCALE_PATHS also gets
 prepended and lands at position 0, so the project wins. But the underlying
 design issue (that `__getitem__` gives prepended catalogs priority for
 non-plural lookups) remains.
-- 
Ticket URL: <https://code.djangoproject.com/ticket/36980#comment:2>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

-- 
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion visit 
https://groups.google.com/d/msgid/django-updates/0107019d0c38d64a-f51c63c6-02af-4060-b2e9-12dc0502b88c-000000%40eu-central-1.amazonses.com.

Reply via email to