Skip to content

Commit 408cc2a

Browse files
Backend/i18n: Localize region & spoken language names (#9173)
1 parent fab9331 commit 408cc2a

9 files changed

Lines changed: 132 additions & 15 deletions

File tree

app/backend/src/couchers/i18n/context.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
localize_list,
2222
localize_time,
2323
localize_timezone,
24+
try_localize_language_name_from_iso639,
25+
try_localize_region_name_from_iso3166,
2426
)
2527
from couchers.models.users import User
2628
from couchers.utils import to_timezone
@@ -65,6 +67,12 @@ def __setattr__(self, name: str, value: Any) -> None:
6567
def localized_timezone(self) -> str:
6668
return localize_timezone(self.timezone, self.babel_locale)
6769

70+
def try_localize_language_name_from_iso639(self, code: str, standalone: bool = False) -> str | None:
71+
return try_localize_language_name_from_iso639(code, self.babel_locale, standalone=standalone)
72+
73+
def try_localize_region_name_from_iso3166(self, code: str) -> str | None:
74+
return try_localize_region_name_from_iso3166(code, self.babel_locale)
75+
6876
def localize_string(
6977
self, key: str, *, i18next: I18Next | None = None, substitutions: dict[str, str | int] | None = None
7078
) -> str:

app/backend/src/couchers/i18n/localize.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from babel.lists import format_list
1515

1616
from couchers.i18n.locales import DEFAULT_LOCALE, get_main_i18next
17+
from couchers.resources import get_region_code_iso3166_alpha3_to_alpha2
1718

1819

1920
def localize_string(lang: str | None, key: str, *, substitutions: Mapping[str, str | int] | None = None) -> str:
@@ -35,6 +36,43 @@ def localize_list(items: Sequence[str], locale: babel.Locale) -> str:
3536
return format_list(items, locale=locale)
3637

3738

39+
def try_localize_language_name_from_iso639(code: str, locale: babel.Locale, standalone: bool = False) -> str | None:
40+
"""
41+
Attempts to localize the name of a language expressed as an ISO639 code.
42+
43+
Args:
44+
code: The ISO639 language code.
45+
locale: The locale to render the language name in.
46+
standalone: The result won't be part of a larger sentence and should be capitalized if the language has capitals.
47+
48+
Returns:
49+
The localized name, or None if no localized name is available.
50+
"""
51+
try:
52+
name = babel.Locale.parse(code).get_language_name(locale)
53+
if name is None:
54+
return None
55+
if standalone:
56+
# The Unicode CLDR returns a casing that allows embedding in a larger sentence, e.g. "español".
57+
# If we're displaying the language name on its own, capitalize its first letter if applicable.
58+
# An LLM prompt revealed that this holds for all major languages.
59+
# It is a no-op for scripts that don't have capital letters.
60+
name = name[:1].title() + name[1:]
61+
return name
62+
except (ValueError, babel.UnknownLocaleError):
63+
return None
64+
65+
66+
def try_localize_region_name_from_iso3166(code: str, locale: babel.Locale) -> str | None:
67+
"""
68+
Gets a region name specified as an ISO3166 alpha2 or alpha3 code, localized in the given locale.
69+
"""
70+
# The Unicode CLDR uses alpha2 codes as keys (all alpha3 codes have a corresponding alpha2 code)
71+
code = get_region_code_iso3166_alpha3_to_alpha2().get(code, code)
72+
region_name: str | None = locale.territories.get(code, None)
73+
return region_name
74+
75+
3876
def localize_date(
3977
value: date, locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
4078
) -> str:

app/backend/src/couchers/resources.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import functools
22
import json
33
import logging
4+
from collections.abc import Mapping
45
from dataclasses import dataclass
56
from pathlib import Path
67
from typing import Any, cast
@@ -36,14 +37,26 @@ def get_icon(name: str) -> str:
3637

3738

3839
@functools.cache
39-
def get_region_dict() -> dict[str, str]:
40+
def get_region_dict() -> Mapping[str, str]:
4041
"""
4142
Get a list of allowed regions as a dictionary of {alpha3: name}.
4243
"""
4344
with session_scope() as session:
4445
return {region.code: region.name for region in session.execute(select(Region)).scalars().all()}
4546

4647

