Coverage for gws-app/gws/lib/intl/__init__.py: 0%

126 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-17 01:37 +0200

1"""Intl and localization tools.""" 

2 

3import babel 

4import babel.dates 

5import babel.numbers 

6import pycountry 

7 

8import gws 

9import gws.lib.datetimex 

10 

11_DEFAULT_UID = 'en_CA' # English with metric units 

12 

13 

14# NB in the following code, `name` is a locale or language name (`de` or `de_DE`), `uid` is strictly a uid (`de_DE`) 

15 

16def default_locale(): 

17 """Returns the default locale object (``en_CA``).""" 

18 

19 return locale(_DEFAULT_UID, fallback=False) 

20 

21 

22def locale(name: str, allowed: list[str] = None, fallback: bool = True) -> gws.Locale: 

23 """Locates a Locale object by locale name. 

24 

25 If the name is invalid, and ``fallback`` is ``True``, return the first ``allowed`` locale, 

26 or the default locale. Otherwise, raise an exception. 

27 

28 Args: 

29 name: Language or locale name like ``de`` or ``de_DE``. 

30 allowed: A list of allowed locale uids. 

31 fallback: Fall back to the default locale. 

32 """ 

33 

34 lo = _locale_by_name(name, allowed) 

35 if lo: 

36 return lo 

37 

38 if not fallback: 

39 raise gws.Error(f'locale {name!r} not found') 

40 

41 if allowed: 

42 lo = _locale_by_uid(allowed[0]) 

43 if lo: 

44 return lo 

45 

46 return default_locale() 

47 

48 

49def _locale_by_name(name, allowed): 

50 if not name: 

51 return 

52 

53 name = name.strip().replace('-', '_') 

54 

55 if '_' in name: 

56 # name is a uid 

57 if allowed and name not in allowed: 

58 return 

59 return _locale_by_uid(name) 

60 

61 # just a lang name, try to find an allowed locale for this lang 

62 if allowed: 

63 for uid in allowed: 

64 if uid.startswith(name): 

65 return _locale_by_uid(uid) 

66 

67 # try to get a generic locale 

68 return _locale_by_uid(name + '_zz') 

69 

70 

71def _locale_by_uid(uid): 

72 def _get(): 

73 p = babel.Locale.parse(uid, resolve_likely_subtags=True) 

74 

75 lo = gws.Locale() 

76 

77 # @TODO script etc 

78 lo.uid = p.language + '_' + p.territory 

79 

80 lo.language = p.language 

81 lo.languageName = p.language_name 

82 

83 lg = pycountry.languages.get(alpha_2=lo.language) 

84 if not lg: 

85 raise ValueError(f'unknown language {lo.language}') 

86 lo.language3 = getattr(lg, 'alpha_3', '') 

87 lo.languageBib = getattr(lg, 'bibliographic', lo.language3) 

88 lo.languageNameEn = getattr(lg, 'name', lo.languageName) 

89 

90 lo.territory = p.territory 

91 lo.territoryName = p.territory_name 

92 

93 lo.dateFormatLong = str(p.date_formats['long']) 

94 lo.dateFormatMedium = str(p.date_formats['medium']) 

95 lo.dateFormatShort = str(p.date_formats['short']) 

96 lo.dateUnits = ( 

97 p.unit_display_names['duration-year']['narrow'] + 

98 p.unit_display_names['duration-month']['narrow'] + 

99 p.unit_display_names['duration-day']['narrow']) 

100 

101 lo.dayNamesLong = list(p.days['format']['wide'].values()) 

102 lo.dayNamesNarrow = list(p.days['format']['narrow'].values()) 

103 lo.dayNamesShort = list(p.days['format']['abbreviated'].values()) 

104 

105 lo.firstWeekDay = p.first_week_day 

106 

107 lo.monthNamesLong = list(p.months['format']['wide'].values()) 

108 lo.monthNamesNarrow = list(p.months['format']['narrow'].values()) 

109 lo.monthNamesShort = list(p.months['format']['abbreviated'].values()) 

110 

111 lo.numberDecimal = p.number_symbols['latn']['decimal'] 

112 lo.numberGroup = p.number_symbols['latn']['group'] 

113 

114 return lo 

115 

116 try: 

117 return gws.u.get_app_global(f'gws.lib.intl.locale.{uid}', _get) 

118 except (AttributeError, ValueError, babel.UnknownLocaleError): 

