From aa077ca9c04d6e9ae7ffb355cb2e33bb38384d60 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 26 Nov 2025 15:49:21 +0000 Subject: [PATCH 01/10] Add environmental data retrieval (EDR) capabilities --- .devcontainer/dev_container.dockerfile | 2 +- .github/workflows/github-python-workflow.yml | 4 +- example/app.py | 26 +- ogc/__init__.py | 12 +- ogc/core.py | 2 + ogc/edr/__init__.py | 3 + ogc/edr/config/default.json | 51 ++ ogc/edr/edr_config.py | 201 ++++++ ogc/edr/edr_provider.py | 686 +++++++++++++++++++ ogc/edr/edr_routes.py | 199 ++++++ ogc/edr/static/img/favicon.ico | Bin 0 -> 1150 bytes ogc/edr/static/img/logo.png | Bin 0 -> 33585 bytes ogc/edr/static/img/pygeoapi.png | Bin 0 -> 21458 bytes ogc/edr/test/conftest.py | 82 +++ ogc/edr/test/test_edr_config.py | 64 ++ ogc/edr/test/test_edr_provider.py | 554 +++++++++++++++ ogc/edr/test/test_edr_routes.py | 346 ++++++++++ ogc/servers.py | 139 ++++ ogc/settings.py | 10 + ogc/test/test_core.py | 4 +- ogc/test/test_servers.py | 3 +- pyproject.toml | 11 +- sonar-project.properties | 2 +- 23 files changed, 2386 insertions(+), 15 deletions(-) create mode 100644 ogc/edr/__init__.py create mode 100644 ogc/edr/config/default.json create mode 100644 ogc/edr/edr_config.py create mode 100644 ogc/edr/edr_provider.py create mode 100644 ogc/edr/edr_routes.py create mode 100644 ogc/edr/static/img/favicon.ico create mode 100644 ogc/edr/static/img/logo.png create mode 100644 ogc/edr/static/img/pygeoapi.png create mode 100644 ogc/edr/test/conftest.py create mode 100644 ogc/edr/test/test_edr_config.py create mode 100644 ogc/edr/test/test_edr_provider.py create mode 100644 ogc/edr/test/test_edr_routes.py diff --git a/.devcontainer/dev_container.dockerfile b/.devcontainer/dev_container.dockerfile index 07e7dd0..741b275 100644 --- a/.devcontainer/dev_container.dockerfile +++ b/.devcontainer/dev_container.dockerfile @@ -1,5 +1,5 @@ # Base image for the development container -ARG BASE_URL=python:3.8-slim +ARG BASE_URL=python:3.11-slim FROM ${BASE_URL} USER root diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml index 9f09f67..69a4f81 100644 --- a/.github/workflows/github-python-workflow.yml +++ b/.github/workflows/github-python-workflow.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.8" + python-version: "3.11" - name: Install dependencies run: | @@ -64,7 +64,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.8" + python-version: "3.11" - name: Install dependencies run: | diff --git a/example/app.py b/example/app.py index 10f26a5..c1a4508 100755 --- a/example/app.py +++ b/example/app.py @@ -23,12 +23,18 @@ data2 = np.random.default_rng(1).random((11, 21)) node2 = podpac.data.Array(source=data2, coordinates=coords) +time = np.array(["2025-10-24T12:00:00"], dtype="datetime64") +coords = podpac.Coordinates([lat, lon, time], dims=["lat", "lon", "time"]) +data3 = np.random.default_rng(1).random((11, 21, 1)) +node3 = podpac.data.Array(source=data3, coordinates=coords) + # use podpac nodes to create some OGC layers layer1 = pogc.Layer( node=node1, identifier="layer1", title="OGC/POPAC layer containing random data", abstract="This layer contains some random data", + group="Layers", ) layer2 = pogc.Layer( @@ -37,9 +43,20 @@ title="FOUO: Another OGC/POPAC layer containing random data", abstract="Marked as FOUO. This layer contains some random data. Same coordinates as layer1, but different values.", is_fouo=True, + group="Layers", +) + +layer3 = pogc.Layer( + node=node3, + identifier="layer3", + title="OGC/POPAC layer containing random data with time instances available.", + abstract="This layer contains some random data with time instances available.", + group="Layers", + time_instances={str(time[0])}, + valid_times=[dt.astype(datetime) for dt in time], ) -all_layers = [layer1, layer2] +all_layers = [layer1, layer2, layer3] non_fouo_layers = [layer for layer in all_layers if not layer.is_fouo] # create a couple of different ogc endpoints @@ -69,6 +86,13 @@ def api_home(endpoint):
  • WMS GetLegend Example (PNG) (v1.3.0)
  • +
  • EDR: Open Geospatial Consortium (OGC) Environmental Data Retrieval (EDR) (v1.0.1) + +
  • """ diff --git a/ogc/__init__.py b/ogc/__init__.py index 7e130a5..ae89a49 100755 --- a/ogc/__init__.py +++ b/ogc/__init__.py @@ -1,5 +1,5 @@ """ -OGC WMS/WCS (v1.3.0/v1.0.0) server +OGC WMS/WCS (v1.3.0/v1.0.0) server """ import traitlets as tl @@ -70,15 +70,15 @@ class Layer(tl.HasTraits): identifier = tl.Unicode() title = tl.Unicode(default_value="An OGC Layer") abstract = tl.Unicode(default_value="This is an example OGC Layer") + group = tl.Unicode(default_value="Default") is_fouo = tl.Bool(default_value=False) - grid_coordinates = tl.Instance( - klass=GridCoordinates, default_value=GridCoordinates() - ) + grid_coordinates = tl.Instance(klass=GridCoordinates, default_value=GridCoordinates()) valid_times = tl.List( trait=tl.Instance(datetime.datetime), default_value=tl.Undefined, allow_none=True, ) + time_instances = tl.Set(tl.Unicode) # Available time instances all_times_valid = tl.Bool(default_value=False) legend_graphic_width_inches = tl.Float(default_value=0.7) # inches @@ -101,9 +101,7 @@ def __init__(self, *args, **kwargs): elif "title" in kwargs: string_repr = kwargs["title"] if "is_enumerated" in kwargs: - self._style = Style( - string_repr=string_repr, is_enumerated=kwargs["is_enumerated"] - ) + self._style = Style(string_repr=string_repr, is_enumerated=kwargs["is_enumerated"]) else: self._style = Style(string_repr=string_repr) if self.valid_times is not tl.Undefined: diff --git a/ogc/core.py b/ogc/core.py index b9f04b8..570188b 100755 --- a/ogc/core.py +++ b/ogc/core.py @@ -14,6 +14,7 @@ from . import wcs_response_1_0_0 from . import wms_response_1_3_0 from . import ogc_common +from .edr import EdrRoutes from ogc.ogc_common import WCSException @@ -62,6 +63,7 @@ def __init__(self, layers=[], **kwargs): service_abstract=self.service_abstract, service_group_title=self.service_group_title, ) + self.edr_routes = EdrRoutes(base_url=f"{self.server_address}{self.endpoint}/edr", layers=layers) def get_coverage_from_id(self, identifier): for coverage in self.wcs_capabilities.coverages: diff --git a/ogc/edr/__init__.py b/ogc/edr/__init__.py new file mode 100644 index 0000000..68fde54 --- /dev/null +++ b/ogc/edr/__init__.py @@ -0,0 +1,3 @@ +from .edr_routes import EdrRoutes + +__all__ = ["EdrRoutes"] diff --git a/ogc/edr/config/default.json b/ogc/edr/config/default.json new file mode 100644 index 0000000..867c134 --- /dev/null +++ b/ogc/edr/config/default.json @@ -0,0 +1,51 @@ +{ + "server": { + "mimetype": "application/json; charset=UTF-8", + "encoding": "utf-8", + "language": "en-US", + "cors": true, + "pretty_print": true, + "limits": { + "default_items": 50, + "max_items": 1000, + "max_distance_x": 999999999999, + "max_distance_y": 999999999999, + "max_distance_units": "km", + "on_exceed": "error" + }, + "admin": false, + "map": { + "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "attribution": "'© OpenStreetMap contributors'" + }, + "ogc_schemas_location": "/opt/schemas.opengis.net" + }, + "logging": { + "level": "ERROR" + }, + "metadata": { + "identification": { + "title": "OGC Server", + "description": "An example OGC Server", + "keywords": [ + "geospatial", + "podpac" + ], + "keywords_type": "theme", + "terms_of_service": "http://www.apache.org/licenses/LICENSE-2.0", + "url": "https://github.com/creare-com/ogc" + }, + "license": { + "name": "Apache 2.0 license", + "url": "http://www.apache.org/licenses/LICENSE-2.0" + }, + "provider": { + "name": "Creare LLC", + "url": "https://github.com/creare-com" + }, + "contact": { + "name": "Creare LLC", + "url": "https://github.com/creare-com" + } + } +} \ No newline at end of file diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py new file mode 100644 index 0000000..30dab0a --- /dev/null +++ b/ogc/edr/edr_config.py @@ -0,0 +1,201 @@ +import os +import json +import numpy as np +import traitlets as tl +from datetime import datetime +from collections import defaultdict +from typing import List, Dict, Tuple, Any +from ogc import podpac as pogc +from .. import settings + + +class EdrConfig: + """Defines the configuration for the pygeoapi based server. + + This configuration is used to replace the typical YAML based configurations in order to provide dynamic properties. + """ + + @staticmethod + def get_configuration(base_url: str, layers: List[pogc.Layer]) -> Dict[str, Any]: + """Generate the configuration for the API. + + Parameters + ---------- + base_url : str + The base URL for the EDR endpoints. + layers : List[pogc.Layer] + The layers which define the data sources for the EDR server. + + Returns + ------- + Dict[str, Any] + The configuration for the API as a dictionary. + """ + configuration_path = settings.EDR_CONFIGURATION_PATH + if configuration_path is None: + configuration_path = os.path.abspath(os.path.join(os.path.dirname(__file__) + "/config/default.json")) + + configuration = {} + with open(configuration_path) as f: + configuration = json.load(f) + + # Add default static files with an absolute path + server = configuration.get("server", {}) + configuration["server"] = server | { + "templates": { + "path": os.path.abspath(os.path.join(os.path.dirname(__file__) + "/templates/")), + "static": os.path.abspath(os.path.join(os.path.dirname(__file__) + "/static/")), + } + } + configuration["server"]["url"] = base_url + + # Add the data resources and provider information + resources = configuration.get("resources", {}) + configuration["resources"] = resources | EdrConfig._resources_definition(layers) + + return configuration + + @staticmethod + def _resources_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: + """Define resource related data for the configuration. + + The resources dictionary holds the information needed to generate the collections. + Each group is mapped to a collection with the layers in the group forming the collection parameters. + The custom provider is specified with a data value of the group name. + This allows for the provider to generate the collection data for each group. + + Parameters + ---------- + layers : List[pogc.Layer] + The layers which define the data sources for the EDR server. + + Returns + ------- + Dict[str, Any] + The resources configuration for the API as a dictionary. + """ + + resources = {} + groups = defaultdict(list) + + # Organize the data into groups + for layer in layers: + groups[layer.group].append(layer) + + # Generate collection resources based on groups + for group_name, layers in groups.items(): + resource = { + group_name: { + "type": "collection", + "visibility": "default", + "title": group_name, + "description": f"Collection of data related to {group_name}", + "keywords": ["podpac"], + "extents": EdrConfig._generate_extents(layers), + "providers": [ + { + "type": "edr", + "default": True, + "name": "ogc.edr.edr_provider.EdrProvider", + "data": group_name, + "crs": [ + "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "http://www.opengis.net/def/crs/EPSG/0/4326", + ], + "format": { + "name": "geotiff", + "mimetype": "image/tiff", + }, + } + ], + } + } + resources.update(resource) + + return resources + + @staticmethod + def _generate_extents(layers: List[pogc.Layer]) -> Dict[str, Any]: + """Generate the extents dictionary for provided layers. + + Parameters + ---------- + layers : List[pogc.Layer] + The layers to create the temporal and spatial extents for. + + Returns + ------- + Dict[str, Any] + The extents dictionary for the layers. + """ + llc_lon, llc_lat, urc_lon, urc_lat = None, None, None, None + min_time, max_time = None, None + time_range = None + # Determine bounding box which holds all layers + for layer in layers: + llc_lon_tmp, llc_lat_tmp, urc_lon_tmp, urc_lat_tmp = EdrConfig._wgs84_bounding_box(layer) + if any(coord is None for coord in [llc_lon, llc_lat, urc_lon, urc_lat]): + llc_lon, llc_lat, urc_lon, urc_lat = llc_lon_tmp, llc_lat_tmp, urc_lon_tmp, urc_lat_tmp + else: + llc_lon = min(llc_lon, llc_lon_tmp) + llc_lat = min(llc_lat, llc_lat_tmp) + urc_lon = max(urc_lon, urc_lon_tmp) + urc_lat = max(urc_lat, urc_lat_tmp) + + if hasattr(layer, "valid_times") and layer.valid_times is not tl.Undefined and len(layer.valid_times) > 0: + layer_min_time = np.min(layer.valid_times) + layer_max_time = np.max(layer.valid_times) + if any(time is None for time in [min_time, max_time]): + min_time = layer_min_time + max_time = layer_max_time + else: + min_time = min(min_time, layer_min_time) + max_time = max(max_time, layer_max_time) + + time_range = [ + min_time.isoformat(), + max_time.isoformat(), + ] + + return { + "spatial": { + "bbox": [llc_lon, llc_lat, urc_lon, urc_lat], # minx, miny, maxx, maxy + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + }, + **( + { + "temporal": { + "begin": datetime.fromisoformat(time_range[0]), # start datetime in RFC3339 + "end": datetime.fromisoformat(time_range[-1]), # end datetime in RFC3339 + "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", + } + } + if time_range is not None + else {} + ), + } + + @staticmethod + def _wgs84_bounding_box(layer: pogc.Layer) -> Tuple[float, float, float, float]: + """Retrieve the bounding box for the layer with a default fallback. + + Parameters + ---------- + layer : pogc.Layer + The layer from which to get the bounding box coordinates. + + Returns + ------- + Tuple[float, float, float, float] + Lower-left longitude, lower-left latitude, upper-right longitude, upper-right latitude. + """ + try: + return ( + layer.grid_coordinates.LLC.lon, + layer.grid_coordinates.LLC.lat, + layer.grid_coordinates.URC.lon, + layer.grid_coordinates.URC.lat, + ) + except Exception: + crs_extents = settings.EDR_CRS["crs:84"] + return (crs_extents["minx"], crs_extents["miny"], crs_extents["maxx"], crs_extents["maxy"]) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py new file mode 100644 index 0000000..9ded426 --- /dev/null +++ b/ogc/edr/edr_provider.py @@ -0,0 +1,686 @@ +import base64 +import io +import numpy as np +import zipfile +import traitlets as tl +from datetime import datetime +from typing import List, Dict, Tuple, Any +from shapely.geometry.base import BaseGeometry +from pygeoapi.provider.base import ProviderConnectionError, ProviderInvalidQueryError +from pygeoapi.provider.base_edr import BaseEDRProvider +from ogc import podpac as pogc +import podpac + +from .. import settings + + +class EdrProvider(BaseEDRProvider): + """Custom provider to be used with layer data sources.""" + + layers = [] + forecast_time_delta_units = tl.Unicode(default_value="h") + + def __init__(self, provider_def: Dict[str, Any]): + """Construct the provider using the provider definition. + + Parameters + ---------- + provider_def : Dict[str, Any] + The provider configuration definition. + + Raises + ------ + ProviderConnectionError + Raised if the specified collection is not found within any layers. + ProviderConnectionError + Raised if the provider does not specify any data sources. + """ + super().__init__(provider_def) + collection_id = provider_def.get("data", None) + if collection_id is None: + raise ProviderConnectionError("Data not found.") + + self.collection_id = str(collection_id) + + if len(self.layers) == 0: + raise ProviderConnectionError("Valid data sources not found.") + + @classmethod + def set_resources(cls, layers: List[pogc.Layer]): + """Set the layer resources which will be available to the provider. + + Parameters + ---------- + layers : List[pogc.Layer] + The layers which the provider will have access to. + """ + cls.layers = layers + + @property + def parameters(self) -> Dict[str, pogc.Layer]: + """The parameters which are defined in a given collection. + + The parameters map to the layers which are a part of the group, with keys of the layer identifiers. + + Returns + ------- + Dict[str, pogc.Layer] + The parameters as a dictionary of layer identifiers and layer objects. + """ + return {layer.identifier: layer for layer in self.layers if layer.group == self.collection_id} + + def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): + """Handle the requests to the EDR server at the specified requested coordinates. + The coordinates are expected to be latitude and longitude values determined by the specific query function. + + Parameters + ---------- + requested_coordinates : podpac.Coordinates + The coordinates for evaluation, it is expected that the coordinates passed in only hold lat and lon. + instance : str + The time instance for the request. + select_properties : List[str] + The selected properties (parameters) for the request. + format_ : str + The requested output format of the data. + datetime_ : str + The requested datetime/datetimes for data retrieval. + z : str + The requested vertical level/levels for data retrieval. + + Returns + ------- + Any + Coverage data as a dictionary of CoverageJSON or native format. + + Raises + ------ + ProviderInvalidQueryError + Raised if the parameters are invalid. + ProviderInvalidQueryError + Raised if a datetime string is provided but cannot be interpreted. + ProviderInvalidQueryError + Raised if an altitude string is provided but cannot be interpreted. + ProviderInvalidQueryError + Raised if no matching parameters are found in the server. + ProviderInvalidQueryError + Raised if native coordinates could not be found. + ProviderInvalidQueryError + Raised if the request queries for native coordinates exceeding the max allowable size. + """ + instance = kwargs.get("instance") + requested_parameters = kwargs.get("select_properties") + output_format = kwargs.get("format_") + datetime_arg = kwargs.get("datetime_") + z_arg = kwargs.get("z") + output_format = str(output_format).lower() + + if not isinstance(requested_parameters, List) or len(requested_parameters) == 0: + raise ProviderInvalidQueryError("Invalid parameters provided.") + + available_times = self.get_datetimes(list(self.parameters.values())) + available_altitudes = self.get_altitudes(list(self.parameters.values())) + time_coords = self.interpret_time_coordinates(available_times, datetime_arg, requested_coordinates.crs) + altitude_coords = self.interpret_altitude_coordinates(available_altitudes, z_arg, requested_coordinates.crs) + + if datetime_arg is not None and time_coords is None: + raise ProviderInvalidQueryError("Invalid datetime provided.") + if z_arg is not None and altitude_coords is None: + raise ProviderInvalidQueryError("Invalid altitude provided.") + + if time_coords is not None: + requested_coordinates = podpac.coordinates.merge_dims([time_coords, requested_coordinates]) + if altitude_coords is not None: + requested_coordinates = podpac.coordinates.merge_dims([altitude_coords, requested_coordinates]) + + instance_time = None + if instance is not None: + try: + # Check if it can be formatted as a datetime before adding to requested coordinates + instance_time = np.datetime64(instance) + except ValueError: + raise ProviderInvalidQueryError("Invalid instance time provided.") + + dataset = {} + native_coordinates = None + + # Allow parameters without case-sensitivity + parameters_lower = [param.lower() for param in requested_parameters] + parameters_filtered = [key for key in self.parameters.keys() if key.lower() in parameters_lower] + + for requested_parameter in parameters_filtered: + layer = self.parameters.get(requested_parameter, None) + if layer is not None: + # Handle defining native coordinates for the query, these should match between each layer + if native_coordinates is None: + coordinates_list = layer.node.find_coordinates() + + if len(coordinates_list) == 0: + raise ProviderInvalidQueryError("Native coordinates not found.") + + native_coordinates = requested_coordinates + + if ( + len(requested_coordinates["lat"].coordinates) > 1 + or len(requested_coordinates["lon"].coordinates) > 1 + ): + native_coordinates = coordinates_list[0].intersect(requested_coordinates) + native_coordinates = native_coordinates.transform(requested_coordinates.crs) + + if native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE: + raise ProviderInvalidQueryError( + "Grid coordinates x_size * y_size must be less than %d" + % settings.MAX_GRID_COORDS_REQUEST_SIZE + ) + + if ( + "forecastOffsetHr" in coordinates_list[0].udims + and "time" in native_coordinates.udims + and instance_time is not None + ): + time_deltas = [] + for time in native_coordinates["time"].coordinates: + offset = np.timedelta64(time - instance_time, self.forecast_time_delta_units) + time_deltas.append(offset) + + # This modifies the time coordinates to account for the new forecast offset hour + new_coordinates = podpac.Coordinates( + [[instance_time], time_deltas], + ["time", "forecastOffsetHr"], + crs=native_coordinates.crs, + ) + native_coordinates = podpac.coordinates.merge_dims( + [native_coordinates.drop("time"), new_coordinates] + ) + + units_data_array = layer.node.eval(native_coordinates) + dataset[requested_parameter] = units_data_array + + if len(dataset) == 0: + raise ProviderInvalidQueryError("No matching parameters found.") + + # Return a coverage json if specified, else return Base64 encoded native response + if output_format == "json" or output_format == "coveragejson": + crs = self.interpret_crs(native_coordinates.crs if native_coordinates else None) + return self.to_coverage_json(self.layers, dataset, crs) + else: + if len(dataset) == 1: + geotiff_bytes = units_data_array.to_format("geotiff").read() + units_data_array = next(iter(dataset.values())) + return { + "fp": base64.b64encode(geotiff_bytes).decode("utf-8"), + "fn": f"{ next(iter(dataset.keys()))}.tif", + } + else: + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for parameter, data_array in dataset.items(): + geotiff_memory_file = data_array.to_format("geotiff") + tiff_filename = f"{parameter}.tif" + zip_file.writestr(tiff_filename, geotiff_memory_file.read()) + + zip_buffer.seek(0) + return {"fp": base64.b64encode(zip_buffer.read()).decode("utf-8"), "fn": f"{self.collection_id}.zip"} + + def position(self, **kwargs): + """Handles requests for the position query type. + + Parameters + ---------- + wkt : shapely.geometry + WKT geometry + + crs : str + The requested CRS for the return coordinates and data. + + Returns + ------- + Any + Coverage data as a dictionary of CoverageJSON or native format. + + Raises + ------ + ProviderInvalidQueryError + Raised if the wkt string is not provided. + ProviderInvalidQueryError + Raised if the wkt string is an unknown type. + """ + lat, lon = [], [] + wkt = kwargs.get("wkt") + crs = kwargs.get("crs") + crs = EdrProvider.interpret_crs(crs) + + if not isinstance(wkt, BaseGeometry): + raise ProviderInvalidQueryError("Invalid wkt provided.") + elif wkt.geom_type == "Point": + lon, lat = EdrProvider.crs_converter([wkt.x], [wkt.y], crs) + else: + raise ProviderInvalidQueryError("Unknown WKT Type (Use Point).") + + requested_coordinates = podpac.Coordinates([lat, lon], dims=["lat", "lon"], crs=crs) + + return self.handle_query(requested_coordinates, **kwargs) + + def cube(self, **kwargs): + """Handles requests for the cube query type. + + Parameters + ---------- + bbox : List[float] + Bbox geometry (for cube queries) + + crs : str + The requested CRS for the return coordinates and data. + + Returns + ------- + Any + Coverage data as a dictionary of CoverageJSON or native format. + + Raises + ------ + ProviderInvalidQueryError + Raised if the bounding box is invalid. + """ + bbox = kwargs.get("bbox") + crs = kwargs.get("crs") + crs = EdrProvider.interpret_crs(crs) + + if not isinstance(bbox, List) or len(bbox) != 4: + raise ProviderInvalidQueryError("Invalid bounding box provided.") + + xmin, ymin, xmax, ymax = bbox + lon, lat = EdrProvider.crs_converter([xmin, xmax], [ymin, ymax], crs) + + requested_coordinates = podpac.Coordinates([lat, lon], dims=["lat", "lon"], crs=crs) + + return self.handle_query(requested_coordinates, **kwargs) + + def area(self, **kwargs): + """Handles requests for the area query type. + + Parameters + ---------- + wkt : shapely.geometry + WKT geometry + + crs : str + The requested CRS for the return coordinates and data. + + Returns + ------- + Any + Coverage data as a dictionary of CoverageJSON or native format. + + Raises + ------ + ProviderInvalidQueryError + Raised if the wkt string is not provided. + ProviderInvalidQueryError + Raised if the wkt string is an unknown type. + """ + lat, lon = [], [] + wkt = kwargs.get("wkt") + crs = kwargs.get("crs") + crs = EdrProvider.interpret_crs(crs) + + if not isinstance(wkt, BaseGeometry): + raise ProviderInvalidQueryError("Invalid wkt provided.") + elif wkt.geom_type == "Polygon": + lon, lat = EdrProvider.crs_converter(wkt.exterior.xy[0], wkt.exterior.xy[1], crs) + else: + raise ProviderInvalidQueryError("Unknown WKT Type (Use Polygon).") + + requested_coordinates = podpac.Coordinates([lat, lon], dims=["lat", "lon"], crs=crs) + + return self.handle_query(requested_coordinates, **kwargs) + + def get_instance(self, instance: str) -> str | None: + """Validate instance identifier. + + Parameters + ---------- + instance : str + The instance identifier to validate. + + Returns + ------- + str + The instance identifier if valid, otherwise returns None. + """ + return instance if instance in self.instances() else None + + def instances(self, **kwargs) -> List[str]: + """The instances in the collection. + + Returns + ------- + List[str] + The instances available in the collection. + """ + instances = set() + for layer in self.layers: + if layer.group == self.collection_id and layer.time_instances is not None: + instances.update(layer.time_instances) + return list(instances) + + def get_fields(self) -> Dict[str, Any]: + """The observed property fields (parameters) in the collection. + + Returns + ------- + Dict[str, Any] + The fields based on the available parameters. + """ + fields = {} + for parameter_key, layer in self.parameters.items(): + units = layer.node.units if layer.node.units is not None else layer.node.style.units + fields[parameter_key] = { + "type": "number", + "title": parameter_key, + "x-ogc-unit": units, + } + return fields + + @staticmethod + def get_altitudes(layers: List[pogc.Layer]) -> List[float]: + """The list of available altitudes for the provided layers. + + Parameters + ---------- + layers : List[pogc.Layer] + The list of layers to determine altitudes for. + + Returns + ------- + List[float] + Available altitudes for the providers layers. + """ + + available_altitudes = set() + for layer in layers: + coordinates_list = layer.node.find_coordinates() + if len(coordinates_list) > 0 and "alt" in coordinates_list[0].udims: + available_altitudes.update(coordinates_list[0]["alt"]) + + return list(available_altitudes) + + @staticmethod + def get_datetimes(layers: List[pogc.Layer]) -> List[str]: + """The list of available times for the provided layers. + + Parameters + ---------- + layers : List[pogc.Layer] + The list of layers to determine datetimes for. + + Returns + ------- + List[str] + Available time strings for the provider layers. + """ + + available_times = set() + for layer in layers: + if hasattr(layer, "valid_times") and layer.valid_times is not tl.Undefined and len(layer.valid_times) > 0: + available_times.update(layer.valid_times) + + return list(available_times) + + @staticmethod + def interpret_crs(crs: str | None) -> str: + """Interpret the CRS id string into a valid PyProj CRS format. + + If None provided or the CRS is unknown, return the default. + + Parameters + ---------- + crs : str + The input CRS id string which needs to be validated/converted. + + Returns + ------- + str + Pyproj CRS string. + """ + default_crs = "urn:ogc:def:crs:OGC:1.3:CRS84" # Pyproj acceptable format + if crs is None or crs.lower() == "crs:84" or crs.lower() not in settings.EDR_CRS.keys(): + return default_crs + + return crs + + @staticmethod + def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]: + """Convert the X, Y data to Longitude, Latitude data with the provided crs. + + Parameters + ---------- + x : Any + X data in any form. + + y: Any + Y data in any form. + + crs : str + The input CRS id string to apply to convert the X,Y data. + + Returns + ------- + Tuple[Any, Any] + The X,Y as Longitude/Latitude data. + """ + if crs.lower() == "epsg:4326": + return (y, x) + + return (x, y) + + @staticmethod + def interpret_altitude_coordinates( + available_altitudes: List[float], altitude_string: str | None, crs: str | None + ) -> podpac.Coordinates | None: + """Interpret the string into altitude coordinates using known formats. + + Specification: + single-level = level + interval-closed = min-level "/" max-level + repeating-interval = "R"number of intervals "/" min-level "/" height to increment by + level-list = level1 "," level2 "," level3 + + Parameters + ---------- + available_altitudes: List[float] + The available altitudes for interpretation. + altitude_string : str | None + The string representation of the requested altitudes. + crs : str + The CRS that the coordinates need to match. + + Returns + ------- + podpac.Coordinates | None + Altitude coordinates for the request or None if conversion fails. + """ + if not altitude_string or len(available_altitudes) == 0: + return None + + try: + altitudes = None + if "/" in altitude_string: + altitudes_split = altitude_string.split("/") + if len(altitudes_split) == 2: + minimum = float(altitudes_split[0]) + maximum = float(altitudes_split[1]) + altitudes = [alt for alt in available_altitudes if minimum <= alt <= maximum] + if len(altitudes_split) == 3: + if altitudes_split[0].startswith("R"): + altitudes = float(altitudes_split[1]) + np.arange(float(altitudes_split[0][1:])) * float( + altitudes_split[2] + ) + else: + altitudes = [float(alt) for alt in altitude_string.split(",")] + except ValueError: + return None + + return podpac.Coordinates([altitudes], dims=["alt"], crs=crs) if altitudes is not None else None + + @staticmethod + def interpret_time_coordinates( + available_times: List[str], time_string: str | None, crs: str | None + ) -> podpac.Coordinates | None: + """Interpret the string into a list of times using known formats. + + Specification: + interval-closed = date-time "/" date-time + interval-open-start = "../" date-time + interval-open-end = date-time "/.." + interval = interval-closed / interval-open-start / interval-open-end + datetime = date-time / interval + + Parameters + ---------- + available_times: List[str] + The available times for interpretation. + time_string : str | None + The string representation of the requested times. + crs : str + The CRS that the coordinates need to match. + + Returns + ------- + podpac.Coordinates | None + Time coordinates for the request or None if conversion fails. + """ + + if not time_string or len(available_times) == 0: + return None + + try: + times = None + np_available_times = [np.datetime64(time) for time in available_times] + if "/" in time_string: + times_split = time_string.split("/") + if len(times_split) == 2: + minimum = times_split[0] + maximum = times_split[1] + if minimum == "..": + times = [time for time in np_available_times if time <= np.datetime64(maximum)] + elif maximum == "..": + times = [time for time in np_available_times if time >= np.datetime64(minimum)] + else: + times = [ + time + for time in np_available_times + if np.datetime64(minimum) <= time <= np.datetime64(maximum) + ] + else: + times = [np.datetime64(time_string)] + except ValueError: + return None + + return podpac.Coordinates([times], dims=["time"], crs=crs) if times is not None else None + + @staticmethod + def to_coverage_json( + layers: List[pogc.Layer], dataset: Dict[str, podpac.UnitsDataArray], crs: str + ) -> Dict[str, Any]: + """Generate a CoverageJSON of the data for the provided parameters. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers which were used in the dataset creation for metadata information. + + dataset : Dict[str, podpac.UnitsDataArray] + Data in an units data array format with matching parameter key. + + crs : str + The CRS associated with the requested coordinates and data response. + + Returns + ------- + Dict[str, Any] + A dictionary of the CoverageJSON data. + """ + + # Determine the bounding coordinates, assume they all are the same + coordinates = next(iter(dataset.values())).coords + x_arr, y_arr = EdrProvider.crs_converter(coordinates["lon"].values, coordinates["lat"].values, crs) + + coverage_json = { + "type": "Coverage", + "domain": { + "type": "Domain", + "domainType": "Grid", + "axes": { + "x": { + "start": x_arr[0], + "stop": x_arr[-1], + "num": len(x_arr), + }, + "y": { + "start": y_arr[0], + "stop": y_arr[-1], + "num": len(y_arr), + }, + }, + "referencing": [ + { + "coordinates": ["lon", "lat"], + "system": {"type": "GeographicCRS", "id": crs}, + }, + { + "coordinates": ["t"], + "system": { + "type": "TemporalRS", + "calendar": "Gregorian", + }, + }, + ], + "parameters": {}, + "ranges": {}, + }, + } + if "time" in coordinates.dims: + coverage_json["domain"]["axes"]["t"] = { + "values": [ + time.astype("datetime64[ms]").astype(datetime).isoformat() + "Z" + for time in coordinates["time"].values + ] + } + + for param, data_array in dataset.items(): + layer = next(layer for layer in layers if layer.identifier == param) + units = layer.node.units if layer.node.units is not None else layer.node.style.units + parameter_definition = { + param: { + "type": "Parameter", + "observedProperty": { + "id": param, + "label": layer.title, + "description": { + "en": layer.abstract, + }, + }, + "unit": { + "label": {"en": units}, + "symbol": { + "value": units, + "type": None, + }, + }, + } + } + coverage_json["domain"]["parameters"].update(parameter_definition) + coverage_json["domain"]["ranges"].update( + { + param: { + "type": "NdArray", + "dataType": "float", + "axisNames": list(data_array.coords.keys()), + "shape": data_array.shape, + "values": list(data_array.values.flatten()), # Row Major Order + } + } + ) + + return coverage_json diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py new file mode 100644 index 0000000..0633c0f --- /dev/null +++ b/ogc/edr/edr_routes.py @@ -0,0 +1,199 @@ +import os +import mimetypes +import json +import base64 +import io +import traitlets as tl +import pygeoapi.plugin +import pygeoapi.api +import pygeoapi.api.environmental_data_retrieval as pygeoedr +from typing import Tuple +from http import HTTPStatus +from copy import deepcopy +from pygeoapi.openapi import get_oas +from ogc import podpac as pogc + +from .edr_config import EdrConfig +from .edr_provider import EdrProvider + + +class EdrRoutes(tl.HasTraits): + """Class responsible for routing EDR requests to the appropriate pygeoapi API method.""" + + base_url = tl.Unicode(default_value="http://127.0.0.1:5000/") + layers = tl.List(trait=tl.Instance(pogc.Layer), default_value=[]) + + @property + def api(self) -> pygeoapi.api.API: + """Property for the API created using a custom configuration. + + Returns + ------- + pygeoapi.api.API + The API which handles all EDR requests. + """ + # Allow specifying GeoTiff or CoverageJSON in the format argument. + # This is a bypass which is needed to get by a conditional check in pygeoapi. + pygeoapi.plugin.PLUGINS["formatter"]["GeoTiff"] = "" + pygeoapi.plugin.PLUGINS["formatter"]["CoverageJSON"] = "" + + config = EdrConfig.get_configuration(self.base_url, self.layers) + open_api = get_oas(config, fail_on_invalid_collection=False) + EdrProvider.set_resources(self.layers) + return pygeoapi.api.API(config=deepcopy(config), openapi=open_api) + + def static_files(self, request: pygeoapi.api.APIRequest, file_path: str) -> Tuple[dict, int, str | bytes]: + """Handle static file requests using the custom static file folder or the pygeoapi default folder. + + Parameters + ---------- + file_path : str + The file path of the requested static resource. + + Returns + ------- + Tuple[dict, int, str | bytes] + Headers, HTTP Status, and Content returned as a tuple to make the server response. + """ + static_path = os.path.join(os.path.dirname(pygeoapi.__file__), "static") + if "templates" in self.api.config["server"]: + static_path = self.api.config["server"]["templates"].get("static", static_path) + file_path = os.path.join(static_path, file_path) + if os.path.isfile(file_path): + mime_type, _ = mimetypes.guess_type(file_path) + mime_type = mime_type or "application/octet-stream" + with open(file_path, "rb") as f: + content = f.read() + return {"Content-Type": mime_type}, HTTPStatus.OK, content + else: + return {}, HTTPStatus.NOT_FOUND, b"File not found" + + def landing_page(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: + """Handle landing page requests for the server. + + Parameters + ---------- + request : pygeoapi.api.APIRequest + The pygeoapi request for the server. + + Returns + ------- + Tuple[dict, int, str | bytes] + Headers, HTTP Status, and Content returned as a tuple to make the server response. + """ + return pygeoapi.api.landing_page(self.api, request) + + def openapi(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: + """Handle API documentation requests for the server. + + Parameters + ---------- + request : pygeoapi.api.APIRequest + The pygeoapi request for the server. + + Returns + ------- + Tuple[dict, int, str | bytes] + Headers, HTTP Status, and Content returned as a tuple to make the server response. + """ + return pygeoapi.api.openapi_(self.api, request) + + def conformance(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: + """Handle conformance requests for the server. + + Parameters + ---------- + request : pygeoapi.api.APIRequest + The pygeoapi request for the server. + + Returns + ------- + Tuple[dict, int, str | bytes] + Headers, HTTP Status, and Content returned as a tuple to make the server response. + """ + return pygeoapi.api.conformance(self.api, request) + + def describe_collections( + self, + request: pygeoapi.api.APIRequest, + collection_id: str | None, + ) -> Tuple[dict, int, str | bytes]: + """Handle describe collection requests for the server. + + Parameters + ---------- + request : pygeoapi.api.APIRequest + The pygeoapi request for the server. + collection_id : str | None + The collection ID to describe. + + Returns + ------- + Tuple[dict, int, str | bytes] + Headers, HTTP Status, and Content returned as a tuple to make the server response. + """ + return pygeoapi.api.describe_collections(self.api, request, collection_id) + + def describe_instances( + self, + request: pygeoapi.api.APIRequest, + collection_id: str, + instance_id: str | None, + ) -> Tuple[dict, int, str | bytes]: + """Handle collection instances requests for the server. + + Parameters + ---------- + request : pygeoapi.api.APIRequest + The pygeoapi request for the server. + collection_id : str + The collection ID for the instances. + instance_id: str + The instance ID to describe. + + Returns + ------- + Tuple[dict, int, str | bytes] + Headers, HTTP Status, and Content returned as a tuple to make the server response. + """ + + return pygeoedr.get_collection_edr_instances(self.api, request, collection_id, instance_id=instance_id) + + def collection_query( + self, + request: pygeoapi.api.APIRequest, + collection_id: str, + instance_id: str | None, + query_type: str, + ) -> Tuple[dict, int, str | bytes]: + """Handle collection and instance query requests for the server. + + Parameters + ---------- + request : pygeoapi.api.APIRequest + The pygeoapi request for the server. + query_type: str + The query type for the request. + collection_id : str + The collection ID for the query. + instance_id: str + The instance ID for the query. + + Returns + ------- + Tuple[dict, int, str | bytes] + Headers, HTTP Status, and Content returned as a tuple to make the server response. + """ + headers, http_status, content = pygeoedr.get_collection_edr_query( + self.api, request, collection_id, instance_id, query_type=query_type, location_id=None + ) + + content = json.loads(content) + if "fn" in content and "fp" in content: + # Return the file name in the header and the content as only the binary data + filename = content["fn"] + headers["Content-Disposition"] = f"attachment; filename={filename}" + # Decode the content string which is the Base64 representation of the data + content = io.BytesIO(base64.b64decode(content["fp"])) + + return headers, http_status, content diff --git a/ogc/edr/static/img/favicon.ico b/ogc/edr/static/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1e20f9cc660e2d0da56b1279ef867996dea36f1b GIT binary patch literal 1150 zcmaizO-~b16oyY*KmtYzM2Jz6Qoc&D&J=6uOiOExd?<-uQ4^vDCDAe%HSUaVaG`PG z!bDUsZcJSH6LjOkf8gIS!RMU;BwFK4o;i2Uyywih_nvDO<4z`xXUbMu%=*kM1Gw-+ zevBF4dO9P*_kTP#0@q+19>RTC0DBuaO(Ql|(66|yXcW^e?YqFOV+-5w*Rb)8z-hx~ zp^NWfF2;&YVCUgFEWr>g!Z7TEThZ9LfK@Kh1*%gVf(9sm3a-3gb-hznZ=~e;&U(Wg zl@E$fE}8V2PZbzDqJ{A}D9blp@vK<#Q=ae7`MzHxN5Sl}y}lFvYXFUE?2%TNEcC+7 zuaA$$Jet@Y#`EPibh9wBZ8dins*}ZX|A)Iy3f)fqr~h2PAJ4OK)_nr~ zNl=b4C_r`Vb6WqNg>d~g8{xXkr~Nw*{;S)r9UPmE6{h?_@+e20z08fd<<@6QlhSJ5 zyZgsjz9#+Sc$=$EV(a6OQM3B+Dom{}XQkiMKVh%a+s*rzKHt;7;XZ4fcKdhF{B?dZ zoQ40LpA5PKpwOn5zJ^Lvl+*-iCZ#VGa{&p#!X literal 0 HcmV?d00001 diff --git a/ogc/edr/static/img/logo.png b/ogc/edr/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4b8ebad83bd1b365c87bd2821f54be6cdc6be701 GIT binary patch literal 33585 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 !!1v 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 List[pogc.Layer]: + """List of test layers. + + Returns + ------- + List[pogc.Layer] + The test layers. + """ + return [layer1, layer2] + + +@pytest.fixture() +def single_layer_cube_args() -> Dict[str, Any]: + """Dictionary of valid request arguments that align to a single test layer cube request. + + Returns + ------- + Dict[str, Any] + Valid cube request arguments for a single test layer. + """ + + return { + "f": "json", + "bbox": "-180, -90, 180, 90", + "datetime": str(time[0]), + "parameter-name": [layer1.identifier], + } + + +@pytest.fixture() +def single_layer_cube_args_internal() -> Dict[str, Any]: + """Dictionary of valid arguments that align to a single test layer request with internal pygeoapi keys. + + Returns + ------- + Dict[str, Any] + Valid internal cube arguments for a single test layer. + """ + + return { + "format_": "json", + "bbox": [-180, -90, 180, 90], + "datetime_": str(time[0]), + "select_properties": [layer1.identifier], + } diff --git a/ogc/edr/test/test_edr_config.py b/ogc/edr/test/test_edr_config.py new file mode 100644 index 0000000..0a1a710 --- /dev/null +++ b/ogc/edr/test/test_edr_config.py @@ -0,0 +1,64 @@ +from typing import Dict, List, Any +from ogc import podpac as pogc +from ogc.edr.edr_config import EdrConfig + + +def test_edr_default_configuration_has_required_keys(): + """Test the EDR default configuration loads the required keys.""" + configuration = EdrConfig.get_configuration("/ogc", []) + + assert configuration.keys() == {"server", "logging", "metadata", "resources"} + + +def test_edr_configuration_contains_layer_groups(layers: List[pogc.Layer]): + """Test the EDR configuration contains the layer groups. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + group_keys = {layer.group for layer in layers} + configuration = EdrConfig.get_configuration("/ogc", layers) + + assert len(group_keys) > 0 + for key in group_keys: + assert configuration["resources"].get(key, None) is not None + + +def test_edr_configuration_contains_spatial_extent(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]): + """Test the EDR configuration contains the spatial extent. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args : Dict[str, Any] + Single layer arguments for validation checking provided by a test fixture. + """ + group_keys = {layer.group for layer in layers} + configuration = EdrConfig.get_configuration("/ogc", layers) + + assert len(group_keys) > 0 + for key in group_keys: + assert configuration["resources"][key]["extents"]["spatial"]["bbox"] == list( + map(float, single_layer_cube_args["bbox"].split(",")) + ) + + +def test_edr_configuration_contains_custom_provider(layers: List[pogc.Layer]): + """Test the EDR configuration contains the custom provider. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + group_keys = {layer.group for layer in layers} + configuration = EdrConfig.get_configuration("/ogc", layers) + + assert len(group_keys) > 0 + for key in group_keys: + assert configuration["resources"][key]["providers"][0]["type"] == "edr" + assert configuration["resources"][key]["providers"][0]["name"] == "ogc.edr.edr_provider.EdrProvider" diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py new file mode 100644 index 0000000..2549b37 --- /dev/null +++ b/ogc/edr/test/test_edr_provider.py @@ -0,0 +1,554 @@ +import pytest +import numpy as np +import zipfile +import base64 +import io +from shapely import Point, Polygon +from typing import Dict, List, Any +from ogc import podpac as pogc +from ogc.edr.edr_provider import EdrProvider +from pygeoapi.provider.base import ProviderInvalidQueryError + +# Define the provider definition which is typically handled by pygeoapi +provider_definition = { + "type": "edr", + "default": True, + "name": "ogc.edr.edr_provider.EdrProvider", + "data": "Layers", + "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84", "http://www.opengis.net/def/crs/EPSG/0/4326"], + "format": {"name": "GeoJSON", "mimetype": "application/json"}, +} + + +def test_edr_provider_set_resources(layers: List[pogc.Layer]): + """Test the set_resources method of the EDR Provider class. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + identifiers = [layer.identifier for layer in layers] + + EdrProvider.set_resources(layers) + + assert len(EdrProvider.layers) == len(layers) + assert all(layer.identifier in identifiers for layer in EdrProvider.layers) + + +def test_edr_provider_get_instance_valid_id(layers: List[pogc.Layer]): + """Test the get_instance method of the EDR Provider class with a valid id. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + assert provider.get_instance(str(list(layers[0].time_instances)[0])) == str(list(layers[0].time_instances)[0]) + + +def test_edr_provider_get_instance_invalid_id(layers: List[pogc.Layer]): + """Test the get_instance method of the EDR Provider class with an invalid id. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + assert provider.get_instance("invalid") is None + + +def test_edr_provider_parameter_keys(layers: List[pogc.Layer]): + """Test the parameters property of the EDR Provider class. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + identifiers = [layer.identifier for layer in layers] + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + parameters = provider.parameters + + assert len(list(parameters.keys())) == len(layers) + assert all(identifier in identifiers for identifier in parameters.keys()) + + +def test_edr_provider_instances(layers: List[pogc.Layer]): + """Test the instances method of the EDR Provider class. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + instance_sets = [layer.time_instances for layer in layers] + time_instances = set().union(*instance_sets) + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + instances = provider.instances() + + assert len(instances) == len(time_instances) + assert instances == [str(t) for t in time_instances] + + +def test_edr_provider_get_fields(layers: List[pogc.Layer]): + """Test the get fields method of the EDR Provider class. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + identifiers = [layer.identifier for layer in layers] + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + fields = provider.get_fields() + + assert len(fields.keys()) == len(layers) + assert all(identifier in identifiers for identifier in fields.keys()) + + +def test_edr_provider_position_request_valid_wkt( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the position method of the EDR Provider class with a valid WKT. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + del args["bbox"] + args["wkt"] = Point(5.2, 52.1) + parameter_name = single_layer_cube_args_internal["select_properties"][0] + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + response = provider.position(**args) + + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set( + layers[0].node.find_coordinates()[0].dims + ) + assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( + response["domain"]["ranges"][parameter_name]["values"] + ) + + +def test_edr_provider_position_request_invalid_wkt( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the position method of the EDR Provider class with an invalid WKT. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + del args["bbox"] + args["wkt"] = "invalid" + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + with pytest.raises(ProviderInvalidQueryError): + provider.position(**args) + + +def test_edr_provider_position_request_invalid_property( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the position method of the EDR Provider class with an invalid property. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + args["select_properties"] = "invalid" + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + with pytest.raises(ProviderInvalidQueryError): + provider.position(**args) + + +def test_edr_provider_cube_request_valid_bbox( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the cube method of the EDR Provider class with a valid bounding box. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + parameter_name = single_layer_cube_args_internal["select_properties"][0] + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + response = provider.cube(**args) + + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set( + layers[0].node.find_coordinates()[0].dims + ) + assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( + response["domain"]["ranges"][parameter_name]["values"] + ) + + +def test_edr_provider_cube_request_invalid_bbox( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the cube method of the EDR Provider class with an invalid bounding box. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + args["bbox"] = "invalid" + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + with pytest.raises(ProviderInvalidQueryError): + provider.cube(**args) + + +def test_edr_provider_cube_request_invalid_altitude( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the cube method of the EDR Provider class with an invalid altitude. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + args["z"] = "invalid" + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + with pytest.raises(ProviderInvalidQueryError): + provider.position(**args) + + +def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any]): + """Test the area method of the EDR Provider class with a valid wkt. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + del args["bbox"] + args["wkt"] = Polygon(((-180.0, -90.0), (-180.0, 90.0), (180.0, -90.0), (180.0, 90.0))) + parameter_name = single_layer_cube_args_internal["select_properties"][0] + x_array, y_array = args["wkt"].exterior.xy + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + response = provider.area(**args) + + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set( + layers[0].node.find_coordinates()[0].dims + ) + assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( + response["domain"]["ranges"][parameter_name]["values"] + ) + + +def test_edr_provider_area_request_invalid_wkt( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the area method of the EDR Provider class with an invalid wkt. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + del args["bbox"] + args["wkt"] = "invalid" + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + with pytest.raises(ProviderInvalidQueryError): + provider.area(**args) + + +def test_edr_provider_cube_request_invalid_datetime( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the area method of the EDR Provider class with an invalid datetime. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + args["datetime_"] = "10_24/2025" + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + with pytest.raises(ProviderInvalidQueryError): + provider.cube(**args) + + +def test_edr_provider_cube_request_valid_geotiff_format( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the query method of the EDR Provider class with a valid geotiff request. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + args["format_"] = "geotiff" + parameter_name = single_layer_cube_args_internal["select_properties"][0] + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + response = provider.cube(**args) + + assert response["fn"] == f"{parameter_name}.tif" + assert len(base64.b64decode(response["fp"])) > 0 + + +def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the query method of the EDR Provider class with a valid geotiff request. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + args = single_layer_cube_args_internal + args["format_"] = "geotiff" + + # Set the properties argument as multiple layers from the same group/collection + group = layers[0].group + selected_layers = [layer.identifier for layer in layers if layer.group == group] + args["select_properties"] = selected_layers + + EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=provider_definition) + + response = provider.cube(**args) + buffer = io.BytesIO(base64.b64decode(response["fp"])) + + assert response["fn"] == f"{group}.zip" + assert zipfile.is_zipfile(buffer) + with zipfile.ZipFile(buffer, "r") as zf: + namelist = zf.namelist() + assert len(namelist) > 0 + assert all(f"{layer}.tif" in namelist for layer in selected_layers) + + +def test_edr_provider_datetime_single_value(): + """Test the datetime interpreter method of the EDR Provider class with a single datetime value.""" + + time_string = "2025-10-24" + available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] + expected_times = [np.datetime64(available_times[0])] + + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is not None + np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) + + +def test_edr_provider_datetime_range_closed(): + """Test the datetime interpreter method of the EDR Provider class with a closed datetime range.""" + + time_string = "2025-10-24/2025-10-26" + available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] + expected_times = [np.datetime64(time) for time in available_times[0:3]] + + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is not None + np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) + + +def test_edr_provider_datetime_open_start(): + """Test the datetime interpreter method of the EDR Provider class with a open datetime start.""" + + time_string = "../2025-10-27" + available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] + expected_times = [np.datetime64(time) for time in available_times[0:4]] + + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is not None + np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) + + +def test_edr_provider_datetime_open_end(): + """Test the datetime interpreter method of the EDR Provider class with a open datetime end.""" + + time_string = "2025-10-25/.." + available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] + expected_times = [np.datetime64(time) for time in available_times[1:]] + + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is not None + np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) + + +def test_edr_provider_datetime_invalid_string(): + """Test the datetime interpreter method of the EDR Provider class with an invalid string.""" + + time_string = "2025-10-25/../../.." + available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] + + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is None + + +def test_edr_provider_altitude_single_value(): + """Test the altitude interpreter method of the EDR Provider class with a single datetime value.""" + + altitude_string = "10" + available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] + expected_altitudes = [10.0] + + altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is not None + np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes) + + +def test_edr_provider_altitude_range_closed(): + """Test the altitude interpreter method of the EDR Provider class with a closed datetime range.""" + + altitude_string = "10/20" + available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] + expected_altitudes = [10.0, 15.0, 20.0] + + altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is not None + np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes) + + +def test_edr_provider_altitude_repeating_interval(): + """Test the altitude interpreter method of the EDR Provider class with a repeating interval.""" + + altitude_string = "R2/5/5" + available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] + expected_altitudes = [5.0, 10.0] + + altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is not None + np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes) + + +def test_edr_provider_altitude_list(): + """Test the altitude interpreter method of the EDR Provider class with a list.""" + + altitude_string = "5,10,15" + available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] + expected_altitudes = [5.0, 10.0, 15.0] + + altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is not None + np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes) + + +def test_edr_provider_altitude_invalid_string(): + """Test the altitude interpreter method of the EDR Provider class with an invalid string.""" + + altitude_string = "../20" + available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] + + altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is None + + +def test_edr_provider_crs_interpreter_default_value(): + """Test the CRS interpretation returns a default value when the argument is None.""" + + assert EdrProvider.interpret_crs(None) == "urn:ogc:def:crs:OGC:1.3:CRS84" + + +def test_edr_provider_crs_interpreter_valid_value(): + """Test the CRS interpretation returns a valid value when the argument is acceptable.""" + + assert EdrProvider.interpret_crs("epsg:4326") == "epsg:4326" + + +def test_edr_provider_crs_interpreter_invalid_value(): + """Test the CRS interpretation returns default when the argument is unacceptable.""" + + assert EdrProvider.interpret_crs("epsp:4444") == "urn:ogc:def:crs:OGC:1.3:CRS84" + + +def test_edr_provider_crs_converter(): + """Test the CRS converter returns latitude and longitude data properly.""" + x = [1, 2, 3] + y = [3, 4, 5] + + # EPSG:4326 specifies x (latitude) and y (longitude) + lon = y + lat = x + + assert EdrProvider.crs_converter(x, y, "epsg:4326") == (lon, lat) diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py new file mode 100644 index 0000000..45d13ab --- /dev/null +++ b/ogc/edr/test/test_edr_routes.py @@ -0,0 +1,346 @@ +import json +import numpy as np +from pygeoapi.api import APIRequest +from http import HTTPStatus +from typing import Dict, List, Any +from werkzeug.test import create_environ +from werkzeug.wrappers import Request +from werkzeug.datastructures import ImmutableMultiDict +from ogc import podpac as pogc +from ogc.edr.edr_routes import EdrRoutes + + +def mock_request(request_args: Dict[str, Any] = {}) -> APIRequest: + """Creates a mock request for EDR routes to use. + + + Parameters + ---------- + request_args: Dict[str, Any], optional + The dictionary for query string arguments. + + Returns + ------- + APIRequest + Mock API request for route testing. + """ + environ = create_environ(base_url="http://127.0.0.1:5000/ogc/") + request = Request(environ) + request.args = ImmutableMultiDict(request_args.items()) + return APIRequest(request, ["en"]) + + +def test_edr_routes_static_files_valid_path(): + """Test the EDR static routes with a valid static file path.""" + request = mock_request() + edr_routes = EdrRoutes(layers=[]) + + headers, status, _ = edr_routes.static_files(request, "img/logo.png") + + assert status == HTTPStatus.OK + assert headers["Content-Type"] == "image/png" + + +def test_edr_routes_static_files_invalid_path(): + """Test the EDR static routes with an invalid static file path.""" + request = mock_request() + edr_routes = EdrRoutes(layers=[]) + + _, status, _ = edr_routes.static_files(request, "invalid") + + assert status == HTTPStatus.NOT_FOUND + + +def test_edr_routes_landing_page(): + """Test the EDR landing page for a response.""" + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=[]) + + headers, status, _ = edr_routes.landing_page(request) + + assert status == HTTPStatus.OK + assert headers["Content-Type"] == "application/json" + + +def test_edr_routes_landing_page_html(): + """Test the EDR landing page for a response.""" + request = mock_request({"f": "html"}) + edr_routes = EdrRoutes(layers=[]) + + headers, status, _ = edr_routes.landing_page(request) + + assert status == HTTPStatus.OK + assert headers["Content-Type"] == "text/html" + + +def test_edr_routes_conformance(layers: List[pogc.Layer]): + """Test the EDR conformance for a response. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=layers) + + _, status, content = edr_routes.conformance(request) + response = json.loads(content) + + assert status == HTTPStatus.OK + assert len(response["conformsTo"]) > 0 + assert "http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/core" in response["conformsTo"] + + +def test_edr_routes_api(): + """Test the EDR api documentation for a response.""" + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=[]) + + _, status, content = edr_routes.openapi(request) + response = json.loads(content) + + assert status == HTTPStatus.OK + assert response["paths"]["/"] + assert response["paths"]["/openapi"] + assert response["paths"]["/conformance"] + assert response["paths"]["/collections"] + + +def test_edr_routes_describe_collections(layers: List[pogc.Layer]): + """Test the EDR collections description for a response. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=layers) + collections = {layer.group for layer in layers} + + _, status, content = edr_routes.describe_collections(request, collection_id=None) + response = json.loads(content) + + assert status == HTTPStatus.OK + assert len(response["collections"]) == len(collections) + + response_collection_ids = [collection["id"] for collection in response["collections"]] + assert response_collection_ids == list(collections) + + +def test_edr_routes_describe_collection(layers: List[pogc.Layer]): + """Test the EDR collection description for a response. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=layers) + collection_id = layers[0].group + collection_layers = [layer for layer in layers if layer.group == collection_id] + + _, status, content = edr_routes.describe_collections(request, collection_id=collection_id) + response = json.loads(content) + + assert status == HTTPStatus.OK + assert response["id"] == collection_id + assert list(response["parameter_names"].keys()) == [layer.identifier for layer in collection_layers] + assert list(response["data_queries"].keys()) == ["position", "cube", "area", "instances"] + + +def test_edr_routes_describe_instances(layers: List[pogc.Layer]): + """Test the EDR instances description for a response. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=layers) + collection_id = layers[0].group + time_instances = set() + for layer in layers: + if layer.group == collection_id: + time_instances.update(layer.time_instances) + + _, status, content = edr_routes.describe_instances(request, collection_id=collection_id, instance_id=None) + response = json.loads(content) + + assert status == HTTPStatus.OK + assert len(response["instances"]) == len(time_instances) + + response_time_instances_ids = [instance["id"] for instance in response["instances"]] + assert response_time_instances_ids == list(time_instances) + + +def test_edr_routes_describe_instance(layers: List[pogc.Layer]): + """Test the EDR instance description for a response. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=layers) + collection_id = layers[0].group + instance_id = list(layers[0].time_instances)[0] + + _, status, content = edr_routes.describe_instances(request, collection_id=collection_id, instance_id=instance_id) + response = json.loads(content) + + assert status == HTTPStatus.OK + assert response["id"] == instance_id + assert list(response["data_queries"].keys()) == ["position", "cube", "area"] + + +def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]): + """Test the EDR collection query for a reponse. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args : Dict[str, Any] + Single layer arguments provided by a test fixture. + """ + collection_id = layers[0].group + instance_id = list(layers[0].time_instances)[0] + parameter_name = single_layer_cube_args["parameter-name"][0] + + single_layer_cube_args["f"] = "json" + + request = mock_request(single_layer_cube_args) + + edr_routes = EdrRoutes(layers=layers) + + _, status, content = edr_routes.collection_query( + request, + collection_id=collection_id, + instance_id=instance_id, + query_type="cube", + ) + + assert status == HTTPStatus.OK + + assert content["domain"]["ranges"][parameter_name]["axisNames"] == list(layers[0].node.find_coordinates()[0].dims) + assert np.prod(np.array(content["domain"]["ranges"][parameter_name]["shape"])) == len( + content["domain"]["ranges"][parameter_name]["values"] + ) + + +def test_edr_routes_collection_query_geotiff_format(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]): + """Test the EDR collection query for a GeoTiff formatted reponse. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args : Dict[str, Any] + Single layer arguments provided by a test fixture. + """ + collection_id = layers[0].group + instance_id = list(layers[0].time_instances)[0] + + single_layer_cube_args["f"] = "geotiff" + + request = mock_request(single_layer_cube_args) + + edr_routes = EdrRoutes(layers=layers) + + headers, status, content = edr_routes.collection_query( + request, + collection_id=collection_id, + instance_id=instance_id, + query_type="cube", + ) + + assert status == HTTPStatus.OK + assert headers["Content-Disposition"] == f"attachment; filename={layers[0].identifier}.tif" + + +def test_edr_routes_collection_query_invalid_type(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]): + """Test the EDR collection query with an invalid query type. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args : Dict[str, Any] + Single layer arguments provided by a test fixture. + """ + collection_id = layers[0].group + instance_id = list(layers[0].time_instances)[0] + + request = mock_request(single_layer_cube_args) + edr_routes = EdrRoutes(layers=layers) + + _, status, _ = edr_routes.collection_query( + request, + collection_id=collection_id, + instance_id=instance_id, + query_type="corridor", + ) + + assert status == HTTPStatus.BAD_REQUEST + + +def test_edr_routes_collection_query_invalid_bbox(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]): + """Test the EDR collection query with an invalid bounding box. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args : Dict[str, Any] + Single layer arguments provided by a test fixture. + """ + single_layer_cube_args["bbox"] = "invalid" + + request = mock_request(single_layer_cube_args) + edr_routes = EdrRoutes(layers=layers) + + _, status, _ = edr_routes.collection_query( + request, + collection_id=layers[0].group, + instance_id=str(list(layers[0].time_instances)[0]), + query_type="cube", + ) + + assert status == HTTPStatus.BAD_REQUEST + + +def test_edr_routes_collection_query_missing_parameter( + layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any] +): + """Test the EDR colletion query with a missing parameter. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args : Dict[str, Any] + Single layer arguments provided by a test fixture. + """ + del single_layer_cube_args["parameter-name"] + + request = mock_request(single_layer_cube_args) + edr_routes = EdrRoutes(layers=layers) + + _, status, _ = edr_routes.collection_query( + request, + collection_id=layers[0].group, + instance_id=str(list(layers[0].time_instances)[0]), + query_type="cube", + ) + + assert status == HTTPStatus.BAD_REQUEST diff --git a/ogc/servers.py b/ogc/servers.py index dd3eab0..3e324b1 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -11,8 +11,11 @@ import six import traceback import logging +from typing import Callable +from werkzeug.datastructures import ImmutableMultiDict from ogc.ogc_common import WCSException +from pygeoapi.api import APIRequest logger = logging.getLogger(__name__) @@ -104,6 +107,77 @@ def method(): self.add_url_rule(endpoint, view_func=method, methods=["GET", "POST"]) # add render method as flask route setattr(self, method_name, method) # bind route function call to instance method + # Set up the EDR endpoints for the server + self.add_url_rule( + f"{endpoint}/edr", + endpoint=f"{endpoint}_landing_page", + view_func=self.edr_render(ogc.edr_routes.landing_page), + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/static/", + endpoint=f"{endpoint}_static_files", + view_func=self.edr_render(ogc.edr_routes.static_files), + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/api", + endpoint=f"{endpoint}_api", + view_func=self.edr_render(ogc.edr_routes.openapi), + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/openapi", + endpoint=f"{endpoint}_openapi", + view_func=self.edr_render(ogc.edr_routes.openapi), + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/conformance", + endpoint=f"{endpoint}_conformance", + view_func=self.edr_render(ogc.edr_routes.conformance), + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/collections", + endpoint=f"{endpoint}_collections", + view_func=self.edr_render(ogc.edr_routes.describe_collections), + defaults={"collection_id": None}, + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/collections/", + endpoint=f"{endpoint}_collection", + view_func=self.edr_render(ogc.edr_routes.describe_collections), + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/collections//instances", + endpoint=f"{endpoint}_instances", + view_func=self.edr_render(ogc.edr_routes.describe_instances), + defaults={"instance_id": None}, + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/collections//instances/", + endpoint=f"{endpoint}_instance", + view_func=self.edr_render(ogc.edr_routes.describe_instances), + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/collections//", + endpoint=f"{endpoint}_collection_query", + view_func=self.edr_render(ogc.edr_routes.collection_query), + defaults={"instance_id": None}, + methods=["GET"], + ) + self.add_url_rule( + f"{endpoint}/edr/collections//instances//", + endpoint=f"{endpoint}_instance_query", + view_func=self.edr_render(ogc.edr_routes.collection_query), + methods=["GET"], + ) + def ogc_render(self, ogc_idx): logger.info("OGC server.ogc_render %i", ogc_idx) if request.method != "GET": @@ -164,6 +238,71 @@ def ogc_render(self, ogc_idx): ee = WCSException() return respond_xml(ee.to_xml(), status=500) + def edr_render(self, callable: Callable) -> Callable: + """Function which returns a wrapper for the provided callable. + Filters arguments and handles any necessary exceptions. + + Parameters + ---------- + callable : Callable + The callable request handler to be wrapped. + + Returns + ------- + Callable + Wrapped callable with exception handling and argument filtering. + """ + + def wrapper(*args, **kwargs) -> Response: + """Wrapper for the request handler. + + Returns + ------- + Response + The response to the request from the callable or a 500 response on error. + """ + logger.info("OGC server.edr_render") + if request.method != "GET": + return respond_xml("

    Only GET supported

    ", status=405) + try: + # We'll filter out any characters from URl parameter values that + # are not in the allowlist. + # Note the parameter with key "params" has a serialized JSON value, + # so we allow braces, brackets, and quotes. + # Allowed chars are: + # -, A through Z, a through z, 0 through 9, spaces + # and the characters + . , _ / : * { } ( ) [ ] " + allowed_chars = r'-A-Za-z0-9 +.,_/:*\{\}\(\)\[\]"' + match_one_unallowed_char = "[^%s]" % allowed_chars + filtered_args = { + # Find every unallowed char in the value and replace it + # with nothing (remove it). + k: re.sub(match_one_unallowed_char, "", str(v)) + for (k, v) in request.args.items() + } + # Replace the arguments with the filtered option + request.args = ImmutableMultiDict(filtered_args) + pygeoapi_request = APIRequest.from_flask(request, ["en"]) + # Build the flask response + headers, status, content = callable(pygeoapi_request, *args, **kwargs) + response = make_response(content, status) + if headers: + response.headers = headers + # Check Content Disposition for attachment downloads + match = re.search(r'filename="?([^"]+)"?', headers.get("Content-Disposition", "")) + if match: + filename = match.group(1) + as_attach = True if filename.endswith("zip") or filename.endswith("tif") else False + return send_file(content, as_attachment=as_attach, download_name=filename) + else: + return response + except Exception as e: + logger.error("OGC: server.edr_render Exception: %s", str(e), exc_info=True) + ee = WCSException() + return respond_xml(ee.to_xml(), status=500) + + return wrapper + class FastAPI(object): """ diff --git a/ogc/settings.py b/ogc/settings.py index 1bb3a3b..e17b106 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -28,6 +28,10 @@ "epsg:4326": {"minx": -90, "miny": -180, "maxx": 90, "maxy": 180}, "crs:84": {"minx": -180, "miny": -90, "maxx": 180, "maxy": 90}, } +EDR_CRS = { + "epsg:4326": {"minx": -90.0, "miny": -180.0, "maxx": 90.0, "maxy": 180.0}, + "crs:84": {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, +} # WMS Capabilities timestamp format USE_TIMES_LIST = False @@ -55,3 +59,9 @@ CLASSIFICATION = "NONE" # not used any more seemingly PUBLIC_CONSTRAINT_STRING = "PUBLIC" CONSTRAINTS = PUBLIC_CONSTRAINT_STRING + +# get EDR configuration file path +try: + EDR_CONFIGURATION_PATH = os.environ["EDR_CONFIGURATION_PATH"] +except Exception: + EDR_CONFIGURATION_PATH = None diff --git a/ogc/test/test_core.py b/ogc/test/test_core.py index bd0ac24..b7ff4bb 100644 --- a/ogc/test/test_core.py +++ b/ogc/test/test_core.py @@ -7,9 +7,9 @@ from ogc.wcs_response_1_0_0 import Coverage # Create some podpac nodes -data = np.ones((10, 10)) lat = np.linspace(90, -90, 11) lon = np.linspace(-180, 180, 21) +data = np.random.default_rng(1).random((11, 21)) coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"]) node1 = podpac.data.Array(source=data, coordinates=coords) node2 = podpac.data.Array(source=data, coordinates=coords) @@ -20,6 +20,7 @@ identifier="layer1", title="Layer 1", abstract="Layer1 Data", + group="Layers", ) layer2 = pogc.Layer( @@ -27,6 +28,7 @@ identifier="layer2", title="Layer 2", abstract="Layer2 Data", + group="Layers", ) diff --git a/ogc/test/test_servers.py b/ogc/test/test_servers.py index 00c75f3..27f620c 100644 --- a/ogc/test/test_servers.py +++ b/ogc/test/test_servers.py @@ -18,9 +18,9 @@ def client(): A test client for the Flask server. """ # Create some test OGC layers - data = np.ones((10, 10)) lat = np.linspace(90, -90, 11) lon = np.linspace(-180, 180, 21) + data = np.random.default_rng(1).random((11, 21)) coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"]) node1 = podpac.data.Array(source=data, coordinates=coords) @@ -29,6 +29,7 @@ def client(): identifier="layer1", title="Layer 1", abstract="Layer 1 Data", + group="Layers", ) # Create an OGC instance with the test layers ogc = core.OGC(layers=[layer1]) diff --git a/pyproject.toml b/pyproject.toml index 0b9a391..2cf8ea1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,13 +29,18 @@ classifiers = [ # that you indicate whether you support Python 2, Python 3 or both. "Programming Language :: Python 3", ] - +requires-python = ">= 3.10" dependencies = [ 'podpac[datatype]', 'flask', 'lxml', 'webob', 'traitlets', + 'pygeoapi', + 'shapely', + 'numpy', + 'traitlets', + 'werkzeug', ] [project.optional-dependencies] @@ -48,6 +53,7 @@ dev = [ "sphinx-autobuild", # TESTING "pytest", + "pytest-cov", "pytest-mock", "pytest-html", "pytest-remotedata", @@ -62,6 +68,9 @@ dev = [ [tool.setuptools.packages.find] where = ["."] +[tool.setuptools.package-data] +ogc = ["edr/static/**/*", "edr/template/static/**/*", "edr/config/*"] + [tool.pytest.ini_options] testpaths = ["ogc"] diff --git a/sonar-project.properties b/sonar-project.properties index 43c8211..e0b3003 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,5 +2,5 @@ sonar.projectKey=creare-com_ogc sonar.organization=creare-com sonar.qualitygate.wait=true -sonar.python.version=3.8 +sonar.python.version=3.11 sonar.python.coverage.reportPaths=coverage.xml \ No newline at end of file From 4822f13181972a690c1762cee3eb3ecdeeb76920 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 8 Dec 2025 10:25:39 -0500 Subject: [PATCH 02/10] Improve EDR instance generation and minor cleanup --- example/app.py | 9 ++- ogc/__init__.py | 1 - ogc/edr/edr_provider.py | 106 +++++++++++++++++------------- ogc/edr/edr_routes.py | 6 +- ogc/edr/test/conftest.py | 11 ++-- ogc/edr/test/test_edr_provider.py | 31 +++++---- ogc/edr/test/test_edr_routes.py | 24 +++---- ogc/podpac.py | 23 ++++++- 8 files changed, 122 insertions(+), 89 deletions(-) diff --git a/example/app.py b/example/app.py index c1a4508..8394228 100755 --- a/example/app.py +++ b/example/app.py @@ -13,6 +13,9 @@ import podpac import numpy as np +# Setup new dimension +podpac.core.coordinates.utils.add_valid_dimension("forecastOffsetHr") + # create some podpac nodes data = np.random.default_rng(1).random((11, 21)) lat = np.linspace(90, -90, 11) @@ -24,8 +27,9 @@ node2 = podpac.data.Array(source=data2, coordinates=coords) time = np.array(["2025-10-24T12:00:00"], dtype="datetime64") -coords = podpac.Coordinates([lat, lon, time], dims=["lat", "lon", "time"]) -data3 = np.random.default_rng(1).random((11, 21, 1)) +offsets = [np.timedelta64(0, "h")] +coords = podpac.Coordinates([lat, lon, time, offsets], dims=["lat", "lon", "time", "forecastOffsetHr"]) +data3 = np.random.default_rng(1).random((11, 21, 1, 1)) node3 = podpac.data.Array(source=data3, coordinates=coords) # use podpac nodes to create some OGC layers @@ -52,7 +56,6 @@ title="OGC/POPAC layer containing random data with time instances available.", abstract="This layer contains some random data with time instances available.", group="Layers", - time_instances={str(time[0])}, valid_times=[dt.astype(datetime) for dt in time], ) diff --git a/ogc/__init__.py b/ogc/__init__.py index ae89a49..b8aa1a1 100755 --- a/ogc/__init__.py +++ b/ogc/__init__.py @@ -78,7 +78,6 @@ class Layer(tl.HasTraits): default_value=tl.Undefined, allow_none=True, ) - time_instances = tl.Set(tl.Unicode) # Available time instances all_times_valid = tl.Bool(default_value=False) legend_graphic_width_inches = tl.Float(default_value=0.7) # inches diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 9ded426..b71daba 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -18,7 +18,6 @@ class EdrProvider(BaseEDRProvider): """Custom provider to be used with layer data sources.""" layers = [] - forecast_time_delta_units = tl.Unicode(default_value="h") def __init__(self, provider_def: Dict[str, Any]): """Construct the provider using the provider definition. @@ -142,7 +141,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): raise ProviderInvalidQueryError("Invalid instance time provided.") dataset = {} - native_coordinates = None + requested_native_coordinates = None # Allow parameters without case-sensitivity parameters_lower = [param.lower() for param in requested_parameters] @@ -152,48 +151,23 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): layer = self.parameters.get(requested_parameter, None) if layer is not None: # Handle defining native coordinates for the query, these should match between each layer - if native_coordinates is None: + if requested_native_coordinates is None: coordinates_list = layer.node.find_coordinates() if len(coordinates_list) == 0: raise ProviderInvalidQueryError("Native coordinates not found.") - native_coordinates = requested_coordinates + requested_native_coordinates = self.get_native_coordinates( + requested_coordinates, coordinates_list[0], instance_time + ) - if ( - len(requested_coordinates["lat"].coordinates) > 1 - or len(requested_coordinates["lon"].coordinates) > 1 - ): - native_coordinates = coordinates_list[0].intersect(requested_coordinates) - native_coordinates = native_coordinates.transform(requested_coordinates.crs) - - if native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE: + if requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE: raise ProviderInvalidQueryError( "Grid coordinates x_size * y_size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE ) - if ( - "forecastOffsetHr" in coordinates_list[0].udims - and "time" in native_coordinates.udims - and instance_time is not None - ): - time_deltas = [] - for time in native_coordinates["time"].coordinates: - offset = np.timedelta64(time - instance_time, self.forecast_time_delta_units) - time_deltas.append(offset) - - # This modifies the time coordinates to account for the new forecast offset hour - new_coordinates = podpac.Coordinates( - [[instance_time], time_deltas], - ["time", "forecastOffsetHr"], - crs=native_coordinates.crs, - ) - native_coordinates = podpac.coordinates.merge_dims( - [native_coordinates.drop("time"), new_coordinates] - ) - - units_data_array = layer.node.eval(native_coordinates) + units_data_array = layer.node.eval(requested_native_coordinates) dataset[requested_parameter] = units_data_array if len(dataset) == 0: @@ -201,12 +175,12 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): # Return a coverage json if specified, else return Base64 encoded native response if output_format == "json" or output_format == "coveragejson": - crs = self.interpret_crs(native_coordinates.crs if native_coordinates else None) + crs = self.interpret_crs(requested_native_coordinates.crs if requested_native_coordinates else None) return self.to_coverage_json(self.layers, dataset, crs) else: if len(dataset) == 1: - geotiff_bytes = units_data_array.to_format("geotiff").read() units_data_array = next(iter(dataset.values())) + geotiff_bytes = units_data_array.to_format("geotiff").read() return { "fp": base64.b64encode(geotiff_bytes).decode("utf-8"), "fn": f"{ next(iter(dataset.keys()))}.tif", @@ -229,7 +203,6 @@ def position(self, **kwargs): ---------- wkt : shapely.geometry WKT geometry - crs : str The requested CRS for the return coordinates and data. @@ -268,7 +241,6 @@ def cube(self, **kwargs): ---------- bbox : List[float] Bbox geometry (for cube queries) - crs : str The requested CRS for the return coordinates and data. @@ -303,7 +275,6 @@ def area(self, **kwargs): ---------- wkt : shapely.geometry WKT geometry - crs : str The requested CRS for the return coordinates and data. @@ -360,8 +331,8 @@ def instances(self, **kwargs) -> List[str]: """ instances = set() for layer in self.layers: - if layer.group == self.collection_id and layer.time_instances is not None: - instances.update(layer.time_instances) + if layer.group == self.collection_id: + instances.update(layer.time_instances()) return list(instances) def get_fields(self) -> Dict[str, Any]: @@ -401,7 +372,7 @@ def get_altitudes(layers: List[pogc.Layer]) -> List[float]: for layer in layers: coordinates_list = layer.node.find_coordinates() if len(coordinates_list) > 0 and "alt" in coordinates_list[0].udims: - available_altitudes.update(coordinates_list[0]["alt"]) + available_altitudes.update(coordinates_list[0]["alt"].coordinates) return list(available_altitudes) @@ -457,10 +428,8 @@ def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]: ---------- x : Any X data in any form. - y: Any Y data in any form. - crs : str The input CRS id string to apply to convert the X,Y data. @@ -589,10 +558,8 @@ def to_coverage_json( ---------- layers : List[pogc.Layer] Layers which were used in the dataset creation for metadata information. - dataset : Dict[str, podpac.UnitsDataArray] Data in an units data array format with matching parameter key. - crs : str The CRS associated with the requested coordinates and data response. @@ -684,3 +651,52 @@ def to_coverage_json( ) return coverage_json + + @staticmethod + def get_native_coordinates( + source_coordinates: podpac.Coordinates, + target_coordinates: podpac.Coordinates, + source_time_instance: np.datetime64 | None, + ) -> podpac.Coordinates: + """Find the intersecting coordinates between source and target coordinates. + Convert time instances to offsets for node evalutation. + + Parameters + ---------- + source_coordinates : podpac.Coordinates + The source coordinates to be converted. + target_coordinates : podpac.Coordinates + The target coordinates to find intersections on. + source_time_instance : np.datetime64 | None + The time instance of the source coordinates to convert to offsets. + + Returns + ------- + podpac.Coordinates + The converted coordinates source coordinates intersecting with the target coordinates. + """ + # Handle conversion from times and instance to time and offsets + if ( + "forecastOffsetHr" in target_coordinates.udims + and "time" in target_coordinates.udims + and "time" in source_coordinates.udims + and source_time_instance is not None + ): + time_deltas = [] + for time in source_coordinates["time"].coordinates: + offset = np.timedelta64(time - source_time_instance, "h") + time_deltas.append(offset) + + # This modifies the time coordinates to account for the new forecast offset hour + new_coordinates = podpac.Coordinates( + [[source_time_instance], time_deltas], + ["time", "forecastOffsetHr"], + crs=source_coordinates.crs, + ) + source_coordinates = podpac.coordinates.merge_dims([source_coordinates.drop("time"), new_coordinates]) + + # Find intersections with target keeping source crs + source_intersection_coordinates = target_coordinates.intersect(source_coordinates) + source_intersection_coordinates = source_intersection_coordinates.transform(source_coordinates.crs) + + return source_intersection_coordinates diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index 0633c0f..8cc84a4 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -7,7 +7,7 @@ import pygeoapi.plugin import pygeoapi.api import pygeoapi.api.environmental_data_retrieval as pygeoedr -from typing import Tuple +from typing import Tuple, Any from http import HTTPStatus from copy import deepcopy from pygeoapi.openapi import get_oas @@ -165,7 +165,7 @@ def collection_query( collection_id: str, instance_id: str | None, query_type: str, - ) -> Tuple[dict, int, str | bytes]: + ) -> Tuple[dict, int, Any]: """Handle collection and instance query requests for the server. Parameters @@ -181,7 +181,7 @@ def collection_query( Returns ------- - Tuple[dict, int, str | bytes] + Tuple[dict, int, Any] Headers, HTTP Status, and Content returned as a tuple to make the server response. """ headers, http_status, content = pygeoedr.get_collection_edr_query( diff --git a/ogc/edr/test/conftest.py b/ogc/edr/test/conftest.py index b27ff10..b87b3e5 100644 --- a/ogc/edr/test/conftest.py +++ b/ogc/edr/test/conftest.py @@ -5,11 +5,15 @@ from ogc import podpac as pogc from typing import Dict, List, Any +# Setup new dimension +podpac.core.coordinates.utils.add_valid_dimension("forecastOffsetHr") + lat = np.linspace(90, -90, 11) lon = np.linspace(-180, 180, 21) time = np.array(["2025-10-24T12:00:00"], dtype="datetime64") -data = np.random.default_rng(1).random((11, 21, 1)) -coords = podpac.Coordinates([lat, lon, time], dims=["lat", "lon", "time"]) +offsets = [np.timedelta64(0, "h")] +data = np.random.default_rng(1).random((11, 21, 1, 1)) +coords = podpac.Coordinates([lat, lon, time, offsets], dims=["lat", "lon", "time", "forecastOffsetHr"]) # Define test layers using sample data and coordinates node1 = podpac.data.Array(source=data, coordinates=coords) @@ -19,7 +23,6 @@ title="Layer 1", abstract="Layer1 Data", group="Layers", - time_instances=[str(t) for t in coords["time"].coordinates], valid_times=[dt.astype(datetime.datetime) for dt in time], ) node2 = podpac.data.Array(source=data, coordinates=coords) @@ -29,7 +32,6 @@ title="Layer 2", abstract="Layer2 Data", group="Layers", - time_instances=[str(t) for t in coords["time"].coordinates], valid_times=[dt.astype(datetime.datetime) for dt in time], ) @@ -76,6 +78,7 @@ def single_layer_cube_args_internal() -> Dict[str, Any]: return { "format_": "json", + "instance": str(time[0]), "bbox": [-180, -90, 180, 90], "datetime_": str(time[0]), "select_properties": [layer1.identifier], diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 2549b37..e23fa45 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -44,9 +44,12 @@ def test_edr_provider_get_instance_valid_id(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ + time_instance = next(iter(layers[0].time_instances())) + EdrProvider.set_resources(layers) provider = EdrProvider(provider_def=provider_definition) - assert provider.get_instance(str(list(layers[0].time_instances)[0])) == str(list(layers[0].time_instances)[0]) + + assert provider.get_instance(time_instance) == time_instance def test_edr_provider_get_instance_invalid_id(layers: List[pogc.Layer]): @@ -89,7 +92,7 @@ def test_edr_provider_instances(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ - instance_sets = [layer.time_instances for layer in layers] + instance_sets = [layer.time_instances() for layer in layers] time_instances = set().union(*instance_sets) EdrProvider.set_resources(layers) @@ -285,7 +288,6 @@ def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_la del args["bbox"] args["wkt"] = Polygon(((-180.0, -90.0), (-180.0, 90.0), (180.0, -90.0), (180.0, 90.0))) parameter_name = single_layer_cube_args_internal["select_properties"][0] - x_array, y_array = args["wkt"].exterior.xy EdrProvider.set_resources(layers) provider = EdrProvider(provider_def=provider_definition) @@ -410,135 +412,132 @@ def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters( def test_edr_provider_datetime_single_value(): """Test the datetime interpreter method of the EDR Provider class with a single datetime value.""" - time_string = "2025-10-24" available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] expected_times = [np.datetime64(available_times[0])] time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is not None np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) def test_edr_provider_datetime_range_closed(): """Test the datetime interpreter method of the EDR Provider class with a closed datetime range.""" - time_string = "2025-10-24/2025-10-26" available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] expected_times = [np.datetime64(time) for time in available_times[0:3]] time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is not None np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) def test_edr_provider_datetime_open_start(): """Test the datetime interpreter method of the EDR Provider class with a open datetime start.""" - time_string = "../2025-10-27" available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] expected_times = [np.datetime64(time) for time in available_times[0:4]] time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is not None np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) def test_edr_provider_datetime_open_end(): """Test the datetime interpreter method of the EDR Provider class with a open datetime end.""" - time_string = "2025-10-25/.." available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] expected_times = [np.datetime64(time) for time in available_times[1:]] time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is not None np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) def test_edr_provider_datetime_invalid_string(): """Test the datetime interpreter method of the EDR Provider class with an invalid string.""" - time_string = "2025-10-25/../../.." available_times = ["2025-10-24", "2025-10-25", "2025-10-26", "2025-10-27", "2025-10-28"] time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + assert time_coords is None def test_edr_provider_altitude_single_value(): """Test the altitude interpreter method of the EDR Provider class with a single datetime value.""" - altitude_string = "10" available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] expected_altitudes = [10.0] altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is not None np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes) def test_edr_provider_altitude_range_closed(): """Test the altitude interpreter method of the EDR Provider class with a closed datetime range.""" - altitude_string = "10/20" available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] expected_altitudes = [10.0, 15.0, 20.0] altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is not None np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes) def test_edr_provider_altitude_repeating_interval(): """Test the altitude interpreter method of the EDR Provider class with a repeating interval.""" - altitude_string = "R2/5/5" available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] expected_altitudes = [5.0, 10.0] altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is not None np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes) def test_edr_provider_altitude_list(): """Test the altitude interpreter method of the EDR Provider class with a list.""" - altitude_string = "5,10,15" available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] expected_altitudes = [5.0, 10.0, 15.0] altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is not None np.testing.assert_array_equal(altitude_coords["alt"].coordinates, expected_altitudes) def test_edr_provider_altitude_invalid_string(): """Test the altitude interpreter method of the EDR Provider class with an invalid string.""" - altitude_string = "../20" available_altitudes = [0.0, 5.0, 10.0, 15.0, 20.0] altitude_coords = EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, None) + assert altitude_coords is None def test_edr_provider_crs_interpreter_default_value(): """Test the CRS interpretation returns a default value when the argument is None.""" - assert EdrProvider.interpret_crs(None) == "urn:ogc:def:crs:OGC:1.3:CRS84" def test_edr_provider_crs_interpreter_valid_value(): """Test the CRS interpretation returns a valid value when the argument is acceptable.""" - assert EdrProvider.interpret_crs("epsg:4326") == "epsg:4326" def test_edr_provider_crs_interpreter_invalid_value(): """Test the CRS interpretation returns default when the argument is unacceptable.""" - assert EdrProvider.interpret_crs("epsp:4444") == "urn:ogc:def:crs:OGC:1.3:CRS84" diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index 45d13ab..e24dbf8 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -126,6 +126,7 @@ def test_edr_routes_describe_collections(layers: List[pogc.Layer]): assert len(response["collections"]) == len(collections) response_collection_ids = [collection["id"] for collection in response["collections"]] + assert response_collection_ids == list(collections) @@ -165,7 +166,7 @@ def test_edr_routes_describe_instances(layers: List[pogc.Layer]): time_instances = set() for layer in layers: if layer.group == collection_id: - time_instances.update(layer.time_instances) + time_instances.update(layer.time_instances()) _, status, content = edr_routes.describe_instances(request, collection_id=collection_id, instance_id=None) response = json.loads(content) @@ -188,7 +189,7 @@ def test_edr_routes_describe_instance(layers: List[pogc.Layer]): request = mock_request({"f": "json"}) edr_routes = EdrRoutes(layers=layers) collection_id = layers[0].group - instance_id = list(layers[0].time_instances)[0] + instance_id = next(iter(layers[0].time_instances())) _, status, content = edr_routes.describe_instances(request, collection_id=collection_id, instance_id=instance_id) response = json.loads(content) @@ -210,13 +211,10 @@ def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube Single layer arguments provided by a test fixture. """ collection_id = layers[0].group - instance_id = list(layers[0].time_instances)[0] + instance_id = next(iter(layers[0].time_instances())) parameter_name = single_layer_cube_args["parameter-name"][0] - single_layer_cube_args["f"] = "json" - request = mock_request(single_layer_cube_args) - edr_routes = EdrRoutes(layers=layers) _, status, content = edr_routes.collection_query( @@ -246,12 +244,9 @@ def test_edr_routes_collection_query_geotiff_format(layers: List[pogc.Layer], si Single layer arguments provided by a test fixture. """ collection_id = layers[0].group - instance_id = list(layers[0].time_instances)[0] - + instance_id = next(iter(layers[0].time_instances())) single_layer_cube_args["f"] = "geotiff" - request = mock_request(single_layer_cube_args) - edr_routes = EdrRoutes(layers=layers) headers, status, content = edr_routes.collection_query( @@ -277,8 +272,7 @@ def test_edr_routes_collection_query_invalid_type(layers: List[pogc.Layer], sing Single layer arguments provided by a test fixture. """ collection_id = layers[0].group - instance_id = list(layers[0].time_instances)[0] - + instance_id = next(iter(layers[0].time_instances())) request = mock_request(single_layer_cube_args) edr_routes = EdrRoutes(layers=layers) @@ -304,14 +298,13 @@ def test_edr_routes_collection_query_invalid_bbox(layers: List[pogc.Layer], sing Single layer arguments provided by a test fixture. """ single_layer_cube_args["bbox"] = "invalid" - request = mock_request(single_layer_cube_args) edr_routes = EdrRoutes(layers=layers) _, status, _ = edr_routes.collection_query( request, collection_id=layers[0].group, - instance_id=str(list(layers[0].time_instances)[0]), + instance_id=next(iter(layers[0].time_instances())), query_type="cube", ) @@ -332,14 +325,13 @@ def test_edr_routes_collection_query_missing_parameter( Single layer arguments provided by a test fixture. """ del single_layer_cube_args["parameter-name"] - request = mock_request(single_layer_cube_args) edr_routes = EdrRoutes(layers=layers) _, status, _ = edr_routes.collection_query( request, collection_id=layers[0].group, - instance_id=str(list(layers[0].time_instances)[0]), + instance_id=next(iter(layers[0].time_instances())), query_type="cube", ) diff --git a/ogc/podpac.py b/ogc/podpac.py index 7e86b05..e64a41f 100755 --- a/ogc/podpac.py +++ b/ogc/podpac.py @@ -6,7 +6,7 @@ import podpac from podpac.core.coordinates import Coordinates import traitlets as tl - +from typing import List from matplotlib import pyplot as plt import matplotlib as mpl import io @@ -55,6 +55,27 @@ def __init__(self, **kwargs): if self.node is not None and self.node.style.enumeration_legend: self._style.is_enumerated = True + def time_instances(self) -> List[str]: + """Retrieve the time instances available for the layer. + + Returns + ------- + List[str] + List of available time instances as a strings. + """ + time_instances = set() + coordinates_list = self.node.find_coordinates() + + # Time instances are created if a node has both time and offsets. + if ( + len(coordinates_list) > 0 + and "time" in coordinates_list[0].udims + and "forecastOffsetHr" in coordinates_list[0].udims + ): + time_instances.update([str(time) for time in coordinates_list[0]["time"].coordinates]) + + return list(time_instances) + def get_node(self, args): return self.node From 08b7e3edf3f0fbb8e62cbf1f00cc5ffd27242e90 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 8 Dec 2025 10:26:07 -0500 Subject: [PATCH 03/10] Fix EDR routing --- ogc/servers.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/ogc/servers.py b/ogc/servers.py index 3e324b1..e84a77e 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -16,6 +16,7 @@ from ogc.ogc_common import WCSException from pygeoapi.api import APIRequest +from pygeoapi.util import get_api_rules logger = logging.getLogger(__name__) @@ -108,74 +109,86 @@ def method(): setattr(self, method_name, method) # bind route function call to instance method # Set up the EDR endpoints for the server + strict_slashes = get_api_rules(ogc.edr_routes.api.config) self.add_url_rule( - f"{endpoint}/edr", + f"/{endpoint}/edr", endpoint=f"{endpoint}_landing_page", view_func=self.edr_render(ogc.edr_routes.landing_page), methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/static/", + f"/{endpoint}/edr/static/", endpoint=f"{endpoint}_static_files", view_func=self.edr_render(ogc.edr_routes.static_files), methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/api", + f"/{endpoint}/edr/api", endpoint=f"{endpoint}_api", view_func=self.edr_render(ogc.edr_routes.openapi), methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/openapi", + f"/{endpoint}/edr/openapi", endpoint=f"{endpoint}_openapi", view_func=self.edr_render(ogc.edr_routes.openapi), methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/conformance", + f"/{endpoint}/edr/conformance", endpoint=f"{endpoint}_conformance", view_func=self.edr_render(ogc.edr_routes.conformance), methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/collections", + f"/{endpoint}/edr/collections", endpoint=f"{endpoint}_collections", view_func=self.edr_render(ogc.edr_routes.describe_collections), defaults={"collection_id": None}, methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/collections/", + f"/{endpoint}/edr/collections/", endpoint=f"{endpoint}_collection", view_func=self.edr_render(ogc.edr_routes.describe_collections), methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/collections//instances", + f"/{endpoint}/edr/collections//instances", endpoint=f"{endpoint}_instances", view_func=self.edr_render(ogc.edr_routes.describe_instances), defaults={"instance_id": None}, methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/collections//instances/", + f"/{endpoint}/edr/collections//instances/", endpoint=f"{endpoint}_instance", view_func=self.edr_render(ogc.edr_routes.describe_instances), methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/collections//", + f"/{endpoint}/edr/collections//", endpoint=f"{endpoint}_collection_query", view_func=self.edr_render(ogc.edr_routes.collection_query), defaults={"instance_id": None}, methods=["GET"], + strict_slashes=strict_slashes, ) self.add_url_rule( - f"{endpoint}/edr/collections//instances//", + f"/{endpoint}/edr/collections//instances//", endpoint=f"{endpoint}_instance_query", view_func=self.edr_render(ogc.edr_routes.collection_query), methods=["GET"], + strict_slashes=strict_slashes, ) def ogc_render(self, ogc_idx): From 2c572cfe3568e7bd11054a135d7bba6769846311 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 8 Dec 2025 17:46:32 +0000 Subject: [PATCH 04/10] Resolve sonarqube findings --- ogc/edr/edr_config.py | 8 +- ogc/edr/edr_provider.py | 173 +++++++++++++++++++----------- ogc/edr/test/test_edr_provider.py | 2 +- ogc/edr/test/test_edr_routes.py | 2 +- ogc/settings.py | 14 +-- 5 files changed, 122 insertions(+), 77 deletions(-) diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 30dab0a..0d01dee 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -99,8 +99,8 @@ def _resources_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: "name": "ogc.edr.edr_provider.EdrProvider", "data": group_name, "crs": [ - "http://www.opengis.net/def/crs/OGC/1.3/CRS84", - "http://www.opengis.net/def/crs/EPSG/0/4326", + "https://www.opengis.net/def/crs/OGC/1.3/CRS84", + "https://www.opengis.net/def/crs/EPSG/0/4326", ], "format": { "name": "geotiff", @@ -160,14 +160,14 @@ def _generate_extents(layers: List[pogc.Layer]) -> Dict[str, Any]: return { "spatial": { "bbox": [llc_lon, llc_lat, urc_lon, urc_lat], # minx, miny, maxx, maxy - "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "crs": "https://www.opengis.net/def/crs/OGC/1.3/CRS84", }, **( { "temporal": { "begin": datetime.fromisoformat(time_range[0]), # start datetime in RFC3339 "end": datetime.fromisoformat(time_range[-1]), # end datetime in RFC3339 - "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", + "trs": "https://www.opengis.net/def/uom/ISO-8601/0/Gregorian", } } if time_range is not None diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index b71daba..a7d30ba 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -94,15 +94,15 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): Raises ------ - ProviderInvalidQueryError - Raised if the parameters are invalid. ProviderInvalidQueryError Raised if a datetime string is provided but cannot be interpreted. ProviderInvalidQueryError Raised if an altitude string is provided but cannot be interpreted. ProviderInvalidQueryError - Raised if no matching parameters are found in the server. - ProviderInvalidQueryError + Raised if the parameters are invalid. + ProviderInvalidQueryError + Raised if an instance is provided and it is invalid. + ProviderInvalidQueryError Raised if native coordinates could not be found. ProviderInvalidQueryError Raised if the request queries for native coordinates exceeding the max allowable size. @@ -112,89 +112,59 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): output_format = kwargs.get("format_") datetime_arg = kwargs.get("datetime_") z_arg = kwargs.get("z") - output_format = str(output_format).lower() - - if not isinstance(requested_parameters, List) or len(requested_parameters) == 0: - raise ProviderInvalidQueryError("Invalid parameters provided.") + output_format = str(output_format).lower() available_times = self.get_datetimes(list(self.parameters.values())) available_altitudes = self.get_altitudes(list(self.parameters.values())) time_coords = self.interpret_time_coordinates(available_times, datetime_arg, requested_coordinates.crs) altitude_coords = self.interpret_altitude_coordinates(available_altitudes, z_arg, requested_coordinates.crs) + # Allow parameters without case-sensitivity + parameters_lower = [param.lower() for param in requested_parameters or []] + parameters_filtered = { + key: value + for key, value in self.parameters.items() + if key.lower() in parameters_lower and value is not None + } - if datetime_arg is not None and time_coords is None: - raise ProviderInvalidQueryError("Invalid datetime provided.") - if z_arg is not None and altitude_coords is None: - raise ProviderInvalidQueryError("Invalid altitude provided.") + self.check_query_condition(datetime_arg is not None and time_coords is None, "Invalid datetime provided.") + self.check_query_condition(z_arg is not None and altitude_coords is None, "Invalid altitude provided.") + self.check_query_condition(len(parameters_filtered) == 0, "Invalid parameters provided.") + self.check_query_condition( + instance is not None and not self.validate_datetime(instance), "Invalid instance time provided." + ) if time_coords is not None: requested_coordinates = podpac.coordinates.merge_dims([time_coords, requested_coordinates]) if altitude_coords is not None: requested_coordinates = podpac.coordinates.merge_dims([altitude_coords, requested_coordinates]) - instance_time = None - if instance is not None: - try: - # Check if it can be formatted as a datetime before adding to requested coordinates - instance_time = np.datetime64(instance) - except ValueError: - raise ProviderInvalidQueryError("Invalid instance time provided.") + # Handle defining native coordinates for the query, these should match between each layer + coordinates_list = next(iter(parameters_filtered.values())).node.find_coordinates() - dataset = {} - requested_native_coordinates = None + self.check_query_condition(len(coordinates_list) == 0, "Native coordinates not found.") - # Allow parameters without case-sensitivity - parameters_lower = [param.lower() for param in requested_parameters] - parameters_filtered = [key for key in self.parameters.keys() if key.lower() in parameters_lower] - - for requested_parameter in parameters_filtered: - layer = self.parameters.get(requested_parameter, None) - if layer is not None: - # Handle defining native coordinates for the query, these should match between each layer - if requested_native_coordinates is None: - coordinates_list = layer.node.find_coordinates() - - if len(coordinates_list) == 0: - raise ProviderInvalidQueryError("Native coordinates not found.") - - requested_native_coordinates = self.get_native_coordinates( - requested_coordinates, coordinates_list[0], instance_time - ) - - if requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE: - raise ProviderInvalidQueryError( - "Grid coordinates x_size * y_size must be less than %d" - % settings.MAX_GRID_COORDS_REQUEST_SIZE - ) + requested_native_coordinates = self.get_native_coordinates( + requested_coordinates, coordinates_list[0], np.datetime64(instance) + ) - units_data_array = layer.node.eval(requested_native_coordinates) - dataset[requested_parameter] = units_data_array + self.check_query_condition( + requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE, + "Grid coordinates x_size * y_size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE, + ) - if len(dataset) == 0: - raise ProviderInvalidQueryError("No matching parameters found.") + dataset = {} + for requested_parameter, layer in parameters_filtered.items(): + units_data_array = layer.node.eval(requested_native_coordinates) + dataset[requested_parameter] = units_data_array + + self.check_query_condition(len(dataset) == 0, "No matching parameters found.") # Return a coverage json if specified, else return Base64 encoded native response if output_format == "json" or output_format == "coveragejson": crs = self.interpret_crs(requested_native_coordinates.crs if requested_native_coordinates else None) return self.to_coverage_json(self.layers, dataset, crs) else: - if len(dataset) == 1: - units_data_array = next(iter(dataset.values())) - geotiff_bytes = units_data_array.to_format("geotiff").read() - return { - "fp": base64.b64encode(geotiff_bytes).decode("utf-8"), - "fn": f"{ next(iter(dataset.keys()))}.tif", - } - else: - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: - for parameter, data_array in dataset.items(): - geotiff_memory_file = data_array.to_format("geotiff") - tiff_filename = f"{parameter}.tif" - zip_file.writestr(tiff_filename, geotiff_memory_file.read()) - - zip_buffer.seek(0) - return {"fp": base64.b64encode(zip_buffer.read()).decode("utf-8"), "fn": f"{self.collection_id}.zip"} + return self.to_geotiff_response(dataset, self.collection_id) def position(self, **kwargs): """Handles requests for the position query type. @@ -652,6 +622,79 @@ def to_coverage_json( return coverage_json + @staticmethod + def check_query_condition(conditional: bool, message: str): + """Check the provided conditional and raise a ProviderInvalidQueryError if true. + + Parameters + ---------- + conditional : bool + The conditional value to check for raising a query error. + message : str + The message to include if the query error is raised. + + Raises + ------ + ProviderInvalidQueryError + Raised if the conditional provided is true. + """ + if conditional: + raise ProviderInvalidQueryError(message) + + @staticmethod + def validate_datetime(datetime_string: str) -> bool: + """Validate whether a string can be converted to a numpy datetime. + + Parameters + ---------- + date_string : str + The datetime string to be validated. + + Returns + ------- + bool + Whether the datetime string can be converted to a numpy datetime. + """ + try: + np.datetime64(datetime_string) + return True + except ValueError: + return False + + @staticmethod + def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id: str) -> Dict[str, Any]: + """Generate a CoverageJSON of the data for the provided parameters. + + Parameters + ---------- + dataset : Dict[str, podpac.UnitsDataArray] + Data in an units data array format with matching parameter key. + collection_id : str + The collection id of the data used in naming the zip file if needed. + + Returns + ------- + Dict[str, Any] + A dictionary the file name and data with a Base64 encoding. + """ + if len(dataset) == 1: + units_data_array = next(iter(dataset.values())) + geotiff_bytes = units_data_array.to_format("geotiff").read() + return { + "fp": base64.b64encode(geotiff_bytes).decode("utf-8"), + "fn": f"{next(iter(dataset.keys()))}.tif", + } + else: + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for parameter, data_array in dataset.items(): + geotiff_memory_file = data_array.to_format("geotiff") + tiff_filename = f"{parameter}.tif" + zip_file.writestr(tiff_filename, geotiff_memory_file.read()) + + zip_buffer.seek(0) + return {"fp": base64.b64encode(zip_buffer.read()).decode("utf-8"), "fn": f"{collection_id}.zip"} + @staticmethod def get_native_coordinates( source_coordinates: podpac.Coordinates, diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index e23fa45..ddb217d 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -15,7 +15,7 @@ "default": True, "name": "ogc.edr.edr_provider.EdrProvider", "data": "Layers", - "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84", "http://www.opengis.net/def/crs/EPSG/0/4326"], + "crs": ["https://www.opengis.net/def/crs/OGC/1.3/CRS84", "https://www.opengis.net/def/crs/EPSG/0/4326"], "format": {"name": "GeoJSON", "mimetype": "application/json"}, } diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index e24dbf8..caa5cd6 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -249,7 +249,7 @@ def test_edr_routes_collection_query_geotiff_format(layers: List[pogc.Layer], si request = mock_request(single_layer_cube_args) edr_routes = EdrRoutes(layers=layers) - headers, status, content = edr_routes.collection_query( + headers, status, _ = edr_routes.collection_query( request, collection_id=collection_id, instance_id=instance_id, diff --git a/ogc/settings.py b/ogc/settings.py index e17b106..4d65dbe 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -9,6 +9,8 @@ import os # Settings applied around the OGC server package. +crs_84 = "crs:84" +epsg_4326 = "epsg:4326" # Default/Supported WMS CRS/SRS WMS_CRS = { @@ -21,16 +23,16 @@ # 'epsg:3785': ... <-- this is deprecated but the same as 3857 # Apparently it lat=x lon=y from Example 2 on page 18 of the WMS version 1.3.0 spec # http://portal.opengeospatial.org/files/?artifact_id=14416 - "epsg:4326": {"minx": -90, "miny": -180, "maxx": 90, "maxy": 180}, - "crs:84": {"minx": -180, "miny": -90, "maxx": 180, "maxy": 90}, + epsg_4326: {"minx": -90, "miny": -180, "maxx": 90, "maxy": 180}, + crs_84: {"minx": -180, "miny": -90, "maxx": 180, "maxy": 90}, } WCS_CRS = { - "epsg:4326": {"minx": -90, "miny": -180, "maxx": 90, "maxy": 180}, - "crs:84": {"minx": -180, "miny": -90, "maxx": 180, "maxy": 90}, + epsg_4326: {"minx": -90, "miny": -180, "maxx": 90, "maxy": 180}, + crs_84: {"minx": -180, "miny": -90, "maxx": 180, "maxy": 90}, } EDR_CRS = { - "epsg:4326": {"minx": -90.0, "miny": -180.0, "maxx": 90.0, "maxy": 180.0}, - "crs:84": {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, + epsg_4326: {"minx": -90.0, "miny": -180.0, "maxx": 90.0, "maxy": 180.0}, + crs_84: {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, } # WMS Capabilities timestamp format From 5aa995b7e2923fd8fe506c6a1245d16f273a33ce Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 8 Dec 2025 18:18:30 +0000 Subject: [PATCH 05/10] Remove unneeded schema setting --- ogc/edr/config/default.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ogc/edr/config/default.json b/ogc/edr/config/default.json index 867c134..c827b04 100644 --- a/ogc/edr/config/default.json +++ b/ogc/edr/config/default.json @@ -17,8 +17,7 @@ "map": { "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", "attribution": "'©
    OpenStreetMap contributors'" - }, - "ogc_schemas_location": "/opt/schemas.opengis.net" + } }, "logging": { "level": "ERROR" From 0daf606851b61f8a0cb22be95465b55249227cdf Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 11 Dec 2025 21:01:50 +0000 Subject: [PATCH 06/10] Fix strict slashes --- ogc/servers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ogc/servers.py b/ogc/servers.py index e84a77e..8b4932e 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -109,7 +109,7 @@ def method(): setattr(self, method_name, method) # bind route function call to instance method # Set up the EDR endpoints for the server - strict_slashes = get_api_rules(ogc.edr_routes.api.config) + strict_slashes = get_api_rules(ogc.edr_routes.api.config).strict_slashes self.add_url_rule( f"/{endpoint}/edr", endpoint=f"{endpoint}_landing_page", From 927a9361b2b4d41470cf630132db325eeae9a086 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 11 Dec 2025 21:03:56 +0000 Subject: [PATCH 07/10] Improve CRS error handling --- ogc/edr/edr_provider.py | 18 +++++++++++++----- ogc/edr/test/test_edr_provider.py | 5 +++-- ogc/settings.py | 2 ++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index a7d30ba..01ff7c2 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -372,7 +372,8 @@ def get_datetimes(layers: List[pogc.Layer]) -> List[str]: def interpret_crs(crs: str | None) -> str: """Interpret the CRS id string into a valid PyProj CRS format. - If None provided or the CRS is unknown, return the default. + If None provided, return the default. + If the provided CRS is invalid, raise an error. Parameters ---------- @@ -383,10 +384,17 @@ def interpret_crs(crs: str | None) -> str: ------- str Pyproj CRS string. + + Raises + ------ + ProviderInvalidQueryError + Raised if the provided CRS string is unknown. """ - default_crs = "urn:ogc:def:crs:OGC:1.3:CRS84" # Pyproj acceptable format - if crs is None or crs.lower() == "crs:84" or crs.lower() not in settings.EDR_CRS.keys(): - return default_crs + if crs is None or crs.lower() == "crs:84": + return settings.crs_84_pyproj_format # Pyproj acceptable format + + if crs.lower() not in [key.lower() for key in settings.EDR_CRS.keys()]: + raise ProviderInvalidQueryError("Invalid CRS provided.") return crs @@ -663,7 +671,7 @@ def validate_datetime(datetime_string: str) -> bool: @staticmethod def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id: str) -> Dict[str, Any]: - """Generate a CoverageJSON of the data for the provided parameters. + """Generate a geotiff of the data for the provided parameters. Parameters ---------- diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index ddb217d..923b604 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -537,8 +537,9 @@ def test_edr_provider_crs_interpreter_valid_value(): def test_edr_provider_crs_interpreter_invalid_value(): - """Test the CRS interpretation returns default when the argument is unacceptable.""" - assert EdrProvider.interpret_crs("epsp:4444") == "urn:ogc:def:crs:OGC:1.3:CRS84" + """Test the CRS interpretation raises an exception when an invalid argument is provided.""" + with pytest.raises(ProviderInvalidQueryError): + EdrProvider.interpret_crs("epsp:4444") def test_edr_provider_crs_converter(): diff --git a/ogc/settings.py b/ogc/settings.py index 4d65dbe..a9df028 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -10,6 +10,7 @@ # Settings applied around the OGC server package. crs_84 = "crs:84" +crs_84_pyproj_format = "urn:ogc:def:crs:OGC:1.3:CRS84" epsg_4326 = "epsg:4326" # Default/Supported WMS CRS/SRS @@ -33,6 +34,7 @@ EDR_CRS = { epsg_4326: {"minx": -90.0, "miny": -180.0, "maxx": 90.0, "maxy": 180.0}, crs_84: {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, + crs_84_pyproj_format: {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, } # WMS Capabilities timestamp format From 2a83f742ba64c4c7186019f2188b98e5e685c8fb Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 11 Dec 2025 21:05:05 +0000 Subject: [PATCH 08/10] Fix multi-provider scenarios and limit api creation --- ogc/edr/edr_config.py | 1 + ogc/edr/edr_provider.py | 14 +---- ogc/edr/edr_routes.py | 27 ++++++--- ogc/edr/test/test_edr_provider.py | 91 +++++++++++++++---------------- 4 files changed, 67 insertions(+), 66 deletions(-) diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 0d01dee..f1b9c12 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -98,6 +98,7 @@ def _resources_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: "default": True, "name": "ogc.edr.edr_provider.EdrProvider", "data": group_name, + "layers": layers, "crs": [ "https://www.opengis.net/def/crs/OGC/1.3/CRS84", "https://www.opengis.net/def/crs/EPSG/0/4326", diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 01ff7c2..897d12a 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -17,8 +17,6 @@ class EdrProvider(BaseEDRProvider): """Custom provider to be used with layer data sources.""" - layers = [] - def __init__(self, provider_def: Dict[str, Any]): """Construct the provider using the provider definition. @@ -41,20 +39,10 @@ def __init__(self, provider_def: Dict[str, Any]): self.collection_id = str(collection_id) + self.layers = provider_def.get("layers", []) if len(self.layers) == 0: raise ProviderConnectionError("Valid data sources not found.") - @classmethod - def set_resources(cls, layers: List[pogc.Layer]): - """Set the layer resources which will be available to the provider. - - Parameters - ---------- - layers : List[pogc.Layer] - The layers which the provider will have access to. - """ - cls.layers = layers - @property def parameters(self) -> Dict[str, pogc.Layer]: """The parameters which are defined in a given collection. diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index 8cc84a4..a9ea68f 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -7,25 +7,39 @@ import pygeoapi.plugin import pygeoapi.api import pygeoapi.api.environmental_data_retrieval as pygeoedr -from typing import Tuple, Any +from typing import Tuple, Any, Dict from http import HTTPStatus from copy import deepcopy from pygeoapi.openapi import get_oas from ogc import podpac as pogc from .edr_config import EdrConfig -from .edr_provider import EdrProvider class EdrRoutes(tl.HasTraits): """Class responsible for routing EDR requests to the appropriate pygeoapi API method.""" base_url = tl.Unicode(default_value="http://127.0.0.1:5000/") - layers = tl.List(trait=tl.Instance(pogc.Layer), default_value=[]) + layers = tl.List(trait=tl.Instance(pogc.Layer)) - @property - def api(self) -> pygeoapi.api.API: - """Property for the API created using a custom configuration. + def __init__(self, **kwargs): + """Initialize the API based on the available layers.""" + super().__init__(**kwargs) + self.api = self.create_api() + + @tl.observe("layers") + def layers_change(self, change: Dict[str, Any]): + """Monitor the layers and update the API when a change occurs. + + Parameters + ---------- + change : Dict[str, Any] + Dictionary holding type of modification and name of the attribute that triggered it. + """ + self.api = self.create_api() + + def create_api(self) -> pygeoapi.api.API: + """Create the pygeoapi API using a custom configuration. Returns ------- @@ -39,7 +53,6 @@ def api(self) -> pygeoapi.api.API: config = EdrConfig.get_configuration(self.base_url, self.layers) open_api = get_oas(config, fail_on_invalid_collection=False) - EdrProvider.set_resources(self.layers) return pygeoapi.api.API(config=deepcopy(config), openapi=open_api) def static_files(self, request: pygeoapi.api.APIRequest, file_path: str) -> Tuple[dict, int, str | bytes]: diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 923b604..a7e89b6 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -9,19 +9,33 @@ from ogc.edr.edr_provider import EdrProvider from pygeoapi.provider.base import ProviderInvalidQueryError -# Define the provider definition which is typically handled by pygeoapi -provider_definition = { - "type": "edr", - "default": True, - "name": "ogc.edr.edr_provider.EdrProvider", - "data": "Layers", - "crs": ["https://www.opengis.net/def/crs/OGC/1.3/CRS84", "https://www.opengis.net/def/crs/EPSG/0/4326"], - "format": {"name": "GeoJSON", "mimetype": "application/json"}, -} +def get_provider_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: + """Define the provider definition which is typically handled by pygeoapi. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers for the provider to use in defining available data sources. + + Returns + ------- + Dict[str, Any] + The provider definition which defines data sources. + """ + return { + "type": "edr", + "default": True, + "name": "ogc.edr.edr_provider.EdrProvider", + "data": "Layers", + "layers": layers, + "crs": ["https://www.opengis.net/def/crs/OGC/1.3/CRS84", "https://www.opengis.net/def/crs/EPSG/0/4326"], + "format": {"name": "GeoJSON", "mimetype": "application/json"}, + } -def test_edr_provider_set_resources(layers: List[pogc.Layer]): - """Test the set_resources method of the EDR Provider class. + +def test_edr_provider_resources(layers: List[pogc.Layer]): + """Test the available resources of the EDR Provider class. Parameters ---------- @@ -30,10 +44,10 @@ def test_edr_provider_set_resources(layers: List[pogc.Layer]): """ identifiers = [layer.identifier for layer in layers] - EdrProvider.set_resources(layers) + provider = EdrProvider(provider_def=get_provider_definition(layers)) - assert len(EdrProvider.layers) == len(layers) - assert all(layer.identifier in identifiers for layer in EdrProvider.layers) + assert len(provider.layers) == len(layers) + assert all(layer.identifier in identifiers for layer in provider.layers) def test_edr_provider_get_instance_valid_id(layers: List[pogc.Layer]): @@ -46,8 +60,7 @@ def test_edr_provider_get_instance_valid_id(layers: List[pogc.Layer]): """ time_instance = next(iter(layers[0].time_instances())) - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) assert provider.get_instance(time_instance) == time_instance @@ -60,8 +73,7 @@ def test_edr_provider_get_instance_invalid_id(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) assert provider.get_instance("invalid") is None @@ -76,8 +88,7 @@ def test_edr_provider_parameter_keys(layers: List[pogc.Layer]): """ identifiers = [layer.identifier for layer in layers] - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) parameters = provider.parameters assert len(list(parameters.keys())) == len(layers) @@ -95,8 +106,7 @@ def test_edr_provider_instances(layers: List[pogc.Layer]): instance_sets = [layer.time_instances() for layer in layers] time_instances = set().union(*instance_sets) - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) instances = provider.instances() assert len(instances) == len(time_instances) @@ -113,8 +123,7 @@ def test_edr_provider_get_fields(layers: List[pogc.Layer]): """ identifiers = [layer.identifier for layer in layers] - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) fields = provider.get_fields() assert len(fields.keys()) == len(layers) @@ -139,8 +148,7 @@ def test_edr_provider_position_request_valid_wkt( args["wkt"] = Point(5.2, 52.1) parameter_name = single_layer_cube_args_internal["select_properties"][0] - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) response = provider.position(**args) @@ -169,8 +177,7 @@ def test_edr_provider_position_request_invalid_wkt( del args["bbox"] args["wkt"] = "invalid" - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) with pytest.raises(ProviderInvalidQueryError): provider.position(**args) @@ -192,8 +199,7 @@ def test_edr_provider_position_request_invalid_property( args = single_layer_cube_args_internal args["select_properties"] = "invalid" - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) with pytest.raises(ProviderInvalidQueryError): provider.position(**args) @@ -215,8 +221,7 @@ def test_edr_provider_cube_request_valid_bbox( args = single_layer_cube_args_internal parameter_name = single_layer_cube_args_internal["select_properties"][0] - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) response = provider.cube(**args) @@ -244,8 +249,7 @@ def test_edr_provider_cube_request_invalid_bbox( args = single_layer_cube_args_internal args["bbox"] = "invalid" - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) with pytest.raises(ProviderInvalidQueryError): provider.cube(**args) @@ -266,8 +270,8 @@ def test_edr_provider_cube_request_invalid_altitude( """ args = single_layer_cube_args_internal args["z"] = "invalid" - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + + provider = EdrProvider(provider_def=get_provider_definition(layers)) with pytest.raises(ProviderInvalidQueryError): provider.position(**args) @@ -289,8 +293,7 @@ def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_la args["wkt"] = Polygon(((-180.0, -90.0), (-180.0, 90.0), (180.0, -90.0), (180.0, 90.0))) parameter_name = single_layer_cube_args_internal["select_properties"][0] - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) response = provider.area(**args) @@ -319,8 +322,7 @@ def test_edr_provider_area_request_invalid_wkt( del args["bbox"] args["wkt"] = "invalid" - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) with pytest.raises(ProviderInvalidQueryError): provider.area(**args) @@ -342,8 +344,7 @@ def test_edr_provider_cube_request_invalid_datetime( args = single_layer_cube_args_internal args["datetime_"] = "10_24/2025" - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) with pytest.raises(ProviderInvalidQueryError): provider.cube(**args) @@ -366,8 +367,7 @@ def test_edr_provider_cube_request_valid_geotiff_format( args["format_"] = "geotiff" parameter_name = single_layer_cube_args_internal["select_properties"][0] - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) response = provider.cube(**args) @@ -396,8 +396,7 @@ def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters( selected_layers = [layer.identifier for layer in layers if layer.group == group] args["select_properties"] = selected_layers - EdrProvider.set_resources(layers) - provider = EdrProvider(provider_def=provider_definition) + provider = EdrProvider(provider_def=get_provider_definition(layers)) response = provider.cube(**args) buffer = io.BytesIO(base64.b64decode(response["fp"])) From 21890574a9bd235a5c887df5df2a281e877f23e5 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 11 Dec 2025 21:17:06 +0000 Subject: [PATCH 09/10] Fix name shadowing --- ogc/edr/edr_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index f1b9c12..100b262 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -83,7 +83,7 @@ def _resources_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: groups[layer.group].append(layer) # Generate collection resources based on groups - for group_name, layers in groups.items(): + for group_name, group_layers in groups.items(): resource = { group_name: { "type": "collection", @@ -91,14 +91,14 @@ def _resources_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: "title": group_name, "description": f"Collection of data related to {group_name}", "keywords": ["podpac"], - "extents": EdrConfig._generate_extents(layers), + "extents": EdrConfig._generate_extents(group_layers), "providers": [ { "type": "edr", "default": True, "name": "ogc.edr.edr_provider.EdrProvider", "data": group_name, - "layers": layers, + "layers": group_layers, "crs": [ "https://www.opengis.net/def/crs/OGC/1.3/CRS84", "https://www.opengis.net/def/crs/EPSG/0/4326", From 9e8d328b3fc3dccf4c3d3bd2058285842c08dd43 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 12 Dec 2025 15:47:23 +0000 Subject: [PATCH 10/10] Fix cache issues with multiple EDR configurations --- ogc/edr/edr_routes.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index a9ea68f..1c752da 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -4,6 +4,7 @@ import base64 import io import traitlets as tl +import pygeoapi.l10n import pygeoapi.plugin import pygeoapi.api import pygeoapi.api.environmental_data_retrieval as pygeoedr @@ -55,6 +56,10 @@ def create_api(self) -> pygeoapi.api.API: open_api = get_oas(config, fail_on_invalid_collection=False) return pygeoapi.api.API(config=deepcopy(config), openapi=open_api) + def clean_configuration_cache(self): + """Clean a pygeoapi internal translation cache so that multiple configurations can be used simultaneously.""" + pygeoapi.l10n._cfg_cache = {} + def static_files(self, request: pygeoapi.api.APIRequest, file_path: str) -> Tuple[dict, int, str | bytes]: """Handle static file requests using the custom static file folder or the pygeoapi default folder. @@ -68,6 +73,7 @@ def static_files(self, request: pygeoapi.api.APIRequest, file_path: str) -> Tupl Tuple[dict, int, str | bytes] Headers, HTTP Status, and Content returned as a tuple to make the server response. """ + self.clean_configuration_cache() static_path = os.path.join(os.path.dirname(pygeoapi.__file__), "static") if "templates" in self.api.config["server"]: static_path = self.api.config["server"]["templates"].get("static", static_path) @@ -94,6 +100,7 @@ def landing_page(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str Tuple[dict, int, str | bytes] Headers, HTTP Status, and Content returned as a tuple to make the server response. """ + self.clean_configuration_cache() return pygeoapi.api.landing_page(self.api, request) def openapi(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: @@ -109,6 +116,7 @@ def openapi(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | by Tuple[dict, int, str | bytes] Headers, HTTP Status, and Content returned as a tuple to make the server response. """ + self.clean_configuration_cache() return pygeoapi.api.openapi_(self.api, request) def conformance(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: @@ -124,6 +132,7 @@ def conformance(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str Tuple[dict, int, str | bytes] Headers, HTTP Status, and Content returned as a tuple to make the server response. """ + self.clean_configuration_cache() return pygeoapi.api.conformance(self.api, request) def describe_collections( @@ -145,6 +154,7 @@ def describe_collections( Tuple[dict, int, str | bytes] Headers, HTTP Status, and Content returned as a tuple to make the server response. """ + self.clean_configuration_cache() return pygeoapi.api.describe_collections(self.api, request, collection_id) def describe_instances( @@ -169,7 +179,7 @@ def describe_instances( Tuple[dict, int, str | bytes] Headers, HTTP Status, and Content returned as a tuple to make the server response. """ - + self.clean_configuration_cache() return pygeoedr.get_collection_edr_instances(self.api, request, collection_id, instance_id=instance_id) def collection_query( @@ -197,6 +207,7 @@ def collection_query( Tuple[dict, int, Any] Headers, HTTP Status, and Content returned as a tuple to make the server response. """ + self.clean_configuration_cache() headers, http_status, content = pygeoedr.get_collection_edr_query( self.api, request, collection_id, instance_id, query_type=query_type, location_id=None )