Skip to content

Commit 90d1370

Browse files
authored
Stop reintroducing the wrong plural catchall (#4468)
MF2 syntax writes the catchall as a bare `*`, so parsing a message back from it cannot recover the label and `parse_source_string_to_json` assumed `other`. gettext addresses plural forms by category, so in be, pl, ru, szl and uk that produced a catchall matching no form, serialized as an empty msgstr. Migration 0131 repaired the existing rows; this stops new ones being written.
1 parent 3a424f4 commit 90d1370

7 files changed

Lines changed: 85 additions & 7 deletions

File tree

pontoon/base/models/locale.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
from typing import TYPE_CHECKING
3+
from typing import TYPE_CHECKING, Literal
44

55
from django.contrib.auth.models import Group
66
from django.core.exceptions import ValidationError
@@ -346,8 +346,18 @@ def cldr_plurals_list(self) -> list[str]:
346346
log.error(
347347
f"Invalid cldr_plurals for locale {self.code}: {self.cldr_plurals}"
348348
)
349+
if res and res[-1] not in ("many", "other"):
350+
log.error(
351+
f"cldr_plurals for locale {self.code} does not end in a catchall"
352+
f" category: {self.cldr_plurals}"
353+
)
349354
return res
350355

356+
@property
357+
def plural_catchall(self) -> Literal["many", "other"]:
358+
categories = self.cldr_plurals_list()
359+
return "many" if categories and categories[-1] == "many" else "other"
360+
351361
@property
352362
def nplurals(self) -> int:
353363
return self.cldr_plurals.count(",") + 1

pontoon/base/tests/models/test_locale.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import pytest
22

3+
from pontoon.base.models import Locale
4+
35

46
@pytest.mark.django_db
57
def test_locale_latest_activity_with_latest(translation_a):
@@ -68,3 +70,17 @@ def test_locale_managers_group(locale_a, locale_b, user_a):
6870
assert user_a.has_perm("base.can_manage_locale") is False
6971
assert user_a.has_perm("base.can_manage_locale", locale_a) is True
7072
assert user_a.has_perm("base.can_manage_locale", locale_b) is True
73+
74+
75+
@pytest.mark.parametrize(
76+
"cldr_plurals, expected",
77+
[
78+
("1,3,4", "many"), # be, pl, ru, szl, uk
79+
("1,5", "other"),
80+
("", "other"), # no plurals recorded
81+
("nonsense", "other"), # cldr_plurals_list() logs and skips
82+
("1,3", "other"), # last category is not a catchall, so logged and coerced
83+
],
84+
)
85+
def test_locale_plural_catchall(cldr_plurals, expected):
86+
assert Locale(code="kl", cldr_plurals=cldr_plurals).plural_catchall == expected

pontoon/batch/actions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def copy_translation_from_locale(
316316
# Copy from other locale (entity.string directly)
317317
for entity in entities:
318318
_, value, properties = parse_source_string_to_json(
319-
entity.resource.format, entity.string
319+
entity.resource.format, entity.string, locale.plural_catchall
320320
)
321321
translations_to_create.append(
322322
Translation(
@@ -335,7 +335,7 @@ def copy_translation_from_locale(
335335
else:
336336
for t in other_locale_translations:
337337
_, value, properties = parse_source_string_to_json(
338-
t.entity.resource.format, t.string
338+
t.entity.resource.format, t.string, locale.plural_catchall
339339
)
340340
translations_to_create.append(
341341
Translation(

pontoon/batch/utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ def find_and_replace(
127127
errors = False
128128
try:
129129
_, new_translation.value, new_translation.properties = (
130-
parse_source_string_to_json(res_format, new_translation.string)
130+
parse_source_string_to_json(
131+
res_format,
132+
new_translation.string,
133+
new_translation.locale.plural_catchall,
134+
)
131135
)
132136
except ValueError:
133137
errors = True

pontoon/pretranslation/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def pretranslate(project: Project, paths: set[str] | None):
142142
string, author_key = pretranslation
143143
try:
144144
_, value, properties = parse_source_string_to_json(
145-
entity.resource.format, string
145+
entity.resource.format, string, locale.plural_catchall
146146
)
147147
except ValueError as e:
148148
log.error(
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from pontoon.base.models import Resource
2+
from pontoon.translations.utils import parse_source_string_to_json
3+
4+
5+
PLURAL_MF2 = (
6+
".input {$n :number}\n"
7+
".match $n\n"
8+
"one {{jedna rzecz}}\n"
9+
"few {{kilka rzeczy}}\n"
10+
"* {{wiele rzeczy}}\n"
11+
)
12+
13+
14+
def catchall_keys(value):
15+
return [
16+
key
17+
for variant in value["alt"]
18+
for key in variant["keys"]
19+
if isinstance(key, dict)
20+
]
21+
22+
23+
def test_plural_catchall_uses_locale_category():
24+
"""For a locale whose last plural category is `many`, the catchall must be
25+
labelled `many` (#4453).
26+
"""
27+
_, value, _ = parse_source_string_to_json(
28+
Resource.Format.GETTEXT, PLURAL_MF2, "many"
29+
)
30+
assert catchall_keys(value) == [{"*": "many"}]
31+
32+
33+
def test_plural_catchall_defaults_to_other():
34+
"""Without a catchall name, the catchall keeps the source locale's category."""
35+
_, value, _ = parse_source_string_to_json(Resource.Format.GETTEXT, PLURAL_MF2)
36+
assert catchall_keys(value) == [{"*": "other"}]
37+
38+
39+
def test_plural_catchall_for_locale_ending_in_other():
40+
_, value, _ = parse_source_string_to_json(
41+
Resource.Format.GETTEXT, PLURAL_MF2, "other"
42+
)
43+
assert catchall_keys(value) == [{"*": "other"}]

pontoon/translations/utils.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any
1+
from typing import Any, Literal
22

33
from moz.l10n.formats.fluent import fluent_parse_entry, fluent_serialize_entry
44
from moz.l10n.formats.mf2 import mf2_parse_message, mf2_serialize_message
@@ -18,11 +18,16 @@
1818
def parse_source_string_to_json(
1919
res_format: str,
2020
source: str,
21+
catchall_name: Literal["many", "other"] = "other",
2122
) -> tuple[list[str], JsonMessage, dict[str, JsonMessage] | None]:
2223
"""Parse an entity's `source` string into its `(key, value, properties)` JSON.
2324
2425
Used to build entities that aren't backed by a synced row, i.e. in the
2526
pretranslate API and in test factories.
27+
28+
`catchall_name` is the category a catchall plural variant is labelled with,
29+
i.e. `Locale.plural_catchall`. Pass it when parsing a translation, leave it
30+
unset for source strings.
2631
"""
2732
match res_format:
2833
case Resource.Format.FLUENT:
@@ -45,7 +50,7 @@ def parse_source_string_to_json(
4550
for keys in msg.variants:
4651
for key in keys:
4752
if isinstance(key, CatchallKey):
48-
key.value = "other"
53+
key.value = catchall_name
4954
return [], message_to_json(msg), None
5055
case Resource.Format.PROPERTIES:
5156
msg = properties_parse_message(source)

0 commit comments

Comments
 (0)