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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
1"""Intl and localization tools."""
3import babel
4import babel.dates
5import babel.numbers
6import pycountry
8import gws
9import gws.lib.datetimex
11_DEFAULT_UID = 'en_CA' # English with metric units
14# NB in the following code, `name` is a locale or language name (`de` or `de_DE`), `uid` is strictly a uid (`de_DE`)
17def default_locale():
18 """Returns the default locale object (``en_CA``)."""
20 return locale(_DEFAULT_UID, fallback=False)
23def locale(name: str | None, allowed: list[str] = None, fallback: bool = True) -> gws.Locale:
24 """Locates a Locale object by locale name.
26 If the name is invalid, and ``fallback`` is ``True``, return the first ``allowed`` locale,
27 or the default locale. Otherwise, raise an exception.
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 """
35 lo = _locale_by_name(name, allowed)
36 if lo:
37 return lo
39 if not fallback:
40 raise gws.Error(f'locale {name!r} not found')
42 if allowed:
43 lo = _locale_by_uid(allowed[0])
44 if lo:
45 return lo
47 return default_locale()
50def _locale_by_name(name, allowed):
51 if not name:
52 return
54 name = name.strip().replace('-', '_')
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)
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)
68 # try to get a generic locale
69 return _locale_by_uid(name + '_zz')
72def _locale_by_uid(uid):
73 def _get():
74 p = babel.Locale.parse(uid, resolve_likely_subtags=True)
76 lo = gws.Locale()
78 # @TODO script etc
79 terr = p.territory or 'ZZ'
80 lo.uid = p.language + '_' + terr
82 lo.language = p.language
83 lo.languageName = p.language_name
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)
92 lo.territory = terr
93 lo.territoryName = p.territory_name
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 )
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())
108 lo.firstWeekDay = p.first_week_day
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())
114 lo.numberDecimal = p.number_symbols['latn']['decimal']
115 lo.numberGroup = p.number_symbols['latn']['group']
117 return lo
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
126##
129class _FnStr:
130 """Allow a property to act both as a method and as a string."""
132 def __init__(self, method, arg):
133 self.method = method
134 self.arg = arg
136 def __str__(self):
137 return self.method(self.arg)
139 def __call__(self, a=None):
140 return self.method(self.arg, a)
143# @TODO support RFC 2822
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)
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))
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)
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))
182# @TODO scientific, compact...
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 }
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)
201 def decimal(self, n, *args, **kwargs):
202 return babel.numbers.format_decimal(n, locale=self.locale.uid, group_separator=False, *args, **kwargs)
204 def grouped(self, n, *args, **kwargs):
205 return babel.numbers.format_decimal(n, locale=self.locale.uid, group_separator=True, *args, **kwargs)
207 def currency(self, n, currency, *args, **kwargs):
208 return babel.numbers.format_currency(n, currency=currency, locale=self.locale.uid, *args, **kwargs)
210 def percent(self, n, *args, **kwargs):
211 return babel.numbers.format_percent(n, locale=self.locale.uid, *args, **kwargs)
214##
217def formatters(loc: gws.Locale) -> tuple[DateFormatter, TimeFormatter, NumberFormatter]:
218 """Return a tuple of locale-aware formatters."""
220 def _get():
221 return (
222 DateFormatter(loc),
223 TimeFormatter(loc),
224 NumberFormatter(loc),
225 )
227 return gws.u.get_app_global(f'gws.lib.intl.formatters.{loc.uid}', _get)