Coverage for app/backend/src/couchers/i18n/context.py: 85%
54 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +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 to_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 Only represents locale we support on the backend.
34 """
36 # The locales to be used from most to least preferred, for example: pt-BR, pt, en.
37 locale_list: list[str]
39 # Babel objects for the locales in locale_list.
40 # In some cases we might remap locales.
41 # For example "en" could use the "en-001" babel locale for international date formats.
42 babel_locale_list: list[babel.Locale]
44 # The timezone to use when formatting date-times and instants.
45 timezone: tzinfo
47 def __init__(self, locale: str, timezone: tzinfo) -> None:
48 locale = to_supported_locale(locale)
50 self.locale_list = get_locale_chain(locale)
51 self.babel_locale_list = list(map(get_babel_locale, self.locale_list))
52 self.timezone = timezone
54 def __setattr__(self, name: str, value: Any) -> None:
55 # Freeze after initialization. We can't use @dataclass(frozen=True) because then
56 # we need the default initializer and some of our fields shouldn't be parameters.
57 if hasattr(self, "timezone"): 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 raise FrozenInstanceError(f"Cannot modify attribute {name}.")
59 return object.__setattr__(self, name, value)
61 @property
62 def preferred_locale(self) -> str:
63 return self.locale_list[0]
65 @property
66 def preferred_babel_locale(self) -> babel.Locale:
67 return self.babel_locale_list[0]
69 @property
70 def localized_timezone(self) -> str:
71 return localize_timezone(self.timezone, self.babel_locale_list)
73 def localize_string(
74 self, key: str, *, i18next: I18Next | None = None, substitutions: SubstitutionDict | None = None
75 ) -> str:
76 i18next = i18next or get_main_i18next()
77 return i18next.localize(key, self.locale_list, substitutions=substitutions)
79 def localize_list(self, items: Sequence[str]) -> str:
80 return localize_list(items, self.babel_locale_list)
82 def localize_date(
83 self, value: date | datetime, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
84 ) -> str:
85 if isinstance(value, datetime): 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 value = to_timezone(value, self.timezone).date()
87 return localize_date(
88 value, self.preferred_babel_locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week
89 )
91 def localize_date_from_iso(
92 self, value: str, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
93 ) -> str:
94 return self.localize_date(
95 date.fromisoformat(value),
96 abbrev=abbrev,
97 with_year=with_year,
98 with_day_of_week=with_day_of_week,
99 )
101 def localize_datetime(
102 self,
103 value: datetime | Timestamp,
104 *,
105 display_timezone: tzinfo | None = None,
106 abbrev: bool = False,
107 with_year: bool = True,
108 with_day_of_week: bool = False,
109 with_seconds: bool = False,
110 ) -> str:
111 """
112 Formats a date and time according to this localization context.
114 Params:
115 value: An instant in time to be formatted in date and time components.
116 datetimes should be timezone-aware so they represent an unambiguous instant,
117 but their timezones are irrelevant for formatting.
118 display_timezone: The timezone to use when formatting the datetime.
119 Defaults to the value from this localization context.
120 """
121 return localize_datetime(
122 # By default we display the datetime in the user's timezone.
123 # The "timezone" parameter overrides this behavior.
124 to_timezone(value, display_timezone or self.timezone),
125 self.preferred_babel_locale,
126 abbrev=abbrev,
127 with_year=with_year,
128 with_day_of_week=with_day_of_week,
129 with_seconds=with_seconds,
130 )
132 def localize_time(self, value: datetime | time, *, with_seconds: bool = False) -> str:
133 if isinstance(value, datetime):
134 value = to_timezone(value, self.timezone).time()
135 return localize_time(value, self.preferred_babel_locale, with_seconds=with_seconds)
137 @staticmethod
138 def en_utc() -> LocalizationContext:
139 return LocalizationContext(locale="en", timezone=UTC)
141 @staticmethod
142 def from_user(user: User) -> LocalizationContext:
143 return LocalizationContext(
144 locale=user.ui_language_preference or DEFAULT_LOCALE,
145 timezone=ZoneInfo(user.timezone) if user.timezone else UTC,
146 )