3f42069d9e
* Revert "change port" This reverts commit92e0b0da9e. * better map size * render pretty basemap if API key is available * page layout * better error message; all redirects to a new tab * neat tables * upd readme * api key variable * remove manual zoom setting * reply on speckle_type for traversal * receive custom attributes * fix attributes * fixes * fixed deserialization; default color * assign all props to all elements * send complete displayValues for Meshes * refactor * more refactor * last fixes * use 3 dimensions * support ICurves and new GisFeatures, and (possible) hatches * fix hatches * fix orientation function * fix orientation for mesh, brep, hatch, polygon * patch deserializer * accept vertical polygons * prioritize mesh colors, if available * clean url arguments * color mesh by 2 pts * Revert "color mesh by 2 pts" This reverts commit0d2c12db1d. * query comments and attachments * fix time formatting * fixed comments * fix threads * patch before launch * separate comments and geometry in displayProperties * add url parameter for data type * split features by type * max zoom for tiles to load * cors * rename url * typo * leaflet_demo_comments * url fix * Demo with masterplan * openlayers demo, no zoom extents * openLayers example * Update README - QGIS WFS * explicit error for deprecated data * move around * Update README.md * better openLayers demo * Update README.md * Update README.md * Update README.md * better display colors * attribution * no print * explode meshes. todo: URL param; fix Revit * some improvements * preserveAttributes parameter * fix * proper comment resource_id * add map to empty page * sort by height * remove print * page description * page description complete * change port
512 lines
18 KiB
Python
512 lines
18 KiB
Python
# =================================================================
|
|
#
|
|
# Authors: Sander Schaminee <sander.schaminee@geocat.net>
|
|
#
|
|
# Copyright (c) 2021 GeoCat BV
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person
|
|
# obtaining a copy of this software and associated documentation
|
|
# files (the "Software"), to deal in the Software without
|
|
# restriction, including without limitation the rights to use,
|
|
# copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the
|
|
# Software is furnished to do so, subject to the following
|
|
# conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
# OTHER DEALINGS IN THE SOFTWARE.
|
|
#
|
|
# =================================================================
|
|
|
|
import logging
|
|
from typing import Union
|
|
from collections import OrderedDict
|
|
from copy import deepcopy
|
|
|
|
from babel import Locale
|
|
from babel import UnknownLocaleError as _UnknownLocaleError
|
|
from urllib import parse
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
# Specifies the name of a request query parameter used to set a locale
|
|
QUERY_PARAM = 'lang'
|
|
|
|
# Cache Babel Locale lookups by string
|
|
_lc_cache = {}
|
|
|
|
# Cache translated configurations
|
|
_cfg_cache = {}
|
|
|
|
|
|
class LocaleError(Exception):
|
|
""" General exception for any kind of locale parsing error. """
|
|
pass
|
|
|
|
|
|
def str2locale(value, silent: bool = False) -> Union[Locale, None]:
|
|
"""
|
|
Converts a web locale or language tag into a Babel Locale instance.
|
|
|
|
.. note:: If `value` already is a Locale, it is returned as-is.
|
|
|
|
:param value: A string containing a (web) locale (e.g. 'fr-CH')
|
|
or language tag (e.g. 'de').
|
|
:param silent: If True (default = False), no errors will be raised
|
|
when parsing failed. Instead, `None` will be returned.
|
|
|
|
:returns: babel.core.Locale or None
|
|
|
|
:raises LocaleError:
|
|
"""
|
|
|
|
if isinstance(value, Locale):
|
|
return value
|
|
|
|
loc = _lc_cache.get(value)
|
|
if loc:
|
|
# Value has been converted before: return cached Locale
|
|
return loc
|
|
|
|
try:
|
|
loc = Locale.parse(value.strip().replace('-', '_'))
|
|
except (ValueError, AttributeError) as err:
|
|
LOGGER.warning(err)
|
|
if not silent:
|
|
raise LocaleError(f"invalid locale '{value}'")
|
|
except _UnknownLocaleError as err:
|
|
LOGGER.warning(err)
|
|
if not silent:
|
|
raise LocaleError(err)
|
|
else:
|
|
# Add to Locale cache
|
|
_lc_cache[value] = loc
|
|
|
|
return loc
|
|
|
|
|
|
def locale2str(value: Locale) -> str:
|
|
"""
|
|
Converts a Babel Locale instance into a web locale string.
|
|
|
|
:param value: babel.core.Locale
|
|
|
|
:returns: A string containing a web locale (e.g. 'fr-CH')
|
|
or language tag (e.g. 'de').
|
|
|
|
:raises LocaleError:
|
|
"""
|
|
|
|
if not isinstance(value, Locale):
|
|
raise LocaleError(f"'{value}' is not of type {Locale.__name__}")
|
|
return str(value).replace('_', '-')
|
|
|
|
|
|
def best_match(accept_languages, available_locales) -> Locale:
|
|
"""
|
|
Takes an Accept-Languages string (from header or request query params)
|
|
and finds the best matching locale from a list of available locales.
|
|
|
|
This function provides a framework-independent alternative to the
|
|
`best_match()` function available in Flask/Werkzeug.
|
|
|
|
If no match can be found for the Accept-Languages,
|
|
the first available locale is returned.
|
|
|
|
This function always returns a Babel Locale instance. If you require the
|
|
web locale string, please use the :func:`locale2str` function.
|
|
If you only ever need the language part of the locale, use the `language`
|
|
property of the returned locale.
|
|
|
|
.. note:: Any tag in the `accept_languages` string that is an invalid
|
|
or unknown locale is ignored. However, if no
|
|
`available_locales` are specified, a `LocaleError` is raised.
|
|
|
|
:param accept_languages: A Locale or string with one or more languages.
|
|
This can be as simple as "de" for example,
|
|
but it's also possible to include a territory
|
|
(e.g. "en-US" or "fr_BE") or even a complex
|
|
string with quality values, e.g.
|
|
"fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5".
|
|
:param available_locales: A list containing the available locales.
|
|
For example, a pygeoapi provider might only
|
|
support ["de", "en"].
|
|
Locales in the list can be specified as strings
|
|
(e.g. "nl-NL") or `Locale` instances.
|
|
|
|
:returns: babel.core.Locale
|
|
|
|
:raises LocaleError:
|
|
"""
|
|
|
|
def get_match(locale_, available_locales_):
|
|
""" Finds the first match of `locale_` in `available_locales_`. """
|
|
if not locale_:
|
|
return None
|
|
territories_ = available_locales_.get(locale_.language, {})
|
|
if locale_.territory in territories_:
|
|
# Full match on language and territory
|
|
return locale_
|
|
if None in territories_:
|
|
# Match on language only (generic, no territory)
|
|
return Locale(locale_.language)
|
|
if territories_:
|
|
# Match on language but another territory (use first)
|
|
return Locale(locale_.language, territory=territories_[0])
|
|
# No match at all
|
|
return None
|
|
|
|
if not available_locales:
|
|
raise LocaleError('No available locales specified')
|
|
|
|
if isinstance(accept_languages, Locale):
|
|
# If a Babel Locale was used as input, transform back into a string
|
|
accept_languages = locale2str(accept_languages)
|
|
|
|
if not isinstance(accept_languages, str):
|
|
# If `accept_languages` is not a string, ignore it
|
|
LOGGER.debug(f"ignoring invalid accept-languages '{accept_languages}'")
|
|
accept_languages = ''
|
|
|
|
tags = accept_languages.split(',')
|
|
num_tags = len(tags)
|
|
req_locales = {}
|
|
for i, lang in enumerate(tags):
|
|
q_raw = None
|
|
q_out = None
|
|
if not lang:
|
|
continue
|
|
|
|
# Check if complex (i.e. with quality weights)
|
|
try:
|
|
lang, q_raw = (v.strip() for v in lang.split(';'))
|
|
except ValueError:
|
|
# Tuple unpacking failed: tag is not complex (or too complex :))
|
|
pass
|
|
|
|
# Validate locale tag
|
|
loc = str2locale(lang, True)
|
|
if not loc:
|
|
LOGGER.debug(f"ignoring invalid accept-language '{lang}'")
|
|
continue
|
|
|
|
# Validate quality weight (e.g. "q=0.7")
|
|
if q_raw:
|
|
try:
|
|
q_out = float([v.strip() for v in q_raw.split('=')][1])
|
|
except (ValueError, IndexError):
|
|
# Tuple unpacking failed: not a valid q tag
|
|
pass
|
|
|
|
# If there's no actual q, set one based on the language order
|
|
if not q_out:
|
|
q_out = num_tags - i
|
|
|
|
# Store locale
|
|
req_locales[q_out] = loc
|
|
|
|
# Process supported locales
|
|
prv_locales = OrderedDict()
|
|
for a in available_locales:
|
|
loc = str2locale(a)
|
|
prv_locales.setdefault(loc.language, []).append(loc.territory)
|
|
|
|
# Return best match from accepted languages
|
|
for _, loc in sorted(req_locales.items(), reverse=True):
|
|
match = get_match(loc, prv_locales)
|
|
if match:
|
|
LOGGER.debug(f"'{match}' matches requested '{accept_languages}'")
|
|
return match
|
|
|
|
# Nothing matched: return the first available locale
|
|
for lang, territories in prv_locales.items():
|
|
match = Locale(lang, territory=territories[0])
|
|
LOGGER.debug(f"No match found for language '{accept_languages}'; "
|
|
f"returning default locale '{match}'")
|
|
return match
|
|
|
|
|
|
def translate(value, language: Union[Locale, str]):
|
|
"""
|
|
If `value` is a language struct (where its keys are language codes
|
|
and its values are translations for each language), this function tries to
|
|
find and return the translation for the given `language`.
|
|
|
|
If the given `value` is not a dict, the original value is returned.
|
|
If the requested language does not exist in the struct,
|
|
the first language value is returned. If there are no valid language keys
|
|
in the struct, the original value is returned as well.
|
|
|
|
If `language` is not a string or Locale, a LocaleError is raised.
|
|
|
|
:param value: A value to translate. Typically either a string or
|
|
a language struct dictionary.
|
|
:param language: A locale string (e.g. "en-US" or "en") or Babel Locale.
|
|
|
|
:returns: A translated string or the original value.
|
|
|
|
:raises LocaleError:
|
|
"""
|
|
|
|
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)
|
|
if not isinstance(language, (str, Locale)):
|
|
raise LocaleError('language is not a str or Locale')
|
|
|
|
# First try fast approach: directly fetch expected language key
|
|
translation = value.get(locale2str(language)
|
|
if hasattr(language, 'language') else language)
|
|
if translation:
|
|
return translation
|
|
|
|
# Find valid locale keys in language struct
|
|
# Also maps Locale instances to actual key names
|
|
loc_items = OrderedDict((str2locale(k, True), k) for k in value.keys())
|
|
if not loc_items or None in loc_items:
|
|
# Return as-is if not ALL keys in the struct are locales
|
|
return value
|
|
|
|
# Find best language match and return value by its key
|
|
out_locale = best_match(language, loc_items.keys())
|
|
return value[loc_items[out_locale]]
|
|
|
|
|
|
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 struct: A dict or list (of dicts) to filter/translate.
|
|
:param locale_: The Babel Locale to filter on.
|
|
: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 struct is cached for speed.
|
|
|
|
:returns: A translated dict or list
|
|
"""
|
|
|
|
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
|
|
obj[k] = tr
|
|
|
|
max_level = 1 if is_config else -1
|
|
result = {}
|
|
if not struct:
|
|
return result
|
|
if not locale_:
|
|
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(struct)
|
|
_translate_dict(result)
|
|
|
|
# Cache translated pygeoapi configs for faster retrieval next time
|
|
if is_config:
|
|
_cfg_cache[locale_] = result
|
|
|
|
return result
|
|
|
|
|
|
def locale_from_headers(headers) -> str:
|
|
"""
|
|
Gets a valid Locale from a request headers dictionary.
|
|
Supported are complex strings (e.g. "fr-CH, fr;q=0.9, en;q=0.8"),
|
|
web locales (e.g. "en-US") or basic language tags (e.g. "en").
|
|
A value of `None` is returned if the locale was not found or invalid.
|
|
|
|
:param headers: Mapping of request headers.
|
|
|
|
:returns: locale string or None
|
|
"""
|
|
|
|
lang = {k.lower(): v for k, v in headers.items()}.get('accept-language')
|
|
if lang:
|
|
LOGGER.debug(f"Got locale '{lang}' from 'Accept-Language' header")
|
|
return lang
|
|
|
|
|
|
def locale_from_params(params) -> str:
|
|
"""
|
|
Gets a valid Locale from a request query parameters dictionary.
|
|
Supported are complex strings (e.g. "fr-CH, fr;q=0.9, en;q=0.8"),
|
|
web locales (e.g. "en-US") or basic language tags (e.g. "en").
|
|
A value of `None` is returned if the locale was not found or invalid.
|
|
|
|
:param params: Mapping of request query parameters.
|
|
|
|
:returns: locale string or None
|
|
"""
|
|
|
|
lang = params.get(QUERY_PARAM)
|
|
if lang:
|
|
LOGGER.debug(f"Got locale '{lang}' from query parameter '{QUERY_PARAM}'") # noqa
|
|
return lang
|
|
|
|
|
|
def set_response_language(headers: dict, *locale_: Locale):
|
|
"""
|
|
Sets the Content-Language on the given HTTP response headers dict.
|
|
|
|
:param headers: A dict of HTTP response headers.
|
|
:param locale_: The Babel Locale(s) to which to set the
|
|
Content-Language header.
|
|
Multiple locales can be set for this header.
|
|
Note that duplicates will be removed.
|
|
|
|
:raises LocaleError: if no valid Babel Locale was found.
|
|
"""
|
|
|
|
if not hasattr(headers, '__setitem__'):
|
|
LOGGER.warning(f"Cannot set headers on object '{headers}'")
|
|
return
|
|
|
|
locales = []
|
|
for loc in locale_:
|
|
try:
|
|
loc_str = locale2str(loc)
|
|
except LocaleError:
|
|
if len(locale_) == 1:
|
|
raise
|
|
else:
|
|
if loc_str not in locales:
|
|
locales.append(loc_str)
|
|
|
|
if not locales:
|
|
raise LocaleError('no valid locales set')
|
|
loc_str = ', '.join(locales)
|
|
|
|
LOGGER.debug(f'Setting Content-Language to {loc_str}')
|
|
headers['Content-Language'] = loc_str
|
|
headers['Access-Control-Allow-Origin'] = "*"
|
|
|
|
|
|
def add_locale(url, locale_) -> str:
|
|
"""
|
|
Adds a locale query parameter (e.g. 'lang=en-US') to a URL.
|
|
If `locale_` is None or an empty string, the URL will be returned as-is.
|
|
|
|
:param url: The web page URL (may contain query string).
|
|
:param locale_: The web locale or language tag to append to the query.
|
|
|
|
:returns: A new URL with a 'lang=<locale>' query parameter.
|
|
|
|
:raises requests.exceptions.MissingSchema:
|
|
"""
|
|
|
|
loc = str2locale(locale_, True)
|
|
if not loc:
|
|
# Validation of locale failed
|
|
LOGGER.warning(
|
|
f"Invalid locale '{locale_}': returning URL as-is")
|
|
return url
|
|
|
|
try:
|
|
url_comp = parse.urlparse(url)
|
|
params = dict(parse.parse_qsl(url_comp.query))
|
|
params[QUERY_PARAM] = locale2str(loc)
|
|
qstr = parse.urlencode(params, quote_via=parse.quote, safe='/')
|
|
return parse.urlunparse((
|
|
url_comp.scheme,
|
|
url_comp.netloc,
|
|
url_comp.path,
|
|
url_comp.params,
|
|
qstr,
|
|
url_comp.fragment
|
|
))
|
|
except (TypeError, ValueError):
|
|
LOGGER.warning(
|
|
f"Failed to append '{QUERY_PARAM}={loc}': returning URL as-is") # noqa
|
|
return url
|
|
|
|
|
|
def get_locales(config: dict) -> list:
|
|
"""
|
|
Reads the configured locales/languages from the given configuration.
|
|
The first Locale in the returned list should be the default locale.
|
|
|
|
:param config: A pygeaapi configuration dict
|
|
|
|
:returns: A list of supported Locale instances
|
|
"""
|
|
|
|
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('Bad value in supported server language(s)')
|
|
|
|
|
|
def get_plugin_locale(
|
|
config: dict,
|
|
requested_locale: Union[str, None]) -> Union[Locale, None]:
|
|
"""
|
|
Returns the supported locale (best match) for a plugin
|
|
based on the requested raw locale string.
|
|
Returns None if the plugin does not support any locales.
|
|
Returns the default (= first) locale that the plugin supports
|
|
if no match for the requested locale could be found.
|
|
|
|
:param config: The plugin definition
|
|
:param requested_locale: The requested locale string (or None)
|
|
"""
|
|
|
|
plugin_name = f"{config.get('name', '')} plugin".strip()
|
|
if not requested_locale:
|
|
LOGGER.debug(f'No requested locale for {plugin_name}')
|
|
requested_locale = ''
|
|
|
|
LOGGER.debug(f'Requested {plugin_name} locale: {requested_locale}')
|
|
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
|
|
|
|
LOGGER.info(f'{plugin_name} has no locale support')
|
|
return None
|