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

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 

6 

7import babel 

8from google.protobuf.timestamp_pb2 import Timestamp 

9 

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 

27 

28 

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 """ 

35 

36 # The locales to be used from most to least preferred, for example: pt-BR, pt, en. 

37 locale_list: list[str] 

38 

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] 

43 

44 # The timezone to use when formatting date-times and instants. 

45 timezone: tzinfo 

46 

47 def __init__(self, locale: str, timezone: tzinfo) -> None: 

48 locale = to_supported_locale(locale) 

49 

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 

53 

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) 

60 

61 @property 

62 def preferred_locale(self) -> str: 

63 return self.locale_list[0] 

64 

65 @property 

66 def preferred_babel_locale(self) -> babel.Locale: 

67 return self.babel_locale_list[0] 

68 

69 @property 

70 def localized_timezone(self) -> str: 

71 return localize_timezone(self.timezone, self.babel_locale_list) 

72 

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) 

78 

79 def localize_list(self, items: Sequence[str]) -> str: 

80 return localize_list(items, self.babel_locale_list) 

81 

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 ) 

90 

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 ) 

100 

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. 

113 

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 ) 

131 

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) 

136 

137 @staticmethod 

138 def en_utc() -> LocalizationContext: 

139 return LocalizationContext(locale="en", timezone=UTC) 

140 

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 )