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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +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 ``en_CA`` (English with metric units). ``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 = 'en_CA' # English with metric units
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 ``en_CA``.
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'])
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 )
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())
138 lo.firstWeekDay = p.first_week_day
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())
144 lo.numberDecimal = p.number_symbols['latn']['decimal']
145 lo.numberGroup = p.number_symbols['latn']['group']
147 return lo
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
156##
159class _FnStr:
160 """Allow a property to act both as a method and as a string."""
162 def __init__(self, method, arg):
163 self.method = method
164 self.arg = arg
166 def __str__(self):
167 return self.method(self.arg)
169 def __call__(self, a=None):
170 return self.method(self.arg, a)
173# @TODO support RFC 2822
176class DateFormatter(gws.DateFormatter):
177 """Date formatter, based on ``babel.dates``."""
179 def __init__(self, loc: gws.Locale):
180 """Create a date formatter.
182 ``short``, ``medium``, ``long`` and ``iso`` can be called with a date, or used as strings,
183 which format the current date.
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)
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))
204class TimeFormatter(gws.TimeFormatter):
205 """Time formatter, based on ``babel.dates``."""
207 def __init__(self, loc: gws.Locale):
208 """Create a time formatter.
210 ``short``, ``medium``, ``long`` and ``iso`` can be called with a time, or used as strings,
211 which format the current time.
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)
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))
232# @TODO scientific, compact...
235class NumberFormatter(gws.NumberFormatter):
236 """Number formatter, based on ``babel.numbers``."""
238 def __init__(self, loc: gws.Locale):
239 """Create a number formatter.
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 }
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)
258 def decimal(self, n, *args, **kwargs):
259 return babel.numbers.format_decimal(n, locale=self.locale.uid, group_separator=False, *args, **kwargs)
261 def grouped(self, n, *args, **kwargs):
262 return babel.numbers.format_decimal(n, locale=self.locale.uid, group_separator=True, *args, **kwargs)
264 def currency(self, n, currency, *args, **kwargs):
265 return babel.numbers.format_currency(n, currency=currency, locale=self.locale.uid, *args, **kwargs)
267 def percent(self, n, *args, **kwargs):
268 return babel.numbers.format_percent(n, locale=self.locale.uid, *args, **kwargs)
271##
274def formatters(loc: gws.Locale) -> tuple[DateFormatter, TimeFormatter, NumberFormatter]:
275 """Return the formatters for a locale.
277 Formatters are cached per locale.
279 Args:
280 loc: A Locale object.
282 Returns:
283 A date, a time and a number formatter.
284 """
286 def _get():
287 return (
288 DateFormatter(loc),
289 TimeFormatter(loc),
290 NumberFormatter(loc),
291 )
293 return gws.u.get_app_global(f'gws.lib.intl.formatters.{loc.uid}', _get)