48+
@functools.cache
49+
def get_region_code_iso3166_alpha3_to_alpha2() -> Mapping[str, str]:
50+
"""
51+
Gets a best-effort mapping table from ISO-3166 alpha3 to alpha2 codes.
52+
The alpha3 keys of get_region_dict() are not guaranteed to be present in this table.
53+
Valid region codes, this mapping, and localized strings are all versioned separately (database / repo / CLDR).
54+
"""
55+
with open(resources_folder / "regions.json", "r") as f:
56+
json_regions = json.load(f)
57+
return {region["alpha3"]: region["alpha2"] for region in json_regions if "alpha2" in region}
58+
59+
4760
def region_is_allowed(code: str) -> bool:
4861
"""
4962
Check a region code is valid
@@ -52,7 +65,7 @@ def region_is_allowed(code: str) -> bool:
5265

5366

5467
@functools.cache
55-
def get_language_dict() -> dict[str, str]:
68+
def get_language_dict() -> Mapping[str, str]:
5669
"""
5770
Get a list of allowed languages as a dictionary of {code: name}.
5871
"""
@@ -61,7 +74,7 @@ def get_language_dict() -> dict[str, str]:
6174

6275

6376
@functools.cache
64-
def get_badge_data() -> dict[str, Any]:
77+
def get_badge_data() -> Mapping[str, Any]:
6578
"""
6679
Get a list of profile badges in form {id: Badge}
6780
"""
@@ -83,7 +96,7 @@ class Badge:
8396

8497

8598
@functools.cache
86-
def get_badge_dict() -> dict[str, Badge]:
99+
def get_badge_dict() -> Mapping[str, Badge]:
87100
"""
88101
Get a list of profile badges in form {id: Badge}
89102
"""
@@ -92,7 +105,7 @@ def get_badge_dict() -> dict[str, Badge]:
92105

93106

94107
@functools.cache
95-
def get_static_badge_dict() -> dict[str, list[int]]:
108+
def get_static_badge_dict() -> Mapping[str, list[int]]:
96109
"""
97110
Get a list of static badges in form {id: list(user_ids)}
98111
"""
@@ -124,7 +137,7 @@ def get_postcard_font() -> bytes:
124137

125138

126139
@functools.cache
127-
def get_postcard_metadata() -> dict[str, Any]:
140+
def get_postcard_metadata() -> Mapping[str, Any]:
128141
"""
129142
Returns the postcard metadata (coordinates, sizes, etc.) from postcard-metadata.json.
130143
"""

app/backend/src/couchers/servicers/resources.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ def GetRegions(
4848
) -> resources_pb2.GetRegionsRes:
4949
return resources_pb2.GetRegionsRes(
5050
regions=[
51-
resources_pb2.Region(alpha3=alpha3, name=name) for alpha3, name in sorted(get_region_dict().items())
51+
resources_pb2.Region(
52+
alpha3=alpha3,
53+
name=context.localization.try_localize_region_name_from_iso3166(alpha3) or english_name,
54+
)
55+
for alpha3, english_name in sorted(get_region_dict().items())
5256
]
5357
)
5458

@@ -57,7 +61,12 @@ def GetLanguages(
5761
) -> resources_pb2.GetLanguagesRes:
5862
return resources_pb2.GetLanguagesRes(
5963
languages=[
60-
resources_pb2.Language(code=code, name=name) for code, name in sorted(get_language_dict().items())
64+
resources_pb2.Language(
65+
code=code,
66+
name=context.localization.try_localize_language_name_from_iso639(code, standalone=True)
67+
or english_name,
68+
)
69+
for code, english_name in sorted(get_language_dict().items())
6170
]
6271
)
6372

app/backend/src/tests/fixtures/sessions.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,10 @@ class FakeChannel:
153153
instances when handlers are invoked.
154154
"""
155155

156-
def __init__(self, token: str | None = None):
156+
def __init__(self, token: str | None = None, *, locale: str | None = None):
157157
self.handlers: dict[str, Any] = {}
158158
self._token = token
159+
self._locale = locale or DEFAULT_LOCALE
159160
self._pool = get_descriptor_pool()
160161

161162
def add_generic_rpc_handlers(self, generic_rpc_handlers: Any):
@@ -188,7 +189,7 @@ def fake_handler(request):
188189
is_api_key=False,
189190
token=self._token if auth_info else None,
190191
localization=LocalizationContext(
191-
locale=(auth_info and auth_info.ui_language_preference) or DEFAULT_LOCALE,
192+
locale=(auth_info and auth_info.ui_language_preference) or self._locale,
192193
timezone=ZoneInfo((auth_info and auth_info.timezone) or "Etc/UTC"),
193194
),
194195
sofa="test_sofa_cookie_value",
@@ -528,7 +529,7 @@ def bugs_session(token: str | None = None):
528529

529530

530531
@contextmanager
531-
def resources_session():
532-
channel = FakeChannel()
532+
def resources_session(*, locale: str | None = None):
533+
channel = FakeChannel(locale=locale)
533534
resources_pb2_grpc.add_ResourcesServicer_to_server(Resources(), channel)
534535
yield resources_pb2_grpc.ResourcesStub(channel)