119 gws.log.exception() 

120 return None 

121 

122 

123## 

124 

125 

126class _FnStr: 

127 """Allow a property to act both as a method and as a string.""" 

128 

129 def __init__(self, method, arg): 

130 self.method = method 

131 self.arg = arg 

132 

133 def __str__(self): 

134 return self.method(self.arg) 

135 

136 def __call__(self, a=None): 

137 return self.method(self.arg, a) 

138 

139 

140# @TODO support RFC 2822 

141 

142class DateFormatter(gws.DateFormatter): 

143 def __init__(self, loc: gws.Locale): 

144 self.locale = loc 

145 self.short = _FnStr(self.format, gws.DateTimeFormat.short) 

146 self.medium = _FnStr(self.format, gws.DateTimeFormat.medium) 

147 self.long = _FnStr(self.format, gws.DateTimeFormat.long) 

148 self.iso = _FnStr(self.format, gws.DateTimeFormat.iso) 

149 

150 def format(self, fmt: gws.DateTimeFormat, date=None): 

151 date = date or gws.lib.datetimex.now() 

152 d = gws.lib.datetimex.parse(date) 

153 if not d: 

154 raise gws.Error(f'invalid {date=}') 

155 if fmt == gws.DateTimeFormat.iso: 

156 return gws.lib.datetimex.to_iso_date_string(d) 

157 return babel.dates.format_date(d, locale=self.locale.uid, format=str(fmt)) 

158 

159 

160class TimeFormatter(gws.TimeFormatter): 

161 def __init__(self, loc: gws.Locale): 

162 self.locale = loc 

163 self.short = _FnStr(self.format, gws.DateTimeFormat.short) 

164 self.medium = _FnStr(self.format, gws.DateTimeFormat.medium) 

165 self.long = _FnStr(self.format, gws.DateTimeFormat.long) 

166 self.iso = _FnStr(self.format, gws.DateTimeFormat.iso) 

167 

168 def format(self, fmt: gws.DateTimeFormat, date=None) -> str: 

169 date = date or gws.lib.datetimex.now() 

170 d = gws.lib.datetimex.parse(date) or gws.lib.datetimex.parse_time(date) 

171 if not d: 

172 raise gws.Error(f'invalid {date=}') 

173 if fmt == gws.DateTimeFormat.iso: 

174 return gws.lib.datetimex.to_iso_time_string(d) 

175 return babel.dates.format_time(d, locale=self.locale.uid, format=str(fmt)) 

176 

177 

178# @TODO scientific, compact... 

179 

180class NumberFormatter(gws.NumberFormatter): 

181 def __init__(self, loc: gws.Locale): 

182 self.locale = loc 

183 self.fns = { 

184 gws.NumberFormat.decimal: self.decimal, 

185 gws.NumberFormat.grouped: self.grouped, 

186 gws.NumberFormat.currency: self.currency, 

187 gws.NumberFormat.percent: self.percent, 

188 } 

189 

190 def format(self, fmt, n, *args, **kwargs): 

191 fn = self.fns.get(fmt) 

192 if not fn: 

193 return str(n) 

194 return fn(n, *args, **kwargs) 

195 

196 def decimal(self, n, *args, **kwargs): 

197 return babel.numbers.format_decimal(n, locale=self.locale.uid, group_separator=False, *args, **kwargs) 

198 

199 def grouped(self, n, *args, **kwargs): 

200 return babel.numbers.format_decimal(n, locale=self.locale.uid, group_separator=True, *args, **kwargs) 

201 

202 def currency(self, n, currency, *args, **kwargs): 

203 return babel.numbers.format_currency(n, currency=currency, locale=self.locale.uid, *args, **kwargs) 

204 

205 def percent(self, n, *args, **kwargs): 

206 return babel.numbers.format_percent(n, locale=self.locale.uid, *args, **kwargs) 

207 

208 

209## 

210 

211 

212def formatters(loc: gws.Locale) -> tuple[DateFormatter, TimeFormatter, NumberFormatter]: 

213 """Return a tuple of locale-aware formatters.""" 

214 

215 def _get(): 

216 return ( 

217 DateFormatter(loc), 

218 TimeFormatter(loc), 

219 NumberFormatter(loc), 

220 ) 

221 

222 return gws.u.get_app_global(f'gws.lib.intl.formatters.{loc.uid}', _get)