Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Couchers.org backend application"
requires-python = "==3.14.*"
dependencies = [
"alembic>=1.17.1",
"babel>=2.18.0",
"boto3>=1.39.3",
"cachetools>=6.2.1",
"cryptography>=46.0.3",
Expand Down
12 changes: 5 additions & 7 deletions app/backend/src/couchers/i18n/i18next.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dataclasses import dataclass, field
from typing import Any

from couchers.i18n.plurals import PluralRule
from babel import Locale

PLURALIZABLE_VARIABLE_NAME = "count"
"""Special variable name for which i18next supports pluralization forms."""
Expand All @@ -21,8 +21,8 @@ class I18Next:
default_language: Language | None = None
"""The language used to look up strings in unsupported languages."""

def add_language(self, code: str, plural_rule: PluralRule, *, json_dict: dict[str, Any] | None = None) -> Language:
language = Language(code, plural_rule)
def add_language(self, code: str, *, json_dict: dict[str, Any] | None = None) -> Language:
language = Language(code)
self.languages_by_code[code] = language
if json_dict:
language.load_json_dict(json_dict)
Expand Down Expand Up @@ -56,8 +56,6 @@ class Language:

code: str
"""The language code, e.g. 'en'"""
plural_rule: PluralRule
"""The rule for plurals in this language."""
strings_by_key: dict[str, String] = field(default_factory=dict)
fallbacks: list[Language] = field(default_factory=list)

Expand Down Expand Up @@ -85,8 +83,8 @@ def find_string(self, key: str, substitutions: Mapping[str, str | int] | None =
if substitutions:
if count := substitutions.get(PLURALIZABLE_VARIABLE_NAME):
if isinstance(count, int):
plural_category = self.plural_rule(count)
plural_key = key + "_" + plural_category.value
plural_form = Locale(self.code).plural_form(count)
plural_key = key + "_" + plural_form
if string := self.strings_by_key.get(plural_key):
return string
return self.strings_by_key.get(key)
Expand Down
6 changes: 2 additions & 4 deletions app/backend/src/couchers/i18n/locales.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from pathlib import Path

from couchers.i18n.i18next import I18Next
from couchers.i18n.plurals import PluralRules

# The default locale if a language or string is unavailable.
# Note: "en" is a valid locale even if it doesn't include a region.
Expand Down Expand Up @@ -41,12 +40,11 @@ def load_locales(directory: Path) -> I18Next:
with open(locale_file, "r", encoding="utf-8") as f:
translations = json.load(f)

plural_rule = PluralRules.for_language(lang_code) or PluralRules.en
language = i18next.add_language(lang_code, plural_rule)
language = i18next.add_language(lang_code)
language.load_json_dict(translations)

# English is our default for undefined languages
en = i18next.languages_by_code.get("en")
en = i18next.languages_by_code.get(DEFAULT_LOCALE)
if en is None:
raise RuntimeError("English translations must be loaded")
i18next.default_language = en
Expand Down
58 changes: 44 additions & 14 deletions app/backend/src/couchers/i18n/localize.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
Most code should use the higher-level couchers.i18n.LocalizationContext object.
"""

from collections.abc import Mapping
from collections.abc import Callable, Mapping
from datetime import date, datetime, time
from functools import lru_cache
from pathlib import Path
from zoneinfo import ZoneInfo

import phonenumbers
from babel.dates import format_date, format_datetime, format_time, get_timezone_name
from google.protobuf.timestamp_pb2 import Timestamp

from couchers.i18n.i18next import I18Next
from couchers.i18n.locales import load_locales
from couchers.i18n.locales import DEFAULT_LOCALE, get_locale_fallbacks, load_locales
from couchers.utils import to_aware_datetime


Expand All @@ -35,13 +36,16 @@ def localize_string(lang: str | None, key: str, *, substitutions: Mapping[str, s
Returns:
The translated string with substitutions applied
"""
return get_main_i18next().localize(key, lang or "en", substitutions)
return get_main_i18next().localize(key, lang or DEFAULT_LOCALE, substitutions)


def localize_date(value: date, locale: str) -> str:
"""Formats a time- and timezone-agnostic date for the given locale."""
# TODO(#7590): Account for locale
return value.strftime("%A %-d %B %Y")
return _localize_with_fallbacks(
locale,
lambda candidate_locale: format_date(value, locale=candidate_locale),
lambda: value.strftime("%A %-d %B %Y"),
)


def localize_date_from_iso(value: str, locale: str) -> str:
Expand All @@ -51,8 +55,11 @@ def localize_date_from_iso(value: str, locale: str) -> str:

def localize_time(value: time, locale: str) -> str:
"""Formats a date- and timezone-agnostic time for the given locale."""
# TODO(#7590): Account for locale
return value.strftime("%-I:%M %p (%H:%M)")
return _localize_with_fallbacks(
locale,
lambda candidate_locale: format_time(value, locale=candidate_locale),
lambda: value.strftime("%-I:%M %p (%H:%M)"),
)


def localize_datetime(value: datetime | Timestamp, timezone: ZoneInfo | None, locale: str) -> str:
Expand All @@ -76,16 +83,39 @@ def localize_datetime(value: datetime | Timestamp, timezone: ZoneInfo | None, lo
if timezone is not None:
value = value.astimezone(timezone)

localized_date = localize_date(value.date(), locale)
localized_time = localize_time(value.time(), locale)

# TODO(#7590): Account for locale
return f"{localized_date} at {localized_time}"
return _localize_with_fallbacks(
locale,
lambda candidate_locale: format_datetime(value, locale=candidate_locale),
lambda: localize_date(value.date(), locale) + " " + localize_time(value.time(), locale),
)


def localize_timezone(timezone: ZoneInfo, locale: str) -> str:
# TODO(#7590): Account for locale
return datetime.now(tz=timezone).strftime("%Z/UTC%z")
return _localize_with_fallbacks(
locale,
lambda candidate_locale: get_timezone_name(timezone, locale=candidate_locale),
lambda: datetime.now(tz=timezone).strftime("%Z/UTC%z"),
)


def _localize_with_fallbacks(
locale: str, localize: Callable[[str], str], fallback: Callable[[], str] | None = None
) -> str:
"""
Attempts to localize a value using fallback locales if the locale is unavailable, or a final unlocalized fallback.
"""
exception: Exception | None
for candidate_locale in [locale] + get_locale_fallbacks(locale):
try:
return localize(candidate_locale.replace("-", "_")) # Babel expects "en_US", not "en-US".
except Exception as e:
exception = e
pass

if fallback:
return fallback()

raise exception or Exception(f"Failed to localize to {locale}.")


def format_phone_number(value: str) -> str:
Expand Down
171 changes: 0 additions & 171 deletions app/backend/src/couchers/i18n/plurals.py

This file was deleted.

2 changes: 1 addition & 1 deletion app/backend/src/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def test_ChangeUserBirthdate(db, push_collector: PushCollector):

push = push_collector.pop_for_user(normal_user.id, last=True)
assert push.content.title == "Birthdate changed"
assert push.content.body == "An admin changed your date of birth to Friday 25 May 1990."
assert push.content.body == "An admin changed your date of birth to May 25, 1990."


def test_BanUser(db):
Expand Down
Loading