app/backend/src/tests/test_localize.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,46 @@
33

44
import babel
55

6-
from couchers.i18n.localize import localize_date, localize_datetime, localize_list, localize_time, localize_timezone
6+
from couchers.i18n.localize import (
7+
localize_date,
8+
localize_datetime,
9+
localize_list,
10+
localize_time,
11+
localize_timezone,
12+
try_localize_language_name_from_iso639,
13+
try_localize_region_name_from_iso3166,
14+
)
715

816
babel_en = babel.Locale.parse("en")
917
babel_es = babel.Locale.parse("es")
1018
babel_fr = babel.Locale.parse("fr")
1119
babel_zh = babel.Locale.parse("zh")
1220

1321

22+
def test_localize_language_name() -> None:
23+
assert try_localize_language_name_from_iso639("en", babel_en) == "English"
24+
assert try_localize_language_name_from_iso639("es", babel_en) == "Spanish"
25+
26+
assert try_localize_language_name_from_iso639("en", babel_es) == "inglés"
27+
assert try_localize_language_name_from_iso639("es", babel_es) == "español"
28+
29+
assert try_localize_language_name_from_iso639("en", babel_es, standalone=True) == "Inglés" # Sentence case
30+
assert try_localize_language_name_from_iso639("eng", babel_es) == "inglés" # ISO639-3 code
31+
assert try_localize_language_name_from_iso639("xx", babel_en) is None
32+
33+
34+
def test_localize_region_name() -> None:
35+
assert try_localize_region_name_from_iso3166("US", babel_en) == "United States"
36+
assert try_localize_region_name_from_iso3166("DE", babel_en) == "Germany"
37+
38+
assert try_localize_region_name_from_iso3166("US", babel_es) == "Estados Unidos"
39+
assert try_localize_region_name_from_iso3166("DE", babel_es) == "Alemania"
40+
41+
assert try_localize_region_name_from_iso3166("USA", babel_en) == "United States" # alpha3 code
42+
43+
assert try_localize_region_name_from_iso3166("xx", babel_en) is None
44+
45+
1446
def test_localize_date() -> None:
1547
assert localize_date(date(2000, 1, 2), babel_en) == "January 2, 2000"
1648
assert localize_date(date(2000, 1, 2), babel_es, abbrev=True) == "2 ene 2000"

app/backend/src/tests/test_resources.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ def test_GetRegions(db):
3535
assert ("SWE", "Sweden") in regions_list
3636
assert ("???", "Nonexistent region") not in regions_list
3737

38+
with resources_session(locale="es") as api:
39+
regions = api.GetRegions(empty_pb2.Empty()).regions
40+
regions_list = [(r.alpha3, r.name) for r in regions]
41+
assert ("FIN", "Finlandia") in regions_list
42+
assert ("FIN", "Finland") not in regions_list
43+
3844

3945
def test_GetLanguages(db):
4046
with resources_session() as api:
@@ -44,6 +50,12 @@ def test_GetLanguages(db):
4450
assert ("swe", "Swedish") in languages_list
4551
assert ("???", "Nonexistent language") not in languages_list
4652

53+
with resources_session(locale="es") as api:
54+
languages = api.GetLanguages(empty_pb2.Empty()).languages
55+
languages_list = [(r.code, r.name) for r in languages]
56+
assert ("swe", "Sueco") in languages_list
57+
assert ("swe", "Swedish") not in languages_list
58+
4759

4860
def test_GetBadges(db):
4961
with resources_session() as api:

app/web/features/profile/hooks/useLanguages.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { useQuery } from "@tanstack/react-query";
22
import { languagesKey } from "features/queryKeys";
3+
import { useTranslation } from "i18n";
34
import { service } from "service";
45

56
export const useLanguages = () => {
7+
const locale = useTranslation().i18n.language;
68
const { data: languages, ...rest } = useQuery({
7-
queryKey: [languagesKey],
9+
queryKey: [languagesKey, locale],
810
queryFn: () =>
911
service.resources
1012
.getLanguages()

app/web/features/profile/hooks/useRegions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { useQuery } from "@tanstack/react-query";
22
import { regionsKey } from "features/queryKeys";
3+
import { useTranslation } from "i18n";
34
import { service } from "service";
45

56
export const useRegions = () => {
7+
const locale = useTranslation().i18n.language;
68
const { data: regions, ...rest } = useQuery({
7-
queryKey: [regionsKey],
9+
queryKey: [regionsKey, locale],
810
queryFn: () =>
911
service.resources
1012
.getRegions()

0 commit comments

Comments
 (0)