diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 5e6c28255..2cb5188fe 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -192,6 +192,9 @@ def __init__( self.previous_id = [] self.lineno = lineno self.context = context + # msgstr[N] entries dropped while reading a PO file because + # N was >= catalog.num_plurals. Does not affect message.string. + self.discarded_plural_forms = 0 def __repr__(self) -> str: return f"<{type(self).__name__} {self.id!r} (flags: {list(self.flags)!r})>" @@ -232,7 +235,7 @@ def is_identical(self, other: Message) -> bool: return self.__dict__ == other.__dict__ def clone(self) -> Message: - return Message( + cloned = Message( id=copy(self.id), string=copy(self.string), locations=copy(self.locations), @@ -243,6 +246,8 @@ def clone(self) -> Message: lineno=self.lineno, # immutable (str/None) context=self.context, # immutable (str/None) ) + cloned.discarded_plural_forms = self.discarded_plural_forms + return cloned def check(self, catalog: Catalog | None = None) -> list[TranslationError]: """Run various validation checks on the message. Some validations @@ -774,6 +779,10 @@ def __setitem__(self, id: _MessageID, message: Message) -> None: current.auto_comments = list(dict.fromkeys([*current.auto_comments, *message.auto_comments])) # fmt:skip current.user_comments = list(dict.fromkeys([*current.user_comments, *message.user_comments])) # fmt:skip current.flags |= message.flags + current.discarded_plural_forms = max( + current.discarded_plural_forms, + message.discarded_plural_forms, + ) elif id == '': # special treatment for the header message self.mime_headers = message_from_string(message.string).items() @@ -996,6 +1005,7 @@ def _merge( oldmsg = remaining.pop(oldkey, None) assert oldmsg is not None message.string = oldmsg.string + message.discarded_plural_forms = oldmsg.discarded_plural_forms if keep_user_comments and oldmsg.user_comments: message.user_comments = list(dict.fromkeys(oldmsg.user_comments)) diff --git a/babel/messages/checkers.py b/babel/messages/checkers.py index 4026ab1b3..5070f5c7d 100644 --- a/babel/messages/checkers.py +++ b/babel/messages/checkers.py @@ -38,7 +38,8 @@ def num_plurals(catalog: Catalog | None, message: Message) -> None: msgstrs = message.string if not isinstance(msgstrs, (list, tuple)): msgstrs = (msgstrs,) - if len(msgstrs) != catalog.num_plurals: + n_forms = len(msgstrs) + message.discarded_plural_forms + if n_forms != catalog.num_plurals: raise TranslationError( f"Wrong number of plural forms (expected {catalog.num_plurals})", ) diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index 27f113ddf..b8a421d7f 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -194,6 +194,7 @@ def _add_message(self) -> None: Add a message to the catalog based on the current parser state and clear the state ready to process the next message. """ + discarded_plural_forms = 0 if len(self.messages) > 1: msgid = tuple(m.denormalize() for m in self.messages) string = ['' for _ in range(self.catalog.num_plurals)] @@ -204,6 +205,7 @@ def _add_message(self) -> None: self.offset, "msg has more translations than num_plurals of catalog", ) + discarded_plural_forms += 1 continue string[idx] = translation.denormalize() string = tuple(string) @@ -221,6 +223,7 @@ def _add_message(self) -> None: lineno=self.offset + 1, context=msgctxt, ) + message.discarded_plural_forms = discarded_plural_forms if self.obsolete: if not self.ignore_obsolete: self.catalog.obsolete[self.catalog._key_for(msgid, msgctxt)] = message diff --git a/tests/messages/test_checkers.py b/tests/messages/test_checkers.py index 9b0b4b779..714d254b0 100644 --- a/tests/messages/test_checkers.py +++ b/tests/messages/test_checkers.py @@ -79,8 +79,8 @@ def test_1_num_plurals_checkers(): def test_2_num_plurals_checkers(): - # in this testcase we add an extra msgstr[idx], we should be - # disregarding it + # Extra msgstr[idx] is dropped from message.string, but the checker + # still reports the mismatch. for _locale in [p for p in PLURALS if PLURALS[p][0] == 2]: if _locale in ['nn', 'no']: _locale = 'nn_NO' @@ -132,12 +132,31 @@ def test_2_num_plurals_checkers(): msgstr[2] "" """.encode('utf-8') - # we should be adding the missing msgstr[0] - - # This test will fail for revisions <= 406 because so far - # catalog.num_plurals was neglected catalog = read_po(BytesIO(po_file), _locale) message = catalog['foobar'] + assert len(message.string) == num_plurals + with pytest.raises(TranslationError, match="Wrong number of plural forms"): + checkers.num_plurals(catalog, message) + + +def test_num_plurals_checker_on_parsed_extra_forms(): + po_file = b'''\ +msgid "" +msgstr "" +"Language: en\\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" + +msgid "file" +msgid_plural "files" +msgstr[0] "file" +msgstr[1] "files" +msgstr[2] "too many" +''' + catalog = read_po(BytesIO(po_file), 'en') + message = catalog['file'] + assert message.string == ('file', 'files') + assert message.discarded_plural_forms == 1 + with pytest.raises(TranslationError, match="Wrong number of plural forms"): checkers.num_plurals(catalog, message) diff --git a/tests/messages/test_pofile_read.py b/tests/messages/test_pofile_read.py index d17f5d4af..79a8d70ef 100644 --- a/tests/messages/test_pofile_read.py +++ b/tests/messages/test_pofile_read.py @@ -514,6 +514,25 @@ def test_missing_plural_in_the_middle(): assert message.string[2] == 'Vohs [text]' +def test_plural_forms_beyond_nplurals_are_discarded(): + buf = StringIO('''\ +msgid "" +msgstr "" +"Plural-Forms: nplurals=2; plural=(n != 1);\\n" + +msgid "foo" +msgid_plural "foos" +msgstr[0] "Voh" +msgstr[1] "Vohs" +msgstr[2] "extra" +''') + catalog = pofile.read_po(buf, locale='en') + message = catalog['foo'] + assert catalog.num_plurals == 2 + assert message.string == ('Voh', 'Vohs') + assert message.discarded_plural_forms == 1 + + def test_with_location(): buf = StringIO('''\ #: main.py:1 \u2068filename with whitespace.py\u2069:123