diff --git a/pygeoapi/api.py b/pygeoapi/api.py index 19508d12a..1939a47ff 100644 --- a/pygeoapi/api.py +++ b/pygeoapi/api.py @@ -321,11 +321,10 @@ def get_response_headers(self, content_type: str = None, :returns: A header dict """ headers = HEADERS.copy() - if self._raw_locale or locale: - # Add a Content-Language response header if the user requested - # a specific language or if the locale override is applied - response_loc = l10n.locale2str(locale if locale else self._locale) - headers['Content-Language'] = response_loc + # Always add a Content-Language response header: + # use user-override if specified + response_loc = locale if locale else self._locale + l10n.set_response_language(headers, response_loc) if content_type: # Set custom MIME type if specified headers['Content-Type'] = content_type @@ -1216,11 +1215,8 @@ def get_collection_items(self, request: Union[APIRequest, Any], dataset, pathinf content['timeStamp'] = datetime.utcnow().strftime( '%Y-%m-%dT%H:%M:%S.%fZ') - if p.locale: - # If provider supports locales, override/set response locale - headers['Content-Language'] = p.locale - if request.format == 'html': # render + l10n.set_response_language(headers, p.locale) # For constructing proper URIs to items if pathinfo: @@ -1246,6 +1242,8 @@ def get_collection_items(self, request: Union[APIRequest, Any], dataset, pathinf content, request.locale) return headers, 200, content elif request.format == 'csv': # render + l10n.set_response_language(headers, p.locale) + formatter = load_plugin('formatter', {'name': 'CSV', 'geom': True}, request.raw_locale) @@ -1268,9 +1266,11 @@ def get_collection_items(self, request: Union[APIRequest, Any], dataset, pathinf return headers, 200, content elif request.format == 'jsonld': + l10n.set_response_language(headers, p.locale, True) content = geojson2geojsonld(self.config, content, dataset) return headers, 200, content + l10n.set_response_language(headers, p.locale, True) return headers, 200, to_json(content, self.pretty_print) @pre_process @@ -1379,11 +1379,9 @@ def get_collection_item(self, request: Union[APIRequest, Any], dataset, identifi self.config['server']['url'], dataset, identifier) }] - if p.locale: - # If provider supports locales, override/set response locale - headers['Content-Language'] = p.locale - if request.format == 'html': # render + l10n.set_response_language(headers, p.locale) + content['title'] = l10n.translate(collections[dataset]['title'], request.locale) content['id_field'] = p.id_field @@ -1396,11 +1394,14 @@ def get_collection_item(self, request: Union[APIRequest, Any], dataset, identifi return headers, 200, content elif request.format == 'jsonld': + l10n.set_response_language(headers, p.locale, True) + content = geojson2geojsonld( self.config, content, dataset, identifier=identifier ) return headers, 200, content + l10n.set_response_language(headers, p.locale, True) return headers, 200, to_json(content, self.pretty_print) @pre_process @@ -2442,15 +2443,13 @@ def get_collection_edr_query(self, request: Union[APIRequest, Any], return self.get_exception( 500, headers, request.format, 'NoApplicableCode', msg) - if p.locale: - # If provider supports locales, override/set response locale - headers['Content-Language'] = p.locale - if request.format == 'html': # render + l10n.set_response_language(headers, p.locale) content = render_j2_template(self.config, 'collections/edr/query.html', data, request.locale) else: + l10n.set_response_language(headers, p.locale, True) content = to_json(data, self.pretty_print) return headers, 200, content diff --git a/pygeoapi/l10n.py b/pygeoapi/l10n.py index 069881158..0bca24464 100644 --- a/pygeoapi/l10n.py +++ b/pygeoapi/l10n.py @@ -240,9 +240,10 @@ def translate(value, language: Union[Locale, str]): :returns: A translated string or the original value. :raises: LocaleError """ - if not isinstance(value, dict): - # Perhaps use a translation service for strings at a later stage? - # For now just return the value as-is + nested_dicts = isinstance(value, dict) and any(isinstance(v, dict) + for v in value.values()) + if not isinstance(value, dict) or nested_dicts: + # Return non-dicts or dicts with nested dicts as-is return value # Validate language key by type (do not check if parsable) @@ -272,46 +273,50 @@ def translate(value, language: Union[Locale, str]): return value[loc_items[out_locale]] -def translate_dict(dictionary: dict, locale_: Locale, is_config: bool = False) -> dict: # noqa - """ Returns a copy of a given dict, where all language structs - are filtered on the given locale. This results in a dictionary in which - the language structs are replaced by translated strings. +def translate_struct(struct, locale_: Locale, is_config: bool = False): + """ Returns a copy of a given dict or list, where all language structs + are filtered on the given locale, i.e. all language structs are replaced + by translated values for the best matching locale. - :param dictionary: A dict to filter/translate. + :param struct: A dict or list (of dicts) to filter/translate. :param locale_: The Babel Locale to filter on. - :param is_config: If True, the dict is treated as a pygeoapi config. + :param is_config: If True, the struct is treated as a pygeoapi config. This means that the first 2 levels won't be translated - and the translated dict is cached for speed. - :returns: A translated dict + and the translated struct is cached for speed. + :returns: A translated dict or list """ - def _translate_dict(d: dict, level: int = 0): - """ Recursive function to walk and translate a dictionary. """ - for k, v in d.items(): - if 0 <= level <= max_level and isinstance(v, dict): + def _translate_dict(obj, level: int = 0): + """ Recursive function to walk and translate a struct. """ + items = obj.items() if isinstance(obj, dict) else enumerate(obj) + for k, v in items: + if 0 <= level <= max_level and isinstance(v, (dict, list)): # Skip first 2 levels (don't translate) _translate_dict(v, level + 1) continue + if isinstance(v, list): + _translate_dict(v, level + 1) # noqa + continue tr = translate(v, locale_) if isinstance(tr, dict): # Look for language structs in next level _translate_dict(tr, level + 1) else: # Overwrite level with translated value - d[k] = tr + obj[k] = tr max_level = 1 if is_config else -1 result = {} - if not dictionary: + if not struct: return result if not locale_: - return dictionary + return struct # Check if we already translated the dict before result = _cfg_cache.get(locale_) if is_config else result if not result: # Create deep copy of config and translate/filter values - result = deepcopy(dictionary) + result = deepcopy(struct) _translate_dict(result) # Cache translated pygeoapi configs for faster retrieval next time @@ -355,6 +360,38 @@ def locale_from_params(params) -> str: return lang +def set_response_language(headers: dict, locale_: Union[Locale, None], remove: bool = False): # noqa + """ Sets the Content-Language on the given HTTP response headers dict. + + If `locale_` is None and `remove` is True, this will delete an existing + Content-Language header. If `remove` is False (default), an existing + Content-Language header will never be deleted. In that case, if `locale_` + is None, the Content-Language will remain unchanged (if set). + + :param headers: A dict of HTTP response headers. + :param locale_: The Babel Locale to which to set the Content-Language. + :param remove: If True and `locale_` is None, the Content-Language header + will be removed. + """ + if not hasattr(headers, '__setitem__'): + LOGGER.warning(f"Cannot set headers on object '{headers}'") + return + if not isinstance(locale_, Locale): + if locale_ is None and remove: + try: + del headers['Content-Language'] + except KeyError: + return + LOGGER.debug('No locale: removed Content-Language header') + return + LOGGER.debug('Keeping existing Content-Language header (if set)') + return + + loc_str = locale2str(locale_) + LOGGER.debug(f'Setting Content-Language to {loc_str}') + headers['Content-Language'] = loc_str + + def add_locale(url, locale_): """ Adds a locale query parameter (e.g. 'l=en-US') to a URL. If `locale_` is None or an empty string, the URL will be returned as-is. @@ -397,22 +434,21 @@ def get_locales(config: dict) -> list: :param config: A pygeaapi configuration dict :returns: A list of supported Locale instances """ - try: - # New setting (multiple languages, first specifies default) - lang = config.get('server', {})['languages'] - except KeyError: - # Old setting (single language) - lang = [config.get('server', {}).get('language')] - - if not lang: - LOGGER.error("Missing 'language(s)' key in config or empty value") + srv_cfg = config.get('server', {}) + lang = srv_cfg.get('languages', srv_cfg.get('language', [])) + + if isinstance(lang, str): + LOGGER.info(f"pygeoapi only supports 1 language: {lang}") + lang = [lang] + if not isinstance(lang, list) or len(lang) == 0: + LOGGER.error("Missing 'language(s)' key in config or bad value(s)") raise LocaleError('No languages have been configured') try: return [str2locale(loc) for loc in lang] except LocaleError as err: LOGGER.debug(err) - raise LocaleError('Config error in supported server languages') + raise LocaleError('Bad value in supported server language(s)') def get_plugin_locale(config: dict, requested_locale: str) -> Union[Locale, None]: # noqa @@ -431,8 +467,10 @@ def get_plugin_locale(config: dict, requested_locale: str) -> Union[Locale, None requested_locale = '' LOGGER.debug(f'Requested {plugin_name} locale: {requested_locale}') - locales = config.get('languages', []) + locales = config.get('languages', config.get('language', [])) if locales: + if not isinstance(locales, list): + locales = [locales] locale = best_match(requested_locale, locales) LOGGER.info(f'{plugin_name} locale set to {locale}') return locale diff --git a/pygeoapi/util.py b/pygeoapi/util.py index cdcdcac2e..4d271903b 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -310,7 +310,7 @@ def render_j2_template(config, template, data, locale_=None): else: raise - return template.render(config=l10n.translate_dict(config, locale_, True), + return template.render(config=l10n.translate_struct(config, locale_, True), data=data, version=__version__) diff --git a/tests/test_l10n.py b/tests/test_l10n.py index 3fbfe89d6..456b43051 100644 --- a/tests/test_l10n.py +++ b/tests/test_l10n.py @@ -109,6 +109,8 @@ def test_translate(language_struct, nonlanguage_struct): assert l10n.translate({}, 'en-US') == {} assert l10n.translate(42, 'fr') == 42 assert l10n.translate(None, 'de') is None + assert l10n.translate(['list item'], Locale('en')) == ['list item'] + assert l10n.translate({'nested dict': {'en': 1, 'fr': 2}}, 'en') == {'nested dict': {'en': 1, 'fr': 2}} # noqa assert l10n.translate(nonlanguage_struct, 'fr') == nonlanguage_struct assert l10n.translate(nonlanguage_struct, 'fla') == 'non-language key' @@ -166,6 +168,8 @@ def test_getlocales(): assert l10n.get_locales(config) == [Locale.parse('en_US')] config['server']['language'] = 'de_CH' assert l10n.get_locales(config) == [Locale.parse('de_CH')] + config['server']['language'] = ['de', 'en-US'] # noqa + assert l10n.get_locales(config) == [Locale.parse('de'), Locale.parse('en_US')] # noqa config = { 'server': { @@ -174,7 +178,11 @@ def test_getlocales(): } with pytest.raises(l10n.LocaleError): l10n.get_locales(config) - config['server']['languages'] = [None] + + config['server']['languages'] = [None] + with pytest.raises(l10n.LocaleError): + l10n.get_locales(config) + config['server']['languages'] = ['de', 'en-US'] assert l10n.get_locales(config) == [Locale.parse('de'), Locale.parse('en_US')] # noqa @@ -183,12 +191,35 @@ def test_getpluginlocale(): assert l10n.get_plugin_locale({}, 'de') is None assert l10n.get_plugin_locale({}, None) is None # noqa assert l10n.get_plugin_locale({}, '') is None + assert l10n.get_plugin_locale({'language': 'de'}, 'en') == Locale('de') + assert l10n.get_plugin_locale({'language': None}, 'en') is None assert l10n.get_plugin_locale({'languages': ['en']}, None) == Locale('en') # noqa assert l10n.get_plugin_locale({'languages': []}, 'nl') is None assert l10n.get_plugin_locale({'languages': ['en']}, 'fr') == Locale('en') assert l10n.get_plugin_locale({'languages': ['en', 'de']}, 'de') == Locale('de') # noqa +def test_setresponselanguage(): + # the following should not raise (just do nothing) + l10n.set_response_language(None, None) # noqa + + headers = {} + l10n.set_response_language(headers, None) # noqa + assert headers == {} + + l10n.set_response_language(headers, Locale('en')) + assert headers['Content-Language'] == 'en' + + l10n.set_response_language(headers, Locale('de')) + assert headers['Content-Language'] == 'de' + + l10n.set_response_language(headers, None) + assert headers['Content-Language'] == 'de' + + l10n.set_response_language(headers, None, True) + assert 'Content-Language' not in headers + + def get_test_file_path(filename): """helper function to open test file safely""" @@ -210,19 +241,19 @@ def locale_(): def test_translatedict(config, locale_): - cfg = l10n.translate_dict(config, locale_, True) + cfg = l10n.translate_struct(config, locale_, True) assert cfg['metadata']['identification']['title'] == 'pygeoapi default instance' # noqa assert cfg['metadata']['identification']['keywords'] == ['geospatial', 'data', 'api'] # noqa # test full equality (must come from cache) - cfg2 = l10n.translate_dict(config, locale_, True) + cfg2 = l10n.translate_struct(config, locale_, True) assert cfg is cfg2 # missing locale_ should return the same dict - assert l10n.translate_dict(config, None) is config # noqa + assert l10n.translate_struct(config, None) is config # noqa # missing or empty dict should return an empty dict - assert l10n.translate_dict(None, locale_) == {} # noqa + assert l10n.translate_struct(None, locale_) == {} # noqa # test custom dict (translate from level 0, do not cache) test_dict = { @@ -231,8 +262,43 @@ def test_translatedict(config, locale_): 'fr': 'valeur de test' } } - tr_dict = l10n.translate_dict(test_dict, locale_) + tr_dict = l10n.translate_struct(test_dict, locale_) assert tr_dict['level0'] == 'test value' - tr_dict2 = l10n.translate_dict(test_dict, locale_) + tr_dict2 = l10n.translate_struct(test_dict, locale_) assert tr_dict == tr_dict2 assert tr_dict is not tr_dict2 + + # test mixed structure + test_input = [ + {'test': { + 'en': 'test value', + 'fr': 'valeur de test' + }}, + 'some string', + {'item1': 1}, + {'item2a': [ + 'list_item1', + 'list_item2', + { + 'en': 'list value', + 'fr': 'valeur de liste' + } + ], + 'item2b': { + 'en': 'test value', + 'fr': 'valeur de test' + }} + ] + test_output = [ + {'test': 'test value'}, + 'some string', + {'item1': 1}, + {'item2a': [ + 'list_item1', + 'list_item2', + 'list value' + ], + 'item2b': 'test value' + } + ] + assert l10n.translate_struct(test_input, locale_) == test_output