Coverage for app/backend/src/couchers/i18n/context.py: 81%
50 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 04:10 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 04:10 +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)
25from couchers.models.users import User
26from couchers.utils import to_timezone
29class LocalizationContext:
30 """
31 Specifies regional settings used for localization of strings and date/times.
32 Future settings like 12/24h or format preferences would go here as well.
33 """
35 # The locale code (e.g. 'en', 'pt-BR'), used to lookup translations and format dates/numbers.
36 # Note that a locale doesn't necessarily specify a region.
37 locale: str
39 # The locale code and all of its fallbacks.
40 locale_list: list[str]
42 # The timezone to use when formatting date-times and instants.
43 timezone: tzinfo
45 # The Babel locale used for datetime formatting and other Unicode CLDR usage.
46 babel_locale: babel.Locale
48 def __init__(self, locale: str, timezone: tzinfo) -> None:
49 if not is_supported_locale(locale): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 raise ValueError(f"Unsupported locale {locale}.")
52 self.locale = locale
53 self.locale_list = get_locale_chain(self.locale)
54 self.timezone = timezone
55 self.babel_locale = get_babel_locale(locale)
57 def __setattr__(self, name: str, value: Any) -> None:
58 # Freeze after initialization. We can't use @dataclass(frozen=True) because then
59 # we need the default initializer and some of our fields shouldn't be parameters.
60 if hasattr(self, "babel_locale"): 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true
61 raise FrozenInstanceError(f"Cannot modify attribute {name}.")
62 return object.__setattr__(self, name, value)
64 @property
65 def localized_timezone(self) -> str:
66 return localize_timezone(self.timezone, self.babel_locale)
68 def localize_string(
69 self, key: str, *, i18next: I18Next | None = None, substitutions: SubstitutionDict | None = None
70 ) -> str:
71 i18next = i18next or get_main_i18next()
72 return i18next.localize(key, self.locale_list, substitutions=substitutions)
74 def localize_list(self, items: Sequence[str]) -> str:
75 return localize_list(items, self.babel_locale)
77 def localize_date(
78 self, value: date | datetime, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
79 ) -> str:
80 if isinstance(value, datetime): 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true
81 value = to_timezone(value, self.timezone).date()
82 return localize_date(
83 value, self.babel_locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week
84 )
86 def localize_date_from_iso(
87 self, value: str, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
88 ) -> str:
89 return self.localize_date(
90 date.fromisoformat(value),
91 abbrev=abbrev,
92 with_year=with_year,
93 with_day_of_week=with_day_of_week,
94 )
96 def localize_datetime(
97 self,
98 value: datetime | Timestamp,
99 *,
100 abbrev: bool = False,
101 with_year: bool = True,
102 with_day_of_week: bool = False,
103 with_seconds: bool = False,
104 ) -> str:
105 return localize_datetime(
106 to_timezone(value, self.timezone),
107 self.babel_locale,
108 abbrev=abbrev,
109 with_year=with_year,
110 with_day_of_week=with_day_of_week,
111 with_seconds=with_seconds,
112 )
114 def localize_time(self, value: datetime | time, *, with_seconds: bool = False) -> str:
115 if isinstance(value, datetime):
116 value = to_timezone(value, self.timezone).time()
117 return localize_time(value, self.babel_locale, with_seconds=with_seconds)
119 @staticmethod
120 def en_utc() -> LocalizationContext:
121 return LocalizationContext(locale="en", timezone=UTC)
123 @staticmethod
124 def from_user(user: User) -> LocalizationContext:
125 return LocalizationContext(
126 locale=user.ui_language_preference or DEFAULT_LOCALE,
127 timezone=ZoneInfo(user.timezone) if user.timezone else UTC,
128 )