From d790217be718fcc83cc5b225bc85a58b759785a4 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Fri, 19 Sep 2025 11:51:50 -0400 Subject: [PATCH 01/15] Normalize CRS handling --- pygeoapi/api/__init__.py | 77 +-- pygeoapi/api/itemtypes.py | 81 +-- pygeoapi/api/maps.py | 3 +- pygeoapi/crs.py | 490 ++++++++++++++++++ pygeoapi/provider/csv_.py | 3 +- pygeoapi/provider/csw_facade.py | 3 +- pygeoapi/provider/elasticsearch_.py | 2 +- pygeoapi/provider/erddap.py | 2 +- pygeoapi/provider/esri.py | 3 +- pygeoapi/provider/geojson.py | 2 +- pygeoapi/provider/mongo.py | 3 +- pygeoapi/provider/mvt_postgresql.py | 3 +- pygeoapi/provider/ogr.py | 3 +- pygeoapi/provider/opensearch_.py | 3 +- pygeoapi/provider/oracle.py | 4 +- pygeoapi/provider/parquet.py | 2 +- pygeoapi/provider/sensorthings.py | 4 +- pygeoapi/provider/sensorthings_edr.py | 3 +- pygeoapi/provider/socrata.py | 3 +- pygeoapi/provider/sql.py | 3 +- pygeoapi/provider/sqlite.py | 4 +- pygeoapi/provider/tinydb_.py | 3 +- pygeoapi/provider/xarray_.py | 6 +- pygeoapi/util.py | 364 +------------ tests/api/test_itemtypes.py | 3 +- tests/load_tinydb_records.py | 2 - tests/other/test_crs.py | 298 +++++++++++ tests/other/test_util.py | 269 ---------- tests/provider/test_api_ogr_provider.py | 3 +- tests/provider/test_ogr_shapefile_provider.py | 5 +- tests/provider/test_postgresql_provider.py | 8 +- 31 files changed, 843 insertions(+), 819 deletions(-) create mode 100644 pygeoapi/crs.py create mode 100644 tests/other/test_crs.py diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 9dfa83a9e..a67a3130e 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -56,6 +56,8 @@ import pytz from pygeoapi import __version__, l10n +from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS, DEFAULT_CRS_LIST +from pygeoapi.crs import get_supported_crs_list from pygeoapi.linked_data import jsonldify, jsonldify_collection from pygeoapi.log import setup_logger from pygeoapi.plugin import load_plugin @@ -64,11 +66,10 @@ ProviderConnectionError, ProviderGenericError, ProviderTypeError) from pygeoapi.util import ( - CrsTransformSpec, TEMPLATESDIR, UrlPrefetcher, dategetter, + TEMPLATESDIR, UrlPrefetcher, dategetter, filter_dict_by_key_value, filter_providers_by_type, get_api_rules, get_base_url, get_provider_by_type, get_provider_default, get_typed_value, - get_crs_from_uri, get_supported_crs_list, render_j2_template, to_json, - get_choice_from_headers, get_from_headers + render_j2_template, to_json, get_choice_from_headers, get_from_headers ) LOGGER = logging.getLogger(__name__) @@ -115,14 +116,6 @@ OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' -DEFAULT_CRS_LIST = [ - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h', -] - -DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' -DEFAULT_STORAGE_CRS = DEFAULT_CRS - def all_apis() -> dict: """ @@ -639,68 +632,6 @@ def get_dataset_templates(self, dataset: str) -> dict: return templates or self.tpl_config['server']['templates'] - @staticmethod - def _create_crs_transform_spec( - config: dict, - query_crs_uri: Optional[str] = None, - ) -> Union[None, CrsTransformSpec]: - """Create a `CrsTransformSpec` instance based on provider config and - *crs* query parameter. - - :param config: Provider config dictionary. - :type config: dict - :param query_crs_uri: Uniform resource identifier of the coordinate - reference system (CRS) specified in query parameter (if specified). - :type query_crs_uri: str, optional - - :raises ValueError: Error raised if the CRS specified in the query - parameter is not in the list of supported CRSs of the provider. - :raises `CRSError`: Error raised if no CRS could be identified from the - query *crs* parameter (URI). - - :returns: `CrsTransformSpec` instance if the CRS specified in query - parameter differs from the storage CRS, else `None`. - :rtype: Union[None, CrsTransformSpec] - """ - # Get storage/default CRS for Collection. - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) - - if not query_crs_uri: - if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is - # http://www.opengis.net/def/crs/OGC/1.3/CRS84h - query_crs_uri = storage_crs_uri - else: - query_crs_uri = DEFAULT_CRS - LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}') - - supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST) - # Check that the crs specified by the query parameter is supported. - if query_crs_uri not in supported_crs_list: - raise ValueError( - f'CRS {query_crs_uri!r} not supported for this ' - 'collection. List of supported CRSs: ' - f'{", ".join(supported_crs_list)}.' - ) - crs_out = get_crs_from_uri(query_crs_uri) - - storage_crs = get_crs_from_uri(storage_crs_uri) - # Check if the crs specified in query parameter differs from the - # storage crs. - if str(storage_crs) != str(crs_out): - LOGGER.debug( - f'CRS transformation: {storage_crs} -> {crs_out}' - ) - return CrsTransformSpec( - source_crs_uri=storage_crs_uri, - source_crs_wkt=storage_crs.to_wkt(), - target_crs_uri=query_crs_uri, - target_crs_wkt=crs_out.to_wkt(), - ) - else: - LOGGER.debug('No CRS transformation') - return None - @staticmethod def _set_content_crs_header( headers: dict, diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 3207b6f1a..695b7fad5 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -49,17 +49,18 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit +from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS, DEFAULT_CRS_LIST +from pygeoapi.crs import (create_crs_transform_spec, transform_bbox, + get_supported_crs_list, modify_pygeofilter) from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.linked_data import geojson2jsonld from pygeoapi.plugin import load_plugin, PLUGINS from pygeoapi.provider.base import ( ProviderGenericError, ProviderTypeError, SchemaType) -from pygeoapi.util import (CrsTransformSpec, filter_providers_by_type, - filter_dict_by_key_value, get_crs_from_uri, - get_provider_by_type, get_supported_crs_list, - modify_pygeofilter, render_j2_template, str2bool, - to_json, transform_bbox) +from pygeoapi.util import (filter_providers_by_type, to_json, + filter_dict_by_key_value, str2bool, + get_provider_by_type, render_j2_template) from . import ( APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD, @@ -70,13 +71,6 @@ OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' -DEFAULT_CRS_LIST = [ - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h', -] - -DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' -DEFAULT_STORAGE_CRS = DEFAULT_CRS CONFORMANCE_CLASSES_FEATURES = [ 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', @@ -988,69 +982,6 @@ def get_collection_item(api: API, request: APIRequest, return headers, HTTPStatus.OK, to_json(content, api.pretty_print) -def create_crs_transform_spec( - config: dict, - query_crs_uri: Optional[str] = None) -> Union[None, CrsTransformSpec]: # noqa - """ - Create a `CrsTransformSpec` instance based on provider config and - *crs* query parameter. - - :param config: Provider config dictionary. - :type config: dict - :param query_crs_uri: Uniform resource identifier of the coordinate - reference system (CRS) specified in query parameter (if specified). - :type query_crs_uri: str, optional - - :raises ValueError: Error raised if the CRS specified in the query - parameter is not in the list of supported CRSs of the provider. - :raises `CRSError`: Error raised if no CRS could be identified from the - query *crs* parameter (URI). - - :returns: `CrsTransformSpec` instance if the CRS specified in query - parameter differs from the storage CRS, else `None`. - :rtype: Union[None, CrsTransformSpec] - """ - - # Get storage/default CRS for Collection. - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) - - if not query_crs_uri: - if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is - # http://www.opengis.net/def/crs/OGC/1.3/CRS84h - query_crs_uri = storage_crs_uri - else: - query_crs_uri = DEFAULT_CRS - LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}') - - supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST) - # Check that the crs specified by the query parameter is supported. - if query_crs_uri not in supported_crs_list: - raise ValueError( - f'CRS {query_crs_uri!r} not supported for this ' - 'collection. List of supported CRSs: ' - f'{", ".join(supported_crs_list)}.' - ) - crs_out = get_crs_from_uri(query_crs_uri) - - storage_crs = get_crs_from_uri(storage_crs_uri) - # Check if the crs specified in query parameter differs from the - # storage crs. - if str(storage_crs) != str(crs_out): - LOGGER.debug( - f'CRS transformation: {storage_crs} -> {crs_out}' - ) - return CrsTransformSpec( - source_crs_uri=storage_crs_uri, - source_crs_wkt=storage_crs.to_wkt(), - target_crs_uri=query_crs_uri, - target_crs_wkt=crs_out.to_wkt(), - ) - else: - LOGGER.debug('No CRS transformation') - return None - - def set_content_crs_header( headers: dict, config: dict, query_crs_uri: Optional[str] = None): """ diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index 3feae5533..d29140260 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -43,12 +43,13 @@ import logging from typing import Tuple +from pygeoapi.crs import transform_bbox from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderGenericError from pygeoapi.util import ( get_provider_by_type, to_json, filter_providers_by_type, - filter_dict_by_key_value, transform_bbox + filter_dict_by_key_value ) from . import APIRequest, API, validate_datetime diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py new file mode 100644 index 000000000..08e89a23b --- /dev/null +++ b/pygeoapi/crs.py @@ -0,0 +1,490 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# Just van den Broecke +# +# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2025 Just van den Broecke +# +# 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. +# +# ================================================================= + +"""Generic CRS functions used in the code""" + +from copy import deepcopy +import functools +from functools import partial +from dataclasses import dataclass +import logging +from typing import Union, Optional, Callable + +import pyproj +import pygeofilter.ast +import pygeofilter.values +from pyproj.exceptions import CRSError +from shapely import ops +from shapely.geometry import ( + box, + GeometryCollection, + LinearRing, + LineString, + MultiLineString, + MultiPoint, + MultiPolygon, + Polygon, + Point, + shape as geojson_to_geom, + mapping as geom_to_geojson, +) + + +LOGGER = logging.getLogger(__name__) + +DEFAULT_CRS_LIST = [ + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h', +] + +DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' +DEFAULT_STORAGE_CRS = DEFAULT_CRS + + +# Type for Shapely geometrical objects. +GeomObject = Union[ + GeometryCollection, + LinearRing, + LineString, + MultiLineString, + MultiPoint, + MultiPolygon, + Point, + Polygon, +] + + +@dataclass +class CrsTransformSpec: + source_crs_uri: str + source_crs_wkt: str + target_crs_uri: str + target_crs_wkt: str + + +def get_supported_crs_list(config: dict, default_crs_list: list) -> list: + """ + Helper function to get a complete list of supported CRSs + from a (Provider) config dict. Result should always include + a default CRS according to OAPIF Part 2 OGC Standard. + This will be the default when no CRS list in config or + added when (partially) missing in config. + + Author: @justb4 + + :param config: dictionary with or without a list of CRSs + :param default_crs_list: default CRS alternatives, first is default + :returns: list of supported CRSs + """ + supported_crs_list = config.get('crs', list()) + contains_default = False + for uri in supported_crs_list: + if uri in default_crs_list: + contains_default = True + break + + # A default CRS is missing: add the first which is the default + if not contains_default: + supported_crs_list.append(default_crs_list[0]) + return supported_crs_list + + +def get_crs_from_uri(uri: str) -> pyproj.CRS: + """ + Get a `pyproj.CRS` instance from a CRS URI. + Author: @MTachon + + :param uri: Uniform resource identifier of the coordinate + reference system. In accordance with + https://docs.ogc.org/pol/09-048r5.html#_naming_rule URIs can + take either the form of a URL or a URN + :raises `CRSError`: Error raised if no CRS could be identified from the + URI. + + :returns: `pyproj.CRS` instance matching the input URI. + :rtype: `pyproj.CRS` + """ + + # normalize the input `uri` to a URL first + url = uri.replace( + "urn:ogc:def:crs", + "http://www.opengis.net/def/crs" + ).replace(":", "/") + try: + authority, code = url.rsplit("/", maxsplit=3)[1::2] + crs = pyproj.CRS.from_authority(authority, code) + except ValueError: + msg = ( + f"CRS could not be identified from URI {uri!r}. CRS URIs must " + "follow one of two formats: " + "'http://www.opengis.net/def/crs/{authority}/{version}/{code}' or " + "'urn:ogc:def:crs:{authority}:{version}:{code}' " + "(see https://docs.opengeospatial.org/is/18-058r1/18-058r1.html#crs-overview)." # noqa + ) + LOGGER.error(msg) + raise CRSError(msg) + except CRSError: + msg = f"CRS could not be identified from URI {uri!r}" + LOGGER.error(msg) + raise CRSError(msg) + else: + return crs + + +def get_transform_from_crs( + crs_in: pyproj.CRS, crs_out: pyproj.CRS, always_xy: bool = False +) -> Callable[[GeomObject], GeomObject]: + """ Get transformation function from two `pyproj.CRS` instances. + + Get function to transform the coordinates of a Shapely geometrical object + from one coordinate reference system to another. + + :param crs_in: Coordinate Reference System of the input geometrical object. + :type crs_in: `pyproj.CRS` + :param crs_out: Coordinate Reference System of the output geometrical + object. + :type crs_out: `pyproj.CRS` + :param always_xy: should axis order be forced to x,y (lon, lat) even if CRS + declares y,x (lat,lon) + :type always_xy: `bool` + + :returns: Function to transform the coordinates of a `GeomObject`. + :rtype: `callable` + """ + crs_transform = pyproj.Transformer.from_crs( + crs_in, crs_out, always_xy=always_xy, + ).transform + return partial(ops.transform, crs_transform) + + +def crs_transform(func): + """Decorator that transforms the geometry's/geometries' coordinates of a + Feature/FeatureCollection. + + This function can be used to decorate another function which returns either + a Feature or a FeatureCollection (GeoJSON-like `dict`). For a + FeatureCollection, the Features are stored in a ´list´ available at the + 'features' key of the returned `dict`. For each Feature, the geometry is + available at the 'geometry' key. The decorated function may take a + 'crs_transform_spec' parameter, which accepts a `CrsTransformSpec` instance + as value. If the `CrsTransformSpec` instance represents a coordinates + transformation between two different CRSs, the coordinates of the + Feature's/FeatureCollection's geometry/geometries will be transformed + before returning the Feature/FeatureCollection. If the 'crs_transform_spec' + parameter is not given, passed `None` or passed a `CrsTransformSpec` + instance which does not represent a coordinates transformation, the + Feature/FeatureCollection is returned unchanged. This decorator can for + example be use to help supporting coordinates transformation of + Feature/FeatureCollection `dict` objects returned by the `get` and `query` + methods of (new or with no native support for transformations) providers of + type 'feature'. + + :param func: Function to decorate. + :type func: `callable` + + :returns: Decorated function. + :rtype: `callable` + """ + @functools.wraps(func) + def get_geojsonf(*args, **kwargs): + crs_transform_spec = kwargs.get('crs_transform_spec') + result = func(*args, **kwargs) + if crs_transform_spec is None: + # No coordinates transformation for feature(s) returned by the + # decorated function. + LOGGER.debug('crs_transform: NOT applying coordinate transforms') + return result + # Create transformation function and transform the output feature(s)' + # coordinates before returning them. + transform_func = get_transform_from_crs( + pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), + pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), + ) + + LOGGER.debug(f'crs_transform: transforming features CRS ' + f'from {crs_transform_spec.source_crs_uri} ' + f'to {crs_transform_spec.target_crs_uri}') + + features = result.get('features') + # Decorated function returns a single Feature + if features is None: + # Transform the feature's coordinates + crs_transform_feature(result, transform_func) + # Decorated function returns a FeatureCollection + else: + # Transform all features' coordinates + for feature in features: + crs_transform_feature(feature, transform_func) + return result + return get_geojsonf + + +def crs_transform_feature(feature, transform_func): + """Transform the coordinates of a Feature. + + :param feature: Feature (GeoJSON-like `dict`) to transform. + :type feature: `dict` + :param transform_func: Function that transforms the coordinates of a + `GeomObject` instance. + :type transform_func: `callable` + + :returns: None + """ + json_geometry = feature.get('geometry') + if json_geometry is not None: + feature['geometry'] = geom_to_geojson( + transform_func(geojson_to_geom(json_geometry)) + ) + + +def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: + """ + helper function to transform a bounding box (bbox) from + a source to a target CRS. CRSs in URI str format. + Uses pyproj Transformer. + + :param bbox: list of coordinates in 'from_crs' projection + :param from_crs: CRS URI to transform from + :param to_crs: CRS URI to transform to + :raises `CRSError`: Error raised if no CRS could be identified from an + URI. + + :returns: list of 4 or 6 coordinates + """ + + from_crs_obj = get_crs_from_uri(from_crs) + to_crs_obj = get_crs_from_uri(to_crs) + transform_func = pyproj.Transformer.from_crs( + from_crs_obj, to_crs_obj).transform + n_dims = len(bbox) // 2 + return list(transform_func(*bbox[:n_dims]) + transform_func( + *bbox[n_dims:])) + + +def bbox2geojsongeometry(bbox: list) -> dict: + """ + Converts bbox values into GeoJSON geometry + + :param bbox: `list` of minx, miny, maxx, maxy + + :returns: `dict` of GeoJSON geometry + """ + + b = box(*bbox, ccw=False) + return geom_to_geojson(b) + + +def modify_pygeofilter( + ast_tree: pygeofilter.ast.Node, + *, + filter_crs_uri: str, + storage_crs_uri: Optional[str] = None, + geometry_column_name: Optional[str] = None +) -> pygeofilter.ast.Node: + """ + Modifies the input pygeofilter with information from the provider. + + :param ast_tree: `pygeofilter.ast.Node` representing the + already parsed pygeofilter expression + :param filter_crs_uri: URI of the CRS being used in the filtering + expression + :param storage_crs_uri: An optional string containing the URI of + the provider's storage CRS + :param geometry_column_name: An optional string containing the + actual name of the provider's geometry field + :returns: A new pygeofilter.ast.Node, with the modified filter + expression + + This function modifies the parsed pygeofilter that contains the raw + filter expression provided by an external client. It performs the + following modifications: + + - if the filter includes any spatial coordinates and they are being + provided in a different CRS from the provider's storage CRS, the + corresponding geometries are transformed into the storage CRS + + - if the filter includes the generic 'geometry' name as a reference to + the actual geometry of features, it is replaced by the actual name + of the geometry field, as specified by the provider + + """ + new_tree = deepcopy(ast_tree) + if storage_crs_uri: + storage_crs = get_crs_from_uri(storage_crs_uri) + filter_crs = get_crs_from_uri(filter_crs_uri) + _inplace_transform_filter_geometries(new_tree, filter_crs, storage_crs) + if geometry_column_name: + _inplace_replace_geometry_filter_name(new_tree, geometry_column_name) + return new_tree + + +def _inplace_transform_filter_geometries( + node: pygeofilter.ast.Node, + filter_crs: pyproj.CRS, + storage_crs: pyproj.CRS +): + """ + Recursively traverse node tree and convert coordinates to the storage CRS. + + This function modifies nodes in the already-parsed filter in order to find + any geometry literals that may be used in the filter and, if necessary, + proceeds to convert spatial coordinates to the CRS used by the provider. + """ + try: + sub_nodes = node.get_sub_nodes() + except AttributeError: + pass + else: + for sub_node in sub_nodes: + is_geometry_node = isinstance( + sub_node, pygeofilter.values.Geometry) + if is_geometry_node: + # NOTE1: To be flexible, and since pygeofilter + # already supports it, in addition to supporting + # the `filter-crs` parameter, we also support having a + # geometry defined in EWKT, meaning the CRS is provided + # inline, like this `SRID=;` - If provided, + # this overrides the value of `filter-crs`. This enables + # supporting, for example, an exotic filter expression with + # multiple geometries specified in different CRSs + + # NOTE2: We specify a default CRS using a URI of type URN + # because this is what pygeofilter uses internally too + + crs_urn_provided_in_ewkt = sub_node.geometry.get( + 'crs', {}).get('properties', {}).get('name') + if crs_urn_provided_in_ewkt is not None: + crs = get_crs_from_uri(crs_urn_provided_in_ewkt) + else: + crs = filter_crs + if crs != storage_crs: + # convert geometry coordinates to storage crs + geom = geojson_to_geom(sub_node.geometry) + coord_transformer = pyproj.Transformer.from_crs( + crs_from=crs, crs_to=storage_crs).transform + transformed_geom = ops.transform(coord_transformer, geom) + sub_node.geometry = geom_to_geojson(transformed_geom) + # ensure the crs is encoded in the sub-node, otherwise + # pygeofilter will assign it its own default CRS + authority, code = storage_crs.to_authority() + sub_node.geometry['crs'] = { + 'properties': { + 'name': f'urn:ogc:def:crs:{authority}::{code}' + } + } + else: + _inplace_transform_filter_geometries( + sub_node, filter_crs, storage_crs) + + +def _inplace_replace_geometry_filter_name( + node: pygeofilter.ast.Node, + geometry_column_name: str +): + """Recursively traverse node tree and rename nodes of type ``Attribute``. + + Nodes of type ``Attribute`` named ``geometry`` are renamed to the value of + the ``geometry_column_name`` parameter. + """ + try: + sub_nodes = node.get_sub_nodes() + except AttributeError: + pass + else: + for sub_node in sub_nodes: + is_attribute_node = isinstance(sub_node, pygeofilter.ast.Attribute) + if is_attribute_node and sub_node.name == "geometry": + sub_node.name = geometry_column_name + else: + _inplace_replace_geometry_filter_name( + sub_node, geometry_column_name) + + +def create_crs_transform_spec( + config: dict, query_crs_uri: Optional[str] = None) -> Union[None, CrsTransformSpec]: # noqa + """ + Create a `CrsTransformSpec` instance based on provider config and + *crs* query parameter. + + :param config: Provider config dictionary. + :type config: dict + :param query_crs_uri: Uniform resource identifier of the coordinate + reference system (CRS) specified in query parameter (if specified). + :type query_crs_uri: str, optional + + :raises ValueError: Error raised if the CRS specified in the query + parameter is not in the list of supported CRSs of the provider. + :raises `CRSError`: Error raised if no CRS could be identified from the + query *crs* parameter (URI). + + :returns: `CrsTransformSpec` instance if the CRS specified in query + parameter differs from the storage CRS, else `None`. + :rtype: Union[None, CrsTransformSpec] + """ + + # Get storage/default CRS for Collection. + storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) + + if not query_crs_uri: + if storage_crs_uri in DEFAULT_CRS_LIST: + # Could be that storageCrs is + # http://www.opengis.net/def/crs/OGC/1.3/CRS84h + query_crs_uri = storage_crs_uri + else: + query_crs_uri = DEFAULT_CRS + LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}') + + supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST) + # Check that the crs specified by the query parameter is supported. + if query_crs_uri not in supported_crs_list: + raise ValueError( + f'CRS {query_crs_uri!r} not supported for this ' + 'collection. List of supported CRSs: ' + f'{", ".join(supported_crs_list)}.' + ) + crs_out = get_crs_from_uri(query_crs_uri) + + storage_crs = get_crs_from_uri(storage_crs_uri) + # Check if the crs specified in query parameter differs from the + # storage crs. + if str(storage_crs) != str(crs_out): + LOGGER.debug( + f'CRS transformation: {storage_crs} -> {crs_out}' + ) + return CrsTransformSpec( + source_crs_uri=storage_crs_uri, + source_crs_wkt=storage_crs.to_wkt(), + target_crs_uri=query_crs_uri, + target_crs_wkt=crs_out.to_wkt(), + ) + else: + LOGGER.debug('No CRS transformation') + return None diff --git a/pygeoapi/provider/csv_.py b/pygeoapi/provider/csv_.py index 9e3aee5fc..e560685ca 100644 --- a/pygeoapi/provider/csv_.py +++ b/pygeoapi/provider/csv_.py @@ -34,10 +34,11 @@ from shapely.geometry import box, Point +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderInvalidQueryError, ProviderItemNotFoundError, ProviderQueryError) -from pygeoapi.util import get_typed_value, crs_transform +from pygeoapi.util import get_typed_value LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/csw_facade.py b/pygeoapi/provider/csw_facade.py index 22e7483f4..bfd77735a 100644 --- a/pygeoapi/provider/csw_facade.py +++ b/pygeoapi/provider/csw_facade.py @@ -34,11 +34,12 @@ from owslib.csw import CatalogueServiceWeb from owslib.ows import ExceptionReport +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderInvalidQueryError, ProviderItemNotFoundError, ProviderQueryError) -from pygeoapi.util import bbox2geojsongeometry, crs_transform, get_typed_value +from pygeoapi.util import bbox2geojsongeometry, get_typed_value LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/elasticsearch_.py b/pygeoapi/provider/elasticsearch_.py index 2af3ac885..faff311a0 100644 --- a/pygeoapi/provider/elasticsearch_.py +++ b/pygeoapi/provider/elasticsearch_.py @@ -40,10 +40,10 @@ from elasticsearch_dsl import Search +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderQueryError, ProviderItemNotFoundError) -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/erddap.py b/pygeoapi/provider/erddap.py index 2fc71c064..27e5b4115 100644 --- a/pygeoapi/provider/erddap.py +++ b/pygeoapi/provider/erddap.py @@ -49,9 +49,9 @@ import requests +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import ( BaseProvider, ProviderNotFoundError, ProviderQueryError) -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/esri.py b/pygeoapi/provider/esri.py index 47d74e2b9..3c44d8fba 100644 --- a/pygeoapi/provider/esri.py +++ b/pygeoapi/provider/esri.py @@ -32,9 +32,10 @@ import logging from requests import Session, codes +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderTypeError, ProviderQueryError) -from pygeoapi.util import format_datetime, crs_transform +from pygeoapi.util import format_datetime LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/geojson.py b/pygeoapi/provider/geojson.py index 9d7ee30c2..ddbca7da6 100644 --- a/pygeoapi/provider/geojson.py +++ b/pygeoapi/provider/geojson.py @@ -35,8 +35,8 @@ from shapely.geometry import box, shape +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/mongo.py b/pygeoapi/provider/mongo.py index ca258018c..6c1bab729 100644 --- a/pygeoapi/provider/mongo.py +++ b/pygeoapi/provider/mongo.py @@ -35,8 +35,9 @@ from pymongo import GEOSPHERE from pymongo import ASCENDING, DESCENDING from pymongo.collection import ObjectId + +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/mvt_postgresql.py b/pygeoapi/provider/mvt_postgresql.py index 792f7ec1a..ea22ac4e6 100644 --- a/pygeoapi/provider/mvt_postgresql.py +++ b/pygeoapi/provider/mvt_postgresql.py @@ -41,13 +41,14 @@ from sqlalchemy.sql import select from sqlalchemy.orm import Session +from pygeoapi.crs import get_crs_from_uri from pygeoapi.models.provider.base import ( TileSetMetadata, TileMatrixSetEnum, LinkType) from pygeoapi.provider.base import ProviderConnectionError from pygeoapi.provider.base_mvt import BaseMVTProvider from pygeoapi.provider.sql import PostgreSQLProvider from pygeoapi.provider.tile import ProviderTileNotFoundError -from pygeoapi.util import url_join, get_crs_from_uri +from pygeoapi.util import url_join LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/ogr.py b/pygeoapi/provider/ogr.py index 78ca999c2..088b74c16 100644 --- a/pygeoapi/provider/ogr.py +++ b/pygeoapi/provider/ogr.py @@ -41,13 +41,12 @@ from osgeo import ogr as osgeo_ogr from osgeo import osr as osgeo_osr +from pygeoapi.crs import get_crs_from_uri from pygeoapi.provider.base import ( BaseProvider, ProviderGenericError, ProviderQueryError, ProviderConnectionError, ProviderItemNotFoundError) -from pygeoapi.util import get_crs_from_uri - LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/opensearch_.py b/pygeoapi/provider/opensearch_.py index d10cba5af..1b46f6af4 100644 --- a/pygeoapi/provider/opensearch_.py +++ b/pygeoapi/provider/opensearch_.py @@ -40,11 +40,10 @@ from pygeofilter.backends.opensearch import to_filter +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderQueryError, ProviderItemNotFoundError) -from pygeoapi.util import crs_transform - LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/oracle.py b/pygeoapi/provider/oracle.py index 75fe1aa84..e8fa0298e 100644 --- a/pygeoapi/provider/oracle.py +++ b/pygeoapi/provider/oracle.py @@ -38,8 +38,8 @@ import oracledb import pyproj -from pygeoapi.api import DEFAULT_STORAGE_CRS +from pygeoapi.crs import get_crs_from_uri, DEFAULT_STORAGE_CRS from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -49,8 +49,6 @@ ProviderQueryError, ) -from pygeoapi.util import get_crs_from_uri - LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/parquet.py b/pygeoapi/provider/parquet.py index 00d39e07a..0f4ab3de1 100644 --- a/pygeoapi/provider/parquet.py +++ b/pygeoapi/provider/parquet.py @@ -38,6 +38,7 @@ import pyarrow.dataset import s3fs +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -45,7 +46,6 @@ ProviderItemNotFoundError, ProviderQueryError, ) -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/sensorthings.py b/pygeoapi/provider/sensorthings.py index c0f8f7a28..cd444f407 100644 --- a/pygeoapi/provider/sensorthings.py +++ b/pygeoapi/provider/sensorthings.py @@ -36,12 +36,12 @@ from urllib.parse import urlparse from pygeoapi.config import get_config +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import ( BaseProvider, ProviderQueryError, ProviderConnectionError, ProviderInvalidDataError) from pygeoapi.util import ( - url_join, get_provider_default, crs_transform, get_base_url, - get_typed_value) + url_join, get_provider_default, get_base_url, get_typed_value) LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/sensorthings_edr.py b/pygeoapi/provider/sensorthings_edr.py index a403f3871..7119c728e 100644 --- a/pygeoapi/provider/sensorthings_edr.py +++ b/pygeoapi/provider/sensorthings_edr.py @@ -29,6 +29,7 @@ import logging +from pygeoapi.crs import DEFAULT_CRS from pygeoapi.provider.base import ProviderNoDataError from pygeoapi.provider.base_edr import BaseEDRProvider from pygeoapi.provider.sensorthings import SensorThingsProvider @@ -39,7 +40,7 @@ 'coordinates': ['x', 'y'], 'system': { 'type': 'GeographicCRS', - 'id': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + 'id': DEFAULT_CRS } } diff --git a/pygeoapi/provider/socrata.py b/pygeoapi/provider/socrata.py index d8440cb05..4ffdd4f58 100644 --- a/pygeoapi/provider/socrata.py +++ b/pygeoapi/provider/socrata.py @@ -35,9 +35,10 @@ from sodapy import Socrata import logging +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderQueryError, ProviderConnectionError) -from pygeoapi.util import format_datetime, crs_transform +from pygeoapi.util import format_datetime LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index f436353a1..10c95bd50 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -84,6 +84,7 @@ from sqlalchemy.orm import Session, load_only from sqlalchemy.sql.expression import and_ +from pygeoapi.crs import get_transform_from_crs, get_crs_from_uri from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -91,8 +92,6 @@ ProviderQueryError, ProviderItemNotFoundError ) -from pygeoapi.util import get_transform_from_crs, get_crs_from_uri - LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/sqlite.py b/pygeoapi/provider/sqlite.py index d0257609f..d5a03d46d 100644 --- a/pygeoapi/provider/sqlite.py +++ b/pygeoapi/provider/sqlite.py @@ -35,10 +35,12 @@ import logging import os import json + +from pygeoapi.crs import crs_transform from pygeoapi.plugin import InvalidPluginError from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderItemNotFoundError) -from pygeoapi.util import crs_transform + LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/tinydb_.py b/pygeoapi/provider/tinydb_.py index a563e2654..f2fbba228 100644 --- a/pygeoapi/provider/tinydb_.py +++ b/pygeoapi/provider/tinydb_.py @@ -36,10 +36,11 @@ from shapely.geometry import shape from tinydb import TinyDB, Query, where +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderInvalidQueryError, ProviderItemNotFoundError) -from pygeoapi.util import crs_transform, get_typed_value +from pygeoapi.util import get_typed_value LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index 1cdb39a62..dfc5a39f3 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -40,13 +40,13 @@ import pyproj from pyproj.exceptions import CRSError -from pygeoapi.api import DEFAULT_STORAGE_CRS +from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS, get_crs_from_uri from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderNoDataError, ProviderQueryError) -from pygeoapi.util import get_crs_from_uri, read_data +from pygeoapi.util import read_data LOGGER = logging.getLogger(__name__) @@ -449,7 +449,7 @@ def _get_coverage_properties(self): self._data.coords[self.x_field].values[-1], self._data.coords[self.y_field].values[-1], ], - 'bbox_crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'bbox_crs': DEFAULT_CRS, 'crs_type': 'GeographicCRS', 'x_axis_label': self.x_field, 'y_axis_label': self.y_field, diff --git a/pygeoapi/util.py b/pygeoapi/util.py index 4db52582f..30a18de57 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -30,10 +30,7 @@ """Generic util functions used in the code""" import base64 -from copy import deepcopy from filelock import FileLock -import functools -from functools import partial from dataclasses import dataclass from datetime import date, datetime, time, timezone from decimal import Decimal @@ -46,7 +43,7 @@ import pathlib from pathlib import Path import re -from typing import Any, IO, Union, List, Optional, Callable +from typing import Any, IO, Union, List, Optional from urllib.parse import urlparse from urllib.request import urlopen import uuid @@ -55,24 +52,11 @@ from babel.support import Translations from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2.exceptions import TemplateNotFound -import pyproj -import pygeofilter.ast -import pygeofilter.values -from pyproj.exceptions import CRSError from requests import Session from requests.structures import CaseInsensitiveDict -from shapely import ops from shapely.geometry import ( box, - GeometryCollection, - LinearRing, - LineString, - MultiLineString, - MultiPoint, - MultiPolygon, Polygon, - Point, - shape as geojson_to_geom, mapping as geom_to_geojson, ) import yaml @@ -94,27 +78,6 @@ SCHEMASDIR = RESOURCESDIR / 'schemas' -# Type for Shapely geometrical objects. -GeomObject = Union[ - GeometryCollection, - LinearRing, - LineString, - MultiLineString, - MultiPoint, - MultiPolygon, - Point, - Polygon, -] - - -@dataclass -class CrsTransformSpec: - source_crs_uri: str - source_crs_wkt: str - target_crs_uri: str - target_crs_wkt: str - - mimetypes.add_type('text/plain', '.yaml') mimetypes.add_type('text/plain', '.yml') @@ -696,205 +659,6 @@ def get_envelope(coords_list: List[List[float]]) -> list: [bounds[2], bounds[1]]] -def get_supported_crs_list(config: dict, default_crs_list: list) -> list: - """ - Helper function to get a complete list of supported CRSs - from a (Provider) config dict. Result should always include - a default CRS according to OAPIF Part 2 OGC Standard. - This will be the default when no CRS list in config or - added when (partially) missing in config. - - Author: @justb4 - - :param config: dictionary with or without a list of CRSs - :param default_crs_list: default CRS alternatives, first is default - :returns: list of supported CRSs - """ - supported_crs_list = config.get('crs', list()) - contains_default = False - for uri in supported_crs_list: - if uri in default_crs_list: - contains_default = True - break - - # A default CRS is missing: add the first which is the default - if not contains_default: - supported_crs_list.append(default_crs_list[0]) - return supported_crs_list - - -def get_crs_from_uri(uri: str) -> pyproj.CRS: - """ - Get a `pyproj.CRS` instance from a CRS URI. - Author: @MTachon - - :param uri: Uniform resource identifier of the coordinate - reference system. In accordance with - https://docs.ogc.org/pol/09-048r5.html#_naming_rule URIs can - take either the form of a URL or a URN - :raises `CRSError`: Error raised if no CRS could be identified from the - URI. - - :returns: `pyproj.CRS` instance matching the input URI. - :rtype: `pyproj.CRS` - """ - - # normalize the input `uri` to a URL first - url = uri.replace( - "urn:ogc:def:crs", - "http://www.opengis.net/def/crs" - ).replace(":", "/") - try: - authority, code = url.rsplit("/", maxsplit=3)[1::2] - crs = pyproj.CRS.from_authority(authority, code) - except ValueError: - msg = ( - f"CRS could not be identified from URI {uri!r}. CRS URIs must " - "follow one of two formats: " - "'http://www.opengis.net/def/crs/{authority}/{version}/{code}' or " - "'urn:ogc:def:crs:{authority}:{version}:{code}' " - "(see https://docs.opengeospatial.org/is/18-058r1/18-058r1.html#crs-overview)." # noqa - ) - LOGGER.error(msg) - raise CRSError(msg) - except CRSError: - msg = f"CRS could not be identified from URI {uri!r}" - LOGGER.error(msg) - raise CRSError(msg) - else: - return crs - - -def get_transform_from_crs( - crs_in: pyproj.CRS, crs_out: pyproj.CRS, always_xy: bool = False -) -> Callable[[GeomObject], GeomObject]: - """ Get transformation function from two `pyproj.CRS` instances. - - Get function to transform the coordinates of a Shapely geometrical object - from one coordinate reference system to another. - - :param crs_in: Coordinate Reference System of the input geometrical object. - :type crs_in: `pyproj.CRS` - :param crs_out: Coordinate Reference System of the output geometrical - object. - :type crs_out: `pyproj.CRS` - :param always_xy: should axis order be forced to x,y (lon, lat) even if CRS - declares y,x (lat,lon) - :type always_xy: `bool` - - :returns: Function to transform the coordinates of a `GeomObject`. - :rtype: `callable` - """ - crs_transform = pyproj.Transformer.from_crs( - crs_in, crs_out, always_xy=always_xy, - ).transform - return partial(ops.transform, crs_transform) - - -def crs_transform(func): - """Decorator that transforms the geometry's/geometries' coordinates of a - Feature/FeatureCollection. - - This function can be used to decorate another function which returns either - a Feature or a FeatureCollection (GeoJSON-like `dict`). For a - FeatureCollection, the Features are stored in a ´list´ available at the - 'features' key of the returned `dict`. For each Feature, the geometry is - available at the 'geometry' key. The decorated function may take a - 'crs_transform_spec' parameter, which accepts a `CrsTransformSpec` instance - as value. If the `CrsTransformSpec` instance represents a coordinates - transformation between two different CRSs, the coordinates of the - Feature's/FeatureCollection's geometry/geometries will be transformed - before returning the Feature/FeatureCollection. If the 'crs_transform_spec' - parameter is not given, passed `None` or passed a `CrsTransformSpec` - instance which does not represent a coordinates transformation, the - Feature/FeatureCollection is returned unchanged. This decorator can for - example be use to help supporting coordinates transformation of - Feature/FeatureCollection `dict` objects returned by the `get` and `query` - methods of (new or with no native support for transformations) providers of - type 'feature'. - - :param func: Function to decorate. - :type func: `callable` - - :returns: Decorated function. - :rtype: `callable` - """ - @functools.wraps(func) - def get_geojsonf(*args, **kwargs): - crs_transform_spec = kwargs.get('crs_transform_spec') - result = func(*args, **kwargs) - if crs_transform_spec is None: - # No coordinates transformation for feature(s) returned by the - # decorated function. - LOGGER.debug('crs_transform: NOT applying coordinate transforms') - return result - # Create transformation function and transform the output feature(s)' - # coordinates before returning them. - transform_func = get_transform_from_crs( - pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), - pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), - ) - - LOGGER.debug(f'crs_transform: transforming features CRS ' - f'from {crs_transform_spec.source_crs_uri} ' - f'to {crs_transform_spec.target_crs_uri}') - - features = result.get('features') - # Decorated function returns a single Feature - if features is None: - # Transform the feature's coordinates - crs_transform_feature(result, transform_func) - # Decorated function returns a FeatureCollection - else: - # Transform all features' coordinates - for feature in features: - crs_transform_feature(feature, transform_func) - return result - return get_geojsonf - - -def crs_transform_feature(feature, transform_func): - """Transform the coordinates of a Feature. - - :param feature: Feature (GeoJSON-like `dict`) to transform. - :type feature: `dict` - :param transform_func: Function that transforms the coordinates of a - `GeomObject` instance. - :type transform_func: `callable` - - :returns: None - """ - json_geometry = feature.get('geometry') - if json_geometry is not None: - feature['geometry'] = geom_to_geojson( - transform_func(geojson_to_geom(json_geometry)) - ) - - -def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: - """ - helper function to transform a bounding box (bbox) from - a source to a target CRS. CRSs in URI str format. - Uses pyproj Transformer. - - :param bbox: list of coordinates in 'from_crs' projection - :param from_crs: CRS URI to transform from - :param to_crs: CRS URI to transform to - :raises `CRSError`: Error raised if no CRS could be identified from an - URI. - - :returns: list of 4 or 6 coordinates - """ - - from_crs_obj = get_crs_from_uri(from_crs) - to_crs_obj = get_crs_from_uri(to_crs) - transform_func = pyproj.Transformer.from_crs( - from_crs_obj, to_crs_obj).transform - n_dims = len(bbox) // 2 - return list(transform_func(*bbox[:n_dims]) + transform_func( - *bbox[n_dims:])) - - class UrlPrefetcher: """ Prefetcher to get HTTP headers for specific URLs. Allows a maximum of 1 redirect by default. @@ -934,132 +698,6 @@ def bbox2geojsongeometry(bbox: list) -> dict: return geom_to_geojson(b) -def modify_pygeofilter( - ast_tree: pygeofilter.ast.Node, - *, - filter_crs_uri: str, - storage_crs_uri: Optional[str] = None, - geometry_column_name: Optional[str] = None -) -> pygeofilter.ast.Node: - """ - Modifies the input pygeofilter with information from the provider. - - :param ast_tree: `pygeofilter.ast.Node` representing the - already parsed pygeofilter expression - :param filter_crs_uri: URI of the CRS being used in the filtering - expression - :param storage_crs_uri: An optional string containing the URI of - the provider's storage CRS - :param geometry_column_name: An optional string containing the - actual name of the provider's geometry field - :returns: A new pygeofilter.ast.Node, with the modified filter - expression - - This function modifies the parsed pygeofilter that contains the raw - filter expression provided by an external client. It performs the - following modifications: - - - if the filter includes any spatial coordinates and they are being - provided in a different CRS from the provider's storage CRS, the - corresponding geometries are transformed into the storage CRS - - - if the filter includes the generic 'geometry' name as a reference to - the actual geometry of features, it is replaced by the actual name - of the geometry field, as specified by the provider - - """ - new_tree = deepcopy(ast_tree) - if storage_crs_uri: - storage_crs = get_crs_from_uri(storage_crs_uri) - filter_crs = get_crs_from_uri(filter_crs_uri) - _inplace_transform_filter_geometries(new_tree, filter_crs, storage_crs) - if geometry_column_name: - _inplace_replace_geometry_filter_name(new_tree, geometry_column_name) - return new_tree - - -def _inplace_transform_filter_geometries( - node: pygeofilter.ast.Node, - filter_crs: pyproj.CRS, - storage_crs: pyproj.CRS -): - """ - Recursively traverse node tree and convert coordinates to the storage CRS. - - This function modifies nodes in the already-parsed filter in order to find - any geometry literals that may be used in the filter and, if necessary, - proceeds to convert spatial coordinates to the CRS used by the provider. - """ - try: - sub_nodes = node.get_sub_nodes() - except AttributeError: - pass - else: - for sub_node in sub_nodes: - is_geometry_node = isinstance( - sub_node, pygeofilter.values.Geometry) - if is_geometry_node: - # NOTE1: To be flexible, and since pygeofilter - # already supports it, in addition to supporting - # the `filter-crs` parameter, we also support having a - # geometry defined in EWKT, meaning the CRS is provided - # inline, like this `SRID=;` - If provided, - # this overrides the value of `filter-crs`. This enables - # supporting, for example, an exotic filter expression with - # multiple geometries specified in different CRSs - - # NOTE2: We specify a default CRS using a URI of type URN - # because this is what pygeofilter uses internally too - - crs_urn_provided_in_ewkt = sub_node.geometry.get( - 'crs', {}).get('properties', {}).get('name') - if crs_urn_provided_in_ewkt is not None: - crs = get_crs_from_uri(crs_urn_provided_in_ewkt) - else: - crs = filter_crs - if crs != storage_crs: - # convert geometry coordinates to storage crs - geom = geojson_to_geom(sub_node.geometry) - coord_transformer = pyproj.Transformer.from_crs( - crs_from=crs, crs_to=storage_crs).transform - transformed_geom = ops.transform(coord_transformer, geom) - sub_node.geometry = geom_to_geojson(transformed_geom) - # ensure the crs is encoded in the sub-node, otherwise - # pygeofilter will assign it its own default CRS - authority, code = storage_crs.to_authority() - sub_node.geometry['crs'] = { - 'properties': { - 'name': f'urn:ogc:def:crs:{authority}::{code}' - } - } - else: - _inplace_transform_filter_geometries( - sub_node, filter_crs, storage_crs) - - -def _inplace_replace_geometry_filter_name( - node: pygeofilter.ast.Node, - geometry_column_name: str -): - """Recursively traverse node tree and rename nodes of type ``Attribute``. - - Nodes of type ``Attribute`` named ``geometry`` are renamed to the value of - the ``geometry_column_name`` parameter. - """ - try: - sub_nodes = node.get_sub_nodes() - except AttributeError: - pass - else: - for sub_node in sub_nodes: - is_attribute_node = isinstance(sub_node, pygeofilter.ast.Attribute) - if is_attribute_node and sub_node.name == "geometry": - sub_node.name = geometry_column_name - else: - _inplace_replace_geometry_filter_name( - sub_node, geometry_column_name) - - def get_from_headers(headers: dict, header_name: str) -> str: """ Gets case insensitive value from dictionary. diff --git a/tests/api/test_itemtypes.py b/tests/api/test_itemtypes.py index ecd89ba21..ec3b042f7 100644 --- a/tests/api/test_itemtypes.py +++ b/tests/api/test_itemtypes.py @@ -45,7 +45,8 @@ from pygeoapi.api.itemtypes import ( get_collection_queryables, get_collection_item, get_collection_items, manage_collection_item) -from pygeoapi.util import yaml_load, get_crs_from_uri +from pygeoapi.crs import get_crs_from_uri +from pygeoapi.util import yaml_load from tests.util import get_test_file_path, mock_api_request diff --git a/tests/load_tinydb_records.py b/tests/load_tinydb_records.py index fea6dced0..4c01fedf1 100644 --- a/tests/load_tinydb_records.py +++ b/tests/load_tinydb_records.py @@ -190,8 +190,6 @@ def get_anytext(bag: Union[list, str]) -> str: if isinstance(contact, CI_ResponsibleParty): providers.append(contact2party(contact)) - bbox_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' - try: minx = float(m.identification[0].bbox.minx) miny = float(m.identification[0].bbox.miny) diff --git a/tests/other/test_crs.py b/tests/other/test_crs.py new file mode 100644 index 000000000..8a09802f5 --- /dev/null +++ b/tests/other/test_crs.py @@ -0,0 +1,298 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2025 Tom Kralidis +# +# 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. +# +# ================================================================= + +from contextlib import nullcontext as does_not_raise + +import pytest +from pyproj.exceptions import CRSError +import pygeofilter.ast +from pygeofilter.parsers.ecql import parse +from pygeofilter.values import Geometry +from shapely.geometry import Point + +from pygeoapi import crs + + +def test_get_transform_from_crs(): + crs_in = crs.get_crs_from_uri( + 'http://www.opengis.net/def/crs/EPSG/0/4258' + ) + crs_out = crs.get_crs_from_uri( + 'http://www.opengis.net/def/crs/EPSG/0/25833' + ) + transform_func = crs.get_transform_from_crs(crs_in, crs_out) + p_in = Point((67.278972, 14.394493)) + p_out = Point((473901.6105, 7462606.8762)) + assert p_out.equals_exact(transform_func(p_in), 1e-3) + + +def test_get_supported_crs_list(): + DUTCH_CRS = 'http://www.opengis.net/def/crs/EPSG/0/28992' + + # Make various combinations of configs + CONFIGS = \ + [ + dict(), + {'crs': ['http://www.opengis.net/def/crs/OGC/1.3/CRS84']}, + {'crs': ['http://www.opengis.net/def/crs/OGC/1.3/CRS84h']}, + {'crs': ['http://www.opengis.net/def/crs/EPSG/0/4326', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84']}, + {'crs': ['http://www.opengis.net/def/crs/EPSG/0/4326', + DUTCH_CRS]}, + ] + # Apply all configs to util function + for config in CONFIGS: + crs_list = crs.get_supported_crs_list(config, crs.DEFAULT_CRS_LIST) + + # Whatever config: a default should be present + contains_default = False + for crs_ in crs_list: + if crs_ in crs.DEFAULT_CRS_LIST: + contains_default = True + assert contains_default + + # Extra CRSs supplied should also be present + if DUTCH_CRS in config: + assert DUTCH_CRS in crs_list + + +@pytest.mark.parametrize('uri, expected_raise, expected', [ + pytest.param('http://www.opengis.net/not/a/valid/crs/uri', pytest.raises(CRSError), None), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/0', pytest.raises(CRSError), None), # noqa + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS84', does_not_raise(), 'OGC:CRS84'), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', does_not_raise(), 'EPSG:4326'), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', does_not_raise(), 'EPSG:28992'), # noqa + pytest.param('urn:ogc:def:crs:not:a:valid:crs:urn', pytest.raises(CRSError), None), # noqa + pytest.param('urn:ogc:def:crs:epsg:0:0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:epsg::0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC::0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC:0:0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), "OGC:CRS84"), + pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), "OGC:CRS84"), + pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), "EPSG:4326"), + pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), "EPSG:4326"), + pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), "EPSG:4326"), + pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), "EPSG:28992"), # noqa +]) +def test_get_crs_from_uri(uri, expected_raise, expected): + with expected_raise: + crs_ = crs.get_crs_from_uri(uri) + assert crs_.srs.upper() == expected + + +def test_transform_bbox(): + # Use rounded values as fractions may differ + result = [59742, 446645, 129005, 557074] + + bbox = [4, 52, 5, 53] + from_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + to_crs = 'http://www.opengis.net/def/crs/EPSG/0/28992' + bbox_trans = crs.transform_bbox(bbox, from_crs, to_crs) + for n in range(4): + assert round(bbox_trans[n]) == result[n] + + bbox = [52, 4, 53, 5] + from_crs = 'http://www.opengis.net/def/crs/EPSG/0/4326' + bbox_trans = crs.transform_bbox(bbox, from_crs, to_crs) + for n in range(4): + assert round(bbox_trans[n]) == result[n] + + +@pytest.mark.parametrize('original_filter, filter_crs, storage_crs, geometry_colum_name, expected', [ # noqa + pytest.param( + 'INTERSECTS(geometry, POINT(1 1))', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + None, + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (1, 1)}) + ), + id='passthrough' + ), + pytest.param( + "INTERSECTS(geometry, POINT(1 1))", + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + None, + 'custom_geom_name', + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (1, 1)}) + ), + id='unnested-geometry-name' + ), + pytest.param( + "some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))", + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + None, + 'custom_geom_name', + pygeofilter.ast.And( + pygeofilter.ast.Equal( + pygeofilter.ast.Attribute(name='some_attribute'), 10), + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (1, 1)}) + ), + ), + id='nested-geometry-name' + ), + pytest.param( + "(some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))) OR " + "DWITHIN(geometry, POINT(2 2), 10, meters)", + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + None, + 'custom_geom_name', + pygeofilter.ast.Or( + pygeofilter.ast.And( + pygeofilter.ast.Equal( + pygeofilter.ast.Attribute(name='some_attribute'), 10), + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (1, 1)}) + ), + ), + pygeofilter.ast.DistanceWithin( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (2, 2)}), + distance=10, + units='meters', + ) + ), + id='complex-filter-name' + ), + pytest.param( + "INTERSECTS(geometry, POINT(12.512829 41.896698))", + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa + ), + id='unnested-geometry-transformed-coords' + ), + pytest.param( + "some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))", # noqa + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.And( + pygeofilter.ast.Equal( + pygeofilter.ast.Attribute(name='some_attribute'), 10), + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa + ), + ), + id='nested-geometry-transformed-coords' + ), + pytest.param( + "(some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))) OR " # noqa + "DWITHIN(geometry, POINT(12 41), 10, meters)", + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.Or( + pygeofilter.ast.And( + pygeofilter.ast.Equal( + pygeofilter.ast.Attribute(name='some_attribute'), 10), + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa + ), + ), + pygeofilter.ast.DistanceWithin( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2267681.8892602, 4543101.513292163)}), # noqa + distance=10, + units='meters', + ) + ), + id='complex-filter-transformed-coords' + ), + pytest.param( + "INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))", + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa + ), + id='unnested-geometry-transformed-coords-explicit-input-crs-ewkt' + ), + pytest.param( + "INTERSECTS(geometry, POINT(1392921 5145517))", + 'http://www.opengis.net/def/crs/EPSG/0/3857', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa + ), + id='unnested-geometry-transformed-coords-explicit-input-crs-filter-crs' + ), + pytest.param( + "INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))", + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa + ), + id='unnested-geometry-transformed-coords-ewkt-crs-overrides-filter-crs' + ), + pytest.param( + "INTERSECTS(geometry, POINT(12.512829 41.896698))", + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + 'custom_geom_name', + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa + ), + id='unnested-geometry-name-and-transformed-coords' + ), +]) +def test_modify_pygeofilter( + original_filter, + filter_crs, + storage_crs, + geometry_colum_name, + expected +): + parsed_filter = parse(original_filter) + result = crs.modify_pygeofilter( + parsed_filter, + filter_crs_uri=filter_crs, + storage_crs_uri=storage_crs, + geometry_column_name=geometry_colum_name + ) + assert result == expected diff --git a/tests/other/test_util.py b/tests/other/test_util.py index e7570b756..6d3d70aa6 100644 --- a/tests/other/test_util.py +++ b/tests/other/test_util.py @@ -29,18 +29,12 @@ from datetime import datetime, date, time from decimal import Decimal -from contextlib import nullcontext as does_not_raise from copy import deepcopy from io import StringIO from unittest import mock import uuid import pytest -from pyproj.exceptions import CRSError -import pygeofilter.ast -from pygeofilter.parsers.ecql import parse -from pygeofilter.values import Geometry -from shapely.geometry import Point from pygeoapi import util from pygeoapi.api import __version__ @@ -270,95 +264,6 @@ def test_get_api_rules(config, config_with_rules): assert rules.get_url_prefix('django') == r'^test/' -def test_get_transform_from_crs(): - crs_in = util.get_crs_from_uri( - 'http://www.opengis.net/def/crs/EPSG/0/4258' - ) - crs_out = util.get_crs_from_uri( - 'http://www.opengis.net/def/crs/EPSG/0/25833' - ) - transform_func = util.get_transform_from_crs(crs_in, crs_out) - p_in = Point((67.278972, 14.394493)) - p_out = Point((473901.6105, 7462606.8762)) - assert p_out.equals_exact(transform_func(p_in), 1e-3) - - -def test_get_supported_crs_list(): - DEFAULT_CRS_LIST = [ - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h' - ] - DUTCH_CRS = 'http://www.opengis.net/def/crs/EPSG/0/28992' - - # Make various combinations of configs - CONFIGS = \ - [ - dict(), - {'crs': ['http://www.opengis.net/def/crs/OGC/1.3/CRS84']}, - {'crs': ['http://www.opengis.net/def/crs/OGC/1.3/CRS84h']}, - {'crs': ['http://www.opengis.net/def/crs/EPSG/0/4326', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84']}, - {'crs': ['http://www.opengis.net/def/crs/EPSG/0/4326', - DUTCH_CRS]}, - ] - # Apply all configs to util function - for config in CONFIGS: - crs_list = util.get_supported_crs_list(config, DEFAULT_CRS_LIST) - - # Whatever config: a default should be present - contains_default = False - for crs in crs_list: - if crs in DEFAULT_CRS_LIST: - contains_default = True - assert contains_default - - # Extra CRSs supplied should also be present - if DUTCH_CRS in config: - assert DUTCH_CRS in crs_list - - -@pytest.mark.parametrize('uri, expected_raise, expected', [ - pytest.param('http://www.opengis.net/not/a/valid/crs/uri', pytest.raises(CRSError), None), # noqa - pytest.param('http://www.opengis.net/def/crs/EPSG/0/0', pytest.raises(CRSError), None), # noqa - pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS84', does_not_raise(), 'OGC:CRS84'), # noqa - pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', does_not_raise(), 'EPSG:4326'), # noqa - pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', does_not_raise(), 'EPSG:28992'), # noqa - pytest.param('urn:ogc:def:crs:not:a:valid:crs:urn', pytest.raises(CRSError), None), # noqa - pytest.param('urn:ogc:def:crs:epsg:0:0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:epsg::0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:OGC::0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:OGC:0:0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), "OGC:CRS84"), - pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), "OGC:CRS84"), - pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), "EPSG:28992"), # noqa -]) -def test_get_crs_from_uri(uri, expected_raise, expected): - with expected_raise: - crs = util.get_crs_from_uri(uri) - assert crs.srs.upper() == expected - - -def test_transform_bbox(): - # Use rounded values as fractions may differ - result = [59742, 446645, 129005, 557074] - - bbox = [4, 52, 5, 53] - from_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' - to_crs = 'http://www.opengis.net/def/crs/EPSG/0/28992' - bbox_trans = util.transform_bbox(bbox, from_crs, to_crs) - for n in range(4): - assert round(bbox_trans[n]) == result[n] - - bbox = [52, 4, 53, 5] - from_crs = 'http://www.opengis.net/def/crs/EPSG/0/4326' - bbox_trans = util.transform_bbox(bbox, from_crs, to_crs) - for n in range(4): - assert round(bbox_trans[n]) == result[n] - - def test_prefetcher(): prefetcher = util.UrlPrefetcher() assert prefetcher.get_headers('bad_url') == {} @@ -377,180 +282,6 @@ def test_prefetcher(): assert headers.get('content-type') == 'image/png' -@pytest.mark.parametrize('original_filter, filter_crs, storage_crs, geometry_colum_name, expected', [ # noqa - pytest.param( - 'INTERSECTS(geometry, POINT(1 1))', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - None, - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (1, 1)}) - ), - id='passthrough' - ), - pytest.param( - "INTERSECTS(geometry, POINT(1 1))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - None, - 'custom_geom_name', - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (1, 1)}) - ), - id='unnested-geometry-name' - ), - pytest.param( - "some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - None, - 'custom_geom_name', - pygeofilter.ast.And( - pygeofilter.ast.Equal( - pygeofilter.ast.Attribute(name='some_attribute'), 10), - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (1, 1)}) - ), - ), - id='nested-geometry-name' - ), - pytest.param( - "(some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))) OR " - "DWITHIN(geometry, POINT(2 2), 10, meters)", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - None, - 'custom_geom_name', - pygeofilter.ast.Or( - pygeofilter.ast.And( - pygeofilter.ast.Equal( - pygeofilter.ast.Attribute(name='some_attribute'), 10), - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (1, 1)}) - ), - ), - pygeofilter.ast.DistanceWithin( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (2, 2)}), - distance=10, - units='meters', - ) - ), - id='complex-filter-name' - ), - pytest.param( - "INTERSECTS(geometry, POINT(12.512829 41.896698))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa - ), - id='unnested-geometry-transformed-coords' - ), - pytest.param( - "some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))", # noqa - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.And( - pygeofilter.ast.Equal( - pygeofilter.ast.Attribute(name='some_attribute'), 10), - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa - ), - ), - id='nested-geometry-transformed-coords' - ), - pytest.param( - "(some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))) OR " # noqa - "DWITHIN(geometry, POINT(12 41), 10, meters)", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.Or( - pygeofilter.ast.And( - pygeofilter.ast.Equal( - pygeofilter.ast.Attribute(name='some_attribute'), 10), - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa - ), - ), - pygeofilter.ast.DistanceWithin( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2267681.8892602, 4543101.513292163)}), # noqa - distance=10, - units='meters', - ) - ), - id='complex-filter-transformed-coords' - ), - pytest.param( - "INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa - ), - id='unnested-geometry-transformed-coords-explicit-input-crs-ewkt' - ), - pytest.param( - "INTERSECTS(geometry, POINT(1392921 5145517))", - 'http://www.opengis.net/def/crs/EPSG/0/3857', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa - ), - id='unnested-geometry-transformed-coords-explicit-input-crs-filter-crs' - ), - pytest.param( - "INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa - ), - id='unnested-geometry-transformed-coords-ewkt-crs-overrides-filter-crs' - ), - pytest.param( - "INTERSECTS(geometry, POINT(12.512829 41.896698))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - 'custom_geom_name', - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa - ), - id='unnested-geometry-name-and-transformed-coords' - ), -]) -def test_modify_pygeofilter( - original_filter, - filter_crs, - storage_crs, - geometry_colum_name, - expected -): - parsed_filter = parse(original_filter) - result = util.modify_pygeofilter( - parsed_filter, - filter_crs_uri=filter_crs, - storage_crs_uri=storage_crs, - geometry_column_name=geometry_colum_name - ) - assert result == expected - - def test_get_choice_from_headers(): _headers = { 'accept': 'text/html;q=0.5,application/ld+json', diff --git a/tests/provider/test_api_ogr_provider.py b/tests/provider/test_api_ogr_provider.py index 253356673..aa9718c6b 100644 --- a/tests/provider/test_api_ogr_provider.py +++ b/tests/provider/test_api_ogr_provider.py @@ -33,10 +33,11 @@ import logging import pytest +from shapely.geometry import shape as geojson_to_geom from pygeoapi.api import API from pygeoapi.api.itemtypes import get_collection_item, get_collection_items -from pygeoapi.util import yaml_load, geojson_to_geom +from pygeoapi.util import yaml_load from ..util import get_test_file_path, mock_api_request diff --git a/tests/provider/test_ogr_shapefile_provider.py b/tests/provider/test_ogr_shapefile_provider.py index 26d618f61..25512a6b4 100644 --- a/tests/provider/test_ogr_shapefile_provider.py +++ b/tests/provider/test_ogr_shapefile_provider.py @@ -35,12 +35,11 @@ import pytest import pyproj +from shapely.geometry import shape as geojson_to_geom +from pygeoapi.crs import CrsTransformSpec, get_transform_from_crs from pygeoapi.provider.base import ProviderItemNotFoundError from pygeoapi.provider.ogr import OGRProvider -from pygeoapi.util import ( - CrsTransformSpec, get_transform_from_crs, geojson_to_geom, -) LOGGER = logging.getLogger(__name__) diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index 42ceb0b3a..8dffe5c78 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -40,11 +40,12 @@ # See pygeoapi/provider/postgresql.py for instructions on setting up # test database in Docker +from http import HTTPStatus import os import json import pytest import pyproj -from http import HTTPStatus +from shapely.geometry import shape as geojson_to_geom from pygeofilter.parsers.ecql import parse @@ -52,6 +53,7 @@ from pygeoapi.api.itemtypes import ( get_collection_items, get_collection_item, manage_collection_item ) +from pygeoapi.crs import DEFAULT_CRS, get_transform_from_crs, get_crs_from_uri from pygeoapi.provider.base import ( ProviderConnectionError, ProviderItemNotFoundError, @@ -60,13 +62,11 @@ from pygeoapi.provider.sql import PostgreSQLProvider import pygeoapi.provider.sql as postgresql_provider_module -from pygeoapi.util import (yaml_load, geojson_to_geom, - get_transform_from_crs, get_crs_from_uri) +from pygeoapi.util import yaml_load from ..util import get_test_file_path, mock_api_request PASSWORD = os.environ.get('POSTGRESQL_PASSWORD', 'postgres') -DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' @pytest.fixture() From 98c2fe81c702b9ce206f90f894a2bfd7a03254ef Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Fri, 19 Sep 2025 13:33:49 -0400 Subject: [PATCH 02/15] Normalize storage_crs --- pygeoapi/api/__init__.py | 38 +---- pygeoapi/api/itemtypes.py | 44 +---- pygeoapi/crs.py | 180 +++++++++++---------- pygeoapi/provider/base.py | 5 + pygeoapi/provider/esri.py | 4 +- pygeoapi/provider/mvt_postgresql.py | 8 +- pygeoapi/provider/ogr.py | 4 +- pygeoapi/provider/oracle.py | 56 ++----- pygeoapi/provider/sql.py | 21 +-- pygeoapi/provider/xarray_.py | 53 ++---- tests/api/test_itemtypes.py | 4 +- tests/other/test_crs.py | 8 +- tests/provider/test_esri_provider.py | 2 +- tests/provider/test_postgresql_provider.py | 6 +- 14 files changed, 160 insertions(+), 273 deletions(-) diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index a67a3130e..34ddcd3ec 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -49,15 +49,14 @@ import logging import re import sys -from typing import Any, Tuple, Union, Optional, Self +from typing import Any, Tuple, Union, Self from babel import Locale from dateutil.parser import parse as dateparse import pytz from pygeoapi import __version__, l10n -from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS, DEFAULT_CRS_LIST -from pygeoapi.crs import get_supported_crs_list +from pygeoapi.crs import DEFAULT_STORAGE_CRS, get_supported_crs_list from pygeoapi.linked_data import jsonldify, jsonldify_collection from pygeoapi.log import setup_logger from pygeoapi.plugin import load_plugin @@ -632,37 +631,6 @@ def get_dataset_templates(self, dataset: str) -> dict: return templates or self.tpl_config['server']['templates'] - @staticmethod - def _set_content_crs_header( - headers: dict, - config: dict, - query_crs_uri: Optional[str] = None, - ): - """Set the *Content-Crs* header in responses from providers of Feature - type. - - :param headers: Response headers dictionary. - :type headers: dict - :param config: Provider config dictionary. - :type config: dict - :param query_crs_uri: Uniform resource identifier of the coordinate - reference system specified in query parameter (if specified). - :type query_crs_uri: str, optional - """ - if query_crs_uri: - content_crs_uri = query_crs_uri - else: - # If empty use default CRS - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) - if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is one of the defaults like - # http://www.opengis.net/def/crs/OGC/1.3/CRS84h - content_crs_uri = storage_crs_uri - else: - content_crs_uri = DEFAULT_CRS - - headers['Content-Crs'] = f'<{content_crs_uri}>' - @jsonldify def landing_page(api: API, @@ -1076,7 +1044,7 @@ def describe_collections(api: API, request: APIRequest, # OAPIF Part 2 - list supported CRSs and StorageCRS if collection_data_type in ['edr', 'feature']: - collection['crs'] = get_supported_crs_list(collection_data, DEFAULT_CRS_LIST) # noqa + collection['crs'] = get_supported_crs_list(collection_data) collection['storageCrs'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa if 'storage_crs_coordinate_epoch' in collection_data: collection['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 695b7fad5..299e9e36c 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -40,7 +40,7 @@ from datetime import datetime from http import HTTPStatus import logging -from typing import Any, Tuple, Union, Optional +from typing import Any, Tuple, Union import urllib.parse from pygeofilter.parsers.ecql import parse as parse_ecql_text @@ -49,9 +49,10 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit -from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS, DEFAULT_CRS_LIST +from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS from pygeoapi.crs import (create_crs_transform_spec, transform_bbox, - get_supported_crs_list, modify_pygeofilter) + get_supported_crs_list, modify_pygeofilter, + set_content_crs_header) from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.linked_data import geojson2jsonld from pygeoapi.plugin import load_plugin, PLUGINS @@ -359,7 +360,7 @@ def get_collection_items( query_crs_uri = request.params.get('crs') try: crs_transform_spec = create_crs_transform_spec( - provider_def, query_crs_uri, + provider_def, query_crs_uri ) except (ValueError, CRSError) as err: msg = str(err) @@ -384,7 +385,7 @@ def get_collection_items( HTTPStatus.BAD_REQUEST, headers, request.format, 'NoApplicableCode', msg) - supported_crs_list = get_supported_crs_list(provider_def, DEFAULT_CRS_LIST) # noqa + supported_crs_list = get_supported_crs_list(provider_def) if bbox_crs not in supported_crs_list: msg = f'bbox-crs {bbox_crs} not supported for this collection' return api.get_exception( @@ -866,7 +867,7 @@ def get_collection_item(api: API, request: APIRequest, query_crs_uri = request.params.get('crs') try: crs_transform_spec = create_crs_transform_spec( - provider_def, query_crs_uri, + provider_def, query_crs_uri ) except (ValueError, CRSError) as err: msg = str(err) @@ -982,37 +983,6 @@ def get_collection_item(api: API, request: APIRequest, return headers, HTTPStatus.OK, to_json(content, api.pretty_print) -def set_content_crs_header( - headers: dict, config: dict, query_crs_uri: Optional[str] = None): - """ - Set the *Content-Crs* header in responses from providers of Feature type. - - :param headers: Response headers dictionary. - :type headers: dict - :param config: Provider config dictionary. - :type config: dict - :param query_crs_uri: Uniform resource identifier of the coordinate - reference system specified in query parameter (if specified). - :type query_crs_uri: str, optional - - :returns: None - """ - - if query_crs_uri: - content_crs_uri = query_crs_uri - else: - # If empty use default CRS - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) - if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is one of the defaults like - # http://www.opengis.net/def/crs/OGC/1.3/CRS84h - content_crs_uri = storage_crs_uri - else: - content_crs_uri = DEFAULT_CRS - - headers['Content-Crs'] = f'<{content_crs_uri}>' - - def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa """ Get OpenAPI fragments diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index 08e89a23b..ce3b25df3 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -42,22 +42,13 @@ import pygeofilter.ast import pygeofilter.values from pyproj.exceptions import CRSError -from shapely import ops + +from shapely import ops, Geometry from shapely.geometry import ( - box, - GeometryCollection, - LinearRing, - LineString, - MultiLineString, - MultiPoint, - MultiPolygon, - Polygon, - Point, shape as geojson_to_geom, mapping as geom_to_geojson, ) - LOGGER = logging.getLogger(__name__) DEFAULT_CRS_LIST = [ @@ -69,28 +60,36 @@ DEFAULT_STORAGE_CRS = DEFAULT_CRS -# Type for Shapely geometrical objects. -GeomObject = Union[ - GeometryCollection, - LinearRing, - LineString, - MultiLineString, - MultiPoint, - MultiPolygon, - Point, - Polygon, -] - - @dataclass class CrsTransformSpec: source_crs_uri: str source_crs_wkt: str target_crs_uri: str target_crs_wkt: str + always_xy: bool = False + + +def get_srid(crs: pyproj.CRS) -> Union[int, None]: + """ + Helper function to attempt to exctract an ESPG SRID from + a `pyproj.CRS` object. + + :param crs: `pyproj.CRS` object + + :returns: int + """ + if crs.to_epsg(): + return crs.to_epsg() + + try: + return pyproj.CRS(crs.to_proj4()).to_epsg() + except KeyError: + LOGGER.debug('Unable to extract SRID from proj4 string') -def get_supported_crs_list(config: dict, default_crs_list: list) -> list: +def get_supported_crs_list( + provider_def: dict, default_crs_list: list = DEFAULT_CRS_LIST +) -> list: """ Helper function to get a complete list of supported CRSs from a (Provider) config dict. Result should always include @@ -100,11 +99,12 @@ def get_supported_crs_list(config: dict, default_crs_list: list) -> list: Author: @justb4 - :param config: dictionary with or without a list of CRSs + :param provider_def: dictionary with or without a list of CRSs :param default_crs_list: default CRS alternatives, first is default + :returns: list of supported CRSs """ - supported_crs_list = config.get('crs', list()) + supported_crs_list = provider_def.get('crs', list()) contains_default = False for uri in supported_crs_list: if uri in default_crs_list: @@ -114,18 +114,20 @@ def get_supported_crs_list(config: dict, default_crs_list: list) -> list: # A default CRS is missing: add the first which is the default if not contains_default: supported_crs_list.append(default_crs_list[0]) + return supported_crs_list -def get_crs_from_uri(uri: str) -> pyproj.CRS: +def get_crs(crs: Union[str, pyproj.CRS]) -> pyproj.CRS: """ - Get a `pyproj.CRS` instance from a CRS URI. + Get a `pyproj.CRS` instance from a CRS. Author: @MTachon - :param uri: Uniform resource identifier of the coordinate + :param crs: Uniform resource identifier of the coordinate reference system. In accordance with - https://docs.ogc.org/pol/09-048r5.html#_naming_rule URIs can - take either the form of a URL or a URN + https://docs.ogc.org/pol/09-048r5.html#_naming_rule + URIs can take either the form of a URL or a URN + or `pyproj.CRS` object :raises `CRSError`: Error raised if no CRS could be identified from the URI. @@ -133,7 +135,11 @@ def get_crs_from_uri(uri: str) -> pyproj.CRS: :rtype: `pyproj.CRS` """ + if isinstance(crs, pyproj.CRS): + return crs + # normalize the input `uri` to a URL first + uri = str(crs) url = uri.replace( "urn:ogc:def:crs", "http://www.opengis.net/def/crs" @@ -155,13 +161,13 @@ def get_crs_from_uri(uri: str) -> pyproj.CRS: msg = f"CRS could not be identified from URI {uri!r}" LOGGER.error(msg) raise CRSError(msg) - else: - return crs + + return crs def get_transform_from_crs( crs_in: pyproj.CRS, crs_out: pyproj.CRS, always_xy: bool = False -) -> Callable[[GeomObject], GeomObject]: +) -> Callable[[Geometry], Geometry]: """ Get transformation function from two `pyproj.CRS` instances. Get function to transform the coordinates of a Shapely geometrical object @@ -176,7 +182,7 @@ def get_transform_from_crs( declares y,x (lat,lon) :type always_xy: `bool` - :returns: Function to transform the coordinates of a `GeomObject`. + :returns: Function to transform the coordinates of a `Geometry`. :rtype: `callable` """ crs_transform = pyproj.Transformer.from_crs( @@ -227,6 +233,7 @@ def get_geojsonf(*args, **kwargs): transform_func = get_transform_from_crs( pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), + crs_transform_spec.always_xy ) LOGGER.debug(f'crs_transform: transforming features CRS ' @@ -253,7 +260,7 @@ def crs_transform_feature(feature, transform_func): :param feature: Feature (GeoJSON-like `dict`) to transform. :type feature: `dict` :param transform_func: Function that transforms the coordinates of a - `GeomObject` instance. + `Geometry` instance. :type transform_func: `callable` :returns: None @@ -272,16 +279,16 @@ def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: Uses pyproj Transformer. :param bbox: list of coordinates in 'from_crs' projection - :param from_crs: CRS URI to transform from - :param to_crs: CRS URI to transform to + :param from_crs: CRS to transform from + :param to_crs: CRSto transform to :raises `CRSError`: Error raised if no CRS could be identified from an URI. :returns: list of 4 or 6 coordinates """ - from_crs_obj = get_crs_from_uri(from_crs) - to_crs_obj = get_crs_from_uri(to_crs) + from_crs_obj = get_crs(from_crs) + to_crs_obj = get_crs(to_crs) transform_func = pyproj.Transformer.from_crs( from_crs_obj, to_crs_obj).transform n_dims = len(bbox) // 2 @@ -289,25 +296,12 @@ def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: *bbox[n_dims:])) -def bbox2geojsongeometry(bbox: list) -> dict: - """ - Converts bbox values into GeoJSON geometry - - :param bbox: `list` of minx, miny, maxx, maxy - - :returns: `dict` of GeoJSON geometry - """ - - b = box(*bbox, ccw=False) - return geom_to_geojson(b) - - def modify_pygeofilter( - ast_tree: pygeofilter.ast.Node, - *, - filter_crs_uri: str, - storage_crs_uri: Optional[str] = None, - geometry_column_name: Optional[str] = None + ast_tree: pygeofilter.ast.Node, + *, + filter_crs_uri: str, + storage_crs_uri: Optional[str] = None, + geometry_column_name: Optional[str] = None ) -> pygeofilter.ast.Node: """ Modifies the input pygeofilter with information from the provider. @@ -338,9 +332,9 @@ def modify_pygeofilter( """ new_tree = deepcopy(ast_tree) if storage_crs_uri: - storage_crs = get_crs_from_uri(storage_crs_uri) - filter_crs = get_crs_from_uri(filter_crs_uri) - _inplace_transform_filter_geometries(new_tree, filter_crs, storage_crs) + _inplace_transform_filter_geometries( + new_tree, get_crs(filter_crs_uri), get_crs(storage_crs_uri) + ) if geometry_column_name: _inplace_replace_geometry_filter_name(new_tree, geometry_column_name) return new_tree @@ -382,7 +376,7 @@ def _inplace_transform_filter_geometries( crs_urn_provided_in_ewkt = sub_node.geometry.get( 'crs', {}).get('properties', {}).get('name') if crs_urn_provided_in_ewkt is not None: - crs = get_crs_from_uri(crs_urn_provided_in_ewkt) + crs = get_crs(crs_urn_provided_in_ewkt) else: crs = filter_crs if crs != storage_crs: @@ -429,13 +423,14 @@ def _inplace_replace_geometry_filter_name( def create_crs_transform_spec( - config: dict, query_crs_uri: Optional[str] = None) -> Union[None, CrsTransformSpec]: # noqa + provider_def: dict, query_crs_uri: Optional[str] = None +) -> Union[None, CrsTransformSpec]: """ Create a `CrsTransformSpec` instance based on provider config and *crs* query parameter. - :param config: Provider config dictionary. - :type config: dict + :param provider_def: Provider config dictionary. + :type provider_def: dict :param query_crs_uri: Uniform resource identifier of the coordinate reference system (CRS) specified in query parameter (if specified). :type query_crs_uri: str, optional @@ -451,7 +446,9 @@ def create_crs_transform_spec( """ # Get storage/default CRS for Collection. - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) + always_xy = provider_def.get('always_xy', False) + storage_crs_uri = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) + storage_crs = get_crs(storage_crs_uri) if not query_crs_uri: if storage_crs_uri in DEFAULT_CRS_LIST: @@ -462,7 +459,7 @@ def create_crs_transform_spec( query_crs_uri = DEFAULT_CRS LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}') - supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST) + supported_crs_list = get_supported_crs_list(provider_def) # Check that the crs specified by the query parameter is supported. if query_crs_uri not in supported_crs_list: raise ValueError( @@ -470,21 +467,42 @@ def create_crs_transform_spec( 'collection. List of supported CRSs: ' f'{", ".join(supported_crs_list)}.' ) - crs_out = get_crs_from_uri(query_crs_uri) + crs_out = get_crs(query_crs_uri) - storage_crs = get_crs_from_uri(storage_crs_uri) # Check if the crs specified in query parameter differs from the # storage crs. - if str(storage_crs) != str(crs_out): - LOGGER.debug( - f'CRS transformation: {storage_crs} -> {crs_out}' - ) - return CrsTransformSpec( - source_crs_uri=storage_crs_uri, - source_crs_wkt=storage_crs.to_wkt(), - target_crs_uri=query_crs_uri, - target_crs_wkt=crs_out.to_wkt(), - ) - else: + if storage_crs == crs_out: LOGGER.debug('No CRS transformation') return None + + LOGGER.debug( + f'CRS transformation: {storage_crs} -> {crs_out}' + ) + return CrsTransformSpec( + source_crs_uri=storage_crs_uri, + source_crs_wkt=storage_crs.to_wkt(), + target_crs_uri=query_crs_uri, + target_crs_wkt=crs_out.to_wkt(), + always_xy=always_xy + ) + + +def set_content_crs_header( + headers: dict, config: dict, query_crs_uri: Optional[str] = None, +): + """Set the *Content-Crs* header in responses from providers of Feature + type. + + :param headers: Response headers dictionary. + :type headers: dict + :param config: Provider config dictionary. + :type config: dict + :param query_crs_uri: Uniform resource identifier of the coordinate + reference system specified in query parameter (if specified). + :type query_crs_uri: str, optional + """ + content_crs_uri = ( + query_crs_uri or + config.get('storage_crs', DEFAULT_STORAGE_CRS) + ) + headers['Content-Crs'] = f'<{content_crs_uri}>' diff --git a/pygeoapi/provider/base.py b/pygeoapi/provider/base.py index 4fc94302f..0c58d90cd 100644 --- a/pygeoapi/provider/base.py +++ b/pygeoapi/provider/base.py @@ -32,6 +32,7 @@ from enum import Enum from http import HTTPStatus +from pygeoapi.crs import DEFAULT_STORAGE_CRS, get_crs from pygeoapi.error import GenericError LOGGER = logging.getLogger(__name__) @@ -78,6 +79,10 @@ def __init__(self, provider_def): self._fields = {} self.filename = None + # CRS properties + storage_crs_uri = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) + self.storage_crs = get_crs(storage_crs_uri) + # for coverage providers self.axes = [] self.crs = None diff --git a/pygeoapi/provider/esri.py b/pygeoapi/provider/esri.py index 3c44d8fba..85cc69d9e 100644 --- a/pygeoapi/provider/esri.py +++ b/pygeoapi/provider/esri.py @@ -32,7 +32,7 @@ import logging from requests import Session, codes -from pygeoapi.crs import crs_transform +from pygeoapi.crs import crs_transform, get_srid from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderTypeError, ProviderQueryError) from pygeoapi.util import format_datetime @@ -60,7 +60,7 @@ def __init__(self, provider_def): super().__init__(provider_def) self.url = f'{self.data}/query' - self.crs = provider_def.get('crs', '4326') + self.crs = get_srid(self.storage_crs) self.username = provider_def.get('username') self.password = provider_def.get('password') self.token_url = provider_def.get('token_service', ARCGIS_URL) diff --git a/pygeoapi/provider/mvt_postgresql.py b/pygeoapi/provider/mvt_postgresql.py index ea22ac4e6..0823fc304 100644 --- a/pygeoapi/provider/mvt_postgresql.py +++ b/pygeoapi/provider/mvt_postgresql.py @@ -41,7 +41,7 @@ from sqlalchemy.sql import select from sqlalchemy.orm import Session -from pygeoapi.crs import get_crs_from_uri +from pygeoapi.crs import get_crs from pygeoapi.models.provider.base import ( TileSetMetadata, TileMatrixSetEnum, LinkType) from pygeoapi.provider.base import ProviderConnectionError @@ -154,15 +154,13 @@ def get_tiles(self, layer='default', tileset=None, LOGGER.warning(f'Tile {z}/{x}/{y} not found') raise ProviderTileNotFoundError - storage_srid = get_crs_from_uri(self.storage_crs).to_string() - out_srid = get_crs_from_uri(tileset_schema.crs).to_string() envelope = self.get_envelope(z, y, x, tileset) - geom_column = getattr(self.table_model, self.geom) geom_filter = geom_column.intersects( - ST_Transform(envelope, storage_srid) + ST_Transform(envelope, self.storage_crs.to_string()) ) + out_srid = get_crs(tileset_schema.crs).to_string() mvtgeom = ( ST_AsMVTGeom( ST_Transform(ST_CurveToLine(geom_column), out_srid), diff --git a/pygeoapi/provider/ogr.py b/pygeoapi/provider/ogr.py index 088b74c16..409a540df 100644 --- a/pygeoapi/provider/ogr.py +++ b/pygeoapi/provider/ogr.py @@ -41,7 +41,7 @@ from osgeo import ogr as osgeo_ogr from osgeo import osr as osgeo_osr -from pygeoapi.crs import get_crs_from_uri +from pygeoapi.crs import get_crs from pygeoapi.provider.base import ( BaseProvider, ProviderGenericError, ProviderQueryError, ProviderConnectionError, @@ -401,7 +401,7 @@ def _get_spatial_ref_from_uri(self, crs_uri): epsg_code = 4326 force_auth_comply = False else: - pyproj_crs = get_crs_from_uri(crs_uri) + pyproj_crs = get_crs(crs_uri) epsg_code = int(pyproj_crs.srs.split(':')[1]) force_auth_comply = True return self._get_spatial_ref_from_epsg( diff --git a/pygeoapi/provider/oracle.py b/pygeoapi/provider/oracle.py index e8fa0298e..8a26d32db 100644 --- a/pygeoapi/provider/oracle.py +++ b/pygeoapi/provider/oracle.py @@ -38,8 +38,7 @@ import oracledb import pyproj - -from pygeoapi.crs import get_crs_from_uri, DEFAULT_STORAGE_CRS +from pygeoapi.crs import get_srid from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -401,7 +400,6 @@ def __init__(self, provider_def): # Table properties self.table = provider_def["table"] - self.id_field = provider_def["id_field"] self.conn_dic = provider_def["data"] self.geom = provider_def["geom_field"] self.properties = [item.lower() for item in self.properties] @@ -414,14 +412,6 @@ def __init__(self, provider_def): "sql_manipulator_options" ) - # CRS properties - storage_crs_uri = provider_def.get("storage_crs", DEFAULT_STORAGE_CRS) - self.storage_crs = get_crs_from_uri(storage_crs_uri) - - # TODO See Issue #1393 - # default_crs_uri = provider_def.get("default_crs", DEFAULT_CRS) - # self.default_crs = get_crs_from_uri(default_crs_uri) - # SDO properties self.sdo_param = provider_def.get("sdo_param") self.sdo_operator = provider_def.get("sdo_operator", "sdo_filter") @@ -489,7 +479,7 @@ def _get_where_clauses( sdo_param = "mask=anyinteract" bbox_dict["properties"] = { - "srid": self._get_srid_from_crs(bbox_crs), + "srid": get_srid(bbox_crs), "minx": bbox[0], "miny": bbox[1], "maxx": bbox[2], @@ -520,7 +510,7 @@ def _get_where_clauses( else: bbox_dict["properties"] = { - "srid": self._get_srid_from_crs(bbox_crs), + "srid": get_srid(bbox_crs), "minx": bbox[0], "miny": bbox[1], "maxx": bbox[2], @@ -597,20 +587,6 @@ def _output_type_handler( oracledb.DB_TYPE_LONG_RAW, arraysize=cursor.arraysize ) - def _get_srid_from_crs(self, crs): - """ - Works only for EPSG codes! - Anything else is hard coded! - """ - if crs == "OGC:CRS84": - srid = 4326 - elif crs == "OGC:CRS84h": - srid = 4326 - else: - srid = crs.to_epsg() - - return srid - def _process_query_with_sql_manipulator_sup( self, db, sql_query, bind_variables, extra_params, **query_args ): @@ -793,21 +769,16 @@ def query( source_crs = pyproj.CRS.from_wkt( crs_transform_spec.source_crs_wkt ) - source_srid = self._get_srid_from_crs(source_crs) + source_srid = get_srid(source_crs) target_crs = pyproj.CRS.from_wkt( crs_transform_spec.target_crs_wkt ) - target_srid = self._get_srid_from_crs(target_crs) + target_srid = get_srid(target_crs) else: - source_srid = self._get_srid_from_crs(self.storage_crs) + source_srid = get_srid(self.storage_crs) target_srid = source_srid - # TODO See Issue #1393 - # target_srid = self._get_srid_from_crs(self.default_crs) - # If issue is not accepted, this block can be merged with - # the following block. - LOGGER.debug(f"source_srid: {source_srid}") LOGGER.debug(f"target_srid: {target_srid}") @@ -959,22 +930,17 @@ def get(self, identifier, crs_transform_spec=None, **kwargs): source_crs = pyproj.CRS.from_wkt( crs_transform_spec.source_crs_wkt ) - source_srid = self._get_srid_from_crs(source_crs) + source_srid = get_srid(source_crs) target_crs = pyproj.CRS.from_wkt( crs_transform_spec.target_crs_wkt ) - target_srid = self._get_srid_from_crs(target_crs) + target_srid = get_srid(target_crs) else: - source_srid = self._get_srid_from_crs(self.storage_crs) + source_srid = get_srid(self.storage_crs) target_srid = source_srid - # TODO See Issue #1393 - # target_srid = self._get_srid_from_crs(self.default_crs) - # If issue is not accepted, this block can be merged with - # the following block. - LOGGER.debug(f"source_srid: {source_srid}") LOGGER.debug(f"target_srid: {target_srid}") @@ -1148,7 +1114,7 @@ def filter_binds(pair): **bind_variables, "out_id": out_id, "in_geometry": json.dumps(in_geometry), - "srid": self._get_srid_from_crs(self.storage_crs), + "srid": get_srid(self.storage_crs), } # SQL manipulation plugin @@ -1243,7 +1209,7 @@ def filter_binds(pair): **bind_variables, "in_id": identifier, "in_geometry": in_geometry, - "srid": self._get_srid_from_crs(self.storage_crs), + "srid": get_srid(self.storage_crs), } # SQL manipulation plugin diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index 10c95bd50..a5bfdbb0e 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -84,7 +84,7 @@ from sqlalchemy.orm import Session, load_only from sqlalchemy.sql.expression import and_ -from pygeoapi.crs import get_transform_from_crs, get_crs_from_uri +from pygeoapi.crs import get_transform_from_crs, get_srid from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -133,12 +133,6 @@ def __init__( LOGGER.debug(f'Table: {self.table}') LOGGER.debug(f'ID field: {self.id_field}') LOGGER.debug(f'Geometry field: {self.geom}') - - # conforming to the docs: - # https://docs.pygeoapi.io/en/latest/data-publishing/ogcapi-features.html#connection-examples # noqa - self.storage_crs = provider_def.get( - 'storage_crs', 'https://www.opengis.net/def/crs/OGC/0/CRS84' - ) LOGGER.debug(f'Configured Storage CRS: {self.storage_crs}') # Read table information from database @@ -495,10 +489,7 @@ def _feature_to_sqlalchemy(self, json_data, identifier=None): attributes.pop('identifier', None) attributes[self.geom] = from_shape( shapely.geometry.shape(json_data['geometry']), - # NOTE: for some reason, postgis in the github action requires - # explicit crs information. i think it's valid to assume 4326: - # https://portal.ogc.org/files/108198#feature-crs - srid=pyproj.CRS.from_user_input(self.storage_crs).to_epsg() + srid=get_srid(self.storage_crs) ) attributes[self.id_field] = identifier @@ -613,7 +604,8 @@ def _get_crs_transform(self, crs_transform_spec=None): if crs_transform_spec is not None: crs_transform = get_transform_from_crs( pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), - pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt) + pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), + crs_transform_spec.always_xy ) else: crs_transform = None @@ -750,9 +742,8 @@ def _get_bbox_filter(self, bbox: list[float]): if not bbox: return True # Let everything through if no bbox - # Since this provider uses postgis, we can use ST_MakeEnvelope - storage_srid = get_crs_from_uri(self.storage_crs).to_epsg() - envelope = ST_MakeEnvelope(*bbox, storage_srid or 4326) + storage_srid = get_srid(self.storage_crs) + envelope = ST_MakeEnvelope(*bbox, storage_srid) geom_column = getattr(self.table_model, self.geom) bbox_filter = ST_Intersects(envelope, geom_column) diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index dfc5a39f3..6dbd9060f 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -38,10 +38,8 @@ import fsspec import numpy as np import pyproj -from pyproj.exceptions import CRSError - -from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS, get_crs_from_uri +from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS, get_crs, get_srid from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderNoDataError, @@ -94,7 +92,9 @@ def __init__(self, provider_def): else: raise err - self.storage_crs = self._parse_storage_crs(provider_def) + if provider_def.get('storage_crs') is None: + self.storage_crs = self._parse_storage_crs() + self._coverage_properties = self._get_coverage_properties() self.axes = self._coverage_properties['axes'] @@ -481,12 +481,9 @@ def _get_coverage_properties(self): properties['restime'] = self.get_time_resolution() # Update properties based on the xarray's CRS - epsg_code = self.storage_crs.to_epsg() + epsg_code = get_srid(self.storage_crs) LOGGER.debug(f'{epsg_code}') - if epsg_code == 4326 or self.storage_crs == 'OGC:CRS84': - pass - LOGGER.debug('Confirmed default of WGS 84') - else: + if epsg_code != 4326: properties['bbox_crs'] = \ f'https://www.opengis.net/def/crs/EPSG/0/{epsg_code}' properties['inverse_flattening'] = \ @@ -586,50 +583,24 @@ def _parse_grid_mapping(self): LOGGER.debug('No grid mapping information found.') return grid_mapping_name - def _parse_storage_crs( - self, - provider_def: dict - ) -> pyproj.CRS: + def _parse_storage_crs(self) -> pyproj.CRS: """ Parse the storage CRS from an xarray dataset. - :param provider_def: provider definition - :returns: `pyproj.CRS` instance parsed from dataset """ - storage_crs = None + grid_mapping = self._parse_grid_mapping() try: - storage_crs = provider_def['storage_crs'] - crs_function = pyproj.CRS.from_user_input - except KeyError as err: - LOGGER.debug(err) - LOGGER.debug('No storage_crs found. Attempting to parse the CRS.') - - if storage_crs is None: - grid_mapping = self._parse_grid_mapping() if grid_mapping is not None: - storage_crs = self._data[grid_mapping].attrs - crs_function = pyproj.CRS.from_cf + return pyproj.CRS.from_cf(self._data[grid_mapping].attrs) elif 'crs' in self._data.variables.keys(): - storage_crs = self._data['crs'].attrs - crs_function = pyproj.CRS.from_dict - else: - storage_crs = DEFAULT_STORAGE_CRS - crs_function = get_crs_from_uri - LOGGER.debug('Failed to parse dataset CRS. Assuming WGS84.') - - LOGGER.debug(f'Parsing CRS {storage_crs} with {crs_function}') - try: - crs = crs_function(storage_crs) - except CRSError as err: + return pyproj.CRS.from_dict(self._data['crs'].attrs) + except pyproj.exceptions.CRSError as err: LOGGER.debug(f'Unable to parse projection with pyproj: {err}') LOGGER.debug('Assuming default WGS84.') - crs = get_crs_from_uri(DEFAULT_STORAGE_CRS) - - LOGGER.debug(crs) - return crs + return get_crs(DEFAULT_STORAGE_CRS) def _to_datetime_string(datetime_obj): diff --git a/tests/api/test_itemtypes.py b/tests/api/test_itemtypes.py index ec3b042f7..9ee929f97 100644 --- a/tests/api/test_itemtypes.py +++ b/tests/api/test_itemtypes.py @@ -45,7 +45,7 @@ from pygeoapi.api.itemtypes import ( get_collection_queryables, get_collection_item, get_collection_items, manage_collection_item) -from pygeoapi.crs import get_crs_from_uri +from pygeoapi.crs import get_crs from pygeoapi.util import yaml_load from tests.util import get_test_file_path, mock_api_request @@ -508,7 +508,7 @@ def test_get_collection_items_crs(config, api_): # With CRS query parameter resulting in coordinates transformation transform_func = pyproj.Transformer.from_crs( pyproj.CRS.from_epsg(4258), - get_crs_from_uri(default_crs), + get_crs(default_crs), always_xy=False, ).transform for feat_orig in features_4258['features']: diff --git a/tests/other/test_crs.py b/tests/other/test_crs.py index 8a09802f5..9995d9ca8 100644 --- a/tests/other/test_crs.py +++ b/tests/other/test_crs.py @@ -40,10 +40,10 @@ def test_get_transform_from_crs(): - crs_in = crs.get_crs_from_uri( + crs_in = crs.get_crs( 'http://www.opengis.net/def/crs/EPSG/0/4258' ) - crs_out = crs.get_crs_from_uri( + crs_out = crs.get_crs( 'http://www.opengis.net/def/crs/EPSG/0/25833' ) transform_func = crs.get_transform_from_crs(crs_in, crs_out) @@ -100,9 +100,9 @@ def test_get_supported_crs_list(): pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), "EPSG:4326"), pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), "EPSG:28992"), # noqa ]) -def test_get_crs_from_uri(uri, expected_raise, expected): +def test_get_crs(uri, expected_raise, expected): with expected_raise: - crs_ = crs.get_crs_from_uri(uri) + crs_ = crs.get_crs(uri) assert crs_.srs.upper() == expected diff --git a/tests/provider/test_esri_provider.py b/tests/provider/test_esri_provider.py index 2138cdc08..b90b6cf1e 100644 --- a/tests/provider/test_esri_provider.py +++ b/tests/provider/test_esri_provider.py @@ -81,7 +81,7 @@ def test_geometry(config): results = p.query(skip_geometry=True) assert results['features'][0]['geometry'] is None - config['crs'] = 3857 + config['storage_crs'] = 'http://www.opengis.net/def/crs/EPSG/0/3857' p = ESRIServiceProvider(config) results = p.query() geometry = results['features'][0]['geometry'] diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index 8dffe5c78..cf1d3486b 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -53,7 +53,7 @@ from pygeoapi.api.itemtypes import ( get_collection_items, get_collection_item, manage_collection_item ) -from pygeoapi.crs import DEFAULT_CRS, get_transform_from_crs, get_crs_from_uri +from pygeoapi.crs import DEFAULT_CRS, get_transform_from_crs, get_crs from pygeoapi.provider.base import ( ProviderConnectionError, ProviderItemNotFoundError, @@ -767,7 +767,7 @@ def test_get_collection_items_postgresql_crs(pg_api_): break transform_func = get_transform_from_crs( - get_crs_from_uri(DEFAULT_CRS), + get_crs(DEFAULT_CRS), pyproj.CRS.from_epsg(32735), always_xy=False, ) @@ -841,7 +841,7 @@ def test_get_collection_item_postgresql_crs(pg_api_): geom_32735 = geojson_to_geom(feat_32735['geometry']) transform_func = get_transform_from_crs( - get_crs_from_uri(DEFAULT_CRS), + get_crs(DEFAULT_CRS), pyproj.CRS.from_epsg(32735), always_xy=False, ) From addcf879b3d1a352b0de0eb1589dfa7d53012f71 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Fri, 19 Sep 2025 16:41:17 -0400 Subject: [PATCH 03/15] Update crs.py --- pygeoapi/crs.py | 85 +++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 45 deletions(-) diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index ce3b25df3..7d3c23a19 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -69,15 +69,18 @@ class CrsTransformSpec: always_xy: bool = False -def get_srid(crs: pyproj.CRS) -> Union[int, None]: +def get_srid(crs: Union[str, pyproj.CRS]) -> Union[int, None]: """ Helper function to attempt to exctract an ESPG SRID from a `pyproj.CRS` object. :param crs: `pyproj.CRS` object - :returns: int + :returns: int of EPSG SRID, if found """ + if isinstance(crs, str): + crs = get_crs(crs) + if crs.to_epsg(): return crs.to_epsg() @@ -132,7 +135,6 @@ def get_crs(crs: Union[str, pyproj.CRS]) -> pyproj.CRS: URI. :returns: `pyproj.CRS` instance matching the input URI. - :rtype: `pyproj.CRS` """ if isinstance(crs, pyproj.CRS): @@ -141,19 +143,18 @@ def get_crs(crs: Union[str, pyproj.CRS]) -> pyproj.CRS: # normalize the input `uri` to a URL first uri = str(crs) url = uri.replace( - "urn:ogc:def:crs", - "http://www.opengis.net/def/crs" - ).replace(":", "/") + 'urn:ogc:def:crs', 'http://www.opengis.net/def/crs' + ).replace(':', '/') try: - authority, code = url.rsplit("/", maxsplit=3)[1::2] + authority, code = url.rsplit('/', maxsplit=3)[1::2] crs = pyproj.CRS.from_authority(authority, code) except ValueError: msg = ( - f"CRS could not be identified from URI {uri!r}. CRS URIs must " - "follow one of two formats: " - "'http://www.opengis.net/def/crs/{authority}/{version}/{code}' or " - "'urn:ogc:def:crs:{authority}:{version}:{code}' " - "(see https://docs.opengeospatial.org/is/18-058r1/18-058r1.html#crs-overview)." # noqa + f'CRS could not be identified from URI {uri!r}. CRS URIs must ' + 'follow one of two formats: ' + '"http://www.opengis.net/def/crs/{authority}/{version}/{code}" or ' + '"urn:ogc:def:crs:{authority}:{version}:{code}" ' + '(see https://docs.opengeospatial.org/is/18-058r1/18-058r1.html#crs-overview).' # noqa ) LOGGER.error(msg) raise CRSError(msg) @@ -173,17 +174,12 @@ def get_transform_from_crs( Get function to transform the coordinates of a Shapely geometrical object from one coordinate reference system to another. - :param crs_in: Coordinate Reference System of the input geometrical object. - :type crs_in: `pyproj.CRS` - :param crs_out: Coordinate Reference System of the output geometrical - object. - :type crs_out: `pyproj.CRS` - :param always_xy: should axis order be forced to x,y (lon, lat) even if CRS - declares y,x (lat,lon) - :type always_xy: `bool` - - :returns: Function to transform the coordinates of a `Geometry`. - :rtype: `callable` + :param crs_in: `pyproj.CRS` Input Coordinate Reference System + :param crs_out: `pyproj.CRS` Output Coordinate Reference System + :param always_xy: 'bool' should axis order be forced to x,y (lon, lat) + even if CRSdeclares y,x (lat,lon) + + :returns: `callable` Function to transform the coordinates of a `Geometry`. """ crs_transform = pyproj.Transformer.from_crs( crs_in, crs_out, always_xy=always_xy, @@ -214,10 +210,8 @@ def crs_transform(func): type 'feature'. :param func: Function to decorate. - :type func: `callable` :returns: Decorated function. - :rtype: `callable` """ @functools.wraps(func) def get_geojsonf(*args, **kwargs): @@ -258,10 +252,8 @@ def crs_transform_feature(feature, transform_func): """Transform the coordinates of a Feature. :param feature: Feature (GeoJSON-like `dict`) to transform. - :type feature: `dict` :param transform_func: Function that transforms the coordinates of a `Geometry` instance. - :type transform_func: `callable` :returns: None """ @@ -341,10 +333,10 @@ def modify_pygeofilter( def _inplace_transform_filter_geometries( - node: pygeofilter.ast.Node, - filter_crs: pyproj.CRS, - storage_crs: pyproj.CRS -): + node: pygeofilter.ast.Node, + filter_crs: pyproj.CRS, + storage_crs: pyproj.CRS +) -> None: """ Recursively traverse node tree and convert coordinates to the storage CRS. @@ -400,9 +392,9 @@ def _inplace_transform_filter_geometries( def _inplace_replace_geometry_filter_name( - node: pygeofilter.ast.Node, - geometry_column_name: str -): + node: pygeofilter.ast.Node, + geometry_column_name: str +) -> None: """Recursively traverse node tree and rename nodes of type ``Attribute``. Nodes of type ``Attribute`` named ``geometry`` are renamed to the value of @@ -430,10 +422,8 @@ def create_crs_transform_spec( *crs* query parameter. :param provider_def: Provider config dictionary. - :type provider_def: dict :param query_crs_uri: Uniform resource identifier of the coordinate reference system (CRS) specified in query parameter (if specified). - :type query_crs_uri: str, optional :raises ValueError: Error raised if the CRS specified in the query parameter is not in the list of supported CRSs of the provider. @@ -442,7 +432,6 @@ def create_crs_transform_spec( :returns: `CrsTransformSpec` instance if the CRS specified in query parameter differs from the storage CRS, else `None`. - :rtype: Union[None, CrsTransformSpec] """ # Get storage/default CRS for Collection. @@ -489,20 +478,26 @@ def create_crs_transform_spec( def set_content_crs_header( headers: dict, config: dict, query_crs_uri: Optional[str] = None, -): +) -> None: """Set the *Content-Crs* header in responses from providers of Feature type. :param headers: Response headers dictionary. - :type headers: dict :param config: Provider config dictionary. - :type config: dict :param query_crs_uri: Uniform resource identifier of the coordinate reference system specified in query parameter (if specified). - :type query_crs_uri: str, optional + + :returns: None """ - content_crs_uri = ( - query_crs_uri or - config.get('storage_crs', DEFAULT_STORAGE_CRS) - ) + + if query_crs_uri: + content_crs_uri = query_crs_uri + else: + # If empty use default CRS + storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) + if storage_crs_uri in DEFAULT_CRS_LIST: + content_crs_uri = storage_crs_uri + else: + content_crs_uri = DEFAULT_CRS + headers['Content-Crs'] = f'<{content_crs_uri}>' From 2be130c5051ccec94ca79889750be6ba53de5431 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Fri, 19 Sep 2025 17:26:51 -0400 Subject: [PATCH 04/15] Cleanup --- pygeoapi/api/maps.py | 4 +-- pygeoapi/crs.py | 4 +-- pygeoapi/provider/ogr.py | 32 +------------------- pygeoapi/provider/sqlite.py | 1 - tests/api/test_itemtypes.py | 2 +- tests/provider/test_ogr_gpkg_provider.py | 8 +---- tests/provider/test_ogr_wfs_provider.py | 2 -- tests/provider/test_ogr_wfs_provider_live.py | 10 +----- 8 files changed, 7 insertions(+), 56 deletions(-) diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index d29140260..d8df6d354 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -77,9 +77,7 @@ def get_collection_map(api: API, request: APIRequest, :returns: tuple of headers, status code, content """ - query_args = { - 'crs': 'CRS84' - } + query_args = {} format_ = request.format or 'png' headers = request.get_response_headers(**api.api_headers) diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index 7d3c23a19..c83edec6c 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -248,7 +248,7 @@ def get_geojsonf(*args, **kwargs): return get_geojsonf -def crs_transform_feature(feature, transform_func): +def crs_transform_feature(feature: dict, transform_func: Callable): """Transform the coordinates of a Feature. :param feature: Feature (GeoJSON-like `dict`) to transform. @@ -441,7 +441,7 @@ def create_crs_transform_spec( if not query_crs_uri: if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is + # Could be that storage_crs is # http://www.opengis.net/def/crs/OGC/1.3/CRS84h query_crs_uri = storage_crs_uri else: diff --git a/pygeoapi/provider/ogr.py b/pygeoapi/provider/ogr.py index 409a540df..7fee6bb24 100644 --- a/pygeoapi/provider/ogr.py +++ b/pygeoapi/provider/ogr.py @@ -85,8 +85,6 @@ def __init__(self, provider_def): data: source_type: WFS source: WFS:https://service.pdok.nl/kadaster/rdinfo/wfs/v1_0? - source_srs: EPSG:28992 - target_srs: EPSG:4326 source_capabilities: paging: True source_options: @@ -142,29 +140,10 @@ def __init__(self, provider_def): self.source_capabilities = self.data_def.get('source_capabilities', {'paging': False}) - # self.source_srs = int(self.data_def.get('source_srs', - # 'EPSG:4326').split(':')[1]) - # self.target_srs = int(self.data_def.get('target_srs', - # 'EPSG:4326').split(':')[1]) - if self.data_def.get('source_srs') is not None \ - or self.data_def.get('target_srs') is not None: + if self.data_def.get('source_srs') or self.data_def.get('target_srs'): LOGGER.warning('source/target_srs no longer supported in OGRProvider') # noqa LOGGER.warning('Use crs and storage_crs in config, see docs') - # Optional coordinate transformation inward (requests) and - # outward (responses) when the source layers and - # OGC API - Features collections differ in EPSG-codes. - self.transform_in = None - self.transform_out = None - # if self.source_srs != self.target_srs: - # source = self._get_spatial_ref_from_epsg(self.source_srs) - # target = self._get_spatial_ref_from_epsg(self.target_srs) - # - # self.transform_in = \ - # osgeo_osr.CoordinateTransformation(target, source) - # self.transform_out = \ - # osgeo_osr.CoordinateTransformation(source, target) - self._load_source_helper(self.data_def['source_type']) self.geom_field = provider_def.get('geom_field') @@ -334,9 +313,6 @@ def query(self, offset=0, limit=10, resulttype='results', f"{maxx} {miny},{minx} {miny}))" polygon = self.ogr.CreateGeometryFromWkt(wkt) - # if self.transform_in: - # polygon.Transform(self.transform_in) - layer.SetSpatialFilter(polygon) # layer.SetSpatialFilterRect( @@ -430,9 +406,6 @@ def get(self, identifier, crs_transform_spec=None, **kwargs): result = None crs_transform_out = self._get_crs_transform(crs_transform_spec) - # Keep support for source_srs/target_srs - # if crs_transform_out is None: - # crs_transform_out = self.transform_out try: LOGGER.debug(f'Fetching identifier {identifier}') layer = self._get_layer() @@ -565,9 +538,6 @@ def _response_feature_collection( layer.ResetReading() crs_transform_out = self._get_crs_transform(crs_transform_spec) - # Keep support for source_srs/target_srs - # if crs_transform_out is None: - # crs_transform_out = self.transform_out try: # Ignore gdal error ogr_feature = _ignore_gdal_error(layer, 'GetNextFeature') diff --git a/pygeoapi/provider/sqlite.py b/pygeoapi/provider/sqlite.py index d5a03d46d..340071af2 100644 --- a/pygeoapi/provider/sqlite.py +++ b/pygeoapi/provider/sqlite.py @@ -41,7 +41,6 @@ from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderItemNotFoundError) - LOGGER = logging.getLogger(__name__) diff --git a/tests/api/test_itemtypes.py b/tests/api/test_itemtypes.py index 9ee929f97..a601a5259 100644 --- a/tests/api/test_itemtypes.py +++ b/tests/api/test_itemtypes.py @@ -460,7 +460,7 @@ def test_get_collection_items_crs(config, api_): assert code == HTTPStatus.OK assert rsp_headers['Content-Crs'] == f'<{crs}>' - # With CRS query parameter, using storageCrs + # With CRS query parameter, using storage_crs req = mock_api_request({'crs': storage_crs}) rsp_headers, code, response = get_collection_items( api_, req, 'norway_pop') diff --git a/tests/provider/test_ogr_gpkg_provider.py b/tests/provider/test_ogr_gpkg_provider.py index 87ad4f30f..4217ba525 100644 --- a/tests/provider/test_ogr_gpkg_provider.py +++ b/tests/provider/test_ogr_gpkg_provider.py @@ -50,8 +50,6 @@ def config_poi_portugal(): 'data': { 'source_type': 'GPKG', 'source': './tests/data/poi_portugal.gpkg', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -105,8 +103,6 @@ def config_gpkg_4326(): 'source_type': 'GPKG', 'source': './tests/data/dutch_addresses_4326.gpkg', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -126,8 +122,6 @@ def config_gpkg_28992(): 'source_type': 'GPKG', 'source': './tests/data/dutch_addresses_28992.gpkg', - # 'source_srs': 'EPSG:28992', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -136,7 +130,7 @@ def config_gpkg_28992(): 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/28992' ], - 'storageCrs': 'http://www.opengis.net/def/crs/EPSG/0/28992', + 'storage_crs': 'http://www.opengis.net/def/crs/EPSG/0/28992', 'id_field': 'id', 'layer': 'OGRGeoJSON' } diff --git a/tests/provider/test_ogr_wfs_provider.py b/tests/provider/test_ogr_wfs_provider.py index 6535913ab..86e66ece0 100644 --- a/tests/provider/test_ogr_wfs_provider.py +++ b/tests/provider/test_ogr_wfs_provider.py @@ -53,8 +53,6 @@ def config_MapServer_WFS_cities(): 'data': { 'source_type': 'WFS', 'source': 'WFS:https://www.example.com/wfs', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, diff --git a/tests/provider/test_ogr_wfs_provider_live.py b/tests/provider/test_ogr_wfs_provider_live.py index 492e4831b..a0c33b1a9 100644 --- a/tests/provider/test_ogr_wfs_provider_live.py +++ b/tests/provider/test_ogr_wfs_provider_live.py @@ -57,8 +57,6 @@ def config_MapServer_WFS_cities(): 'data': { 'source_type': 'WFS', 'source': 'WFS:https://demo.mapserver.org/cgi-bin/wfs', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -88,8 +86,6 @@ def config_MapServer_WFS_continents(): 'data': { 'source_type': 'WFS', 'source': 'WFS:https://demo.mapserver.org/cgi-bin/wfs', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -120,8 +116,6 @@ def config_geosol_gs_WFS(): 'source_type': 'WFS', 'source': 'WFS:https://gs-stable.geosolutionsgroup.com/geoserver/wfs?', - # 'source_srs': 'EPSG:32632', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -140,7 +134,7 @@ def config_geosol_gs_WFS(): 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/32632' ], - 'storageCrs': 'http://www.opengis.net/def/crs/EPSG/0/32632', + 'storage_crs': 'http://www.opengis.net/def/crs/EPSG/0/32632', 'id_field': 'gml_id', 'layer': 'unesco:Unesco_point', } @@ -155,8 +149,6 @@ def config_geonode_gs_WFS(): 'source_type': 'WFS', 'source': 'WFS:https://geonode.wfp.org/geoserver/wfs', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, From 9004fe2e519397316a7e094497493d73ad27d664 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 22 Sep 2025 09:21:50 -0400 Subject: [PATCH 05/15] Use CrsTransformSpec for all CRS conversion --- pygeoapi/api/itemtypes.py | 12 +++++++----- pygeoapi/api/maps.py | 11 +++++++++-- pygeoapi/crs.py | 13 ++++++------- tests/other/test_crs.py | 33 ++++++++++++++++++++++++++++++--- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 299e9e36c..28a6439b9 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -395,15 +395,17 @@ def get_collection_items( # bbox but no bbox-crs param: assume bbox is in default CRS bbox_crs = DEFAULT_CRS - # Transform bbox to storageCrs - # when bbox-crs different from storageCrs. + # Transform bbox to storage_crs + # when bbox-crs different from storage_crs. if len(bbox) > 0: try: - # Get a pyproj CRS instance for the Collection's Storage CRS - storage_crs = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa + # Create the CRS transform spec + crs_transform_spec = create_crs_transform_spec( + provider_def, bbox_crs + ) # Do the (optional) Transform to the Storage CRS - bbox = transform_bbox(bbox, bbox_crs, storage_crs) + bbox = transform_bbox(bbox, crs_transform_spec) except CRSError as e: return api.get_exception( HTTPStatus.BAD_REQUEST, headers, request.format, diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index d8df6d354..5723f9f26 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -43,7 +43,7 @@ import logging from typing import Tuple -from pygeoapi.crs import transform_bbox +from pygeoapi.crs import transform_bbox, create_crs_transform_spec from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderGenericError @@ -152,7 +152,14 @@ def get_collection_map(api: API, request: APIRequest, if query_args['bbox_crs'] != query_args['crs']: LOGGER.debug(f'Reprojecting bbox CRS: {query_args["crs"]}') - bbox = transform_bbox(bbox, query_args['bbox_crs'], query_args['crs']) + transform_def = { + 'storage_crs': query_args["crs"], + 'crs': [query_args["crs"],] + } + crs_transform_spec = create_crs_transform_spec( + transform_def, query_args['bbox_crs'] + ) + bbox = transform_bbox(bbox, crs_transform_spec) query_args['bbox'] = bbox diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index c83edec6c..a456be59b 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -264,25 +264,24 @@ def crs_transform_feature(feature: dict, transform_func: Callable): ) -def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: +def transform_bbox(bbox: list, crs_transform_spec: CrsTransformSpec) -> list: """ helper function to transform a bounding box (bbox) from a source to a target CRS. CRSs in URI str format. Uses pyproj Transformer. :param bbox: list of coordinates in 'from_crs' projection - :param from_crs: CRS to transform from - :param to_crs: CRSto transform to + :param crs_transform_spec: CrsTransformSpec to transform :raises `CRSError`: Error raised if no CRS could be identified from an URI. :returns: list of 4 or 6 coordinates """ - - from_crs_obj = get_crs(from_crs) - to_crs_obj = get_crs(to_crs) transform_func = pyproj.Transformer.from_crs( - from_crs_obj, to_crs_obj).transform + pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), + pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), + crs_transform_spec.always_xy + ).transform n_dims = len(bbox) // 2 return list(transform_func(*bbox[:n_dims]) + transform_func( *bbox[n_dims:])) diff --git a/tests/other/test_crs.py b/tests/other/test_crs.py index 9995d9ca8..73d336527 100644 --- a/tests/other/test_crs.py +++ b/tests/other/test_crs.py @@ -39,6 +39,21 @@ from pygeoapi import crs +@pytest.fixture +def geojson_point(): + """Valid GeoJSON item for testing.""" + return { + "type": "Feature", + "id": "test_id", + "geometry": { + "type": "Point", + "coordinates": [77.037913, 38.928012] + }, + "properties": {"name": "Test Feature"} + } + + + def test_get_transform_from_crs(): crs_in = crs.get_crs( 'http://www.opengis.net/def/crs/EPSG/0/4258' @@ -111,15 +126,27 @@ def test_transform_bbox(): result = [59742, 446645, 129005, 557074] bbox = [4, 52, 5, 53] + transform_def = { + 'storage_crs': 'http://www.opengis.net/def/crs/EPSG/0/28992', + 'crs': [ + 'http://www.opengis.net/def/crs/EPSG/0/28992', + 'http://www.opengis.net/def/crs/EPSG/0/4326' + ] + } from_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' - to_crs = 'http://www.opengis.net/def/crs/EPSG/0/28992' - bbox_trans = crs.transform_bbox(bbox, from_crs, to_crs) + crs_transform_spec = crs.create_crs_transform_spec( + transform_def, from_crs + ) + bbox_trans = crs.transform_bbox(bbox, crs_transform_spec) for n in range(4): assert round(bbox_trans[n]) == result[n] bbox = [52, 4, 53, 5] from_crs = 'http://www.opengis.net/def/crs/EPSG/0/4326' - bbox_trans = crs.transform_bbox(bbox, from_crs, to_crs) + crs_transform_spec = crs.create_crs_transform_spec( + transform_def, from_crs + ) + bbox_trans = crs.transform_bbox(bbox, crs_transform_spec) for n in range(4): assert round(bbox_trans[n]) == result[n] From 35532729e05148c728671076f25be4055a237fa0 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 22 Sep 2025 11:41:15 -0400 Subject: [PATCH 06/15] Create `get_transform_from_spec` --- pygeoapi/api/itemtypes.py | 5 ++-- pygeoapi/crs.py | 33 ++++++++++++++-------- pygeoapi/provider/sql.py | 18 ++---------- tests/provider/test_postgresql_provider.py | 3 +- 4 files changed, 28 insertions(+), 31 deletions(-) diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 28a6439b9..ded9d4eb5 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -49,10 +49,9 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit -from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS -from pygeoapi.crs import (create_crs_transform_spec, transform_bbox, +from pygeoapi.crs import (DEFAULT_CRS, create_crs_transform_spec, get_supported_crs_list, modify_pygeofilter, - set_content_crs_header) + transform_bbox, set_content_crs_header) from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.linked_data import geojson2jsonld from pygeoapi.plugin import load_plugin, PLUGINS diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index a456be59b..b93d017a5 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -166,6 +166,26 @@ def get_crs(crs: Union[str, pyproj.CRS]) -> pyproj.CRS: return crs +def get_transform_from_spec( + crs_transform_spec: CrsTransformSpec +) -> Callable[[Geometry], Geometry]: + """ Get transformation function from a `CrsTransformSpec` instance. + + :param crs_transform_spec: `CrsTransformSpec` + + :returns: `callable` Function to transform the coordinates of a `Geometry`. + """ + if crs_transform_spec is None: + LOGGER.warning('No transform spec found') + return None + + return get_transform_from_crs( + pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), + pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), + crs_transform_spec.always_xy + ) + + def get_transform_from_crs( crs_in: pyproj.CRS, crs_out: pyproj.CRS, always_xy: bool = False ) -> Callable[[Geometry], Geometry]: @@ -224,12 +244,7 @@ def get_geojsonf(*args, **kwargs): return result # Create transformation function and transform the output feature(s)' # coordinates before returning them. - transform_func = get_transform_from_crs( - pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), - pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), - crs_transform_spec.always_xy - ) - + transform_func = get_transform_from_spec(crs_transform_spec) LOGGER.debug(f'crs_transform: transforming features CRS ' f'from {crs_transform_spec.source_crs_uri} ' f'to {crs_transform_spec.target_crs_uri}') @@ -277,11 +292,7 @@ def transform_bbox(bbox: list, crs_transform_spec: CrsTransformSpec) -> list: :returns: list of 4 or 6 coordinates """ - transform_func = pyproj.Transformer.from_crs( - pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), - pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), - crs_transform_spec.always_xy - ).transform + transform_func = get_transform_from_spec(crs_transform_spec) n_dims = len(bbox) // 2 return list(transform_func(*bbox[:n_dims]) + transform_func( *bbox[n_dims:])) diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index a5bfdbb0e..cba5abaea 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -63,7 +63,6 @@ from geoalchemy2.functions import ST_MakeEnvelope, ST_Intersects from geoalchemy2.shape import to_shape, from_shape from pygeofilter.backends.sqlalchemy.evaluate import to_filter -import pyproj import shapely from sqlalchemy.sql import func from sqlalchemy import ( @@ -84,7 +83,7 @@ from sqlalchemy.orm import Session, load_only from sqlalchemy.sql.expression import and_ -from pygeoapi.crs import get_transform_from_crs, get_srid +from pygeoapi.crs import get_transform_from_spec, get_srid from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -228,7 +227,7 @@ def query( if resulttype == 'hits' or not results: return response - crs_transform_out = self._get_crs_transform(crs_transform_spec) + crs_transform_out = get_transform_from_spec(crs_transform_spec) for item in ( results.order_by(*order_by_clauses).offset(offset).limit(limit) @@ -327,7 +326,7 @@ def get(self, identifier, crs_transform_spec=None, **kwargs): if item is None: msg = f'No such item: {self.id_field}={identifier}.' raise ProviderItemNotFoundError(msg) - crs_transform_out = self._get_crs_transform(crs_transform_spec) + crs_transform_out = get_transform_from_spec(crs_transform_spec) feature = self._sqlalchemy_to_feature(item, crs_transform_out) # Drop non-defined properties @@ -600,17 +599,6 @@ def _select_properties_clause(self, select_properties, skip_geometry): return selected_properties_clause - def _get_crs_transform(self, crs_transform_spec=None): - if crs_transform_spec is not None: - crs_transform = get_transform_from_crs( - pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), - pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), - crs_transform_spec.always_xy - ) - else: - crs_transform = None - return crs_transform - @functools.cache def get_engine( diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index cf1d3486b..c8cb8140e 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -768,8 +768,7 @@ def test_get_collection_items_postgresql_crs(pg_api_): transform_func = get_transform_from_crs( get_crs(DEFAULT_CRS), - pyproj.CRS.from_epsg(32735), - always_xy=False, + pyproj.CRS.from_epsg(32735) ) # Check that the coordinates of returned features were transformed for feat_orig in features_orig['features']: From ec594bfc2350772c0ac747eaacffc372ae6b0a11 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 22 Sep 2025 11:41:22 -0400 Subject: [PATCH 07/15] Add additional tests --- tests/other/test_crs.py | 112 ++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 26 deletions(-) diff --git a/tests/other/test_crs.py b/tests/other/test_crs.py index 73d336527..339c57081 100644 --- a/tests/other/test_crs.py +++ b/tests/other/test_crs.py @@ -29,6 +29,7 @@ from contextlib import nullcontext as does_not_raise +import operator import pytest from pyproj.exceptions import CRSError import pygeofilter.ast @@ -39,21 +40,19 @@ from pygeoapi import crs -@pytest.fixture def geojson_point(): """Valid GeoJSON item for testing.""" return { - "type": "Feature", - "id": "test_id", - "geometry": { - "type": "Point", - "coordinates": [77.037913, 38.928012] + 'type': 'Feature', + 'id': 'test_id', + 'geometry': { + 'type': 'Point', + 'coordinates': [77.037913, 38.928012] }, - "properties": {"name": "Test Feature"} + 'properties': {'name': 'Test Feature'} } - def test_get_transform_from_crs(): crs_in = crs.get_crs( 'http://www.opengis.net/def/crs/EPSG/0/4258' @@ -101,19 +100,21 @@ def test_get_supported_crs_list(): pytest.param('http://www.opengis.net/not/a/valid/crs/uri', pytest.raises(CRSError), None), # noqa pytest.param('http://www.opengis.net/def/crs/EPSG/0/0', pytest.raises(CRSError), None), # noqa pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS84', does_not_raise(), 'OGC:CRS84'), # noqa + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS83', does_not_raise(), 'OGC:CRS83'), # noqa pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', does_not_raise(), 'EPSG:4326'), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4269', does_not_raise(), 'EPSG:4269'), # noqa pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', does_not_raise(), 'EPSG:28992'), # noqa pytest.param('urn:ogc:def:crs:not:a:valid:crs:urn', pytest.raises(CRSError), None), # noqa pytest.param('urn:ogc:def:crs:epsg:0:0', pytest.raises(CRSError), None), pytest.param('urn:ogc:def:crs:epsg::0', pytest.raises(CRSError), None), pytest.param('urn:ogc:def:crs:OGC::0', pytest.raises(CRSError), None), pytest.param('urn:ogc:def:crs:OGC:0:0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), "OGC:CRS84"), - pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), "OGC:CRS84"), - pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), "EPSG:28992"), # noqa + pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), 'OGC:CRS84'), + pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), 'OGC:CRS84'), + pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), 'EPSG:4326'), + pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), 'EPSG:4326'), + pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), 'EPSG:4326'), + pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), 'EPSG:28992'), # noqa ]) def test_get_crs(uri, expected_raise, expected): with expected_raise: @@ -121,6 +122,65 @@ def test_get_crs(uri, expected_raise, expected): assert crs_.srs.upper() == expected +@pytest.mark.parametrize('uri, expected_raise, expected', [ + pytest.param('http://www.opengis.net/not/a/valid/crs/uri', pytest.raises(CRSError), None), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/0', pytest.raises(CRSError), None), # noqa + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS84', does_not_raise(), 4326), # noqa + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS83', does_not_raise(), 4269), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', does_not_raise(), 4326), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4269', does_not_raise(), 4269), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', does_not_raise(), 28992), # noqa + pytest.param('urn:ogc:def:crs:not:a:valid:crs:urn', pytest.raises(CRSError), None), # noqa + pytest.param('urn:ogc:def:crs:epsg:0:0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:epsg::0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC::0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC:0:0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), 28992), +]) +def test_get_srid(uri, expected_raise, expected): + with expected_raise: + srid_ = crs.get_srid(uri) + assert srid_ == expected + + +@pytest.mark.parametrize('uri, expected_axis_order', [ + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS83', operator.eq), + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', operator.ne), + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4269', operator.ne), + pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', operator.eq), + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4289', operator.ne) +]) +def test_always_xy(uri, expected_axis_order): + # pyproj respect URI Authority on axis order + provider_def = { + 'crs': [uri], + 'always_xy': False + } + transform_func = crs.get_transform_from_spec( + crs.create_crs_transform_spec(provider_def, uri) + ) + + feature = geojson_point() + crs.crs_transform_feature(feature, transform_func) + + # pyproj use always_xy, see: + # https://proj.org/en/stable/faq.html#why-is-the-axis-ordering-in-proj-not-consistent + provider_def['always_xy'] = True + feature_always_xy = geojson_point() + + transform_func_always_xy = crs.get_transform_from_spec( + crs.create_crs_transform_spec(provider_def, uri) + ) + crs.crs_transform_feature(feature_always_xy, transform_func_always_xy) + + assert expected_axis_order(feature, feature_always_xy) + + def test_transform_bbox(): # Use rounded values as fractions may differ result = [59742, 446645, 129005, 557074] @@ -164,7 +224,7 @@ def test_transform_bbox(): id='passthrough' ), pytest.param( - "INTERSECTS(geometry, POINT(1 1))", + 'INTERSECTS(geometry, POINT(1 1))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', None, 'custom_geom_name', @@ -175,7 +235,7 @@ def test_transform_bbox(): id='unnested-geometry-name' ), pytest.param( - "some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))", + 'some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', None, 'custom_geom_name', @@ -190,8 +250,8 @@ def test_transform_bbox(): id='nested-geometry-name' ), pytest.param( - "(some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))) OR " - "DWITHIN(geometry, POINT(2 2), 10, meters)", + '(some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))) OR ' + 'DWITHIN(geometry, POINT(2 2), 10, meters)', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', None, 'custom_geom_name', @@ -214,7 +274,7 @@ def test_transform_bbox(): id='complex-filter-name' ), pytest.param( - "INTERSECTS(geometry, POINT(12.512829 41.896698))", + 'INTERSECTS(geometry, POINT(12.512829 41.896698))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/3004', None, @@ -225,7 +285,7 @@ def test_transform_bbox(): id='unnested-geometry-transformed-coords' ), pytest.param( - "some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))", # noqa + 'some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))', # noqa 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/3004', None, @@ -240,8 +300,8 @@ def test_transform_bbox(): id='nested-geometry-transformed-coords' ), pytest.param( - "(some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))) OR " # noqa - "DWITHIN(geometry, POINT(12 41), 10, meters)", + '(some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))) OR ' # noqa + 'DWITHIN(geometry, POINT(12 41), 10, meters)', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/3004', None, @@ -264,7 +324,7 @@ def test_transform_bbox(): id='complex-filter-transformed-coords' ), pytest.param( - "INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))", + 'INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/3004', None, @@ -275,7 +335,7 @@ def test_transform_bbox(): id='unnested-geometry-transformed-coords-explicit-input-crs-ewkt' ), pytest.param( - "INTERSECTS(geometry, POINT(1392921 5145517))", + 'INTERSECTS(geometry, POINT(1392921 5145517))', 'http://www.opengis.net/def/crs/EPSG/0/3857', 'http://www.opengis.net/def/crs/EPSG/0/3004', None, @@ -286,7 +346,7 @@ def test_transform_bbox(): id='unnested-geometry-transformed-coords-explicit-input-crs-filter-crs' ), pytest.param( - "INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))", + 'INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/3004', None, @@ -297,7 +357,7 @@ def test_transform_bbox(): id='unnested-geometry-transformed-coords-ewkt-crs-overrides-filter-crs' ), pytest.param( - "INTERSECTS(geometry, POINT(12.512829 41.896698))", + 'INTERSECTS(geometry, POINT(12.512829 41.896698))', 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/3004', 'custom_geom_name', From a6b5f89de2598c06b44eb2ac166d64b2f8c74491 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 22 Sep 2025 11:43:53 -0400 Subject: [PATCH 08/15] Reduce diff --- pygeoapi/provider/sensorthings_edr.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pygeoapi/provider/sensorthings_edr.py b/pygeoapi/provider/sensorthings_edr.py index 7119c728e..a403f3871 100644 --- a/pygeoapi/provider/sensorthings_edr.py +++ b/pygeoapi/provider/sensorthings_edr.py @@ -29,7 +29,6 @@ import logging -from pygeoapi.crs import DEFAULT_CRS from pygeoapi.provider.base import ProviderNoDataError from pygeoapi.provider.base_edr import BaseEDRProvider from pygeoapi.provider.sensorthings import SensorThingsProvider @@ -40,7 +39,7 @@ 'coordinates': ['x', 'y'], 'system': { 'type': 'GeographicCRS', - 'id': DEFAULT_CRS + 'id': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' } } From b3f80c3d97ac6601db962a6a58d8096d113bbeca Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 22 Sep 2025 12:31:22 -0400 Subject: [PATCH 09/15] Revert transform_bbox changes --- pygeoapi/api/itemtypes.py | 17 ++++++++--------- pygeoapi/api/maps.py | 11 ++--------- pygeoapi/crs.py | 12 +++++++++--- tests/other/test_crs.py | 18 +++--------------- 4 files changed, 22 insertions(+), 36 deletions(-) diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index ded9d4eb5..a1e1bda16 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -49,9 +49,10 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit -from pygeoapi.crs import (DEFAULT_CRS, create_crs_transform_spec, - get_supported_crs_list, modify_pygeofilter, - transform_bbox, set_content_crs_header) +from pygeoapi.crs import (DEFAULT_CRS, DEFAULT_STORAGE_CRS, + create_crs_transform_spec, get_supported_crs_list, + modify_pygeofilter, transform_bbox, + set_content_crs_header) from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.linked_data import geojson2jsonld from pygeoapi.plugin import load_plugin, PLUGINS @@ -392,19 +393,17 @@ def get_collection_items( 'NoApplicableCode', msg) elif len(bbox) > 0: # bbox but no bbox-crs param: assume bbox is in default CRS - bbox_crs = DEFAULT_CRS + bbox_crs = DEFAULT_STORAGE_CRS # Transform bbox to storage_crs # when bbox-crs different from storage_crs. if len(bbox) > 0: try: - # Create the CRS transform spec - crs_transform_spec = create_crs_transform_spec( - provider_def, bbox_crs - ) + # Get a pyproj CRS instance for the Collection's Storage CRS + storage_crs = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) # Do the (optional) Transform to the Storage CRS - bbox = transform_bbox(bbox, crs_transform_spec) + bbox = transform_bbox(bbox, bbox_crs, storage_crs) except CRSError as e: return api.get_exception( HTTPStatus.BAD_REQUEST, headers, request.format, diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index 5723f9f26..d8df6d354 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -43,7 +43,7 @@ import logging from typing import Tuple -from pygeoapi.crs import transform_bbox, create_crs_transform_spec +from pygeoapi.crs import transform_bbox from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderGenericError @@ -152,14 +152,7 @@ def get_collection_map(api: API, request: APIRequest, if query_args['bbox_crs'] != query_args['crs']: LOGGER.debug(f'Reprojecting bbox CRS: {query_args["crs"]}') - transform_def = { - 'storage_crs': query_args["crs"], - 'crs': [query_args["crs"],] - } - crs_transform_spec = create_crs_transform_spec( - transform_def, query_args['bbox_crs'] - ) - bbox = transform_bbox(bbox, crs_transform_spec) + bbox = transform_bbox(bbox, query_args['bbox_crs'], query_args['crs']) query_args['bbox'] = bbox diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index b93d017a5..de77d97b5 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -279,20 +279,26 @@ def crs_transform_feature(feature: dict, transform_func: Callable): ) -def transform_bbox(bbox: list, crs_transform_spec: CrsTransformSpec) -> list: +def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: """ helper function to transform a bounding box (bbox) from a source to a target CRS. CRSs in URI str format. Uses pyproj Transformer. :param bbox: list of coordinates in 'from_crs' projection - :param crs_transform_spec: CrsTransformSpec to transform + :param from_crs: CRS to transform from + :param to_crs: CRSto transform to :raises `CRSError`: Error raised if no CRS could be identified from an URI. :returns: list of 4 or 6 coordinates """ - transform_func = get_transform_from_spec(crs_transform_spec) + + from_crs_obj = get_crs(from_crs) + to_crs_obj = get_crs(to_crs) + transform_func = pyproj.Transformer.from_crs( + from_crs_obj, to_crs_obj).transform + n_dims = len(bbox) // 2 return list(transform_func(*bbox[:n_dims]) + transform_func( *bbox[n_dims:])) diff --git a/tests/other/test_crs.py b/tests/other/test_crs.py index 339c57081..70ec0a46b 100644 --- a/tests/other/test_crs.py +++ b/tests/other/test_crs.py @@ -186,27 +186,15 @@ def test_transform_bbox(): result = [59742, 446645, 129005, 557074] bbox = [4, 52, 5, 53] - transform_def = { - 'storage_crs': 'http://www.opengis.net/def/crs/EPSG/0/28992', - 'crs': [ - 'http://www.opengis.net/def/crs/EPSG/0/28992', - 'http://www.opengis.net/def/crs/EPSG/0/4326' - ] - } from_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' - crs_transform_spec = crs.create_crs_transform_spec( - transform_def, from_crs - ) - bbox_trans = crs.transform_bbox(bbox, crs_transform_spec) + to_crs = 'http://www.opengis.net/def/crs/EPSG/0/28992' + bbox_trans = crs.transform_bbox(bbox, from_crs, to_crs) for n in range(4): assert round(bbox_trans[n]) == result[n] bbox = [52, 4, 53, 5] from_crs = 'http://www.opengis.net/def/crs/EPSG/0/4326' - crs_transform_spec = crs.create_crs_transform_spec( - transform_def, from_crs - ) - bbox_trans = crs.transform_bbox(bbox, crs_transform_spec) + bbox_trans = crs.transform_bbox(bbox, from_crs, to_crs) for n in range(4): assert round(bbox_trans[n]) == result[n] From 34237c306c4da8dac6072f5166c49a7d3bb842c3 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 22 Sep 2025 15:08:13 -0400 Subject: [PATCH 10/15] Update documentation --- docs/source/configuration.rst | 33 +++++----- docs/source/crs.rst | 73 +++++++++++++++++++--- docs/source/plugins.rst | 3 + docs/source/publishing/ogcapi-edr.rst | 8 +-- docs/source/publishing/ogcapi-features.rst | 8 +-- 5 files changed, 85 insertions(+), 40 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index cb50aba24..b76c6c2dc 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -219,21 +219,25 @@ default. end: 2007-10-30T08:57:29Z # end datetime in RFC3339 trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian # TRS providers: # list of 1..n required connections information - # provider name - # see pygeoapi.plugin for supported providers - # for custom built plugins, use the import path (e.g. mypackage.provider.MyProvider) - # see Plugins section for more information - - type: feature # underlying data geospatial type: (allowed values are: feature, coverage, record, tile, edr) - default: true # optional: if not specified, the first provider definition is considered the default - name: CSV + - type: feature # underlying data geospatial type. Allowed values are: feature, coverage, record, tile, edr + name: CSV # required: plugin name or import path. See Plugins section for more information. data: tests/data/obs.csv # required: the data filesystem path or URL, depending on plugin setup id_field: id # required for vector data, the field corresponding to the ID - uri_field: uri # optional field corresponding to the Uniform Resource Identifier (see Linked Data section) - time_field: datetimestamp # optional field corresponding to the temporal property of the dataset - title_field: foo # optional field of which property to display as title/label on HTML pages - properties: # optional: only return the following properties, in order + + # optional fields + uri_field: uri # field corresponding to the Uniform Resource Identifier (see Linked Data section) + time_field: datetimestamp # field corresponding to the temporal property of the dataset + title_field: foo # field of which property to display as title/label on HTML pages + default: true # if not specified, the first provider definition is considered the default + properties: # if specified, return only the following properties, in order - stn_id - value + format: # default format + name: GeoJSON # required: format name + mimetype: application/json # required: format mimetype + options: # optional options to pass to provider (i.e. GDAL creation) + option_name: option_value + include_extra_query_parameters: false # include extra query parameters that are not part of the collection properties (default: false) # editable transactions: DO NOT ACTIVATE unless you have setup access control beyond pygeoapi editable: true # optional: if backend is writable, default is false # coordinate reference systems (CRS) section is optional @@ -245,12 +249,7 @@ default. - http://www.opengis.net/def/crs/EPSG/0/4326 storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 # optional CRS in which data is stored, default: as 'crs' field storage_crs_coordinate_epoch: : 2017.23 # optional, if storage_crs is a dynamic coordinate reference system - format: # optional default format - name: GeoJSON # required: format name - mimetype: application/json # required: format mimetype - options: # optional options to pass to provider (i.e. GDAL creation) - option_name: option_value - include_extra_query_parameters: false # include extra query parameters that are not part of the collection properties (default: false) + always_xy: false # optional should CRS respect axis ordering hello-world: # name of process type: process # REQUIRED (collection, process, or stac-collection) diff --git a/docs/source/crs.rst b/docs/source/crs.rst index 5c299b854..9a0bfd82e 100644 --- a/docs/source/crs.rst +++ b/docs/source/crs.rst @@ -3,26 +3,77 @@ CRS support =========== -pygeoapi supports the complete specification: `OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`_. -The specified CRS-related capabilities are available for all Feature data Providers. +pygeoapi supports multiple Coordinate Reference Systems (CRS). +This enables the import and export of any data according to dedicated projections. +A "projection" is specified with a CRS identifier. +In particular CRS support allows for the following: + +* to specify the CRS in which the data is stored, in pygeoapi the `storage_crs` config option +* to specify the list of valid CRS representations, in pygeoapi the `crs` config option +* to publish these in the collection metadata +* the `bbox-crs=` query parameter to indicate that the `bbox=` parameter is encoded in that CRS +* the `crs=` query parameter for a collection or collection item +* the HTTP response header `Content-Crs` denotes the CRS of the Feature(s) in the data returned + +Although GeoJSON mandates WGS84 in longitude, latitude order, the client and server may still agree on other CRSs. + + +Background +---------- + +pygeoapi implements the complete specification: +`OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`_. +Under the hood, pygeoapi uses the well-known `pyproj`_ Python wrapper to the `PROJ`_ library. +All default data plugins support `bbox-crs` and `crs`. +For information on implementing CRS on custom plugins, see `Implementation`_. + +CRS support exists for the following OGC APIs: + +.. csv-table:: + :header: OGC API, bbox-crs, crs + :align: left + + :ref:`OGC API - Features`,✅,✅ + :ref:`OGC API - Maps`,✅,❌ + :ref:`OGC API - Coverages`,✅,❌ Configuration ------------- -For details visit the :ref:`configuration` section for Feature Providers. At this moment only the 'URI' CRS notation format is supported. +The CRS of a collection is defined in the provider block of your resource. +The configuration controls how the `bbox-crs` and `crs` query parameters behave. +All bbox queries are converted to the configured `storage_crs`. +An error will be returned for any interaction with CRS not included in the configured `crs`. +These are in URI formats like http://www.opengis.net/def/crs/OGC/1.3/CRS84 or +the "OpenGIS" format like http://www.opengis.net/def/crs/EPSG/0/4258. +Both 'URI' and 'URN' CRS notation format are supported. +.. note:: + That the "EPSG:" format like EPSG:4326 is outside the scope of the OGC standard. * `crs` - list of CRSs supported * `storage_crs` - CRS in which the data is stored (must be in `crs` list) * `storage_crs_coordinate_epoch` - epoch of `storage_crs` for a dynamic coordinate reference system +* `always_xy` - CRS axis order should disobey `ISO19111`_ + +.. note:: + If the storage CRS of the spatial feature collection is a dynamic coordinate reference system, + `storage_crs_coordinate_epoch` configures the coordinate epoch of the coordinates. + +.. note:: + There is also support for CRSs that support height like `http://www.opengis.net/def/crs/OGC/1.3/CRS84h`. In that case + bbox parameters (see below) may contain 6 coordinates. + +The per-Provider configuration fields are all optional, +with the following as default configuration: +.. code-block:: yaml -These per-Provider configuration fields are all optional. Default for CRS-values is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`, so "WGS84" with lon/lat axis ordering. -If the storage CRS of the spatial feature collection is a dynamic coordinate reference system, -`storage_crs_coordinate_epoch` configures the coordinate epoch of the coordinates. + crs: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + - http://www.opengis.net/def/crs/OGC/1.3/CRS84h + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 -There is also support for CRSs that support height like `http://www.opengis.net/def/crs/OGC/1.3/CRS84h`. In that case -bbox parameters (see below) may contain 6 coordinates. Metadata -------- @@ -52,7 +103,8 @@ Note that the values of these parameters may need to be URL-encoded. Implementation -------------- -CRS and BBOX CRS support is implemented for all Feature Providers. Some details may help understanding (performance) implications. +CRS and BBOX CRS support is implemented for all Feature Providers. +Some details may help understanding (performance) implications. BBOX CRS Parameter ^^^^^^^^^^^^^^^^^^ @@ -245,4 +297,7 @@ Or you may specify both `crs` and `bbox-crs` and thus `bbox` in that CRS `http:/ . . +.. _`ISO19111`: http://docs.opengeospatial.org/as/18-005r5/18-005r5.html .. _`OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`: https://docs.opengeospatial.org/is/18-058r1/18-058r1.html +.. _`PROJ`: https://proj.org/ +.. _`pyproj`: https://pyproj4.github.io/pyproj/stable \ No newline at end of file diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index ac17ee951..97729ece8 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -7,6 +7,9 @@ In this section we will explain how pygeoapi provides plugin architecture for da Plugin development requires knowledge of how to program in Python as well as Python's package/module system. +.. seealso:: + :ref:`data-publishing` for configuration of default plugins. + Overview -------- diff --git a/docs/source/publishing/ogcapi-edr.rst b/docs/source/publishing/ogcapi-edr.rst index 33da90507..edd205d17 100644 --- a/docs/source/publishing/ogcapi-edr.rst +++ b/docs/source/publishing/ogcapi-edr.rst @@ -59,7 +59,7 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data time_field: time # optionally specify the coordinate reference system of your dataset # else pygeoapi assumes it is WGS84 (EPSG:4326). - storage_crs: 4326 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 format: name: netcdf mimetype: application/x-netcdf @@ -94,11 +94,6 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data S3 URL. Any parameters required to open the dataset using fsspec can be added to the config file under `options` and `s3`, as shown above. -.. note:: - When providing a `storage_crs` value in the EDR configuration, specify the - coordinate reference system using any valid input for - `pyproj.CRS.from_user_input`_. - SensorThingsEDR ^^^^^^^^^^^^^^^ @@ -147,5 +142,4 @@ Data access examples .. _`xarray`: https://docs.xarray.dev/en/stable/ .. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF .. _`Zarr`: https://zarr.readthedocs.io/en/stable -.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input .. _`OGC Environmental Data Retrieval (EDR) (API)`: https://ogcapi.ogc.org/edr diff --git a/docs/source/publishing/ogcapi-features.rst b/docs/source/publishing/ogcapi-features.rst index 04969bcce..3a1c2e9e5 100644 --- a/docs/source/publishing/ogcapi-features.rst +++ b/docs/source/publishing/ogcapi-features.rst @@ -37,19 +37,13 @@ parameters. `TinyDB`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅,✅ .. note:: - - * All Providers that support `bbox` also support the `bbox-crs` parameter. `bbox-crs` is handled within pygeoapi core. - * All Providers support the `crs` parameter to reproject (transform) response data. Some, like PostgreSQL and OGR, perform this natively. + For more information on CRS transformations, see :ref:`crs`. Connection examples ------------------- Below are specific connection examples based on supported providers. -To support `crs` on queries, one needs to configure both a list of supported CRSs, and a 'Storage CRS'. -See also :ref:`crs` and :ref:`configuration`. When no CRS information is configured the -default CRS/'Storage CRS' value http://www.opengis.net/def/crs/OGC/1.3/CRS84 is assumed. -That is: WGS84 with lon,lat axis-ordering as in standard GeoJSON. CSV ^^^ From ddc876e53e1b9476f8e2f55c24390733dfa00326 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 22 Sep 2025 20:59:50 -0400 Subject: [PATCH 11/15] Update CRS docs --- docs/source/crs.rst | 77 ++++++++++++--------- docs/source/publishing/ogcapi-coverages.rst | 2 +- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/docs/source/crs.rst b/docs/source/crs.rst index 9a0bfd82e..d8dfc9d82 100644 --- a/docs/source/crs.rst +++ b/docs/source/crs.rst @@ -24,45 +24,33 @@ Background pygeoapi implements the complete specification: `OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`_. Under the hood, pygeoapi uses the well-known `pyproj`_ Python wrapper to the `PROJ`_ library. -All default data plugins support `bbox-crs` and `crs`. For information on implementing CRS on custom plugins, see `Implementation`_. CRS support exists for the following OGC APIs: .. csv-table:: - :header: OGC API, bbox-crs, crs + :header: OGC API, bbox-crs, filter-crs, crs :align: left - :ref:`OGC API - Features`,✅,✅ - :ref:`OGC API - Maps`,✅,❌ - :ref:`OGC API - Coverages`,✅,❌ + :ref:`OGC API - Features`,✅,✅,✅ + :ref:`OGC API - Maps`,✅,❌,❌ + :ref:`OGC API - Coverages`,✅,❌,❌ Configuration ------------- The CRS of a collection is defined in the provider block of your resource. -The configuration controls how the `bbox-crs` and `crs` query parameters behave. -All bbox queries are converted to the configured `storage_crs`. -An error will be returned for any interaction with CRS not included in the configured `crs`. -These are in URI formats like http://www.opengis.net/def/crs/OGC/1.3/CRS84 or -the "OpenGIS" format like http://www.opengis.net/def/crs/EPSG/0/4258. -Both 'URI' and 'URN' CRS notation format are supported. +The configuration controls how the `crs` related query parameters behave. -.. note:: - That the "EPSG:" format like EPSG:4326 is outside the scope of the OGC standard. - -* `crs` - list of CRSs supported -* `storage_crs` - CRS in which the data is stored (must be in `crs` list) -* `storage_crs_coordinate_epoch` - epoch of `storage_crs` for a dynamic coordinate reference system -* `always_xy` - CRS axis order should disobey `ISO19111`_ -.. note:: - If the storage CRS of the spatial feature collection is a dynamic coordinate reference system, - `storage_crs_coordinate_epoch` configures the coordinate epoch of the coordinates. +* ``crs`` - list of CRSs supported +* ``storage_crs`` - CRS in which the data is stored (must be in `crs` list) +* ``storage_crs_coordinate_epoch`` - epoch of `storage_crs` for a dynamic coordinate reference system +* ``always_xy`` - CRS should ignore authority on axis order, disobeying `ISO-19111`_ (default: false) .. note:: - There is also support for CRSs that support height like `http://www.opengis.net/def/crs/OGC/1.3/CRS84h`. In that case - bbox parameters (see below) may contain 6 coordinates. + bbox-crs and filter-crs are used to convert the request geometry to the configured ``storage_crs``. + An error will be returned for any interaction with CRS not included in the configured ``crs`` list. The per-Provider configuration fields are all optional, with the following as default configuration: @@ -74,22 +62,36 @@ with the following as default configuration: - http://www.opengis.net/def/crs/OGC/1.3/CRS84h storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 +.. note:: + Configuration is done with URI formats like http://www.opengis.net/def/crs/OGC/1.3/CRS84. + Both `URI` and `URN` CRS notation format are supported. + The `EPSG:` format like EPSG:4326 is outside the scope of the OGC standard. + Metadata -------- -The conformance class `http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs` is present as a `conformsTo` field -in the root landing page response. +The conformance class http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs is +present as a `conformsTo` field in the root landing page response. The configured CRSs, or their defaults, `crs` and `storageCrs` and optionally `storageCrsCoordinateEpoch` will be present in the "Describe Collection" response. +.. note:: + If the storage CRS of the spatial feature collection is a dynamic coordinate reference system, + `storage_crs_coordinate_epoch` configures the coordinate epoch of the coordinates. + +.. note:: + There is also support for CRSs that support height like `http://www.opengis.net/def/crs/OGC/1.3/CRS84h`. In that case + bbox parameters (see below) may contain 6 coordinates. + Parameters ---------- The `items` query supports the following parameters: -* `crs` - the CRS in which Features coordinates should be returned, also for the 'get single item' request -* `bbox-crs` - the CRS of the `bbox` parameter (only for Providers that support the `bbox` parameter) +* ``crs`` - the CRS in which Features coordinates should be returned, also for the 'get single item' request +* ``bbox-crs`` - the CRS of the `bbox` parameter (for Providers that support the `bbox` parameter) +* ``filter-crs`` - the CRS of the CQL filter expression (for Providers that support `CQL` filters) If any or both of these parameters are specified, their CRS-value should be from the advertised CRS-list in the Collection metadata (see above). @@ -106,19 +108,26 @@ Implementation CRS and BBOX CRS support is implemented for all Feature Providers. Some details may help understanding (performance) implications. -BBOX CRS Parameter +bbox-crs Parameter ^^^^^^^^^^^^^^^^^^ -The `bbox-crs` parameter is handled at the common level of pygeoapi, thus transparent for Feature Providers. -Obviously the Provider should support `bbox`. -A transformation of the `bbox` parameter is performed +The ``bbox-crs`` parameter is handled at the common level of pygeoapi. +A transformation of the request `bbox` parameter is performed according to the `storage_crs` configuration. Then the (transformed) `bbox` is passed with the other query parameters to the Provider instance. -CRS Parameter +filter-crs Parameter +^^^^^^^^^^^^^^^^^^^^ + +The ``filter-crs`` parameter is handled at the common level of pygeoapi. +A transformation of the request `CQL` filter is performed +according to the `storage_crs` configuration. Then the (transformed) `filter` is passed with the +other query parameters to the Provider instance. + +crs Parameter ^^^^^^^^^^^^^ -When the value of the `crs` parameter differs from the Provider data Storage CRS, the response Feature coordinates +When the value of the ``crs`` parameter differs from the Provider data Storage CRS, the response Feature coordinates need to be transformed to that CRS. As some Feature Providers like PostgreSQL or OGR may support native coordinate transformation, pygeoapi delegates transformation to those Providers, passing the `crs` with the other query parameters. @@ -297,7 +306,7 @@ Or you may specify both `crs` and `bbox-crs` and thus `bbox` in that CRS `http:/ . . -.. _`ISO19111`: http://docs.opengeospatial.org/as/18-005r5/18-005r5.html +.. _`ISO-19111`: http://docs.opengeospatial.org/as/18-005r5/18-005r5.html .. _`OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`: https://docs.opengeospatial.org/is/18-058r1/18-058r1.html .. _`PROJ`: https://proj.org/ .. _`pyproj`: https://pyproj4.github.io/pyproj/stable \ No newline at end of file diff --git a/docs/source/publishing/ogcapi-coverages.rst b/docs/source/publishing/ogcapi-coverages.rst index 8c486ab4e..f277f7c5b 100644 --- a/docs/source/publishing/ogcapi-coverages.rst +++ b/docs/source/publishing/ogcapi-coverages.rst @@ -77,7 +77,7 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data. time_field: time # optionally specify the coordinate reference system of your dataset # else pygeoapi assumes it is WGS84 (EPSG:4326). - storage_crs: 4326 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 format: name: netcdf mimetype: application/x-netcdf From 8fb0d6e2d8ae1652b70536534cd6e352d9ac15e9 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Tue, 21 Oct 2025 15:48:43 -0700 Subject: [PATCH 12/15] Update plugins.rst --- docs/source/plugins.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 97729ece8..dcaa48435 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -8,7 +8,7 @@ In this section we will explain how pygeoapi provides plugin architecture for da Plugin development requires knowledge of how to program in Python as well as Python's package/module system. .. seealso:: - :ref:`data-publishing` for configuration of default plugins. + :ref:`publishing` for configuration of default plugins. Overview -------- From 54ae2a2c95e22818a27c548717649fe9940d5c70 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Sun, 26 Oct 2025 17:43:34 -0600 Subject: [PATCH 13/15] Fix comment spacing in configuration docs --- docs/source/configuration.rst | 58 +++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index b76c6c2dc..8fa25d0e0 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -48,11 +48,15 @@ For more information related to API design rules (the ``api_rules`` property in encoding: utf-8 # default server encoding language: en-US # default server language locale_dir: /path/to/translations - gzip: false # default server config to gzip/compress responses to requests with gzip in the Accept-Encoding header + ogc_schemas_location: /opt/schemas.opengis.net # optional local copy of https://schemas.opengis.net + gzip: false # default server config to gzip/compress responses to requests with gzip in the Accept-Encoding header cors: true # boolean on whether server should support CORS pretty_print: true # whether JSON responses should be pretty-printed + admin: false # whether to enable the Admin API - limits: # server limits on number of items to return. This property can also be defined at the resource level to override global server settings + # server limits on number of items to return. + # overridable when redefined in resource level configuration + limits: default_items: 50 max_items: 1000 max_distance_x: 25 @@ -60,25 +64,26 @@ For more information related to API design rules (the ``api_rules`` property in max_distance_units: m on_exceed: throttle # throttle or error (default=throttle) - admin: false # whether to enable the Admin API - - # optional configuration to specify a different set of templates for HTML pages. Recommend using absolute paths. Omit this to use the default provided templates - # This property can also be defined at the resource level to override global server settings for specific datasets - templates: # optional configuration to specify a different set of templates for HTML pages. Recommend using absolute paths. Omit this to use the default provided templates + # configuration to specify directory tree for HTML page templates + # overridable when redefined in resource level configuration + templates: # omit this to use the default pygeoapi templates + # recommend using absolute paths path: /path/to/jinja2/templates/folder # path to templates folder containing the Jinja2 template HTML files static: /path/to/static/folder # path to static folder containing css, js, images and other static files referenced by the template - map: # leaflet map setup for HTML pages + # leaflet map setup for HTML template rendering + map: url: https://tile.openstreetmap.org/{z}/{x}/{y}.png attribution: '© OpenStreetMap contributors' - ogc_schemas_location: /opt/schemas.opengis.net # local copy of https://schemas.opengis.net - manager: # optional OGC API - Processes asynchronous job management + # optional OGC API - Processes asynchronous job management configuration + manager: name: TinyDB # plugin name (see pygeoapi.plugin for supported process_manager's) connection: /tmp/pygeoapi-process-manager.db # connection info to store jobs (e.g. filepath) output_dir: /tmp/ # temporary file area for storing job results (files) - api_rules: # optional API design rules to which pygeoapi should adhere + # optional API design rules to which pygeoapi should adhere + api_rules: api_version: 1.2.3 # omit to use pygeoapi's software version strict_slashes: true # trailing slashes will not be allowed and result in a 404 url_prefix: 'v{api_major}' # adds a /v1 prefix to all URL paths @@ -95,8 +100,8 @@ The ``logging`` section provides directives for logging messages which are usefu logging: level: ERROR # the logging level (see https://docs.python.org/3/library/logging.html#logging-levels) logfile: /path/to/pygeoapi.log # the full file path to the logfile - logformat: # example for milliseconds:'[%(asctime)s.%(msecs)03d] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s' - dateformat: # example for milliseconds:'%Y-%m-%dT%H:%M:%S' + logformat: # example for milliseconds:'[%(asctime)s.%(msecs)03d] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s' + dateformat: # example for milliseconds:'%Y-%m-%dT%H:%M:%S' .. note:: If ``level`` is defined and ``logfile`` is undefined, logging messages are output to the server's ``stdout``. @@ -112,11 +117,12 @@ The ``rotation`` supports rotation of disk log files. The ``logfile`` file is op logging: logfile: /path/to/pygeoapi.log # the full file path to the logfile rotation: - mode: # [time|size] - when: # [s|m|h|d|w0-w6|midnight] + mode: # [time|size] + when: # [s|m|h|d|w0-w6|midnight] interval: max_bytes: backup_count: + .. note:: Rotation block is not mandatory and defined only when needed. The ``mode`` can be defined by size or time. For RotatingFileHandler_ set mode size and parameters max_bytes and backup_count. @@ -198,13 +204,13 @@ default. keywords: # list of related keywords - observations - monitoring - linked-data: # linked data configuration (see Linked Data section) + linked-data: # linked data configuration (see Linked Data section) context: - datetime: https://schema.org/DateTime - vocab: https://example.com/vocab# stn_id: "vocab:stn_id" value: "vocab:value" - links: # list of 1..n related links + links: # list of 1..n related links - type: text/csv # MIME type rel: canonical # link relations per https://www.iana.org/assignments/link-relations/link-relations.xhtml title: data # title @@ -219,20 +225,20 @@ default. end: 2007-10-30T08:57:29Z # end datetime in RFC3339 trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian # TRS providers: # list of 1..n required connections information - - type: feature # underlying data geospatial type. Allowed values are: feature, coverage, record, tile, edr - name: CSV # required: plugin name or import path. See Plugins section for more information. + - type: feature # underlying data geospatial type. Allowed values are: feature, coverage, record, tile, edr + name: CSV # required: plugin name or import path. See Plugins section for more information. data: tests/data/obs.csv # required: the data filesystem path or URL, depending on plugin setup id_field: id # required for vector data, the field corresponding to the ID # optional fields - uri_field: uri # field corresponding to the Uniform Resource Identifier (see Linked Data section) + uri_field: uri # field corresponding to the Uniform Resource Identifier (see Linked Data section) time_field: datetimestamp # field corresponding to the temporal property of the dataset - title_field: foo # field of which property to display as title/label on HTML pages + title_field: foo # field of which property to display as title/label on HTML pages default: true # if not specified, the first provider definition is considered the default properties: # if specified, return only the following properties, in order - stn_id - value - format: # default format + format: # default format name: GeoJSON # required: format name mimetype: application/json # required: format mimetype options: # optional options to pass to provider (i.e. GDAL creation) @@ -243,13 +249,13 @@ default. # coordinate reference systems (CRS) section is optional # default CRSs are http://www.opengis.net/def/crs/OGC/1.3/CRS84 (coordinates without height) # and http://www.opengis.net/def/crs/OGC/1.3/CRS84h (coordinates with ellipsoidal height) - crs: # supported coordinate reference systems (CRS) for 'crs' and 'bbox-crs' query parameters + crs: # supported coordinate reference systems (CRS) for 'crs' and 'bbox-crs' query parameters - http://www.opengis.net/def/crs/EPSG/0/28992 - http://www.opengis.net/def/crs/OGC/1.3/CRS84 - http://www.opengis.net/def/crs/EPSG/0/4326 - storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 # optional CRS in which data is stored, default: as 'crs' field - storage_crs_coordinate_epoch: : 2017.23 # optional, if storage_crs is a dynamic coordinate reference system - always_xy: false # optional should CRS respect axis ordering + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 # optional CRS in which data is stored, default: as 'crs' field + storage_crs_coordinate_epoch: 2017.23 # optional, if storage_crs is a dynamic coordinate reference system + always_xy: false # optional should CRS respect axis ordering hello-world: # name of process type: process # REQUIRED (collection, process, or stac-collection) From 7fc3371fd3cb36a3896991eab03fc5ffe75e56c1 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Sun, 26 Oct 2025 17:43:47 -0600 Subject: [PATCH 14/15] Incorporate PR review feedback --- docs/source/crs.rst | 18 ++++++++++-------- pygeoapi/crs.py | 3 +-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/source/crs.rst b/docs/source/crs.rst index d8dfc9d82..72b3c06ba 100644 --- a/docs/source/crs.rst +++ b/docs/source/crs.rst @@ -21,10 +21,13 @@ Although GeoJSON mandates WGS84 in longitude, latitude order, the client and ser Background ---------- -pygeoapi implements the complete specification: -`OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`_. -Under the hood, pygeoapi uses the well-known `pyproj`_ Python wrapper to the `PROJ`_ library. -For information on implementing CRS on custom plugins, see `Implementation`_. +pygeoapi implements the `OGC API - Features - Part 2: Coordinate Reference Systems by Reference`_ specification. + +Under the hood, pygeoapi uses the `pyproj`_ Python package. + +.. note:: + + For more information on implementing CRS on custom plugins, see `Implementation`_. CRS support exists for the following OGC APIs: @@ -306,7 +309,6 @@ Or you may specify both `crs` and `bbox-crs` and thus `bbox` in that CRS `http:/ . . -.. _`ISO-19111`: http://docs.opengeospatial.org/as/18-005r5/18-005r5.html -.. _`OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`: https://docs.opengeospatial.org/is/18-058r1/18-058r1.html -.. _`PROJ`: https://proj.org/ -.. _`pyproj`: https://pyproj4.github.io/pyproj/stable \ No newline at end of file +.. _`ISO-19111`: https://docs.ogc.org/as/18-005r5/18-005r5.html +.. _`OGC API - Features - Part 2: Coordinate Reference Systems by Reference`: https://docs.ogc.org/is/18-058r1/18-058r1.html +.. _`pyproj`: https://pyproj4.github.io/pyproj \ No newline at end of file diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index de77d97b5..efeab27fa 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -42,11 +42,10 @@ import pygeofilter.ast import pygeofilter.values from pyproj.exceptions import CRSError - from shapely import ops, Geometry from shapely.geometry import ( shape as geojson_to_geom, - mapping as geom_to_geojson, + mapping as geom_to_geojson ) LOGGER = logging.getLogger(__name__) From ad714fbb5a1f6227b86753dca082b77e008f2417 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Sun, 26 Oct 2025 17:45:10 -0600 Subject: [PATCH 15/15] Small change to verbiage --- docs/source/configuration.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 8fa25d0e0..6f8e3f3ac 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -65,9 +65,10 @@ For more information related to API design rules (the ``api_rules`` property in on_exceed: throttle # throttle or error (default=throttle) # configuration to specify directory tree for HTML page templates + # omit this to use the default pygeoapi templates # overridable when redefined in resource level configuration - templates: # omit this to use the default pygeoapi templates - # recommend using absolute paths + templates: + # recommended to use absolute paths path: /path/to/jinja2/templates/folder # path to templates folder containing the Jinja2 template HTML files static: /path/to/static/folder # path to static folder containing css, js, images and other static files referenced by the template