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

127 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Locales and locale-aware formatting. 

2 

3Builds ``gws.Locale`` objects from ``babel`` and ``pycountry`` data and provides 

4locale-aware formatters for dates, times and numbers. 

5 

6``locale`` finds a locale by a language or locale name (``de`` or ``de_DE``), optionally 

7restricted to a list of allowed locale uids, and falls back to the first allowed locale 

8or to the default locale ``en_CA`` (English with metric units). ``formatters`` returns a 

9``DateFormatter``, ``TimeFormatter`` and ``NumberFormatter`` for a locale. Locales and 

10formatters are cached per application. 

11 

12Example:: 

13 

14 loc = gws.lib.intl.locale('de', allowed=['de_DE', 'en_US']) 

15 date_fmt, time_fmt, num_fmt = gws.lib.intl.formatters(loc) 

16 date_fmt.long('2013-12-11') # '11. Dezember 2013' 

17 num_fmt.grouped(1234567.5) # '1.234.567,5' 

18""" 

19 

20import babel 

21import babel.dates 

22import babel.numbers 

23import pycountry 

24 

25import gws 

26import gws.lib.datetimex 

27 

28_DEFAULT_UID = 'en_CA' # English with metric units 

29 

30 

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

32 

33 

34def default_locale(): 

35 """Return the default locale ``en_CA``. 

36 

37 Returns: 

38 A Locale object. 

39 """ 

40 

41 return locale(_DEFAULT_UID, fallback=False) 

42 

43 

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

45 """Find a locale by a language or locale name. 

46 

47 A language name selects the first allowed locale for that language or, if there is none, 

48 the most likely locale for the language (``de`` becomes ``de_DE``). A locale name must be allowed. If no locale is found and ``fallback`` is ``True``, 

49 the first ``allowed`` locale or the default locale is returned. 

50 

51 Args: 

52 name: Language or locale name like ``de``, ``de_DE`` or ``de-DE``. 

53 allowed: Allowed locale uids. 

54 fallback: If ``True``, fall back to a default instead of raising an error. 

55 

56 Returns: 

57 A Locale object. 

58 

59 Raises: 

60 ``gws.Error``: If no locale is found and ``fallback`` is ``False``. 

61 """ 

62 

63 lo = _locale_by_name(name, allowed) 

64 if lo: 

65 return lo 

66 

67 if not fallback: 

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

69 

70 if allowed: 

71 lo = _locale_by_uid(allowed[0]) 

72 if lo: 

73 return lo 

74 

75 return default_locale() 

76 

77 

78def _locale_by_name(name, allowed): 

79 """Find a locale by a language or locale name, or return ``None``.""" 

80 if not name: 

81 return 

82 

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

84 

85 if '_' in name: 

86 # name is a uid 

87 if allowed and name not in allowed: 

88 return 

89 return _locale_by_uid(name) 

90 

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

92 if allowed: 

93 for uid in allowed: 

94 if uid.startswith(name): 

95 return _locale_by_uid(uid) 

96 

97 # try to get a generic locale 

98 return _locale_by_uid(name + '_zz') 

99 

100 

101def _locale_by_uid(uid): 

102 """Create a cached locale for a uid, or return ``None`` if the uid is unknown.""" 

103 def _get(): 

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

105 

106 lo = gws.Locale() 

107 

108 # @TODO script etc 

109 terr = p.territory or 'ZZ' 

110 lo.uid = p.language + '_' + terr 

111 

112 lo.language = p.language 

113 lo.languageName = p.language_name 

114 

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

116 if not lg: 

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

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

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

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

121 

122 lo.territory = terr 

123 lo.territoryName = p.territory_name 

124 

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

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

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

128 lo.dateUnits = ( 

129 p.unit_display_names['duration-year']['narrow'] 

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

131 + p.unit_display_names['duration-day']['narrow'] 

132 ) 

133 

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

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

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

137 

138 lo.firstWeekDay = p.first_week_day 

139 

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

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

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

143 

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

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

146 

147 return lo 

148 

149 try: 

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

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

152 gws.log.exception() 

153 return None 

154 

155 

156## 

157 

158 

159class _FnStr: 

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

161 

162 def __init__(self, method, arg): 

163 self.method = method 

164 self.arg = arg 

165 

166 def __str__(self): 

167 return self.method(self.arg) 

168 

169 def __call__(self, a=None): 

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

171 

172 

173# @TODO support RFC 2822 

174 

175 

176class DateFormatter(gws.DateFormatter): 

177 """Date formatter, based on ``babel.dates``.""" 

178 

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

180 """Create a date formatter. 

181 

182 ``short``, ``medium``, ``long`` and ``iso`` can be called with a date, or used as strings, 

183 which format the current date. 

184 

185 Args: 

186 loc: Locale to format for. 

187 """ 

188 self.locale = loc 

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

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

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

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

193 

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

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

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

197 if not d: 

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

199 if fmt == gws.DateTimeFormat.iso: 

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

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

202 

203 

204class TimeFormatter(gws.TimeFormatter): 

205 """Time formatter, based on ``babel.dates``.""" 

206 

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

208 """Create a time formatter. 

209 

210 ``short``, ``medium``, ``long`` and ``iso`` can be called with a time, or used as strings, 

211 which format the current time. 

212 

213 Args: 

214 loc: Locale to format for. 

215 """ 

216 self.locale = loc 

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

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

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

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

221 

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

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

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

225 if not d: 

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

227 if fmt == gws.DateTimeFormat.iso: 

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

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

230 

231 

232# @TODO scientific, compact... 

233 

234 

235class NumberFormatter(gws.NumberFormatter): 

236 """Number formatter, based on ``babel.numbers``.""" 

237 

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

239 """Create a number formatter. 

240 

241 Args: 

242 loc: Locale to format for. 

243 """ 

244 self.locale = loc 

245 self.fns = { 

246 gws.NumberFormat.decimal: self.decimal, 

247 gws.NumberFormat.grouped: self.grouped, 

248 gws.NumberFormat.currency: self.currency, 

249 gws.NumberFormat.percent: self.percent, 

250 } 

251 

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

253 fn = self.fns.get(fmt) 

254 if not fn: 

255 return str(n) 

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

257 

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

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

260 

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

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

263 

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

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

266 

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

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

269 

270 

271## 

272 

273 

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

275 """Return the formatters for a locale. 

276 

277 Formatters are cached per locale. 

278 

279 Args: 

280 loc: A Locale object. 

281 

282 Returns: 

283 A date, a time and a number formatter. 

284 """ 

285 

286 def _get(): 

287 return ( 

288 DateFormatter(loc), 

289 TimeFormatter(loc), 

290 NumberFormatter(loc), 

291 ) 

292 

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