Coverage for pygeoapi/util.py: 68%
320 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: Tom Kralidis <tomkralidis@gmail.com>
4#
5# Copyright (c) 2026 Tom Kralidis
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# =================================================================
30"""Generic util functions used in the code"""
32import base64
33from filelock import FileLock
34from dataclasses import dataclass
35from datetime import date, datetime, time, timezone
36from decimal import Decimal
37from enum import Enum
38from heapq import heappush
39import ipaddress
40import json
41import logging
42import mimetypes
43import os
44import pathlib
45from pathlib import Path
46import re
47import socket
48from typing import Any, IO, Union, List, Optional
49from urllib.parse import urlparse
50from urllib.request import urlopen
51import uuid
53import dateutil.parser
54from babel.support import Translations
55from jinja2 import Environment, FileSystemLoader, select_autoescape
56from jinja2.exceptions import TemplateNotFound
57from requests import Session
58from requests.structures import CaseInsensitiveDict
59from shapely.geometry import (
60 box,
61 Polygon,
62 mapping as geom_to_geojson,
63)
64import yaml
66from pygeoapi import __version__
67from pygeoapi import l10n
68from pygeoapi.models import config as config_models
69from pygeoapi.plugin import load_plugin, PLUGINS
70from pygeoapi.provider import get_provider_default
72LOGGER = logging.getLogger(__name__)
74DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
76THISDIR = Path(__file__).parent.resolve()
77RESOURCESDIR = THISDIR / 'resources'
78TEMPLATESDIR = THISDIR / 'templates'
79DEFINITIONSDIR = RESOURCESDIR / 'definitions'
80SCHEMASDIR = RESOURCESDIR / 'schemas'
83mimetypes.add_type('text/plain', '.yaml')
84mimetypes.add_type('text/plain', '.yml')
87def dategetter(date_property: str, collection: dict) -> str:
88 """
89 Attempts to obtain a date value from a collection.
91 :param date_property: property representing the date
92 :param collection: dictionary to check within
94 :returns: `str` (ISO8601) representing the date (allowing
95 for an open interval using null)
96 """
98 value = collection.get(date_property)
100 if value is None or isinstance(value, str):
101 return value
102 else:
103 return value.isoformat()
106def get_typed_value(value: str) -> Union[bool, float, int, str]:
107 """
108 Derive true type from data value
110 :param value: value
112 :returns: value as a native Python data type
113 """
115 try:
116 if '.' in value: # float?
117 value2 = float(value)
118 elif len(value) > 1 and value.startswith('0'): 118 ↛ 119line 118 didn't jump to line 119 because the condition on line 118 was never true
119 value2 = value
120 elif value.lower() in ['true', 'false']:
121 value2 = str2bool(value)
122 else: # int?
123 value2 = int(value)
124 except ValueError: # string (default)?
125 value2 = value
127 return value2
130def yaml_load(fh: IO) -> dict:
131 """
132 serializes a YAML files into a pyyaml object
134 :param fh: file handle
136 :returns: `dict` representation of YAML
137 """
139 # # support environment variables in config
140 # # https://stackoverflow.com/a/55301129
142 env_matcher = re.compile(
143 r'.*?\$\{(?P<varname>\w+)(:-(?P<default>[^}]*))?\}')
145 def env_constructor(loader, node):
146 result = ""
147 current_index = 0
148 raw_value = node.value
149 for match_obj in env_matcher.finditer(raw_value):
150 groups = match_obj.groupdict()
151 varname_start = match_obj.span('varname')[0]
152 result += raw_value[current_index:(varname_start-2)]
153 if (var_value := os.getenv(groups['varname'])) is not None:
154 result += var_value
155 elif (default_value := groups.get('default')) is not None: 155 ↛ 158line 155 didn't jump to line 158 because the condition on line 155 was always true
156 result += default_value
157 else:
158 raise EnvironmentError(
159 f'Could not find the {groups["varname"]!r} environment '
160 f'variable'
161 )
162 current_index = match_obj.end()
163 else:
164 result += raw_value[current_index:]
165 return get_typed_value(result)
167 class EnvVarLoader(yaml.SafeLoader):
168 pass
170 EnvVarLoader.add_implicit_resolver('!env', env_matcher, None)
171 EnvVarLoader.add_constructor('!env', env_constructor)
172 return yaml.load(fh, Loader=EnvVarLoader)
175def get_api_rules(config: dict) -> config_models.APIRules:
176 """ Extracts the default API design rules from the given configuration.
178 :param config: Current pygeoapi configuration (dictionary).
179 :returns: An APIRules instance.
180 """
181 rules = config['server'].get('api_rules') or {}
182 rules.setdefault('api_version', __version__)
183 return config_models.APIRules.create(**rules)
186def get_base_url(config: dict) -> str:
187 """ Returns the full pygeoapi base URL. """
188 rules = get_api_rules(config)
189 return url_join(config['server']['url'], rules.get_url_prefix())
192def yaml_dump(dict_: dict, destfile: str) -> bool:
193 """
194 Dump dict to YAML file
196 :param dict_: `dict` to dump
197 :param destfile: destination filepath
199 :returns: `bool`
200 """
202 def path_representer(dumper, data):
203 return dumper.represent_scalar(u'tag:yaml.org,2002:str', str(data))
205 yaml.add_multi_representer(pathlib.PurePath, path_representer)
207 def datetime_representer(dumper, data: datetime):
208 if data.tzinfo is None:
209 data = data.replace(tzinfo=timezone.utc)
210 else:
211 data = data.astimezone(timezone.utc)
212 value = data.strftime("%Y-%m-%dT%H:%M:%SZ")
214 # timestamp in a specified format, without string quotes
215 return dumper.represent_scalar(u'tag:yaml.org,2002:timestamp', value)
217 yaml.add_representer(datetime, datetime_representer)
219 lock = FileLock(f'{destfile}.lock')
221 with lock:
222 LOGGER.debug('Dumping YAML document')
223 with open(destfile, 'wb') as fh:
224 yaml.dump(dict_, fh, sort_keys=False, encoding='utf8',
225 indent=4, default_flow_style=False,
226 allow_unicode=True)
228 return True
231def str2bool(value: Union[bool, str]) -> bool:
232 """
233 helper function to return Python boolean
234 type (source: https://stackoverflow.com/a/715468)
236 :param value: value to be evaluated
238 :returns: `bool` of whether the value is boolean-ish
239 """
241 value2 = False
243 if isinstance(value, bool):
244 value2 = value
245 else:
246 value2 = value.lower() in ('yes', 'true', 't', '1', 'on')
248 return value2
251def to_json(dict_: dict, pretty: bool = False) -> str:
252 """
253 Serialize dict to JSON
255 :param dict_: `dict` of JSON representation
256 :param pretty: `bool` of whether to prettify JSON (default is `False`)
258 :returns: JSON string representation
259 """
261 if pretty:
262 indent = 4
263 else:
264 indent = None
266 LOGGER.debug('Dumping JSON')
267 json_dump = json.dumps(dict_, default=json_serial, indent=indent,
268 separators=(',', ':'))
270 LOGGER.debug('Removing < and >')
271 json_dump = json_dump.replace('<', '<').replace('>', '>')
273 return json_dump
276def format_datetime(value: str, format_: str = DATETIME_FORMAT) -> str:
277 """
278 Parse datetime as ISO 8601 string; re-present it in particular format
279 for display in HTML
281 :param value: `str` of ISO datetime
282 :param format_: `str` of datetime format for strftime
284 :returns: string
285 """
287 if not isinstance(value, str) or not value.strip():
288 return ''
290 return dateutil.parser.isoparse(value).strftime(format_)
293def get_current_datetime(tz: timezone = timezone.utc,
294 format_: str = DATETIME_FORMAT) -> str:
295 return datetime.now(tz).strftime(format_)
298def file_modified_iso8601(filepath: Path) -> str:
299 """
300 Provide a file's ctime in ISO8601
302 :param filepath: path to file
304 :returns: string of ISO8601
305 """
307 return datetime.fromtimestamp(
308 os.path.getctime(filepath)).strftime('%Y-%m-%dT%H:%M:%SZ')
311def human_size(nbytes: int) -> str:
312 """
313 Provides human readable file size
315 source: https://stackoverflow.com/a/14996816
317 :param nbytes: int of file size (bytes)
318 :param units: list of unit abbreviations
320 :returns: string of human readable filesize
321 """
323 suffixes = ['B', 'K', 'M', 'G', 'T', 'P']
325 i = 0
327 while nbytes >= 1024 and i < len(suffixes)-1:
328 nbytes /= 1024.
329 i += 1
331 if suffixes[i] == 'K':
332 f = str(int(nbytes)).rstrip('0').rstrip('.')
333 elif suffixes[i] == 'B':
334 return nbytes
335 else:
336 f = f'{nbytes:.1f}'.rstrip('0').rstrip('.')
338 return f'{f}{suffixes[i]}'
341def format_duration(start: str, end: str = None) -> str:
342 """
343 Parse a start and (optional) end datetime as ISO 8601 strings, calculate
344 the difference, and return that duration as a string.
346 :param start: `str` of ISO datetime
347 :param end: `str` of ISO datetime, defaults to `start` for a 0 duration
349 :returns: string
350 """
352 if not isinstance(start, str) or not start.strip(): 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true
353 return ''
354 end = end or start
355 duration = dateutil.parser.isoparse(end) - dateutil.parser.isoparse(start)
356 return str(duration)
359def get_path_basename(urlpath: str) -> str:
360 """
361 Helper function to derive file basename
363 :param urlpath: URL path
365 :returns: string of basename of URL path
366 """
368 return Path(urlpath).name
371def json_serial(obj: Any) -> str:
372 """
373 helper function to convert to JSON non-default
374 types (source: https://stackoverflow.com/a/22238613)
376 :param obj: `object` to be evaluated
378 :returns: JSON non-default type to `str`
379 """
381 if isinstance(obj, (datetime, date, time)): 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true
382 return obj.isoformat()
383 elif isinstance(obj, bytes): 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true
384 try:
385 LOGGER.debug('Returning as UTF-8 decoded bytes')
386 return obj.decode('utf-8')
387 except UnicodeDecodeError:
388 LOGGER.debug('Returning as base64 encoded JSON object')
389 return base64.b64encode(obj)
390 elif isinstance(obj, Decimal): 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true
391 return float(obj)
392 elif type(obj).__name__ in ['int32', 'int64']: 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true
393 return int(obj)
394 elif type(obj).__name__ in ['float32', 'float64']: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true
395 return float(obj)
396 elif isinstance(obj, l10n.Locale): 396 ↛ 398line 396 didn't jump to line 398 because the condition on line 396 was always true
397 return l10n.locale2str(obj)
398 elif isinstance(obj, (pathlib.PurePath, Path)):
399 return str(obj)
400 elif isinstance(obj, uuid.UUID):
401 return str(obj)
402 else:
403 msg = f'{obj} type {type(obj)} not serializable'
404 LOGGER.error(msg)
405 raise TypeError(msg)
408def is_url(urlstring: str) -> bool:
409 """
410 Validation function that determines whether a candidate URL should be
411 considered a URI. No remote resource is obtained; this does not check
412 the existence of any remote resource.
414 :param urlstring: `str` to be evaluated as candidate URL.
416 :returns: `bool` of whether the URL looks like a URL.
417 """
418 try:
419 result = urlparse(urlstring)
420 return bool(result.scheme and result.netloc)
421 except ValueError:
422 return False
425def render_j2_template(config: dict, tpl_config: dict, template: Path,
426 data: dict, locale_: str = None) -> str:
427 """
428 render Jinja2 template
430 :param config: dict of configuration
431 :param tpl_config: dict of template configuration
432 :param template: template (relative path)
433 :param data: dict of data
434 :param locale_: the requested output Locale
436 :returns: string of rendered template
437 """
439 template_paths = [TEMPLATESDIR, '.']
441 locale_dir = config['server'].get('locale_dir', 'locale')
442 LOGGER.debug(f'Locale directory: {locale_dir}')
444 try:
445 templates = tpl_config['path']
446 template_paths.insert(0, templates)
447 LOGGER.debug(f'using custom templates: {templates}')
448 except (KeyError, TypeError):
449 LOGGER.debug(f'using default templates: {TEMPLATESDIR}')
451 env = Environment(loader=FileSystemLoader(template_paths),
452 extensions=['jinja2.ext.i18n'],
453 autoescape=select_autoescape())
455 env.filters['to_json'] = to_json
456 env.filters['format_datetime'] = format_datetime
457 env.filters['format_duration'] = format_duration
458 env.filters['human_size'] = human_size
459 env.globals.update(to_json=to_json)
461 env.filters['get_path_basename'] = get_path_basename
462 env.globals.update(get_path_basename=get_path_basename)
464 env.filters['get_breadcrumbs'] = get_breadcrumbs
465 env.globals.update(get_breadcrumbs=get_breadcrumbs)
467 env.filters['filter_dict_by_key_value'] = filter_dict_by_key_value
468 env.globals.update(filter_dict_by_key_value=filter_dict_by_key_value)
470 translations = Translations.load(locale_dir, [locale_])
471 env.install_gettext_translations(translations)
473 try:
474 template = env.get_template(template)
475 except TemplateNotFound:
476 LOGGER.debug(f'template {template} not found')
477 template_paths.remove(templates)
478 template = env.get_template(template)
480 return template.render(config=l10n.translate_struct(config, locale_, True),
481 data=data, locale=locale_, version=__version__)
484def get_mimetype(filename: str) -> str:
485 """
486 helper function to return MIME type of a given file
488 :param filename: filename (with extension)
490 :returns: MIME type of given filename
491 """
493 return mimetypes.guess_type(filename)[0]
496def get_breadcrumbs(urlpath: str) -> list:
497 """
498 helper function to make breadcrumbs from a URL path
500 :param urlpath: URL path
502 :returns: `list` of `dict` objects of labels and links
503 """
505 links = []
507 tokens = urlpath.split('/')
509 s = ''
510 for t in tokens:
511 if s:
512 s += '/' + t
513 else:
514 s = t
515 links.append({
516 'href': s,
517 'title': t,
518 })
520 return links
523def filter_dict_by_key_value(dict_: dict, key: str, value: str) -> dict:
524 """
525 helper function to filter a dict by a dict key
527 :param dict_: ``dict``
528 :param key: dict key
529 :param value: dict key value
531 :returns: filtered ``dict``
532 """
534 return {k: v for (k, v) in dict_.items() if v[key] == value}
537class ProcessExecutionMode(Enum):
538 sync_execute = 'sync-execute'
539 async_execute = 'async-execute'
542class RequestedProcessExecutionMode(Enum):
543 wait = 'wait'
544 respond_async = 'respond-async'
547class RequestedResponse(Enum):
548 raw = 'raw'
549 document = 'document'
552class JobStatus(Enum):
553 """
554 Enum for the job status options specified in the WPS 2.0 specification
555 """
557 # From the specification
558 accepted = 'accepted'
559 running = 'running'
560 successful = 'successful'
561 failed = 'failed'
562 dismissed = 'dismissed'
565@dataclass(frozen=True)
566class Subscriber:
567 """
568 Store subscriber URLs as defined in:
570 https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/schemas/subscriber.yaml # noqa
571 """
573 success_uri: str
574 in_progress_uri: Optional[str]
575 failed_uri: Optional[str]
578def read_data(path: Union[Path, str]) -> Union[bytes, str]:
579 """
580 helper function to read data (file or network)
581 """
583 LOGGER.debug(f'Attempting to read {path}')
585 if isinstance(path, Path) or not path.startswith(('http', 's3')):
586 LOGGER.debug('local file on disk')
587 with Path(path).open('rb') as fh:
588 return fh.read()
589 else:
590 LOGGER.debug('network file')
591 with urlopen(path) as r:
592 return r.read()
595def url_join(*parts: str) -> str:
596 """
597 helper function to join a URL from a number of parts/fragments.
598 Implemented because urllib.parse.urljoin strips subpaths from
599 host urls if they are specified
601 Per https://github.com/geopython/pygeoapi/issues/695
603 :param parts: list of parts to join
605 :returns: str of resulting URL
606 """
608 return '/'.join([p.strip().strip('/') for p in parts]).rstrip('/')
611def get_envelope(coords_list: List[List[float]]) -> list:
612 """
613 helper function to get the envelope for a given coordinates
614 list through the Shapely API.
616 :param coords_list: list of coordinates
618 :returns: list of the envelope's coordinates
619 """
621 coords = [tuple(item) for item in coords_list]
622 polygon = Polygon(coords)
623 bounds = polygon.bounds
624 return [[bounds[0], bounds[3]],
625 [bounds[2], bounds[1]]]
628class UrlPrefetcher:
629 """ Prefetcher to get HTTP headers for specific URLs.
630 Allows a maximum of 1 redirect by default.
631 """
633 def __init__(self):
634 self._session = Session()
635 self._session.max_redirects = 1
637 def get_headers(self, url: str, **kwargs) -> CaseInsensitiveDict:
638 """ Issues an HTTP HEAD request to the given URL.
639 Returns a case-insensitive dictionary of all headers.
640 If the request times out (defaults to 1 second unless `timeout`
641 keyword argument is set), or the response has a bad status code,
642 an empty dictionary is returned.
643 """
644 kwargs.setdefault('timeout', 1)
645 kwargs.setdefault('allow_redirects', True)
646 try:
647 response = self._session.head(url, **kwargs)
648 response.raise_for_status()
649 except Exception: # noqa
650 return CaseInsensitiveDict()
651 return response.headers
654def bbox2geojsongeometry(bbox: list) -> dict:
655 """
656 Converts bbox values into GeoJSON geometry
658 :param bbox: `list` of minx, miny, maxx, maxy
660 :returns: `dict` of GeoJSON geometry
661 """
663 b = box(*bbox, ccw=False)
664 return geom_to_geojson(b)
667def get_from_headers(headers: dict, header_name: str) -> str:
668 """
669 Gets case insensitive value from dictionary.
670 This is particularly useful when trying to get
671 headers from Starlette and Flask without issue
673 :param headers: `dict` of request headers.
674 :param header_name: Name of request header.
676 :returns: `str` value of header
677 """
679 cleaned_headers = {k.strip().lower(): v for k, v in headers.items()}
680 return cleaned_headers.get(header_name.lower(), '')
683def get_choice_from_headers(headers: dict,
684 header_name: str,
685 all: bool = False) -> Union[str, List[str]]:
686 """
687 Gets choices from a request dictionary,
688 considering numerical ordering of preferences.
689 Supported are complex preference strings (e.g. "fr-CH, fr;q=0.9, en;q=0.8")
691 :param headers: `dict` of request headers.
692 :param header_name: Name of request header.
693 :param all: bool to return one or all header values.
695 :returns: Sorted choice or choices from header
696 """
698 # Select header of interest
699 header = get_from_headers(headers=headers, header_name=header_name)
700 if header == '':
701 return
703 # Parse choices, extracting optional q values (defaults to 1.0)
704 choices = []
705 for i, part in enumerate(header.split(',')):
706 match = re.match(r'^([^;]+)(?:;q=([\d.]+))?$', part.strip())
707 if match:
708 value, q_value = match.groups()
709 q_value = float(q_value) if q_value else 1.0
711 # Sort choices by q value and index
712 if 0 <= q_value <= 1: 712 ↛ 705line 712 didn't jump to line 705 because the condition on line 712 was always true
713 heappush(choices, (1 / q_value, i, value))
715 # Drop q value
716 sorted_choices = [choice[-1] for choice in choices]
718 # Return one or all choices
719 return sorted_choices if all else sorted_choices[0]
722def get_dataset_formatters(dataset: dict) -> dict:
723 """
724 Helper function to derive all formatters for a collection
726 :param dataset: `dict` of dataset resource definition
728 :returns: `dict` of formatters
729 """
731 dataset_formatters = {}
732 provider_type = get_provider_default(dataset['providers'])['type']
734 for key, value in PLUGINS['formatter'].items():
735 # workaround to keep items-based collections supporting CSV
736 if provider_type not in ['feature', 'record']: 736 ↛ 737line 736 didn't jump to line 737 because the condition on line 736 was never true
737 continue
739 df2 = load_plugin('formatter', {'name': key})
740 dataset_formatters[key] = df2
742 for df in dataset.get('formatters', []): 742 ↛ 743line 742 didn't jump to line 743 because the loop on line 742 never started
743 df2 = load_plugin('formatter', df)
744 dataset_formatters[df2.name] = df2
746 return dataset_formatters
749def remove_url_auth(url: str) -> str:
750 """
751 Provide a RFC1738 URL without embedded authentication
752 :param url: RFC1738 URL
753 :returns: RFC1738 URL without authentication
754 """
756 u = urlparse(url)
757 auth = f'{u.username}:{u.password}@'
758 return url.replace(auth, '')
761def is_request_allowed(url: str, allow_internal: bool = False) -> bool:
762 """
763 Test whether an HTTP request is allowed to be executed
765 :param url: `str` of URL
766 :param allow_internal: `bool` of whether internal requests are
767 allowed (default `False`)
769 :returns: `bool` of whether HTTP request execution is allowed
770 """
772 is_allowed = False
774 u = urlparse(url)
776 ip = socket.gethostbyname(u.hostname)
778 is_private = ipaddress.ip_address(ip).is_private
780 if not is_private:
781 is_allowed = True
782 if is_private and allow_internal: 782 ↛ 783line 782 didn't jump to line 783 because the condition on line 782 was never true
783 is_allowed = True
785 return is_allowed