Coverage for pygeoapi/l10n.py: 76%
173 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 08:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 08:15 +0000
1# =================================================================
2#
3# Authors: Sander Schaminee <sander.schaminee@geocat.net>
4#
5# Copyright (c) 2021 GeoCat BV
6#
7# Permission is hereby granted, free of charge, to any person
8# obtaining a copy of this software and associated documentation
9# files (the "Software"), to deal in the Software without
10# restriction, including without limitation the rights to use,
11# copy, modify, merge, publish, distribute, sublicense, and/or sell
12# copies of the Software, and to permit persons to whom the
13# Software is furnished to do so, subject to the following
14# conditions:
15#
16# The above copyright notice and this permission notice shall be
17# included in all copies or substantial portions of the Software.
18#
19# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
21# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
23# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
24# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
26# OTHER DEALINGS IN THE SOFTWARE.
27#
28# =================================================================
30import logging
31from typing import List, Union
32from collections import OrderedDict
33from copy import deepcopy
35from babel import Locale
36from babel import UnknownLocaleError as _UnknownLocaleError
37from urllib import parse
39LOGGER = logging.getLogger(__name__)
41# Specifies the name of a request query parameter used to set a locale
42QUERY_PARAM = 'lang'
44# Cache Babel Locale lookups by string
45_lc_cache = {}
47# Cache translated configurations
48_cfg_cache = {}
51class LocaleError(Exception):
52 """ General exception for any kind of locale parsing error. """
53 pass
56def str2locale(value: str, silent: bool = False) -> Union[Locale, None]:
57 """
58 Converts a web locale or language tag into a Babel Locale instance.
60 .. note:: If `value` already is a Locale, it is returned as-is.
62 :param value: A string containing a (web) locale (e.g. 'fr-CH')
63 or language tag (e.g. 'de').
64 :param silent: If True (default = False), no errors will be raised
65 when parsing failed. Instead, `None` will be returned.
67 :returns: babel.core.Locale or None
69 :raises LocaleError:
70 """
72 if isinstance(value, Locale):
73 return value
75 loc = _lc_cache.get(value)
76 if loc:
77 # Value has been converted before: return cached Locale
78 return loc
80 try:
81 loc = Locale.parse(value.strip().replace('-', '_'))
82 except (ValueError, AttributeError) as err:
83 LOGGER.warning(err)
84 if not silent: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 raise LocaleError(f"invalid locale '{value}'")
86 except _UnknownLocaleError as err:
87 LOGGER.warning(err)
88 if not silent: 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 raise LocaleError(err)
90 else:
91 # Add to Locale cache
92 _lc_cache[value] = loc
94 return loc
97def locale2str(value: Locale) -> str:
98 """
99 Converts a Babel Locale instance into a web locale string.
101 :param value: babel.core.Locale
103 :returns: A string containing a web locale (e.g. 'fr-CH')
104 or language tag (e.g. 'de').
106 :raises LocaleError:
107 """
109 if not isinstance(value, Locale):
110 raise LocaleError(f"'{value}' is not of type {Locale.__name__}")
111 return str(value).replace('_', '-')
114def best_match(accept_languages, available_locales) -> Locale:
115 """
116 Takes an Accept-Languages sorted list (from header or request query params)
117 and finds the best matching locale from a list of available locales.
119 This function provides a framework-independent alternative to the
120 `best_match()` function available in Flask/Werkzeug.
122 If no match can be found for the Accept-Languages,
123 the first available locale is returned.
125 This function always returns a Babel Locale instance. If you require the
126 web locale string, please use the :func:`locale2str` function.
127 If you only ever need the language part of the locale, use the `language`
128 property of the returned locale.
130 .. note:: Any tag in the `accept_languages` string that is an invalid
131 or unknown locale is ignored. However, if no
132 `available_locales` are specified, a `LocaleError` is raised.
134 :param accept_languages: A Locale or list of one or more languages.
135 This can be as simple as "de" for example,
136 but it's also possible to include a territory
137 (e.g. "en-US" or "fr_BE") or even a complex
138 list sorted by quality values, e.g.
139 ["fr-CH", "fr", "en", "de", "*"].
140 :param available_locales: A list containing the available locales.
141 For example, a pygeoapi provider might only
142 support ["de", "en"].
143 Locales in the list can be specified as strings
144 (e.g. "nl-NL") or `Locale` instances.
146 :returns: babel.core.Locale
148 :raises LocaleError:
149 """
151 def get_match(locale_, available_locales_):
152 """ Finds the first match of `locale_` in `available_locales_`. """
153 if not locale_: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 return None
155 territories_ = available_locales_.get(locale_.language, {})
156 if locale_.territory in territories_:
157 # Full match on language and territory
158 return locale_
159 if None in territories_:
160 # Match on language only (generic, no territory)
161 return Locale(locale_.language)
162 if territories_: 162 ↛ 164line 162 didn't jump to line 164 because the condition on line 162 was never true
163 # Match on language but another territory (use first)
164 return Locale(locale_.language, territory=territories_[0])
165 # No match at all
166 return None
168 if not available_locales: 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 raise LocaleError('No available locales specified')
171 if isinstance(accept_languages, Locale): 171 ↛ 173line 171 didn't jump to line 173 because the condition on line 171 was never true
172 # If a Babel Locale was used as input, transform back into a string
173 accept_languages = [locale2str(accept_languages)]
175 if not isinstance(accept_languages, list): 175 ↛ 177line 175 didn't jump to line 177 because the condition on line 175 was never true
176 # If `accept_languages` is not a string, ignore it
177 LOGGER.debug(f"ignoring invalid accept-languages '{accept_languages}'")
178 accept_languages = []
180 # Process supported locales
181 prv_locales = OrderedDict()
182 for a in available_locales:
183 loc = str2locale(a)
184 prv_locales.setdefault(loc.language, []).append(loc.territory)
186 # Return best match from accepted languages
187 for lang in accept_languages:
188 loc = str2locale(lang, True)
189 if not loc:
190 LOGGER.debug(f"ignoring invalid accept-language '{lang}'")
191 continue
192 match = get_match(loc, prv_locales)
193 if match:
194 LOGGER.debug(f"'{match}' matches requested '{accept_languages}'")
195 return match
197 # Nothing matched: return the first available locale
198 for lang, territories in prv_locales.items(): 198 ↛ exitline 198 didn't return from function 'best_match' because the loop on line 198 didn't complete
199 match = Locale(lang, territory=territories[0])
200 LOGGER.debug(f"No match found for language '{accept_languages}'; "
201 f"returning default locale '{match}'")
202 return match
205def translate(value: str, language: Union[Locale, str]):
206 """
207 If `value` is a language struct (where its keys are language codes
208 and its values are translations for each language), this function tries to
209 find and return the translation for the given `language`.
211 If the given `value` is not a dict, the original value is returned.
212 If the requested language does not exist in the struct,
213 the first language value is returned. If there are no valid language keys
214 in the struct, the original value is returned as well.
216 If `language` is not a string or Locale, a LocaleError is raised.
218 :param value: A value to translate. Typically either a string or
219 a language struct dictionary.
220 :param language: A locale string (e.g. "en-US" or "en") or Babel Locale.
222 :returns: A translated string or the original value.
224 :raises LocaleError:
225 """
227 nested_dicts = isinstance(value, dict) and any(isinstance(v, dict)
228 for v in value.values())
229 if not isinstance(value, dict) or nested_dicts:
230 # Return non-dicts or dicts with nested dicts as-is
231 return value
233 # Validate language key by type (do not check if parsable)
234 if not isinstance(language, (str, Locale)): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 raise LocaleError('language is not a str or Locale')
237 # First try fast approach: directly fetch expected language key
238 translation = value.get(locale2str(language)
239 if hasattr(language, 'language') else language)
240 if translation: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 return translation
243 # Find valid locale keys in language struct
244 # Also maps Locale instances to actual key names
245 loc_items = OrderedDict((str2locale(k, True), k) for k in value.keys())
246 if not loc_items or None in loc_items:
247 # Return as-is if not ALL keys in the struct are locales
248 return value
250 # Find best language match and return value by its key
251 out_locale = best_match([language], loc_items.keys())
252 return value[loc_items[out_locale]]
255def translate_struct(struct: dict | List[dict],
256 locale_: Locale, is_config: bool = False):
257 """
258 Returns a copy of a given dict or list, where all language structs
259 are filtered on the given locale, i.e. all language structs are replaced
260 by translated values for the best matching locale.
262 :param struct: A dict or list (of dicts) to filter/translate.
263 :param locale_: The Babel Locale to filter on.
264 :param is_config: If True, the struct is treated as a pygeoapi config.
265 This means that the first 2 levels won't be translated
266 and the translated struct is cached for speed.
268 :returns: A translated dict or list
269 """
271 def _translate_dict(obj, level: int = 0):
272 """ Recursive function to walk and translate a struct. """
273 items = obj.items() if isinstance(obj, dict) else enumerate(obj)
274 for k, v in items:
275 if 0 <= level <= max_level and isinstance(v, (dict, list)):
276 # Skip first 2 levels (don't translate)
277 _translate_dict(v, level + 1)
278 continue
279 if isinstance(v, list):
280 _translate_dict(v, level + 1) # noqa
281 continue
282 tr = translate(v, locale_)
283 if isinstance(tr, dict):
284 # Look for language structs in next level
285 _translate_dict(tr, level + 1)
286 else:
287 # Overwrite level with translated value
288 obj[k] = tr
290 max_level = 1 if is_config else -1
291 result = {}
292 if not struct: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true
293 return result
294 if not locale_:
295 return struct
297 # Check if we already translated the dict before
298 result = _cfg_cache.get(locale_) if is_config else result
299 if not result:
300 # Create deep copy of config and translate/filter values
301 result = deepcopy(struct)
302 _translate_dict(result)
304 # Cache translated pygeoapi configs for faster retrieval next time
305 if is_config:
306 _cfg_cache[locale_] = result
308 return result
311def set_response_language(headers: dict, *locale_: Locale):
312 """
313 Sets the Content-Language on the given HTTP response headers dict.
315 :param headers: A dict of HTTP response headers.
316 :param locale_: The Babel Locale(s) to which to set the
317 Content-Language header.
318 Multiple locales can be set for this header.
319 Note that duplicates will be removed.
321 :raises LocaleError: if no valid Babel Locale was found.
322 """
324 if not hasattr(headers, '__setitem__'): 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true
325 LOGGER.warning(f"Cannot set headers on object '{headers}'")
326 return
328 locales = []
329 for loc in locale_:
330 try:
331 loc_str = locale2str(loc)
332 except LocaleError:
333 if len(locale_) == 1: 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true
334 raise
335 else:
336 if loc_str not in locales: 336 ↛ 329line 336 didn't jump to line 329 because the condition on line 336 was always true
337 locales.append(loc_str)
339 if not locales: 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true
340 raise LocaleError('no valid locales set')
341 loc_str = ', '.join(locales)
343 LOGGER.debug(f'Setting Content-Language to {loc_str}')
344 headers['Content-Language'] = loc_str
347def add_locale(url: str, locale_: str) -> str:
348 """
349 Adds a locale query parameter (e.g. 'lang=en-US') to a URL.
350 If `locale_` is None or an empty string, the URL will be returned as-is.
352 :param url: The web page URL (may contain query string).
353 :param locale_: The web locale or language tag to append to the query.
355 :returns: A new URL with a 'lang=<locale>' query parameter.
357 :raises requests.exceptions.MissingSchema:
358 """
360 loc = str2locale(locale_, True)
361 if not loc:
362 # Validation of locale failed
363 LOGGER.warning(
364 f"Invalid locale '{locale_}': returning URL as-is")
365 return url
367 try:
368 url_comp = parse.urlparse(url)
369 params = dict(parse.parse_qsl(url_comp.query))
370 params[QUERY_PARAM] = locale2str(loc)
371 qstr = parse.urlencode(params, quote_via=parse.quote, safe='/')
372 return parse.urlunparse((
373 url_comp.scheme,
374 url_comp.netloc,
375 url_comp.path,
376 url_comp.params,
377 qstr,
378 url_comp.fragment
379 ))
380 except (TypeError, ValueError):
381 LOGGER.warning(
382 f"Failed to append '{QUERY_PARAM}={loc}': returning URL as-is") # noqa
383 return url
386def get_locales(config: dict) -> list:
387 """
388 Reads the configured locales/languages from the given configuration.
389 The first Locale in the returned list should be the default locale.
391 :param config: A pygeaapi configuration dict
393 :returns: A list of supported Locale instances
394 """
396 srv_cfg = config.get('server', {})
397 lang = srv_cfg.get('languages', srv_cfg.get('language', []))
399 if isinstance(lang, str): 399 ↛ 402line 399 didn't jump to line 402 because the condition on line 399 was always true
400 LOGGER.info(f"pygeoapi only supports 1 language: {lang}")
401 lang = [lang]
402 if not isinstance(lang, list) or len(lang) == 0: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true
403 LOGGER.error("Missing 'language(s)' key in config or bad value(s)")
404 raise LocaleError('No languages have been configured')
406 try:
407 return [str2locale(loc) for loc in lang]
408 except LocaleError as err:
409 LOGGER.debug(err)
410 raise LocaleError('Bad value in supported server language(s)')
413def get_plugin_locale(
414 config: dict,
415 requested_locale: Union[str, None]) -> Union[Locale, None]:
416 """
417 Returns the supported locale (best match) for a plugin
418 based on the requested raw locale string.
419 Returns None if the plugin does not support any locales.
420 Returns the default (= first) locale that the plugin supports
421 if no match for the requested locale could be found.
423 :param config: The plugin definition
424 :param requested_locale: The requested locale string (or None)
425 """
427 plugin_name = f"{config.get('name', '')} plugin".strip()
428 if not requested_locale:
429 LOGGER.debug(f'No requested locale for {plugin_name}')
430 requested_locale = ''
432 LOGGER.debug(f'Requested {plugin_name} locale: {requested_locale}')
433 locales = config.get('languages', config.get('language', []))
434 if locales: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true
435 if not isinstance(locales, list):
436 locales = [locales]
437 locale = best_match(requested_locale, locales)
438 LOGGER.info(f'{plugin_name} locale set to {locale}')
439 return locale
441 LOGGER.info(f'{plugin_name} has no locale support')
442 return None