Coverage for app/backend/src/couchers/i18n/context.py: 80%
47 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1from dataclasses import FrozenInstanceError
2from datetime import UTC, date, datetime, time, tzinfo
3from typing import Any
4from zoneinfo import ZoneInfo
6import babel
7from google.protobuf.timestamp_pb2 import Timestamp
9from couchers.i18n.i18next import I18Next
10from couchers.i18n.locales import (
11 DEFAULT_LOCALE,
12 get_babel_locale,
13 get_locale_fallbacks,
14 get_main_i18next,
15 is_supported_locale,
16)
17from couchers.i18n.localize import (
18 localize_date,
19 localize_datetime,
20 localize_time,
21 localize_timezone,
22)
23from couchers.models.users import User
24from couchers.utils import to_timezone
27class LocalizationContext:
28 """
29 Specifies regional settings used for localization of strings and date/times.
30 Future settings like 12/24h or format preferences would go here as well.
31 """
33 # The locale code (e.g. 'en', 'pt-BR'), used to lookup translations and format dates/numbers.
34 # Note that a locale doesn't necessarily specify a region.
35 locale: str
37 # The locale code and all of its fallbacks.
38 locale_list: list[str]
40 # The timezone to use when formatting date-times and instants.
41 timezone: tzinfo
43 # The Babel locale used for datetime formatting and other Unicode CLDR usage.
44 babel_locale: babel.Locale
46 def __init__(self, locale: str, timezone: tzinfo) -> None:
47 if not is_supported_locale(locale): 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true
48 raise ValueError(f"Unsupported locale {locale}.")
50 self.locale = locale
51 self.locale_list = [self.locale] + get_locale_fallbacks(self.locale)
52 self.timezone = timezone
53 self.babel_locale = get_babel_locale(locale)
55 def __setattr__(self, name: str, value: Any) -> None:
56 # Freeze after initialization. We can't use @dataclass(frozen=True) because then
57 # we need the default initializer and some of our fields shouldn't be parameters.
58 if hasattr(self, "babel_locale"): 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 raise FrozenInstanceError(f"Cannot modify attribute {name}.")
60 return object.__setattr__(self, name, value)
62 @property
63 def localized_timezone(self) -> str:
64 return localize_timezone(self.timezone, self.babel_locale)
66 def localize_string(
67 self, key: str, *, i18next: I18Next | None = None, substitutions: dict[str, str | int] | None = None
68 ) -> str:
69 i18next = i18next or get_main_i18next()
70 return i18next.localize(key, self.locale, substitutions=substitutions)
72 def localize_date(
73 self, value: date | datetime, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
74 ) -> str:
75 if isinstance(value, datetime): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true
76 value = to_timezone(value, self.timezone).date()
77 return localize_date(
78 value, self.babel_locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week
79 )
81 def localize_date_from_iso(
82 self, value: str, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
83 ) -> str:
84 return self.localize_date(
85 date.fromisoformat(value),
86 abbrev=abbrev,
87 with_year=with_year,
88 with_day_of_week=with_day_of_week,
89 )
91 def localize_datetime(
92 self,
93 value: datetime | Timestamp,
94 *,
95 abbrev: bool = False,
96 with_year: bool = True,
97 with_day_of_week: bool = False,
98 with_seconds: bool = False,
99 ) -> str:
100 return localize_datetime(
101 to_timezone(value, self.timezone),
102 self.babel_locale,
103 abbrev=abbrev,
104 with_year=with_year,
105 with_day_of_week=with_day_of_week,
106 with_seconds=with_seconds,
107 )
109 def localize_time(self, value: datetime | time, *, with_seconds: bool = False) -> str:
110 if isinstance(value, datetime):
111 value = to_timezone(value, self.timezone).time()
112 return localize_time(value, self.babel_locale, with_seconds=with_seconds)
114 @staticmethod
115 def en_utc() -> LocalizationContext:
116 return LocalizationContext(locale="en", timezone=UTC)
118 @staticmethod
119 def from_user(user: User) -> LocalizationContext:
120 return LocalizationContext(
121 locale=user.ui_language_preference or DEFAULT_LOCALE,
122 timezone=ZoneInfo(user.timezone) if user.timezone else UTC,
123 )