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
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Locales and locale-aware formatting.
3Builds ``gws.Locale`` objects from ``babel`` and ``pycountry`` data and provides
4locale-aware formatters for dates, times and numbers.
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.
12Example::
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"""
20import babel
21import babel.dates
22import babel.numbers
23import pycountry
25import gws
26import gws.lib.datetimex
28_DEFAULT_UID = 'de_DE'
31# NB in the following code, `name` is a locale or language name (`de` or `de_DE`), `uid` is strictly a uid (`de_DE`)
34def default_locale():
35 """Return the default locale ``de_DE``.
37 Returns:
38 A Locale object.
39 """
41 return locale(_DEFAULT_UID, fallback=False)
44def locale(name: str | None, allowed: list[str] = None, fallback: bool = True) -> gws.Locale:
45 """Find a locale by a language or locale name.
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.
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.
56 Returns:
57 A Locale object.
59 Raises:
60 ``gws.Error``: If no locale is found and ``fallback`` is ``False``.
61 """
63 lo = _locale_by_name(name, allowed)
64 if lo:
65 return lo
67 if not fallback:
68 raise gws.Error(f'locale {name!r} not found')
70 if allowed:
71 lo = _locale_by_uid(allowed[0])
72 if lo:
73 return lo
75 return default_locale()
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
83 name = name.strip().replace('-', '_')
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)
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)
97 # try to get a generic locale
98 return _locale_by_uid(name + '_zz')
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)
106 lo = gws.Locale()
108 # @TODO script etc
109 terr = p.territory or 'ZZ'
110 lo.uid = p.language + '_' + terr
112 lo.language = p.language
113 lo.languageName = p.language_name
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)
122 lo.territory = terr
123 lo.territoryName = p.territory_name
125 lo.dateFormatLong = str(p.date_formats['long'])
126 lo.dateFormatMedium = str(p.date_formats['medium'])
127 lo.dateFormatShort = str(p.date_formats['short'])
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())
133 lo.firstWeekDay = p.first_week_day
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())
139 lo.numberDecimal = p.number_symbols['latn']['decimal']
140 lo.numberGroup = p.number_symbols['latn']['group']
142 return lo
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
151##
154class _FnStr:
155 """Allow a property to act both as a method and as a string."""
157 def __init__(self, method, arg):
158 self.method = method
159 self.arg = arg
161 def __str__(self):
162 return self.method(self.arg)
164 def __call__(self, a=None):
165 return self.method(self.arg, a)
168# @TODO support RFC 2822
171class DateFormatter(gws.DateFormatter):
172 """Date formatter, based on ``babel.dates``."""
174 def __init__(self, loc: gws.Locale):
175 """Create a date formatter.
177 ``short``, ``medium``, ``long`` and ``iso`` can be called with a date, or used as strings,
178 which format the current date.
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)
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))
199class TimeFormatter(gws.TimeFormatter):
200 """Time formatter, based on ``babel.dates``."""
202 def __init__(self, loc: gws.Locale):
203 """Create a time formatter.
205 ``short``, ``medium``, ``long`` and ``iso`` can be called with a time, or used as strings,
206 which format the current time.
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)
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))
227# @TODO scientific, compact...
230class NumberFormatter(gws.NumberFormatter):
231 """Number formatter, based on ``babel.numbers``."""
233 def __init__(self, loc: gws.Locale):
234 """Create a number formatter.
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 }
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)
253 def decimal(self, n, *args, **kwargs):
254 return babel.numbers.format_decimal(n, locale=self.locale.uid, group_separator=False, *args, **kwargs)
256 def grouped(self, n, *args, **kwargs):
257 return babel.numbers.format_decimal(n, locale=self.locale.uid, group_separator=True, *args, **kwargs)
259 def currency(self, n, currency, *args, **kwargs):
260 return babel.numbers.format_currency(n, currency=currency, locale=self.locale.uid, *args, **kwargs)
262 def percent(self, n, *args, **kwargs):
263 return babel.numbers.format_percent(n, locale=self.locale.uid, *args, **kwargs)
266##
269def formatters(loc: gws.Locale) -> tuple[DateFormatter, TimeFormatter, NumberFormatter]:
270 """Return the formatters for a locale.
272 Formatters are cached per locale.
274 Args:
275 loc: A Locale object.
277 Returns:
278 A date, a time and a number formatter.
279 """
281 def _get():
282 return (
283 DateFormatter(loc),
284 TimeFormatter(loc),
285 NumberFormatter(loc),
286 )
288 return gws.u.get_app_global(f'gws.lib.intl.formatters.{loc.uid}', _get)