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