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

126 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +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 ``de_DE``. ``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 = 'de_DE' 

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 ``de_DE``. 

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 

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

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

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

132 

133 lo.firstWeekDay = p.first_week_day 

134 

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

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

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

138 

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

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

141 

142 return lo 

143 

144 try: 

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

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

147 gws.log.exception() 

148 return None 

149 

150 

151## 

152 

153 

154class _FnStr: 

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

156 

157 def __init__(self, method, arg): 

158 self.method = method 

159 self.arg = arg 

160 

161 def __str__(self): 

162 return self.method(self.arg) 

163 

164 def __call__(self, a=None): 

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

166 

167 

168# @TODO support RFC 2822 

169 

170 

171class DateFormatter(gws.DateFormatter): 

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

173 

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

175 """Create a date formatter. 

176 

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

178 which format the current date. 

179 

180 Args: 

181 loc: Locale to format for. 

182 """ 

183 self.locale = loc 

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

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

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

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

188 

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

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

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

192 if not d: 

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

194 if fmt == gws.DateTimeFormat.iso: 

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

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

197 

198 

199class TimeFormatter(gws.TimeFormatter): 

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

201 

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

203 """Create a time formatter. 

204 

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

206 which format the current time. 

207 

208 Args: 

209 loc: Locale to format for. 

210 """ 

211 self.locale = loc 

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

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

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

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

216 

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

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

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

220 if not d: 

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

222 if fmt == gws.DateTimeFormat.iso: 

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

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

225 

226 

227# @TODO scientific, compact... 

228 

229 

230class NumberFormatter(gws.NumberFormatter): 

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

232 

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

234 """Create a number formatter. 

235 

236 Args: 

237 loc: Locale to format for. 

238 """ 

239 self.locale = loc 

240 self.fns = { 

241 gws.NumberFormat.decimal: self.decimal, 

242 gws.NumberFormat.grouped: self.grouped, 

243 gws.NumberFormat.currency: self.currency, 

244 gws.NumberFormat.percent: self.percent, 

245 } 

246 

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

248 fn = self.fns.get(fmt) 

249 if not fn: 

250 return str(n) 

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

252 

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

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

255 

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

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

258 

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

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

261 

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

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

264 

265 

266## 

267 

268 

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

270 """Return the formatters for a locale. 

271 

272 Formatters are cached per locale. 

273 

274 Args: 

275 loc: A Locale object. 

276 

277 Returns: 

278 A date, a time and a number formatter. 

279 """ 

280 

281 def _get(): 

282 return ( 

283 DateFormatter(loc), 

284 TimeFormatter(loc), 

285 NumberFormatter(loc), 

286 ) 

287 

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