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

127 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-16 23:09 +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 

16 

17def default_locale(): 

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

19 

20 return locale(_DEFAULT_UID, fallback=False) 

21 

22 

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

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

25 

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

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

28 

29 Args: 

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

31 allowed: A list of allowed locale uids. 

32 fallback: Fall back to the default locale. 

33 """ 

34 

35 lo = _locale_by_name(name, allowed) 

36 if lo: 

37 return lo 

38 

39 if not fallback: 

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

41 

42 if allowed: 

43 lo = _locale_by_uid(allowed[0]) 

44 if lo: 

45 return lo 

46 

47 return default_locale() 

48 

49 

50def _locale_by_name(name, allowed): 

51 if not name: 

52 return 

53 

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

55 

56 if '_' in name: 

57 # name is a uid 

58 if allowed and name not in allowed: 

59 return 

60 return _locale_by_uid(name) 

61 

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

63 if allowed: 

64 for uid in allowed: 

65 if uid.startswith(name): 

66 return _locale_by_uid(uid) 

67 

68 # try to get a generic locale 

69 return _locale_by_uid(name + '_zz') 

70 

71 

72def _locale_by_uid(uid): 

73 def _get(): 

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

75 

76 lo = gws.Locale() 

77 

78 # @TODO script etc 

79 terr = p.territory or 'ZZ' 

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

81 

82 lo.language = p.language 

83 lo.languageName = p.language_name 

84 

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

86 if not lg: 

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

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

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

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

91 

92 lo.territory = terr 

93 lo.territoryName = p.territory_name 

94 

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

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

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

98 lo.dateUnits = ( 

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

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

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

102 ) 

103 

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

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

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

107 

108 lo.firstWeekDay = p.first_week_day 

109 

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

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

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

113 

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

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

116 

117 return lo 

118 

119 try: 

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

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

122 gws.log.exception() 

123 return None 

124 

125 

126## 

127 

128 

129class _FnStr: 

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

131 

132 def __init__(self, method, arg): 

133 self.method = method 

134 self.arg = arg 

135 

136 def __str__(self): 

137 return self.method(self.arg) 

138 

139 def __call__(self, a=None): 

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

141 

142 

143# @TODO support RFC 2822 

144 

145 

146class DateFormatter(gws.DateFormatter): 

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

148 self.locale = loc 

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

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

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

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

153 

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

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

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

157 if not d: 

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

159 if fmt == gws.DateTimeFormat.iso: 

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

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

162 

163 

164class TimeFormatter(gws.TimeFormatter): 

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

166 self.locale = loc 

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

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

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

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

171 

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

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

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

175 if not d: 

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

177 if fmt == gws.DateTimeFormat.iso: 

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

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

180 

181 

182# @TODO scientific, compact... 

183 

184 

185class NumberFormatter(gws.NumberFormatter): 

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

187 self.locale = loc 

188 self.fns = { 

189 gws.NumberFormat.decimal: self.decimal, 

190 gws.NumberFormat.grouped: self.grouped, 

191 gws.NumberFormat.currency: self.currency, 

192 gws.NumberFormat.percent: self.percent, 

193 } 

194 

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

196 fn = self.fns.get(fmt) 

197 if not fn: 

198 return str(n) 

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

200 

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

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

203 

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

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

206 

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

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

209 

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

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

212 

213 

214## 

215 

216 

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

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

219 

220 def _get(): 

221 return ( 

222 DateFormatter(loc), 

223 TimeFormatter(loc), 

224 NumberFormatter(loc), 

225 ) 

226 

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