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

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

29 

30 

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

36 

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 

40 

41 # The locale code and all of its fallbacks. 

42 locale_list: list[str] 

43 

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

45 timezone: tzinfo 

46 

47 # The Babel locale used for datetime formatting and other Unicode CLDR usage. 

48 babel_locale: babel.Locale 

49 

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}.") 

53 

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) 

58 

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) 

65 

66 @property 

67 def localized_timezone(self) -> str: 

68 return localize_timezone(self.timezone, self.babel_locale) 

69 

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) 

72 

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) 

75 

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) 

81 

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

83 return localize_list(items, self.babel_locale) 

84 

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 ) 

93 

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 ) 

103 

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 ) 

121 

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) 

126 

127 @staticmethod 

128 def en_utc() -> LocalizationContext: 

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

130 

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 )