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

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) 

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

34 

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 

38 

39 # The locale code and all of its fallbacks. 

40 locale_list: list[str] 

41 

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

43 timezone: tzinfo 

44 

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

46 babel_locale: babel.Locale 

47 

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

51 

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) 

56 

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) 

63 

64 @property 

65 def localized_timezone(self) -> str: 

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

67 

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) 

73 

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

75 return localize_list(items, self.babel_locale) 

76 

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 ) 

85 

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 ) 

95 

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 ) 

113 

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) 

118 

119 @staticmethod 

120 def en_utc() -> LocalizationContext: 

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

122 

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 )