Coverage for pygeoapi/l10n.py: 76%

173 statements  

« 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# ================================================================= 

29 

30import logging 

31from typing import List, Union 

32from collections import OrderedDict 

33from copy import deepcopy 

34 

35from babel import Locale 

36from babel import UnknownLocaleError as _UnknownLocaleError 

37from urllib import parse 

38 

39LOGGER = logging.getLogger(__name__) 

40 

41# Specifies the name of a request query parameter used to set a locale 

42QUERY_PARAM = 'lang' 

43 

44# Cache Babel Locale lookups by string 

45_lc_cache = {} 

46 

47# Cache translated configurations 

48_cfg_cache = {} 

49 

50 

51class LocaleError(Exception): 

52 """ General exception for any kind of locale parsing error. """ 

53 pass 

54 

55 

56def str2locale(value: str, silent: bool = False) -> Union[Locale, None]: 

57 """ 

58 Converts a web locale or language tag into a Babel Locale instance. 

59 

60 .. note:: If `value` already is a Locale, it is returned as-is. 

61 

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. 

66 

67 :returns: babel.core.Locale or None 

68 

69 :raises LocaleError: 

70 """ 

71 

72 if isinstance(value, Locale): 

73 return value 

74 

75 loc = _lc_cache.get(value) 

76 if loc: 

77 # Value has been converted before: return cached Locale 

78 return loc 

79 

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 

93 

94 return loc 

95 

96 

97def locale2str(value: Locale) -> str: 

98 """ 

99 Converts a Babel Locale instance into a web locale string. 

100 

101 :param value: babel.core.Locale 

102 

103 :returns: A string containing a web locale (e.g. 'fr-CH') 

104 or language tag (e.g. 'de'). 

105 

106 :raises LocaleError: 

107 """ 

108 

109 if not isinstance(value, Locale): 

110 raise LocaleError(f"'{value}' is not of type {Locale.__name__}") 

111 return str(value).replace('_', '-') 

112 

113 

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. 

118 

119 This function provides a framework-independent alternative to the 

120 `best_match()` function available in Flask/Werkzeug. 

121 

122 If no match can be found for the Accept-Languages, 

123 the first available locale is returned. 

124 

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. 

129 

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. 

133 

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. 

145 

146 :returns: babel.core.Locale 

147 

148 :raises LocaleError: 

149 """ 

150 

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 

167 

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') 

170 

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)] 

174 

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 = [] 

179 

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) 

185 

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 

196 

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 

203 

204 

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`. 

210 

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. 

215 

216 If `language` is not a string or Locale, a LocaleError is raised. 

217 

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. 

221 

222 :returns: A translated string or the original value. 

223 

224 :raises LocaleError: 

225 """ 

226 

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 

232 

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') 

236 

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 

242 

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 

249 

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]] 

253 

254 

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. 

261 

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. 

267 

268 :returns: A translated dict or list 

269 """ 

270 

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 

289 

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 

296 

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) 

303 

304 # Cache translated pygeoapi configs for faster retrieval next time 

305 if is_config: 

306 _cfg_cache[locale_] = result 

307 

308 return result 

309 

310 

311def set_response_language(headers: dict, *locale_: Locale): 

312 """ 

313 Sets the Content-Language on the given HTTP response headers dict. 

314 

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. 

320 

321 :raises LocaleError: if no valid Babel Locale was found. 

322 """ 

323 

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 

327 

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) 

338 

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) 

342 

343 LOGGER.debug(f'Setting Content-Language to {loc_str}') 

344 headers['Content-Language'] = loc_str 

345 

346 

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. 

351 

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. 

354 

355 :returns: A new URL with a 'lang=<locale>' query parameter. 

356 

357 :raises requests.exceptions.MissingSchema: 

358 """ 

359 

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 

366 

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 

384 

385 

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. 

390 

391 :param config: A pygeaapi configuration dict 

392 

393 :returns: A list of supported Locale instances 

394 """ 

395 

396 srv_cfg = config.get('server', {}) 

397 lang = srv_cfg.get('languages', srv_cfg.get('language', [])) 

398 

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') 

405 

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)') 

411 

412 

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. 

422 

423 :param config: The plugin definition 

424 :param requested_locale: The requested locale string (or None) 

425 """ 

426 

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 = '' 

431 

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 

440 

441 LOGGER.info(f'{plugin_name} has no locale support') 

442 return None