Skip to content

Commit 60ec83a

Browse files
Backend/i18n: Externalize fallbacks languages from I18Next
1 parent 3489da0 commit 60ec83a

9 files changed

Lines changed: 88 additions & 126 deletions

File tree

app/backend/src/couchers/email/blocks.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def _body_builder(
5151
standard_closing: bool = True,
5252
security_warning: bool = False,
5353
) -> EmailBlocksBuilder:
54-
builder = EmailBlocksBuilder(locale=loc_context.locale, string_key_base=self.string_key_base)
54+
builder = EmailBlocksBuilder(locales=loc_context.locale_list, string_key_base=self.string_key_base)
5555
if standard_greeting:
5656
builder.para("generic.greeting_line", {"name": self.user_name})
5757
if standard_closing:
@@ -78,7 +78,7 @@ def _localize(
7878
self, loc_context: LocalizationContext, key: str, substitutions: SubstitutionDict | None = None
7979
) -> str:
8080
key = full_string_key(key, relative_base=self.string_key_base)
81-
return get_emails_i18next().localize(key, loc_context.locale, substitutions)
81+
return loc_context.localize_string(key, i18next=get_emails_i18next(), substitutions=substitutions)
8282

8383

8484
@dataclass
@@ -153,13 +153,13 @@ class EmailBlocksBuilder:
153153
Builder object for constructing a list of localized EmailBlock's to form the body of an email.
154154
"""
155155

156-
_locale: str
156+
_locales: list[str]
157157
_string_key_base: str
158158
_blocks: list[EmailBlock]
159159
_epilogue: list[EmailBlock]
160160

161-
def __init__(self, locale: str, string_key_base: str):
162-
self._locale = locale
161+
def __init__(self, locales: list[str], string_key_base: str):
162+
self._locales = locales
163163
self._string_key_base = string_key_base
164164
self._blocks = []
165165
self._epilogue = []
@@ -194,11 +194,11 @@ def block(self, block: EmailBlock, epilogue: bool = False) -> Self:
194194

195195
def _text(self, key: str, substitutions: SubstitutionDict | None = None) -> str:
196196
key = full_string_key(key, relative_base=self._string_key_base)
197-
return get_emails_i18next().localize(key, self._locale, substitutions)
197+
return get_emails_i18next().localize(key, self._locales, substitutions)
198198

199199
def _markup(self, key: str, substitutions: SubstitutionDict | None = None) -> Markup:
200200
key = full_string_key(key, relative_base=self._string_key_base)
201-
return get_emails_i18next().localize_with_markup(key, self._locale, substitutions)
201+
return get_emails_i18next().localize_with_markup(key, self._locales, substitutions)
202202

203203

204204
@dataclass(kw_only=True)

app/backend/src/couchers/email/calendar_events.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@ def create_host_request_event(
4242
event.extra.append(ContentLine(name="SEQUENCE", value=str(sequence)))
4343

4444
if hosting:
45-
event.name = get_emails_i18next().localize(
46-
"calendar_events.host_requests.title_host", loc_context.locale, {"name": other_name}
45+
event.name = loc_context.localize_string(
46+
"calendar_events.host_requests.title_host", i18next=get_emails_i18next(), substitutions={"name": other_name}
4747
)
4848
else:
49-
event.name = get_emails_i18next().localize(
50-
"calendar_events.host_requests.title_surfer", loc_context.locale, {"name": other_name}
49+
event.name = loc_context.localize_string(
50+
"calendar_events.host_requests.title_surfer",
51+
i18next=get_emails_i18next(),
52+
substitutions={"name": other_name},
5153
)
5254

5355
# Our to_date is inclusive, iCalendar's DTEND is exclusive (for full-day events)
@@ -76,8 +78,8 @@ def create_host_request_cancellation_calendar(
7678
host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
7779
) -> Calendar:
7880
event = create_host_request_event(host_request, other_name, hosting, loc_context, sequence=1)
79-
event.name = get_emails_i18next().localize(
80-
"calendar_events.title_cancelled", loc_context.locale, {"title": event.name}
81+
event.name = loc_context.localize_string(
82+
"calendar_events.title_cancelled", i18next=get_emails_i18next(), substitutions={"title": event.name}
8183
)
8284
event.status = "CANCELLED"
8385

app/backend/src/couchers/email/emails.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ def get_details_block(self, loc_context: LocalizationContext) -> EmailBlock:
621621
html += time_range_display
622622
if self.online_link:
623623
html += "<br>"
624-
online_link_text = get_emails_i18next().localize("events.generic.online_link", loc_context.locale)
624+
online_link_text = loc_context.localize_string("events.generic.online_link", i18next=get_emails_i18next())
625625
html += f'<i><a href="{escape(self.online_link)}">{escape(online_link_text)}</a></i>'
626626
elif self.address:
627627
html += "<br>"
@@ -633,7 +633,7 @@ def get_description_block(self) -> EmailBlock:
633633
return QuoteBlock(text=Markup(self.description_markdown), markdown=True)
634634

635635
def get_view_action_block(self, loc_context: LocalizationContext) -> EmailBlock:
636-
view_action_text = get_emails_i18next().localize("events.generic.view_action", loc_context.locale)
636+
view_action_text = loc_context.localize_string("events.generic.view_action", i18next=get_emails_i18next())
637637
return ActionBlock(text=view_action_text, target_url=self.view_url)
638638

639639
@classmethod

app/backend/src/couchers/email/rendering.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_
7777
case ParaBlock():
7878
concat.append(_to_plaintext(block.text))
7979
case UserBlock():
80-
line = get_emails_i18next().localize(
80+
line = loc_context.localize_string(
8181
"plaintext_formats.user",
82-
loc_context.locale,
83-
{"name": block.info.name, "age": str(block.info.age), "city": block.info.city},
82+
i18next=get_emails_i18next(),
83+
substitutions={"name": block.info.name, "age": str(block.info.age), "city": block.info.city},
8484
)
8585
concat.append(line)
8686
if block.comment:
@@ -90,8 +90,10 @@ def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_
9090
for line in block.text.splitlines():
9191
concat.append(f"> {line}")
9292
case ActionBlock():
93-
line = get_emails_i18next().localize(
94-
"plaintext_formats.action", loc_context.locale, {"text": block.text, "url": block.target_url}
93+
line = loc_context.localize_string(
94+
"plaintext_formats.action",
95+
i18next=get_emails_i18next(),
96+
substitutions={"text": block.text, "url": block.target_url},
9597
)
9698
concat.append(line)
9799
case _:

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
import babel
77
from google.protobuf.timestamp_pb2 import Timestamp
88

9-
from couchers.i18n.i18next import I18Next
9+
from couchers.i18n.i18next import I18Next, SubstitutionDict
1010
from couchers.i18n.locales import (
1111
DEFAULT_LOCALE,
1212
get_babel_locale,
13-
get_locale_fallbacks,
13+
get_locale_chain,
1414
get_main_i18next,
1515
is_supported_locale,
1616
)
@@ -48,7 +48,7 @@ def __init__(self, locale: str, timezone: tzinfo) -> None:
4848
raise ValueError(f"Unsupported locale {locale}.")
4949

5050
self.locale = locale
51-
self.locale_list = [self.locale] + get_locale_fallbacks(self.locale)
51+
self.locale_list = get_locale_chain(self.locale)
5252
self.timezone = timezone
5353
self.babel_locale = get_babel_locale(locale)
5454

@@ -64,10 +64,10 @@ def localized_timezone(self) -> str:
6464
return localize_timezone(self.timezone, self.babel_locale)
6565

6666
def localize_string(
67-
self, key: str, *, i18next: I18Next | None = None, substitutions: dict[str, str | int] | None = None
67+
self, key: str, *, i18next: I18Next | None = None, substitutions: SubstitutionDict | None = None
6868
) -> str:
6969
i18next = i18next or get_main_i18next()
70-
return i18next.localize(key, self.locale, substitutions=substitutions)
70+
return i18next.localize(key, self.locale_list, substitutions=substitutions)
7171

7272
def localize_date(
7373
self, value: date | datetime, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False

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

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,45 +28,41 @@ class I18Next:
2828
def __init__(self) -> None:
2929
self.translations_by_locale: dict[str, Translation] = dict()
3030

31-
# The translation used to look up strings in unsupported locales.
32-
self.default_translation: Translation | None = None
33-
3431
def add_translation(self, locale: str, *, json_dict: dict[str, Any] | None = None) -> Translation:
3532
translation = Translation(babel_locale=babel.Locale.parse(locale, sep="-"))
3633
self.translations_by_locale[locale] = translation
3734
if json_dict:
3835
translation.load_json_dict(json_dict)
3936
return translation
4037

41-
def find_string(self, key: str, locale: str, substitutions: SubstitutionDict | None = None) -> String | None:
42-
"""Find the string that will be localized, applying fallbacks and variant selection."""
43-
translation = self.translations_by_locale.get(locale, self.default_translation)
44-
candidate_translations = [translation] + translation.fallbacks if translation else []
45-
for candidate_translation in candidate_translations:
46-
if string := candidate_translation.find_string(key, substitutions):
47-
if string.template.can_render(substitutions):
48-
return string
38+
def find_string(self, key: str, locales: list[str], substitutions: SubstitutionDict | None = None) -> String | None:
39+
"""Find the string that will be localized, trying each locale in order."""
40+
for locale in locales:
41+
if t := self.translations_by_locale.get(locale):
42+
if string := t.find_string(key, substitutions):
43+
if string.template.can_render(substitutions):
44+
return string
4945

50-
raise LocalizationError(locale, key)
46+
raise LocalizationError(locales, key)
5147

52-
def localize(self, string_key: str, locale: str, substitutions: SubstitutionDict | None = None) -> str:
48+
def localize(self, string_key: str, locales: list[str], substitutions: SubstitutionDict | None = None) -> str:
5349
"""Finds a translated string in the best matching locale and performs substitutions."""
54-
if string := self.find_string(string_key, locale, substitutions):
50+
if string := self.find_string(string_key, locales, substitutions):
5551
return string.render(substitutions)
5652
else:
57-
raise LocalizationError(locale, string_key)
53+
raise LocalizationError(locales, string_key)
5854

5955
def localize_with_markup(
60-
self, string_key: str, locale: str, substitutions: SubstitutionDict | None = None
56+
self, string_key: str, locales: list[str], substitutions: SubstitutionDict | None = None
6157
) -> Markup:
6258
"""
6359
Finds a translated string that might contain markup in the best matching locale,
6460
and performs substitutions in a way that results in a markup-safe string.
6561
"""
66-
if string := self.find_string(string_key, locale, substitutions):
62+
if string := self.find_string(string_key, locales, substitutions):
6763
return string.render_with_markup(substitutions)
6864
else:
69-
raise LocalizationError(locale, string_key)
65+
raise LocalizationError(locales, string_key)
7066

7167

7268
class Translation:
@@ -76,7 +72,6 @@ def __init__(self, *, babel_locale: babel.Locale) -> None:
7672
# The Babel library locale for this translation. Used to resolved plural forms.
7773
self.babel_locale = babel_locale
7874
self.strings_by_key: dict[str, String] = dict()
79-
self.fallbacks: list[Translation] = []
8075

8176
@property
8277
def locale(self) -> str:
@@ -116,13 +111,13 @@ def find_string(self, key: str, substitutions: SubstitutionDict | None = None) -
116111
def localize(self, string_key: str, substitutions: SubstitutionDict | None = None) -> str:
117112
string = self.find_string(string_key, substitutions)
118113
if string is None:
119-
raise LocalizationError(locale=self.locale, string_key=string_key)
114+
raise LocalizationError(locales=[self.locale], string_key=string_key)
120115
return string.render(substitutions)
121116

122117
def localize_with_markup(self, string_key: str, substitutions: SubstitutionDict | None = None) -> Markup:
123118
string = self.find_string(string_key, substitutions)
124119
if string is None:
125-
raise LocalizationError(locale=self.locale, string_key=string_key)
120+
raise LocalizationError(locales=[self.locale], string_key=string_key)
126121
return string.render_with_markup(substitutions)
127122

128123

@@ -203,12 +198,12 @@ class StringSegment:
203198

204199

205200
class LocalizationError(Exception):
206-
"""Raised failing to localize a string, e.g. if it is not found in any fallback locale."""
201+
"""Raised failing to localize a string, e.g. if it is not found in any of the given locales."""
207202

208-
def __init__(self, locale: str, string_key: str):
209-
self.locale = locale
203+
def __init__(self, locales: list[str], string_key: str):
204+
self.locales = locales
210205
self.string_key = string_key
211-
super().__init__(f"Could not localize string {string_key} for locale {locale}")
206+
super().__init__(f"Could not localize string {string_key} for locales {locales}")
212207

213208

214209
def full_string_key(key: str, *, relative_base: str | None) -> str:

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

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@ def to_supported_locale(locale: str) -> str:
6363
return DEFAULT_LOCALE
6464

6565

66-
def get_locale_fallbacks(locale: str) -> list[str]:
67-
"""Gets the list of locales to which to fallback to if the given one is unavailable."""
66+
def get_locale_chain(locale: str) -> list[str]:
67+
"""Gets the ordered list of locales to try when looking up a string, starting with the given locale."""
6868
if fallback := _LOCALE_FALLBACKS.get(locale):
69-
return [fallback, DEFAULT_LOCALE]
69+
return [locale, fallback, DEFAULT_LOCALE]
7070
if locale == DEFAULT_LOCALE:
71-
return []
72-
return [DEFAULT_LOCALE]
71+
return [locale]
72+
return [locale, DEFAULT_LOCALE]
7373

7474

7575
def get_babel_locale(locale: str) -> babel.Locale:
@@ -99,12 +99,6 @@ def load_locales(directory: Path) -> I18Next:
9999
default_translation = i18next.translations_by_locale.get(DEFAULT_LOCALE)
100100
if default_translation is None:
101101
raise RuntimeError("English translations must be loaded")
102-
i18next.default_translation = default_translation
103-
104-
# Apply fallbacks
105-
for translation in i18next.translations_by_locale.values():
106-
for fallback_locale in get_locale_fallbacks(translation.locale):
107-
translation.fallbacks.append(i18next.translations_by_locale[fallback_locale])
108102

109103
return i18next
110104

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

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,13 @@
44
"""
55

66
import re
7-
from collections.abc import Mapping
87
from datetime import date, datetime, time, tzinfo
98
from typing import cast
109

1110
import babel
1211
import phonenumbers
1312
from babel.dates import get_datetime_format, get_timezone_name, match_skeleton, parse_pattern
1413

15-
from couchers.i18n.locales import DEFAULT_LOCALE, get_main_i18next
16-
17-
18-
def localize_string(lang: str | None, key: str, *, substitutions: Mapping[str, str | int] | None = None) -> str:
19-
"""
20-
Retrieves a translated string and performs substitutions.
21-
22-
Args:
23-
lang: Language code (e.g., "en", "pt-BR"). If None, defaults to the default fallback language ("en")
24-
key: The key for the string to be looked up.
25-
substitutions: Dictionary of variable substitutions for the string (e.g., {"hours": 24})
26-
27-
Returns:
28-
The translated string with substitutions applied
29-
"""
30-
return get_main_i18next().localize(key, lang or DEFAULT_LOCALE, substitutions)
31-
3214

3315
def localize_date(
3416
value: date, locale: babel.Locale, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False

0 commit comments

Comments
 (0)