Coverage for app/backend/src/couchers/i18n/context.py: 80%

47 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +0000

1from dataclasses import FrozenInstanceError 

2from datetime import UTC, date, datetime, time, tzinfo 

3from typing import Any 

4from zoneinfo import ZoneInfo 

5 

6import babel 

7from google.protobuf.timestamp_pb2 import Timestamp 

8 

9from couchers.i18n.i18next import I18Next 

10from couchers.i18n.locales import ( 

11 DEFAULT_LOCALE, 

12 get_babel_locale, 

13 get_locale_fallbacks, 

14 get_main_i18next, 

15 is_supported_locale, 

16) 

17from couchers.i18n.localize import ( 

18 localize_date, 

19 localize_datetime, 

20 localize_time, 

21 localize_timezone, 

22) 

23from couchers.models.users import User 

24from couchers.utils import to_timezone 

25 

26 

27class LocalizationContext: 

28 """ 

29 Specifies regional settings used for localization of strings and date/times. 

30 Future settings like 12/24h or format preferences would go here as well. 

31 """ 

32 

33 # The locale code (e.g. 'en', 'pt-BR'), used to lookup translations and format dates/numbers. 

34 # Note that a locale doesn't necessarily specify a region. 

35 locale: str 

36 

37 # The locale code and all of its fallbacks. 

38 locale_list: list[str] 

39 

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

41 timezone: tzinfo 

42 

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

44 babel_locale: babel.Locale 

45 

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

47 if not is_supported_locale(locale): 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true

48 raise ValueError(f"Unsupported locale {locale}.") 

49 

50 self.locale = locale 

51 self.locale_list = [self.locale] + get_locale_fallbacks(self.locale) 

52 self.timezone = timezone 

53 self.babel_locale = get_babel_locale(locale) 

54 

55 def __setattr__(self, name: str, value: Any) -> None: 

56 # Freeze after initialization. We can't use @dataclass(frozen=True) because then 

57 # we need the default initializer and some of our fields shouldn't be parameters. 

58 if hasattr(self, "babel_locale"): 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true

59 raise FrozenInstanceError(f"Cannot modify attribute {name}.") 

60 return object.__setattr__(self, name, value) 

61 

62 @property 

63 def localized_timezone(self) -> str: 

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

65 

66 def localize_string( 

67 self, key: str, *, i18next: I18Next | None = None, substitutions: dict[str, str | int] | None = None 

68 ) -> str: 

69 i18next = i18next or get_main_i18next() 

70 return i18next.localize(key, self.locale, substitutions=substitutions) 

71 

72 def localize_date( 

73 self, value: date | datetime, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False 

74 ) -> str: 

75 if isinstance(value, datetime): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 value = to_timezone(value, self.timezone).date() 

77 return localize_date( 

78 value, self.babel_locale, abbrev=abbrev, with_year=with_year, with_day_of_week=with_day_of_week 

79 ) 

80 

81 def localize_date_from_iso( 

82 self, value: str, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False 

83 ) -> str: 

84 return self.localize_date( 

85 date.fromisoformat(value), 

86 abbrev=abbrev, 

87 with_year=with_year, 

88 with_day_of_week=with_day_of_week, 

89 ) 

90 

91 def localize_datetime( 

92 self, 

93 value: datetime | Timestamp, 

94 *, 

95 abbrev: bool = False, 

96 with_year: bool = True, 

97 with_day_of_week: bool = False, 

98 with_seconds: bool = False, 

99 ) -> str: 

100 return localize_datetime( 

101 to_timezone(value, self.timezone), 

102 self.babel_locale, 

103 abbrev=abbrev, 

104 with_year=with_year, 

105 with_day_of_week=with_day_of_week, 

106 with_seconds=with_seconds, 

107 ) 

108 

109 def localize_time(self, value: datetime | time, *, with_seconds: bool = False) -> str: 

110 if isinstance(value, datetime): 

111 value = to_timezone(value, self.timezone).time() 

112 return localize_time(value, self.babel_locale, with_seconds=with_seconds) 

113 

114 @staticmethod 

115 def en_utc() -> LocalizationContext: 

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

117 

118 @staticmethod 

119 def from_user(user: User) -> LocalizationContext: 

120 return LocalizationContext( 

121 locale=user.ui_language_preference or DEFAULT_LOCALE, 

122 timezone=ZoneInfo(user.timezone) if user.timezone else UTC, 

123 )