From 9186697dcaca2089446bed0c6da8fef0cb433732 Mon Sep 17 00:00:00 2001 From: Yusuke Hayashi Date: Fri, 31 Jul 2026 09:17:16 +0900 Subject: [PATCH] Fix Catalog.is_identical for context-specific messages is_identical() iterates the internal _messages keys, but looked each one up through Catalog.get(). For a context-specific message that key is a (msgid, msgctxt) tuple, and get() re-runs _key_for() on it, which takes a tuple for a pluralizable message's id and reduces it to msgid alone -- a key no message is stored under. The lookup returned None, so any catalog containing a msgctxt never compared equal, not even to itself. The visible consequence is that pybabel update --check reports every catalog using pgettext/npgettext as out of date on every run, which makes it unusable as the CI staleness gate it was added for (#831). Index _messages directly, since the keys being iterated are its own. Co-Authored-By: Claude Opus 5 --- babel/messages/catalog.py | 9 ++++++-- tests/messages/test_catalog.py | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index e01fd5677..ee5e58c23 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -1046,8 +1046,13 @@ def is_identical(self, other: Catalog) -> bool: """ assert isinstance(other, Catalog) for key in self._messages.keys() | other._messages.keys(): - message_1 = self.get(key) - message_2 = other.get(key) + # Index ``_messages`` directly: these are already internal keys, and + # passing one back through ``get`` would re-run ``_key_for`` on it. + # For a context-specific message that key is a ``(msgid, msgctxt)`` + # tuple, which ``_key_for`` mistakes for a pluralizable message's id + # and reduces to ``msgid`` -- a key no message is stored under. + message_1 = self._messages.get(key) + message_2 = other._messages.get(key) if message_1 is None or message_2 is None or not message_1.is_identical(message_2): return False return dict(self.mime_headers) == dict(other.mime_headers) diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index 4a60208c8..d31937d8f 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -596,3 +596,43 @@ def test_catalog_tz_pickleable(): msgstr "" "POT-Creation-Date: 2007-04-01 15:30+0200\n" """)))) + + +def test_catalog_is_identical_with_context(): + """A catalog holding a context-specific message compares equal to itself. + + ``is_identical`` iterates the internal ``_messages`` keys, and the key of a + context-specific message is a ``(msgid, msgctxt)`` tuple. Looking that key + up through ``Catalog.get`` re-ran ``_key_for`` on it, which took it for a + pluralizable message's id and reduced it to ``msgid`` -- so the lookup found + nothing and every such catalog compared as changed. That made + ``pybabel update --check`` report any catalog using ``pgettext`` or + ``npgettext`` as out of date, on every run. + """ + cat = catalog.Catalog(locale='en') + cat.add('Guide', 'Guide', context='navigation') + assert cat.is_identical(cat) + + same = catalog.Catalog(locale='en') + same.add('Guide', 'Guide', context='navigation') + assert cat.is_identical(same) + + translated_differently = catalog.Catalog(locale='en') + translated_differently.add('Guide', 'Manual', context='navigation') + assert not cat.is_identical(translated_differently) + + # The same msgid under a different context is a different message. + other_context = catalog.Catalog(locale='en') + other_context.add('Guide', 'Guide', context='footer') + assert not cat.is_identical(other_context) + + +def test_catalog_is_identical_with_pluralizable_context(): + """The same, for a context-specific *pluralizable* message.""" + cat = catalog.Catalog(locale='en') + cat.add(('%d page', '%d pages'), ('%d page', '%d pages'), context='report') + assert cat.is_identical(cat) + + same = catalog.Catalog(locale='en') + same.add(('%d page', '%d pages'), ('%d page', '%d pages'), context='report') + assert cat.is_identical(same)