From dcbe6dc906da257e93ec00d01457a19c8e139ea4 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 19 May 2025 08:48:41 -0400 Subject: [PATCH 01/69] ENH: Add toml file for configuration --- pyproject.toml | 69 ++++++++++++++++++++++++++++++++++++++++++++ setup.py | 77 +------------------------------------------------- 2 files changed, 70 insertions(+), 76 deletions(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0145581 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +# Open Geospatial Consortium (OGC) WCS and WMS Python server. +# Currently Supports: +# * WCS v1.0.0 +# * WMS v1.3.0 +# * PODPAC data sources +# * FLASK web server + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "OGC" +dynamic = ["version"] +description = "OGC WCS and WMS server" +authors = [{ name = "Creare" }] +license = "Apache-2.0" # Creare's preferred license +license-files = ["LICENSE.txt"] +readme = "README.md" +classifiers = [ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + "Development Status :: 3 - Alpha", + # Indicate who your project is intended for + "Intended Audience :: Developers", + "Topic :: Scientific/Engineering :: GIS", + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + "Programming Language :: Python 3", +] + +dependencies = [ + 'podpac[datatype]', + 'flask', + 'lxml', + 'webob', + 'traitlets', +] + +[project.optional-dependencies] +dev = [ + # IDE + "pylint>=1.0", # Dependencies can be tagged to a version, but we recommend >= versioning + # DOCUMENTATION + "sphinx>=3.2", + "sphinx-rtd-theme", + "sphinx-autobuild", + # TESTING + "pytest", + "pytest-mock", + "pytest-cov", + "pytest-html", + "pytest-remotedata", + "recommonmark", + # FORMATTING + "pre_commit", + "black", +] + +[tool.setuptools.packages.find] +where = ["."] + +[tool.pytest.ini_options] +testpaths = ["ogc"] + +[tool.black] +line-length = 120 diff --git a/setup.py b/setup.py index 4b36295..0f079d9 100755 --- a/setup.py +++ b/setup.py @@ -1,18 +1,8 @@ -""" -Open Geospatial Consortium (OGC) WCS and WMS Python server. - -Currently Supports: -* WCS v1.0.0 -* WMS v1.3.0 -* PODPAC data sources -* FLASK web server -""" - import sys import subprocess # Always prefer setuptools over distutils -from setuptools import find_packages, setup +from setuptools import setup from setuptools.command.develop import develop # Get version information @@ -21,45 +11,6 @@ __version__ = version.version() -install_requires = [ - 'podpac[datatype]', - 'flask', - 'lxml', - 'webob', - 'traitlets' -] - -extras_require = { - "dev": [ - # IDE - "pylint>=1.0", # Dependencies can be tagged to a version, but we recommend >= versioning - # DOCUMENTATION - "sphinx>=3.2", - "sphinx-rtd-theme", - "sphinx-autobuild", - # TESTING - "pytest", - "pytest-cov", - "pytest-html", - "pytest-remotedata", - "recommonmark", - # FORMATTING - "pre_commit", - "black", - ], -} - -# set long description to readme -with open("README.md") as f: - long_description = f.read() - -all_reqs = [] -for key, val in extras_require.items(): - if "key" == "dev": - continue - all_reqs += val -extras_require["all"] = all_reqs -extras_require["devall"] = all_reqs + extras_require["dev"] # install pre-commit hooks after setup in develop mode # This is used to automatically format the code @@ -74,34 +25,8 @@ def run(self): setup( - # ext_modules=None, - name="OGC", version=__version__, - description="OGC WCS and WMS server", - author="Creare", - # url="https://Project.org", - license="APACHE 2.0", # Creare's preferred license - classifiers=[ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - "Development Status :: 3 - Alpha", - # Indicate who your project is intended for - "Intended Audience :: Developers", - "Topic :: Scientific/Engineering :: GIS", - # Pick your license as you wish (should match "license" above) - "License :: OSI Approved :: Apache Software License", - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - "Programming Language :: Python 3", - ], - packages=find_packages(), - install_requires=install_requires, - extras_require=extras_require, cmdclass={"develop": PostDevelopCommand}, - long_description=long_description, - long_description_content_type="text/markdown" # ADD ANY SHELL SCRIPTS HERE # entry_points = { # 'console_scripts' : [] From fce1403eb5697a836028281170e42fc1544123a0 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 21 May 2025 17:38:36 -0400 Subject: [PATCH 02/69] ENH: Dev Container Setup --- .devcontainer/dev_container.dockerfile | 9 ++++++ .devcontainer/devcontainer.json | 45 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .devcontainer/dev_container.dockerfile create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/dev_container.dockerfile b/.devcontainer/dev_container.dockerfile new file mode 100644 index 0000000..1842ef2 --- /dev/null +++ b/.devcontainer/dev_container.dockerfile @@ -0,0 +1,9 @@ +# Base image for the development container +FROM python:3.11-slim + +# Setup working directory +WORKDIR /app +COPY . /app + +# Install setup tools and dependencies +RUN pip install --upgrade pip setuptools && pip install .[dev] \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..26f0991 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,45 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Unit Test Image", + "build": { + // Sets the build context to one level up instead of the .devcontainer folder. + "context": "..", + "dockerfile": "dev_container.dockerfile" + }, + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/git:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "donjayamanne.git-extension-pack", + "eamodio.gitlens", + "mhutchie.git-graph", + "ms-azuretools.vscode-docker", + "ms-python.python", + "ms-python.debugpy", + "ms-python.black-formatter", + "ms-python.flake8", + "ms-python.vscode-pylance", + "njpwerner.autodocstring", + "redhat.vscode-yaml", + "SonarSource.sonarlint-vscode" + ], + "settings": { + "flake8.args": [ + "--max-line-length=120" + ] + } + } + } + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Uncomment the next line to run commands after the container is created. + // "postCreateCommand": "cat /etc/os-release", + // Configure tool-specific properties. + // "customizations": {}, + // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "devcontainer" +} \ No newline at end of file From 49b46195ceb0f563ccc6139cfa3790342e4a1ca2 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 21 May 2025 17:56:12 -0400 Subject: [PATCH 03/69] ENH: add unit tests --- ogc/test/test_core.py | 268 +++++++++++++++++++++++++++++++++++++++ ogc/test/test_servers.py | 97 ++++++++++++++ ogc/test/test_version.py | 91 +++++++++++++ 3 files changed, 456 insertions(+) create mode 100644 ogc/test/test_core.py create mode 100644 ogc/test/test_servers.py create mode 100644 ogc/test/test_version.py diff --git a/ogc/test/test_core.py b/ogc/test/test_core.py new file mode 100644 index 0000000..e71b738 --- /dev/null +++ b/ogc/test/test_core.py @@ -0,0 +1,268 @@ +from ogc import core +from ogc import podpac as pogc + +import podpac +import numpy as np +from ogc.wcs_response_1_0_0 import Coverage + +# Create some podpac nodes +data = np.random.rand(11, 21) +lat = np.linspace(90, -90, 11) +lon = np.linspace(-180, 180, 21) +coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"]) +node1 = podpac.data.Array(source=data, coordinates=coords) + +data2 = np.random.rand(11, 21) +node2 = podpac.data.Array(source=data2, coordinates=coords) + +# Use podpac nodes to create some test OGC layers +layer1 = pogc.Layer( + node=node1, + identifier="layer1", + title="OGC/POPAC layer containing random data", + abstract="This layer contains some random data", +) + +layer2 = pogc.Layer( + node=node2, + identifier="layer2", + 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, +) + + +def test_ogc_core_get_coverage_from_id(): + """ + Test the get_coverage_from_id method of the OGC class with a valid ID. + """ + ogc = core.OGC(layers=[layer1, layer2]) + coverage = ogc.get_coverage_from_id(layer1.identifier) + + assert isinstance(coverage, Coverage) + assert coverage.identifier == layer1.identifier + assert coverage.title == layer1.title + assert coverage.abstract == layer1.abstract + + +def test_ogc_core_get_coverage_from_invalid_id(): + """ + Test the get_coverage_from_id method of the OGC class with an invalid ID. + """ + ogc = core.OGC(layers=[layer1, layer2]) + try: + ogc.get_coverage_from_id("invalid_id") + except core.WCSException: + pass + else: + assert False, "Expected WCSException not raised." + + +def test_ogc_core_handle_wcs_kv_get_capabilities(): + """ + Test the handle_wcs_kv method of the OGC class with a valid GetCapabilities request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetCapabilities", + "service": "WCS", + "version": "1.0.0", + "base_url": "http://example.com/wcs", + } + response = ogc.handle_wcs_kv(args) + assert isinstance(response, str) + assert "WCS_Capabilities" in response + + +def test_ogc_core_handle_wcs_kv_get_capabilities_invalid_service(): + """ + Test the handle_wcs_kv method of the OGC class with an invalid GetCapabilities request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetCapabilities", + "service": "InvalidService", + "version": "1.0.0", + "base_url": "http://example.com/wcs", + } + try: + ogc.handle_wcs_kv(args) + except core.WCSException: + pass + else: + assert False, "Expected WCSException not raised." + + +def test_ogc_core_handle_wcs_kv_describe_coverage(): + """ + Test the handle_wcs_kv method of the OGC class with a valid DescribeCoverage request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "DescribeCoverage", + "service": "WCS", + "version": "1.0.0", + "coverage": layer1.identifier, + } + response = ogc.handle_wcs_kv(args) + assert isinstance(response, str) + assert "CoverageDescription" in response + + +def test_ogc_core_handle_wcs_kv_describe_coverage_invalid_version(): + """ + Test the handle_wcs_kv method of the OGC class with an invalid DescribeCoverage request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "DescribeCoverage", + "service": "WCS", + "version": "InvalidVersion", + "coverage": layer1.identifier, + } + try: + ogc.handle_wcs_kv(args) + except core.WCSException: + pass + else: + assert False, "Expected WCSException not raised." + + +def test_ogc_core_handle_wcs_kv_get_coverage(): + """ + Test the handle_wcs_kv method of the OGC class with a valid GetCoverage request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetCoverage", + "service": "WCS", + "version": "1.0.0", + "coverage": layer1.identifier, + "format": "GeoTIFF", + "bbox": "-132.90225856307210961,23.62932030249929483,-53.60509752693091912,53.75883389158821046", + "crs": "EPSG:4326", + "response_crs": "EPSG:4326", + "width": 346, + "height": 131, + } + response = ogc.handle_wcs_kv(args) + assert isinstance(response["fn"], str) + assert response["fn"].endswith(".tif") + assert response["fp"] is not None + + +def test_ogc_core_handle_wms_kv_get_capabilities(): + """ + Test the handle_wms_kv method of the OGC class with a valid GetCapabilities request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetCapabilities", + "service": "WMS", + "version": "1.3.0", + "base_url": "http://example.com/wms", + } + response = ogc.handle_wms_kv(args) + assert isinstance(response, str) + assert "WMS_Capabilities" in response + + +def test_ogc_core_handle_wms_kv_get_capabilities_invalid_service(): + """ + Test the handle_wms_kv method of the OGC class with an invalid GetCapabilities request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetCapabilities", + "service": "InvalidService", + "version": "1.3.0", + "base_url": "http://example.com/wms", + } + try: + ogc.handle_wms_kv(args) + except core.WCSException: + pass + else: + assert False, "Expected WCSException not raised." + + +def test_ogc_core_handle_wms_kv_get_feature_info_unsupported(): + """ + Test the handle_wms_kv method of the OGC class with an unsupported GetFeatureInfo request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetFeatureInfo", + "service": "WMS", + "version": "1.3.0", + "coverage": layer1.identifier, + } + try: + ogc.handle_wms_kv(args) + except core.WCSException: + pass + else: + assert False, "Expected WCSException not raised." + + +def test_ogc_core_handle_wms_kv_get_legend_graphic(): + """ + Test the handle_wms_kv method of the OGC class with a valid GetLegendGraphic request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetLegendGraphic", + "service": "WMS", + "version": "1.3.0", + "layer": layer1.identifier, + "style": "default", + "format": "image/png", + } + response = ogc.handle_wms_kv(args) + assert isinstance(response["fn"], str) + assert response["fn"].endswith(".png") + assert response["fp"] is not None + + +def test_ogc_core_handle_wms_kv_get_legend_graphic_invalid_version(): + """ + Test the handle_wms_kv method of the OGC class with an invalid GetLegendGraphic request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetLegendGraphic", + "service": "WMS", + "version": "InvalidVersion", + "layers": layer1.identifier, + "style": "default", + "format": "image/png", + } + try: + ogc.handle_wms_kv(args) + except core.WCSException: + pass + else: + assert False, "Expected WCSException not raised." + + +def test_ogc_core_handle_wms_kv_get_map(): + """ + Test the handle_wms_kv method of the OGC class with a GetMap request. + """ + ogc = core.OGC(layers=[layer1, layer2]) + args = { + "request": "GetMap", + "service": "WMS", + "version": "1.3.0", + "layers": layer1.identifier, + "format": "image/png", + "transparent": "true", + "height": 256, + "width": 256, + "crs": "EPSG:3857", + "bbox": "-10018754.171394622,2504688.5428486555,-7514065.628545966,5009377.08569731", + } + response = ogc.handle_wms_kv(args) + assert isinstance(response["fn"], str) + assert response["fn"].endswith(".png") + assert response["fp"] is not None diff --git a/ogc/test/test_servers.py b/ogc/test/test_servers.py new file mode 100644 index 0000000..2d20f6b --- /dev/null +++ b/ogc/test/test_servers.py @@ -0,0 +1,97 @@ +from ogc import servers +from ogc import core +from ogc import podpac as pogc + +import podpac +import pytest +import numpy as np + + +@pytest.fixture +def client(): + """ + Create a test client for the Flask server. + + Yields + ------ + client : FlaskClient + A test client for the Flask server. + """ + # Create some test OGC layers + data = np.random.rand(11, 21) + lat = np.linspace(90, -90, 11) + lon = np.linspace(-180, 180, 21) + coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"]) + node1 = podpac.data.Array(source=data, coordinates=coords) + + layer1 = pogc.Layer( + node=node1, + identifier="layer1", + title="OGC/POPAC layer containing random data", + abstract="This layer contains some random data", + ) + # Create an OGC instance with the test layers + ogc = core.OGC(layers=[layer1]) + + # Create a FlaskServer instance + app = servers.FlaskServer(__name__, ogcs=[ogc]) + app.config.update({"TESTING": True}) + yield app.test_client() + + +def test_server_construction(client): + """ + Test the construction of the server. + """ + assert isinstance(client.application, servers.FlaskServer) + assert len(client.application.ogcs) == 1 + + +def test_server_ogc_render_test_invalid_method(client): + """ + Test the ogc_render method with an invalid method. + """ + response = client.post("/ogc") + assert response.status_code == 405 + + +def test_server_ogc_render_base_url(client): + """ + Test the ogc_render method with a valid base URL. + """ + response = client.get("/ogc") + assert response.status_code == 200 + + +def test_server_ogc_render_wcs_service(client): + """ + Test the ogc_render method with WCS service. + """ + response = client.get("/ogc?service=WCS&request=GetCapabilities") + assert response.status_code == 200 + assert "WCS_Capabilities" in response.get_data(as_text=True) + + +def test_server_ogc_render_wms_service(client): + """ + Test the ogc_render method with WMS service. + """ + response = client.get("/ogc?service=WMS&request=GetCapabilities") + assert response.status_code == 200 + assert "WMS_Capabilities" in response.get_data(as_text=True) + + +def test_server_ogc_render_invalid_service(client): + """ + Test the ogc_render method with an invalid service. + """ + response = client.get("/ogc?service=InvalidService&request=GetCapabilities") + assert response.status_code == 400 + + +def test_server_ogc_render_invalid_request(client): + """ + Test the ogc_render method with an invalid request. + """ + response = client.get("/ogc?service=WCS&request=InvalidRequest") + assert response.status_code == 400 diff --git a/ogc/test/test_version.py b/ogc/test/test_version.py new file mode 100644 index 0000000..fbe03e5 --- /dev/null +++ b/ogc/test/test_version.py @@ -0,0 +1,91 @@ +import re +import sys + +# Get version information +sys.path.insert(0, "ogc") # Directory where actual code lives +import version + + +def test_version_semantics_match_three_number_pattern(): + """ + Test that the semantic version string matches the pattern "number.number.number". + """ + semantic_version = version.semver() + + # Regular expression pattern for "number.number.number" + pattern = r"^\d+\.\d+\.\d+$" + + assert re.match(pattern, semantic_version) is not None + + +def test_version_semantics_order_match_major_minor_hotfix(): + """ + Test that the semantic version string matches the order of major, minor, and hotfix. + """ + semantic_version = version.semver() + + assert semantic_version == f"{version.MAJOR}.{version.MINOR}.{version.HOTFIX}" + + +def test_version_without_git(mocker): + """ + Test the version function when git is not available. + This test simulates the absence of git by mocking the path.exists function. + + Parameters + ---------- + mocker : MockerFixture + The mocker fixture provided by pytest-mock to mock functions and objects. + """ + mocker.patch("os.path.exists", return_value=False) + + assert version.version() == version.semver() + + +def test_version_with_git(mocker): + """ + Test the version function when git is available. + This test simulates the presence of git by mocking the subprocess.check_output and path.exists functions. + + Parameters + ---------- + mocker : MockerFixture + The mocker fixture provided by pytest-mock to mock functions and objects. + """ + expected_version_output = "v1.2.3+14.test25" + + def subprocess_command_side_effect(cmd, cwd=None): + """ + Mock the subprocess command to simulate git versioning. + + Parameters + ---------- + cmd : list + The command to be executed + cwd : str, optional + The current working directory for the command + + Returns + ------- + bytes + The output of the command as bytes. + + Raises + ------ + ValueError + If the command is not recognized. + """ + mock_git_describe_response = b"v1.2.3-14-test25\n" + mock_git_version_response = b"git version 2.25.1\n" + + if cmd == ["git", "describe", "--always"]: + return mock_git_describe_response + if cmd == ["git", "--version"]: + return mock_git_version_response + raise ValueError(f"Unmocked subprocess call: {cmd} with cwd {cwd}") + + mocker.patch("subprocess.check_output", side_effect=subprocess_command_side_effect) + mocker.patch("os.path.exists", return_value=True) + + version_full = version.version() + assert version_full == expected_version_output From 5b99c3df6555497145b6d9813ae2a8936627b531 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 21 May 2025 18:18:46 -0400 Subject: [PATCH 04/69] Update unit tests with url --- ogc/test/test_core.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/ogc/test/test_core.py b/ogc/test/test_core.py index e71b738..9f03b81 100644 --- a/ogc/test/test_core.py +++ b/ogc/test/test_core.py @@ -11,9 +11,7 @@ lon = np.linspace(-180, 180, 21) coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"]) node1 = podpac.data.Array(source=data, coordinates=coords) - -data2 = np.random.rand(11, 21) -node2 = podpac.data.Array(source=data2, coordinates=coords) +node2 = podpac.data.Array(source=data, coordinates=coords) # Use podpac nodes to create some test OGC layers layer1 = pogc.Layer( @@ -67,7 +65,7 @@ def test_ogc_core_handle_wcs_kv_get_capabilities(): "request": "GetCapabilities", "service": "WCS", "version": "1.0.0", - "base_url": "http://example.com/wcs", + "base_url": None, } response = ogc.handle_wcs_kv(args) assert isinstance(response, str) @@ -83,7 +81,7 @@ def test_ogc_core_handle_wcs_kv_get_capabilities_invalid_service(): "request": "GetCapabilities", "service": "InvalidService", "version": "1.0.0", - "base_url": "http://example.com/wcs", + "base_url": None, } try: ogc.handle_wcs_kv(args) @@ -160,7 +158,7 @@ def test_ogc_core_handle_wms_kv_get_capabilities(): "request": "GetCapabilities", "service": "WMS", "version": "1.3.0", - "base_url": "http://example.com/wms", + "base_url": None, } response = ogc.handle_wms_kv(args) assert isinstance(response, str) @@ -176,7 +174,7 @@ def test_ogc_core_handle_wms_kv_get_capabilities_invalid_service(): "request": "GetCapabilities", "service": "InvalidService", "version": "1.3.0", - "base_url": "http://example.com/wms", + "base_url": None, } try: ogc.handle_wms_kv(args) From cdc437318e86b525c865b79da4c989115dd8d6d7 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 22 May 2025 14:49:18 -0400 Subject: [PATCH 05/69] Setup with non-root user --- .devcontainer/dev_container.dockerfile | 28 ++++++++++++++++++++++---- .devcontainer/devcontainer.json | 8 +++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.devcontainer/dev_container.dockerfile b/.devcontainer/dev_container.dockerfile index 1842ef2..741b275 100644 --- a/.devcontainer/dev_container.dockerfile +++ b/.devcontainer/dev_container.dockerfile @@ -1,9 +1,29 @@ # Base image for the development container -FROM python:3.11-slim +ARG BASE_URL=python:3.11-slim +FROM ${BASE_URL} -# Setup working directory +USER root + +# We'll be running as a non-root user in a container and may want root permissions +RUN apt update && apt -y install nano ssh sudo && apt clean + +# Install setup tools and dependencies WORKDIR /app COPY . /app +RUN pip install --upgrade pip setuptools && pip install .[dev] -# Install setup tools and dependencies -RUN pip install --upgrade pip setuptools && pip install .[dev] \ No newline at end of file +# Set up user to match the host OS (https://stackoverflow.com/a/78621662/415551) +ARG HOST_USER +ARG HOST_UID +ARG HOST_GID + +RUN addgroup --gid ${HOST_GID} ${HOST_USER} \ + && adduser --gecos "" --disabled-password --uid ${HOST_UID} --gid ${HOST_GID} ${HOST_USER} \ + && usermod -aG sudo ${HOST_USER} \ + && echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers + +ENV HOME /home/${HOST_USER} +ENV TMPDIR=/tmp +WORKDIR /home/${HOST_USER} + +USER ${HOST_USER} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 26f0991..9c1d714 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,10 +2,16 @@ // README at: https://github.com/devcontainers/templates/tree/main/src/python { "name": "Unit Test Image", + "remoteUser": "${localEnv:USER}", "build": { // Sets the build context to one level up instead of the .devcontainer folder. "context": "..", - "dockerfile": "dev_container.dockerfile" + "dockerfile": "dev_container.dockerfile", + "args": { + "HOST_USER": "${localEnv:USER}", + "HOST_UID": "${localEnv:HOST_UID:1000}", + "HOST_GID": "${localEnv:HOST_GID:1000}" + } }, // Features to add to the dev container. More info: https://containers.dev/features. "features": { From 78c89b9a5acf12f6d573a68efe8143055025bdc9 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 22 May 2025 15:09:56 -0400 Subject: [PATCH 06/69] Fix try catches and remove random usage --- ogc/test/test_core.py | 54 ++++++++++++++-------------------------- ogc/test/test_servers.py | 6 ++--- 2 files changed, 21 insertions(+), 39 deletions(-) diff --git a/ogc/test/test_core.py b/ogc/test/test_core.py index 9f03b81..bd0ac24 100644 --- a/ogc/test/test_core.py +++ b/ogc/test/test_core.py @@ -2,11 +2,12 @@ from ogc import podpac as pogc import podpac +import pytest import numpy as np from ogc.wcs_response_1_0_0 import Coverage # Create some podpac nodes -data = np.random.rand(11, 21) +data = np.ones((10, 10)) lat = np.linspace(90, -90, 11) lon = np.linspace(-180, 180, 21) coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"]) @@ -17,16 +18,15 @@ layer1 = pogc.Layer( node=node1, identifier="layer1", - title="OGC/POPAC layer containing random data", - abstract="This layer contains some random data", + title="Layer 1", + abstract="Layer1 Data", ) layer2 = pogc.Layer( node=node2, identifier="layer2", - 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, + title="Layer 2", + abstract="Layer2 Data", ) @@ -48,12 +48,9 @@ def test_ogc_core_get_coverage_from_invalid_id(): Test the get_coverage_from_id method of the OGC class with an invalid ID. """ ogc = core.OGC(layers=[layer1, layer2]) - try: + + with pytest.raises(core.WCSException): ogc.get_coverage_from_id("invalid_id") - except core.WCSException: - pass - else: - assert False, "Expected WCSException not raised." def test_ogc_core_handle_wcs_kv_get_capabilities(): @@ -83,12 +80,9 @@ def test_ogc_core_handle_wcs_kv_get_capabilities_invalid_service(): "version": "1.0.0", "base_url": None, } - try: + + with pytest.raises(core.WCSException): ogc.handle_wcs_kv(args) - except core.WCSException: - pass - else: - assert False, "Expected WCSException not raised." def test_ogc_core_handle_wcs_kv_describe_coverage(): @@ -118,12 +112,9 @@ def test_ogc_core_handle_wcs_kv_describe_coverage_invalid_version(): "version": "InvalidVersion", "coverage": layer1.identifier, } - try: + + with pytest.raises(core.WCSException): ogc.handle_wcs_kv(args) - except core.WCSException: - pass - else: - assert False, "Expected WCSException not raised." def test_ogc_core_handle_wcs_kv_get_coverage(): @@ -176,12 +167,9 @@ def test_ogc_core_handle_wms_kv_get_capabilities_invalid_service(): "version": "1.3.0", "base_url": None, } - try: + + with pytest.raises(core.WCSException): ogc.handle_wms_kv(args) - except core.WCSException: - pass - else: - assert False, "Expected WCSException not raised." def test_ogc_core_handle_wms_kv_get_feature_info_unsupported(): @@ -195,12 +183,9 @@ def test_ogc_core_handle_wms_kv_get_feature_info_unsupported(): "version": "1.3.0", "coverage": layer1.identifier, } - try: + + with pytest.raises(core.WCSException): ogc.handle_wms_kv(args) - except core.WCSException: - pass - else: - assert False, "Expected WCSException not raised." def test_ogc_core_handle_wms_kv_get_legend_graphic(): @@ -235,12 +220,9 @@ def test_ogc_core_handle_wms_kv_get_legend_graphic_invalid_version(): "style": "default", "format": "image/png", } - try: + + with pytest.raises(core.WCSException): ogc.handle_wms_kv(args) - except core.WCSException: - pass - else: - assert False, "Expected WCSException not raised." def test_ogc_core_handle_wms_kv_get_map(): diff --git a/ogc/test/test_servers.py b/ogc/test/test_servers.py index 2d20f6b..00c75f3 100644 --- a/ogc/test/test_servers.py +++ b/ogc/test/test_servers.py @@ -18,7 +18,7 @@ def client(): A test client for the Flask server. """ # Create some test OGC layers - data = np.random.rand(11, 21) + data = np.ones((10, 10)) lat = np.linspace(90, -90, 11) lon = np.linspace(-180, 180, 21) coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"]) @@ -27,8 +27,8 @@ def client(): layer1 = pogc.Layer( node=node1, identifier="layer1", - title="OGC/POPAC layer containing random data", - abstract="This layer contains some random data", + title="Layer 1", + abstract="Layer 1 Data", ) # Create an OGC instance with the test layers ogc = core.OGC(layers=[layer1]) From 55cad74cd0bc42eb4ce18f2ecc5a108e22ffe4c6 Mon Sep 17 00:00:00 2001 From: John Walthour Date: Fri, 16 May 2025 22:13:56 -0400 Subject: [PATCH 07/69] constantized --- ogc/core.py | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/ogc/core.py b/ogc/core.py index ec9e392..e4cf1dc 100755 --- a/ogc/core.py +++ b/ogc/core.py @@ -18,6 +18,9 @@ logger = logging.getLogger(__name__) +LOAD_FAILURE = "Failed to load and validate: " +INVALID_ARGUMENTS = "Invalid arguments" + class OGC(tl.HasTraits): wms_capabilities = tl.Instance(klass=wms_response_1_3_0.Capabilities) @@ -59,7 +62,6 @@ def __init__(self, layers=[], **kwargs): service_abstract=self.service_abstract, service_group_title=self.service_group_title, ) - return def get_coverage_from_id(self, identifier): for coverage in self.wcs_capabilities.coverages: @@ -77,9 +79,9 @@ def handle_wcs_kv(self, args): try: get_capabilities.load_from_kv(args) get_capabilities.validate() - except: - logger.error("Failed to load and validate: ", exc_info=True) - raise WCSException(exception_text="Invalid arguments") + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) capabilities = self.wcs_capabilities @@ -104,9 +106,9 @@ def handle_wcs_kv(self, args): try: describe_coverage.load_from_kv(args) describe_coverage.validate() - except: - logger.error("Failed to load and validate: ", exc_info=True) - raise WCSException(exception_text="Invalid arguments") + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) coverages = [ self.get_coverage_from_id(identifier.value) @@ -121,9 +123,9 @@ def handle_wcs_kv(self, args): try: get_coverage.load_from_kv(args) get_coverage.validate() - except: - logger.error("Failed to load and validate: ", exc_info=True) - raise WCSException(exception_text="Invalid arguments") + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) coverage = self.get_coverage_from_id(get_coverage.identifier.value) @@ -171,9 +173,9 @@ def handle_wms_kv(self, args): try: get_capabilities.load_from_kv(args) get_capabilities.validate() - except: - logger.error("Failed to load and validate: ", exc_info=True) - raise WCSException(exception_text="Invalid arguments") + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) wms_capabilities = self.wms_capabilities @@ -202,9 +204,9 @@ def handle_wms_kv(self, args): try: get_legend_graphic.load_from_kv(args) get_legend_graphic.validate() - except: - logger.error("Failed to load and validate: ", exc_info=True) - raise WCSException(exception_text="Invalid arguments") + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) coverage = self.get_coverage_from_id(get_legend_graphic.layer.value) @@ -221,9 +223,9 @@ def handle_wms_kv(self, args): try: get_map.load_from_kv(args) get_map.validate() - except: - logger.error("Failed to load and validate: ", exc_info=True) - raise WCSException(exception_text="Invalid arguments") + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) coverage = self.get_coverage_from_id(get_map.layer.value) @@ -249,9 +251,9 @@ def handle_wms_kv(self, args): try: fp = coverage.layer.get_map(args) - except: + except Exception: logger.error("Failed to get_map from layer: ", exc_info=True) - raise WCSException(exception_text="Invalid arguments") + raise WCSException(exception_text=INVALID_ARGUMENTS) fn = coverage.identifier.split(".")[-1] + ".png" From 15718929368446b9c0016d8eb7807712caaf9f9e Mon Sep 17 00:00:00 2001 From: John Walthour Date: Mon, 19 May 2025 18:02:04 -0400 Subject: [PATCH 08/69] Removed unreachable code that had duplicate constants --- ogc/ogc_common.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/ogc/ogc_common.py b/ogc/ogc_common.py index 0341201..3926189 100755 --- a/ogc/ogc_common.py +++ b/ogc/ogc_common.py @@ -29,7 +29,6 @@ def to_xml(self): def _load_xml_doc(self, xml_doc): """ Override this method with code that unpacks contents of XML into the traits object.""" raise NotImplementedError("XML Parsing not implemented.") - return self def load_from_kv(self, args): self._load_from_kv(args) @@ -72,7 +71,6 @@ def validate(self): def to_xml(self): raise NotImplementedError() - return "%s" % self.value class TemporalSubset(XMLNode): @@ -89,18 +87,9 @@ class TemporalSubset(XMLNode): def validate(self): raise NotImplementedError() - for val in ( - self.lower_corner[0], - self.lower_corner[1], - self.upper_corner[0], - self.upper_corner[1], - ): - assert np.isfinite(val), "error: time values must be finite" def to_xml(self): raise NotImplementedError() - return "%s" % self.value - class WCSException(Exception): def __init__( From 0d2d4e24310e2169b17b670b1e0c4236251099b6 Mon Sep 17 00:00:00 2001 From: John Walthour Date: Tue, 20 May 2025 11:48:04 -0400 Subject: [PATCH 09/69] Fix a default parameter --- ogc/servers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ogc/servers.py b/ogc/servers.py index 835dd3f..0d66aad 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -63,7 +63,7 @@ class FlaskServer(Flask): home_func = None - def __init__(self, *args, ogcs=[], home_func=None): + def __init__(self, *args, ogcs=None, home_func=None): """ Parameters ----------- @@ -81,6 +81,11 @@ def __init__(self, *args, ogcs=[], home_func=None): if self.home_func is None: self.home_func = home + # Can't put an empty list as a default argument, since that would + # share the same list among all instances. + if ogcs is None: + ogcs = [] + self.ogcs = ogcs for idx, ogc in enumerate(ogcs): endpoint = ogc.endpoint # should be a string, e.g. "/ogc" From dfced51069d9ea6fd5d6839882fa040d881e5d79 Mon Sep 17 00:00:00 2001 From: John Walthour Date: Tue, 20 May 2025 11:51:44 -0400 Subject: [PATCH 10/69] Specified exception class --- ogc/settings.py | 2 +- ogc/wcs_response_1_0_0.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ogc/settings.py b/ogc/settings.py index 467d5a6..1bb3a3b 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -48,7 +48,7 @@ else: WMS_FRONT_END_ADDRESS = FRONT_END_ADDRESS + "/services/GEOWCS" WCS_FRONT_END_ADDRESS = FRONT_END_ADDRESS + "/services/GEOWCS" -except: +except Exception: WMS_FRONT_END_ADDRESS = None WCS_FRONT_END_ADDRESS = None diff --git a/ogc/wcs_response_1_0_0.py b/ogc/wcs_response_1_0_0.py index 585f69e..a696401 100755 --- a/ogc/wcs_response_1_0_0.py +++ b/ogc/wcs_response_1_0_0.py @@ -73,7 +73,7 @@ def _wgs84_bounding_box_lower_corner_lat_lon_default(self): self.layer.grid_coordinates.LLC.lat, self.layer.grid_coordinates.LLC.lon, ) - except: + except Exception: return (self.crs_extents["minx"], self.crs_extents["miny"]) wgs84_bounding_box_upper_corner_lat_lon = tl.Tuple(tl.Float(), tl.Float()) @@ -84,7 +84,7 @@ def _wgs84_bounding_box_upper_corner_lat_lon_default(self): self.layer.grid_coordinates.URC.lat, self.layer.grid_coordinates.URC.lon, ) - except: + except Exception: return (self.crs_extents["maxx"], self.crs_extents["maxy"]) From 6dc95e682d95b7a02f7e64f80321d327abd4d44f Mon Sep 17 00:00:00 2001 From: John Walthour Date: Tue, 20 May 2025 11:53:25 -0400 Subject: [PATCH 11/69] Removed unused arguments --- ogc/wcs_response_1_0_0.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ogc/wcs_response_1_0_0.py b/ogc/wcs_response_1_0_0.py index a696401..dd9385e 100755 --- a/ogc/wcs_response_1_0_0.py +++ b/ogc/wcs_response_1_0_0.py @@ -215,9 +215,7 @@ def to_xml(self): xml += """ -""".format( - coverage=coverage, temporal_domain=temporal_domain - ) # +""" xml += """\ GeoTIFF From 85fddd9932566424412984fdaaa021aa78e48003 Mon Sep 17 00:00:00 2001 From: John Walthour Date: Tue, 20 May 2025 11:55:07 -0400 Subject: [PATCH 12/69] Constantized --- ogc/wms_request_1_3_0.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ogc/wms_request_1_3_0.py b/ogc/wms_request_1_3_0.py index 4dc6569..3333688 100755 --- a/ogc/wms_request_1_3_0.py +++ b/ogc/wms_request_1_3_0.py @@ -9,7 +9,7 @@ from . import settings logger = logging.getLogger(__file__) - +WMS_VALIDATION_ERROR = "WMS Request validation error: service should be WMS" class GetCapabilities(ogc_common.XMLNode): """ @@ -27,7 +27,7 @@ class GetCapabilities(ogc_common.XMLNode): def validate(self): assert ( self.service == "WMS" - ), "WMS Request validation error: service should be WMS" + ), WMS_VALIDATION_ERROR for obj in self.accept_formats: obj.validate() @@ -64,7 +64,7 @@ class GetMap(ogc_common.XMLNode): def validate(self): assert ( self.service == "WMS" - ), "WMS Request validation error: service should be WMS" + ), WMS_VALIDATION_ERROR assert self.layer, "WMS Request validation error: no coverage specified" assert self.bbox, "WMS Request validation error: no bounding box specified" @@ -152,7 +152,7 @@ class GetLegendGraphic(ogc_common.XMLNode): def validate(self): assert ( self.service == "WMS" - ), "WMS Request validation error: service should be WMS" + ), WMS_VALIDATION_ERROR assert self.layer, "WMS Request validation error: no coverage specified" def _load_from_kv(self, args): From 2c0dae618efb652b8fff0829f88ab03fd1532044 Mon Sep 17 00:00:00 2001 From: John Walthour Date: Tue, 20 May 2025 12:09:24 -0400 Subject: [PATCH 13/69] Fixed sonarqube findings that can be fixed without refactoring --- ogc/wms_response_1_3_0.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/ogc/wms_response_1_3_0.py b/ogc/wms_response_1_3_0.py index 7e7973e..9e584e9 100755 --- a/ogc/wms_response_1_3_0.py +++ b/ogc/wms_response_1_3_0.py @@ -129,19 +129,13 @@ def layers(self): for coverage in self.coverages: xml += """ \n""" if coverage.identifier: - xml += " {coverage.identifier}\n".format( - coverage=coverage - ) + xml += f" {coverage.identifier}\n" if coverage.title: - xml += " {coverage.title}\n".format( - coverage=coverage, self=self - ) + xml += f" {coverage.title}\n" else: logger.info("Invalid layer. Missing title.") if coverage.abstract: - xml += " {coverage.abstract}\n".format( - coverage=coverage, self=self - ) + xml += f" {coverage.abstract}\n" xml += self._get_CRS_and_BoundingBox() @@ -156,7 +150,7 @@ def layers(self): # Find last time with seconds == 0 try: - latest_LIS_time = next( + latest_lis_time = next( ( t for t in reversed(coverage.layer.valid_times) @@ -165,13 +159,13 @@ def layers(self): None, ) except AttributeError: - latest_LIS_time = next( + latest_lis_time = next( (t for t in reversed(coverage.layer.valid_times)), None ) - if latest_LIS_time is not None: + if latest_lis_time is not None: # default to latest LIS time, if available - default_time = latest_LIS_time + default_time = latest_lis_time else: # otherwise default to first available time default_time = min_time @@ -258,7 +252,7 @@ def to_xml(self): @staticmethod def _format_number(input, float_decimals=9): - if type(input) == float: + if isinstance(input, float): return "{number:.{decimals}f}".format(number=input, decimals=float_decimals) else: return "{}".format(input) From 4dc6edccec5aeed660a9da0dca2178e772ea3789 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 23 May 2025 17:49:51 +0000 Subject: [PATCH 14/69] Add workflows and adjust configuration --- .github/workflows/github-python-workflow.yml | 35 ++++++++++++++++++++ .pre-commit-config.yaml | 7 ++++ pyproject.toml | 1 + 3 files changed, 43 insertions(+) create mode 100644 .github/workflows/github-python-workflow.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml new file mode 100644 index 0000000..3c708ef --- /dev/null +++ b/.github/workflows/github-python-workflow.yml @@ -0,0 +1,35 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python Workflow + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v3 + with: + python-version: "3.11" + - name: Install dependencies + run: | + pip install --upgrade pip setuptools && pip install .[dev] + - name: Run flake8 lint checks + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics + - name: Run black format checks + run: black --check --diff ogc example || true # For now pass even if files need formatting + - name: Run pytest with coverage + run: | + pytest -v --cov=ogc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..08cb765 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: +- repo: https://github.com/ambv/black + rev: stable + hooks: + - id: black + language_version: python3 + files: (^ogc|^example) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0145581..3411a4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dev = [ # FORMATTING "pre_commit", "black", + "flake8", ] [tool.setuptools.packages.find] From 22ab88229f1a8ac564a431ad0bc1fd750b8d9352 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 23 May 2025 18:40:01 +0000 Subject: [PATCH 15/69] Change setuptools version --- .github/workflows/github-python-workflow.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml index 3c708ef..f433b8d 100644 --- a/.github/workflows/github-python-workflow.yml +++ b/.github/workflows/github-python-workflow.yml @@ -21,7 +21,9 @@ jobs: python-version: "3.11" - name: Install dependencies run: | - pip install --upgrade pip setuptools && pip install .[dev] + python -m pip install --upgrade pip + pip install setuptools==65.5.0 + pip install .[dev] - name: Run flake8 lint checks run: | # stop the build if there are Python syntax errors or undefined names From 93e303f718b8c940f861bc44d09848fe04cc1ec5 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 23 May 2025 19:45:20 +0000 Subject: [PATCH 16/69] Fix versioning in workflow --- .github/workflows/github-python-workflow.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml index f433b8d..b9056a1 100644 --- a/.github/workflows/github-python-workflow.yml +++ b/.github/workflows/github-python-workflow.yml @@ -15,15 +15,16 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - name: Set up Python 3.11 uses: actions/setup-python@v3 with: python-version: "3.11" - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install setuptools==65.5.0 - pip install .[dev] + pip install --upgrade pip setuptools && pip install .[dev] - name: Run flake8 lint checks run: | # stop the build if there are Python syntax errors or undefined names From efd36c6ccb4c57921f9bf68459cf8fc0f654312a Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 5 Jun 2025 14:38:50 +0000 Subject: [PATCH 17/69] Fix SonarQube Findings --- example/app.py | 8 +- ogc/core.py | 295 ++++++++++++++++++++------------------ ogc/ogc_common.py | 3 +- ogc/podpac.py | 154 ++++++++++---------- ogc/servers.py | 18 +-- ogc/wcs_request_1_0_0.py | 48 ++----- ogc/wcs_response_1_0_0.py | 263 +++++++++++++++------------------ ogc/wms_request_1_3_0.py | 31 ++-- ogc/wms_response_1_3_0.py | 201 +++++++++++++------------- setup.py | 2 +- 10 files changed, 473 insertions(+), 550 deletions(-) diff --git a/example/app.py b/example/app.py index 8aff167..10f26a5 100755 --- a/example/app.py +++ b/example/app.py @@ -14,13 +14,13 @@ import numpy as np # create some podpac nodes -data = np.random.rand(11, 21) +data = np.random.default_rng(1).random((11, 21)) lat = np.linspace(90, -90, 11) lon = np.linspace(-180, 180, 21) coords = podpac.Coordinates([lat, lon], dims=["lat", "lon"]) node1 = podpac.data.Array(source=data, coordinates=coords) -data2 = np.random.rand(11, 21) +data2 = np.random.default_rng(1).random((11, 21)) node2 = podpac.data.Array(source=data2, coordinates=coords) # use podpac nodes to create some OGC layers @@ -75,10 +75,12 @@ def api_home(endpoint): app = servers.FlaskServer(__name__, ogcs=[NonFouoOGC, FouoOGC], home_func=api_home) + # add in some other endpoints. @app.route("/") def home(): - return f'This is an example OGC flask app. See FULL and PARTIAL endpoints.' + return """This is an example OGC flask app. + See FULL and PARTIAL endpoints.""" @app.route("/layers/") diff --git a/ogc/core.py b/ogc/core.py index e4cf1dc..b9f04b8 100755 --- a/ogc/core.py +++ b/ogc/core.py @@ -3,6 +3,7 @@ Currently holds some definitions for interface classes. """ + import gc import logging import traitlets as tl @@ -21,6 +22,7 @@ LOAD_FAILURE = "Failed to load and validate: " INVALID_ARGUMENTS = "Invalid arguments" + class OGC(tl.HasTraits): wms_capabilities = tl.Instance(klass=wms_response_1_3_0.Capabilities) @@ -28,9 +30,7 @@ class OGC(tl.HasTraits): endpoint = tl.Unicode(default_value="/ogc", allow_none=True) service_title = tl.Unicode(default_value="OGC Server", allow_none=True) - service_abstract = tl.Unicode( - default_value="An example OGC Server", allow_none=True - ) + service_abstract = tl.Unicode(default_value="An example OGC Server", allow_none=True) server_address = tl.Unicode(default_value="http://127.0.0.1:5000", allow_none=True) service_group_title = tl.Unicode(default_value="Data Products", allow_none=True) @@ -73,22 +73,83 @@ def get_coverage_from_id(self, identifier): exception_text="Invalid coverage {}".format(identifier), ) - def handle_wcs_kv(self, args): - if args["request"] == "GetCapabilities": - get_capabilities = wcs_request_1_0_0.GetCapabilities() - try: - get_capabilities.load_from_kv(args) - get_capabilities.validate() - except Exception: - logger.error(LOAD_FAILURE, exc_info=True) - raise WCSException(exception_text=INVALID_ARGUMENTS) + def get_capabilities_wcs(self, args): + get_capabilities = wcs_request_1_0_0.GetCapabilities() + try: + get_capabilities.load_from_kv(args) + get_capabilities.validate() + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) + + capabilities = self.wcs_capabilities + + if args["base_url"]: + capabilities.base_url = args["base_url"] + + return capabilities.to_xml() + + def describe_coverage_wcs(self, args, wcs_request, wcs_response): + describe_coverage = wcs_request.DescribeCoverage() + try: + describe_coverage.load_from_kv(args) + describe_coverage.validate() + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) + + coverages = [self.get_coverage_from_id(identifier.value) for identifier in describe_coverage.identifiers] + coverage_description = wcs_response.CoverageDescription(coverages=coverages) + + return coverage_description.to_xml() + + def get_coverage_wcs(self, args, wcs_request): + get_coverage = wcs_request.GetCoverage() + try: + get_coverage.load_from_kv(args) + get_coverage.validate() + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) + + coverage = self.get_coverage_from_id(get_coverage.identifier.value) + + from dateutil.parser import parse + + if get_coverage.width == 0: + raise WCSException( + exception_code="InvalidParameterValue", + locator="VERSION", + exception_text="Grid coordinates x_size must be greater than 0", + ) + if get_coverage.height == 0: + raise WCSException( + exception_code="InvalidParameterValue", + locator="VERSION", + exception_text="Grid coordinates y_size must be greater than 0", + ) + if get_coverage.height * get_coverage.width > settings.MAX_GRID_COORDS_REQUEST_SIZE: + raise WCSException( + exception_code="InvalidParameterValue", + locator="VERSION", + exception_text="Grid coordinates x_size * y_size must be less than %d" + % settings.MAX_GRID_COORDS_REQUEST_SIZE, + ) + + fp = coverage.layer.get_coverage(args) - capabilities = self.wcs_capabilities + fn = coverage.identifier.split(".")[-1] + ".tif" - if args["base_url"]: - capabilities.base_url = args["base_url"] + # Collect garbage + gc.collect() - return capabilities.to_xml() + response = {"fp": fp, "fn": fn} + + return response + + def handle_wcs_kv(self, args): + if args["request"] == "GetCapabilities": + return self.get_capabilities_wcs(args) if "version" in args and args["version"] == "1.0.0": wcs_response = wcs_response_1_0_0 @@ -101,87 +162,96 @@ def handle_wcs_kv(self, args): ) if args["request"] == "DescribeCoverage": + return self.describe_coverage_wcs(args, wcs_request, wcs_response) - describe_coverage = wcs_request.DescribeCoverage() - try: - describe_coverage.load_from_kv(args) - describe_coverage.validate() - except Exception: - logger.error(LOAD_FAILURE, exc_info=True) - raise WCSException(exception_text=INVALID_ARGUMENTS) + elif args["request"] == "GetCoverage": + return self.get_coverage_wcs(args, wcs_request) - coverages = [ - self.get_coverage_from_id(identifier.value) - for identifier in describe_coverage.identifiers - ] - coverage_description = wcs_response.CoverageDescription(coverages=coverages) + raise WCSException(exception_text="KV Request not handled properly: " + str(args)) - return coverage_description.to_xml() + def get_capabilities_wms(self, args): + get_capabilities = wms_request_1_3_0.GetCapabilities() + try: + get_capabilities.load_from_kv(args) + get_capabilities.validate() + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) - elif args["request"] == "GetCoverage": - get_coverage = wcs_request.GetCoverage() - try: - get_coverage.load_from_kv(args) - get_coverage.validate() - except Exception: - logger.error(LOAD_FAILURE, exc_info=True) - raise WCSException(exception_text=INVALID_ARGUMENTS) + wms_capabilities = self.wms_capabilities - coverage = self.get_coverage_from_id(get_coverage.identifier.value) + if args["base_url"]: + wms_capabilities.base_url = args["base_url"] + return wms_capabilities.to_xml() - from dateutil.parser import parse + def get_legend_graphic(self, args, wms_request): + get_legend_graphic = wms_request.GetLegendGraphic() + try: + get_legend_graphic.load_from_kv(args) + get_legend_graphic.validate() + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) + coverage = self.get_coverage_from_id(get_legend_graphic.layer.value) - if get_coverage.width == 0: - raise WCSException( - exception_code="InvalidParameterValue", - locator="VERSION", - exception_text="Grid coordinates x_size must be greater than 0", - ) - if get_coverage.height == 0: - raise WCSException( - exception_code="InvalidParameterValue", - locator="VERSION", - exception_text="Grid coordinates y_size must be greater than 0", - ) - if get_coverage.height * get_coverage.width > settings.MAX_GRID_COORDS_REQUEST_SIZE: - raise WCSException( - exception_code="InvalidParameterValue", - locator="VERSION", - exception_text="Grid coordinates x_size * y_size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE, - ) + fp = coverage.layer.get_legend_graphic(args) + fn = coverage.identifier.split(".")[-1] + ".png" - fp = coverage.layer.get_coverage(args) + response = {"fp": fp, "fn": fn} + return response - fn = coverage.identifier.split(".")[-1] + ".tif" + def get_map(self, args, wms_request): + get_map = wms_request.GetMap() + try: + get_map.load_from_kv(args) + get_map.validate() + except Exception: + logger.error(LOAD_FAILURE, exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) - # Collect garbage - gc.collect() + coverage = self.get_coverage_from_id(get_map.layer.value) - response = {"fp": fp, "fn": fn} + # Make sure the request size is correct + if get_map.width == 0: + raise WCSException( + exception_code="InvalidParameterValue", + locator="VERSION", + exception_text="Grid coordinates x_size must be greater than 0", + ) + if get_map.height == 0: + raise WCSException( + exception_code="InvalidParameterValue", + locator="VERSION", + exception_text="Grid coordinates y_size must be greater than 0", + ) + if get_map.height * get_map.width > settings.MAX_GRID_COORDS_REQUEST_SIZE: + raise WCSException( + exception_code="InvalidParameterValue", + locator="VERSION", + exception_text="Grid coordinates x_size * y_size must be less than %d" + % settings.MAX_GRID_COORDS_REQUEST_SIZE, + ) - return response + try: + fp = coverage.layer.get_map(args) + except Exception: + logger.error("Failed to get_map from layer: ", exc_info=True) + raise WCSException(exception_text=INVALID_ARGUMENTS) - raise WCSException( - exception_text="KV Request not handled properly: " + str(args) - ) + fn = coverage.identifier.split(".")[-1] + ".png" - def handle_wms_kv(self, args): - if args["request"] == "GetCapabilities": - get_capabilities = wms_request_1_3_0.GetCapabilities() - try: - get_capabilities.load_from_kv(args) - get_capabilities.validate() - except Exception: - logger.error(LOAD_FAILURE, exc_info=True) - raise WCSException(exception_text=INVALID_ARGUMENTS) + # Collect garbage + gc.collect() - wms_capabilities = self.wms_capabilities + response = {"fp": fp, "fn": fn} - if args["base_url"]: - wms_capabilities.base_url = args["base_url"] - return wms_capabilities.to_xml() + return response + + def handle_wms_kv(self, args): + if args["request"] == "GetCapabilities": + return self.get_capabilities_wms(args) if args["request"] == "GetFeatureInfo": raise WCSException( @@ -200,70 +270,9 @@ def handle_wms_kv(self, args): ) if args["request"].lower() == "getlegendgraphic": - get_legend_graphic = wms_request.GetLegendGraphic() - try: - get_legend_graphic.load_from_kv(args) - get_legend_graphic.validate() - except Exception: - logger.error(LOAD_FAILURE, exc_info=True) - raise WCSException(exception_text=INVALID_ARGUMENTS) - - coverage = self.get_coverage_from_id(get_legend_graphic.layer.value) - - fp = coverage.layer.get_legend_graphic(args) - - fn = coverage.identifier.split(".")[-1] + ".png" - - response = {"fp": fp, "fn": fn} - return response + return self.get_legend_graphic(args, wms_request) if args["request"].lower() == "getmap": + return self.get_map(args, wms_request) - get_map = wms_request.GetMap() - try: - get_map.load_from_kv(args) - get_map.validate() - except Exception: - logger.error(LOAD_FAILURE, exc_info=True) - raise WCSException(exception_text=INVALID_ARGUMENTS) - - coverage = self.get_coverage_from_id(get_map.layer.value) - - # Make sure the request size is correct - if get_map.width == 0: - raise WCSException( - exception_code="InvalidParameterValue", - locator="VERSION", - exception_text="Grid coordinates x_size must be greater than 0", - ) - if get_map.height == 0: - raise WCSException( - exception_code="InvalidParameterValue", - locator="VERSION", - exception_text="Grid coordinates y_size must be greater than 0", - ) - if get_map.height * get_map.width > settings.MAX_GRID_COORDS_REQUEST_SIZE: - raise WCSException( - exception_code="InvalidParameterValue", - locator="VERSION", - exception_text="Grid coordinates x_size * y_size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE, - ) - - try: - fp = coverage.layer.get_map(args) - except Exception: - logger.error("Failed to get_map from layer: ", exc_info=True) - raise WCSException(exception_text=INVALID_ARGUMENTS) - - fn = coverage.identifier.split(".")[-1] + ".png" - - # Collect garbage - gc.collect() - - response = {"fp": fp, "fn": fn} - - return response - - raise WCSException( - exception_text="KV Request not handled properly: " + str(args) - ) + raise WCSException(exception_text="KV Request not handled properly: " + str(args)) diff --git a/ogc/ogc_common.py b/ogc/ogc_common.py index 3926189..20b3424 100755 --- a/ogc/ogc_common.py +++ b/ogc/ogc_common.py @@ -27,7 +27,7 @@ def to_xml(self): raise NotImplementedError("XML Serialization not implemented.") def _load_xml_doc(self, xml_doc): - """ Override this method with code that unpacks contents of XML into the traits object.""" + """Override this method with code that unpacks contents of XML into the traits object.""" raise NotImplementedError("XML Parsing not implemented.") def load_from_kv(self, args): @@ -91,6 +91,7 @@ def validate(self): def to_xml(self): raise NotImplementedError() + class WCSException(Exception): def __init__( self, diff --git a/ogc/podpac.py b/ogc/podpac.py index 2490435..7e86b05 100755 --- a/ogc/podpac.py +++ b/ogc/podpac.py @@ -18,7 +18,6 @@ import re - def _uppercase_for_dict_keys(lower_dict): upper_dict = {} for k, v in lower_dict.items(): @@ -49,6 +48,7 @@ class Layer(ogc.Layer): node = tl.Instance(klass=podpac.Node, allow_none=True) convert_requests_to_default_crs = tl.Bool(default_value=True) + crs84_colon_format = "CRS:84" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -70,24 +70,18 @@ def get_map(self, args): orig_w = args["WIDTH"] # don't reduce # pixels to less than MIN_N (unless orig request was smaller) MIN_N = 8 - args["HEIGHT"] = max( - min(int(orig_h), MIN_N), int(round(int(orig_h) / rescale)) - ) - args["WIDTH"] = max( - min(int(orig_w), MIN_N), int(round(int(orig_w) / rescale)) - ) + args["HEIGHT"] = max(min(int(orig_h), MIN_N), int(round(int(orig_h) / rescale))) + args["WIDTH"] = max(min(int(orig_w), MIN_N), int(round(int(orig_w) / rescale))) else: rescale = 0 - if "CRS" in args and args["CRS"].upper() == "CRS:84".upper(): + if "CRS" in args and args["CRS"].upper() == self.crs84_colon_format.upper(): args["CRS"] = "CRS84" # for pyproj if self.convert_requests_to_default_crs and "DEFAULT_CRS" in podpac.settings: # PODPAC transforms input coords to crs of datasource recursively # every time eval is used in a Node included in its dependency tree. # This optimization can be used if most datasources are stored in the same crs. - coords = Coordinates.from_url(args).transform( - podpac.settings["DEFAULT_CRS"] - ) + coords = Coordinates.from_url(args).transform(podpac.settings["DEFAULT_CRS"]) else: coords = Coordinates.from_url(args) @@ -98,17 +92,10 @@ def get_map(self, args): if rescale > 1.0: args["HEIGHT"] = orig_h args["WIDTH"] = orig_w - if ( - self.convert_requests_to_default_crs - and "DEFAULT_CRS" in podpac.settings - ): - rescaledcoords = Coordinates.from_url(args).transform( - podpac.settings["DEFAULT_CRS"] - ) + if self.convert_requests_to_default_crs and "DEFAULT_CRS" in podpac.settings: + rescaledcoords = Coordinates.from_url(args).transform(podpac.settings["DEFAULT_CRS"]) else: rescaledcoords = Coordinates.from_url(args) - # rescaled_node = podpac.data.Array(source=output, coordinates=coords, style = node.style) - # output = rescaled_node.eval(rescaledcoords) output = output.interp( lat=rescaledcoords["lat"].coordinates + 1e-6, lon=rescaledcoords["lon"].coordinates + 1e-6, @@ -122,15 +109,13 @@ def get_map(self, args): def get_coverage(self, args): args = _uppercase_for_dict_keys(args) - if "CRS" in args and args["CRS"].upper() == "CRS:84".upper(): + if "CRS" in args and args["CRS"].upper() == self.crs84_colon_format.upper(): args["CRS"] = "CRS84" # for pyproj if self.convert_requests_to_default_crs and "DEFAULT_CRS" in podpac.settings: # PODPAC transforms input coords to crs of datasource recursively # every time eval is used in a Node included in its dependency tree. # This optimization can be used if most datasources are stored in the same crs. - coords = Coordinates.from_url(args).transform( - podpac.settings["DEFAULT_CRS"] - ) + coords = Coordinates.from_url(args).transform(podpac.settings["DEFAULT_CRS"]) else: coords = Coordinates.from_url(args) @@ -184,17 +169,18 @@ class LegendGraphic(tl.HasTraits): """ A class for generating legend graphics with adjustable properties such as width, height, fonts, and color mappings. """ + width = tl.Float(default_value=0.7) # inches max_width = tl.Float(default_value=1.5) # inches - min_width = tl.Float(default_value=0.8) # inches + min_width = tl.Float(default_value=0.8) # inches height = tl.Float(default_value=2.5) # inches max_heigth = tl.Float(default_value=5.5) # inches dpi = tl.Float(default_value=100) # pixels per inch - units_fontsize = tl.Float(default_value=13) # used for units text at top of legend - colorbar_fontsize = tl.Float(default_value=10) # used for tick marks of colorbar - enumeration_fontsize = tl.Float(default_value=16) # used for labels of categorical legends - enumeration_min_fontsize = tl.Float(default_value=5)# minimum fontsize for categorical legends with lots of labels - max_unit_chars = tl.Float(default_value=16) # maximum characters allowed in a line for units + units_fontsize = tl.Float(default_value=13) # used for units text at top of legend + colorbar_fontsize = tl.Float(default_value=10) # used for tick marks of colorbar + enumeration_fontsize = tl.Float(default_value=16) # used for labels of categorical legends + enumeration_min_fontsize = tl.Float(default_value=5) # minimum fontsize for categorical legends with lots of labels + max_unit_chars = tl.Float(default_value=16) # maximum characters allowed in a line for units units = tl.Unicode(default_value=tl.Undefined, allow_none=True) img_format = tl.Enum(values=["png", "pdf", "ps", "eps", "svg"], default_value="png") cmap = tl.Instance(klass=mpl.colors.Colormap, default_value=mpl.cm.viridis) @@ -205,9 +191,7 @@ class LegendGraphic(tl.HasTraits): default_value=None, allow_none=True, ) - enumeration_colors = tl.Dict( - key_trait=tl.Int(), default_value=None, allow_none=True - ) + enumeration_colors = tl.Dict(key_trait=tl.Int(), default_value=None, allow_none=True) clim = tl.List(default_value=[None, None]) def __init__(self, *args, **kwargs): @@ -219,7 +203,7 @@ def __init__(self, *args, **kwargs): def legend_image(self): """ Generates the legend image based on provided parameters. - + Returns: io.BytesIO: A byte stream containing the legend image in the specified format. """ @@ -257,19 +241,21 @@ def check_if_units_need_to_be_wrapped(self, fig): """ Checks if the unit text needs wrapping. If so, it divieds the string into bins the size of max_unit_chars. - + Args: fig (matplotlib.figure.Figure): The figure object. - + Returns: tuple: Updated figure, boolean flag indicating wrapping, and the wrapped unit string. """ units = "[%s]" % self.units - needs_wrap = len(units)>self.max_unit_chars #if characters are greater than 16 then wrap text, shrink colorbar + needs_wrap = ( + len(units) > self.max_unit_chars + ) # if characters are greater than 16 then wrap text, shrink colorbar # currently only allows for 2 lines wrapped_units = self.wrap_text(units, self.max_unit_chars) # format exponents - units = re.sub(r"\^(\d+)", r"$^{\1}\!$", units) + units = re.sub(r"\^(\d+)", r"$^{\1}\!$", units) units = re.sub(r"\^-(\d+)", r"$^{-\1}\!$", units) # add units to figure fig.text( @@ -279,28 +265,30 @@ def check_if_units_need_to_be_wrapped(self, fig): fontsize=self.units_fontsize, horizontalalignment="center", verticalalignment="top", - wrap=True + wrap=True, ) return fig, needs_wrap, wrapped_units - + def adjust_fig_height_for_wrapped_units(self, fig, wrapped_units): """ Adjusts figure height to accommodate wrapped units. Will increase figure size for each line of wrapped text until maximum figure height is reached - + Args: fig (matplotlib.figure.Figure): The figure object. wrapped_units (str): The wrapped unit string. - + Returns: tuple: Updated figure and adjusted axis. """ - + # wrap text and increase height of figure added_lines = wrapped_units.count("\n") added_height = self.additional_height_for_wrapped_text(added_lines, self.units_fontsize) - fig_height = min(self.max_heigth, added_height + self.height) # add extra height to figure ensure it is less than 6.5 in - # adjust fig size to fit units + fig_height = min( + self.max_heigth, added_height + self.height + ) # add extra height to figure ensure it is less than 6.5 in + # adjust fig size to fit units fig.set_size_inches(self.max_width, fig_height, forward=True) # Standard height ratio (before adjustments) @@ -314,118 +302,122 @@ def adjust_fig_height_for_wrapped_units(self, fig, wrapped_units): def adjust_fig_width_for_unwrapped_units(self, fig, units_str): """ - Adjusts figure width to accommodate units. + Adjusts figure width to accommodate units. Expects units to be under max_unit_chars - + Args: fig (matplotlib.figure.Figure): The figure object. wrapped_units (str): The wrapped unit string. - + Returns: tuple: Updated figure and adjusted axis. """ # add space for units ax = fig.add_axes([0.25, 0.05, 0.15, 0.80]) # Estimates the max label width assuming fontsize 10 - max_label_width_units = self.get_max_text_width([units_str], self.units_fontsize) - + max_label_width_units = self.get_max_text_width([units_str], self.units_fontsize) + # add color bar and see if fig width needs to be bigger for tick marks norm = mpl.colors.Normalize(vmin=self.clim[0], vmax=self.clim[1]) cb = mpl.colorbar.ColorbarBase(ax, cmap=self.cmap, norm=norm) - + # Convert ticks to float32 to avoid errors converting float64 to string - tick_labels = [str(t) for t in cb.ax.get_yticks().astype('f4')] + tick_labels = [str(t) for t in cb.ax.get_yticks().astype("f4")] max_label_width_ticks = self.get_max_text_width(tick_labels, self.colorbar_fontsize) - #define minimum width need or max_label width + some extra margin - fig_width = max(self.min_width, max_label_width_units+0.2, max_label_width_ticks+0.4) + # define minimum width need or max_label width + some extra margin + fig_width = max(self.min_width, max_label_width_units + 0.2, max_label_width_ticks + 0.4) fig.set_size_inches(fig_width, self.height, forward=True) return fig, ax - + def create_enumeration_legend(self, fig, ax): """ - Creates a legend for categorical data. + Creates a legend for categorical data. Dynamically adjusts figure size based on number of labels and label text length Adds colorbar to figure - + Args: fig (matplotlib.figure.Figure): The figure object. ax (matplotlib.axes.Axes): The axis object. - + Returns: matplotlib.figure.Figure: The updated figure. """ enum_values = list(self.enumeration_colors.keys()) enum_colors = list(self.enumeration_colors.values()) enum_labels = list(self.enumeration_legend.values()) - - # Dynamically adjust font size based on the number of ticks - font_size = max(self.enumeration_min_fontsize, self.enumeration_fontsize - (len(enum_values) * 0.35)) # Scale font size - # Change legend dynamically - max_label_width = self.get_max_text_width(enum_labels, font_size) # Estimates the max label width assuming fontsize 10 + # Dynamically adjust font size based on the number of ticks + font_size = max( + self.enumeration_min_fontsize, self.enumeration_fontsize - (len(enum_values) * 0.35) + ) # Scale font size + + # Change legend dynamically + max_label_width = self.get_max_text_width( + enum_labels, font_size + ) # Estimates the max label width assuming fontsize 10 fig_width = 0.5 + max_label_width # Base width + label-dependent width fig_height = min(self.max_heigth, len(enum_colors) * 0.25) # Adjust height based on number of labels fig.set_size_inches(fig_width, fig_height, forward=True) - - self.cmap = mpl.colors.ListedColormap(enum_colors) #create categorical colomap to replace previous cmap - bounds = np.array([val-0.5 for val in np.arange(1,len(enum_values)+2)]) + + self.cmap = mpl.colors.ListedColormap(enum_colors) # create categorical colomap to replace previous cmap + bounds = np.array([val - 0.5 for val in np.arange(1, len(enum_values) + 2)]) norm = mpl.colors.BoundaryNorm(bounds, self.cmap.N) cb = mpl.colorbar.ColorbarBase( ax, cmap=self.cmap, norm=norm, - ticks=np.arange(1,len(self.enumeration_legend)+1), + ticks=np.arange(1, len(self.enumeration_legend) + 1), ) if self.enumeration_legend: cb.ax.set_yticklabels(enum_labels, fontsize=font_size) return fig - + def adjust_fig_width_for_long_tick_marks(self, fig, ax): """ - Adjusts figure width to accommodate tick marks that are long. - + Adjusts figure width to accommodate tick marks that are long. + Args: fig (matplotlib.figure.Figure): The figure object. ax (matplotlib.axes.Axes): The axis object. - + Returns: matplotlib.figure.Figure: The updated figure. """ norm = mpl.colors.Normalize(vmin=self.clim[0], vmax=self.clim[1]) cb = mpl.colorbar.ColorbarBase(ax, cmap=self.cmap, norm=norm) - tick_labels = [str(t) for t in cb.ax.get_yticks().astype('f4')] # Convert ticks to strings + tick_labels = [str(t) for t in cb.ax.get_yticks().astype("f4")] # Convert ticks to strings max_label_width = self.get_max_text_width(tick_labels, self.colorbar_fontsize) fig_width = max_label_width + 0.4 - fig.set_size_inches(fig_width, self.height,forward=True) + fig.set_size_inches(fig_width, self.height, forward=True) return fig def get_max_text_width(self, labels, font_size=10): """ Estimates the maximum width of given text labels in inches. - + Args: labels (list of str): List of text labels. font_size (int): Font size used for labels. - + Returns: float: The maximum width of the labels in inches. """ fig, ax = plt.subplots() # Create a temporary figure renderer = fig.canvas.get_renderer() # Get renderer to measure text - + text_widths = [] for label in labels: text = ax.text(0, 0, label, fontsize=font_size) # Attach text to the figure text_widths.append(text.get_window_extent(renderer).width) - + plt.close(fig) # Close temporary figure return max(text_widths) / self.dpi # Convert pixels to inches - + def wrap_text(self, text, max_width_chars=16): """ Split string into sections of length max_width_chars @@ -435,16 +427,16 @@ def wrap_text(self, text, max_width_chars=16): def additional_height_for_wrapped_text(self, added_lines_num, font_size): """ Calculates additional height needed for wrapped text based on number lines needed - + Args: added_lines_num (int): Number of additional wrapped lines. font_size (int): Font size of the text. - + Returns: float: Additional height required in inches. """ font_height_px = font_size * (self.dpi / 72) # Convert to pixels font_height_in = font_height_px / self.dpi # Convert pixels to inches - additional_height = font_height_in*added_lines_num + additional_height = font_height_in * added_lines_num return additional_height diff --git a/ogc/servers.py b/ogc/servers.py index 0d66aad..dd3eab0 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -21,7 +21,7 @@ def respond_xml(doc, status=200): # First, validate that XML can be parsed. from lxml import etree - root = etree.fromstring(doc.encode("ascii")) + etree.fromstring(doc.encode("ascii")) # Then, return w/ proper content type return Response(doc, mimetype="text/xml", status=status) @@ -101,12 +101,8 @@ def method(): setattr(self, method_name, method) method = getattr(self, method_name) method.__name__ = method_name - 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 + 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 def ogc_render(self, ogc_idx): logger.info("OGC server.ogc_render %i", ogc_idx) @@ -153,15 +149,11 @@ def ogc_render(self, ogc_idx): as_attach = True if fn.endswith("tif") else False return send_file(fp, as_attachment=as_attach, download_name=fn) - logger.warning( - "Could not handle this combination of arguments: %r", dict(request.args) - ) + logger.warning("Could not handle this combination of arguments: %r", dict(request.args)) raise WCSException("No response for this combination of arguments.") except WCSException as e: - logger.error( - "OGC: server.ogc_render WCSException: %s", str(e), exc_info=True - ) + logger.error("OGC: server.ogc_render WCSException: %s", str(e), exc_info=True) # WCSException is raised when the client sends an invalid set of parameters. # Therefore it should result in a client error, in the 400 range. # Security scans have flagged a security concern when returning a 500 error, diff --git a/ogc/wcs_request_1_0_0.py b/ogc/wcs_request_1_0_0.py index cd09b5f..36db9f0 100755 --- a/ogc/wcs_request_1_0_0.py +++ b/ogc/wcs_request_1_0_0.py @@ -9,6 +9,8 @@ logger = logging.getLogger(__file__) +WCS_VALIDATION_ERROR = "WCS Request validation error: service should be WCS" + class XMLNode(tl.HasTraits): """Base class for all Traits objects that correspond to XML schemas.""" @@ -23,7 +25,7 @@ def to_xml(self): raise NotImplementedError("XML Serialization not implemented.") def _load_xml_doc(self, xml_doc): - """ Override this method with code that unpacks contents of XML into the traits object.""" + """Override this method with code that unpacks contents of XML into the traits object.""" raise NotImplementedError("XML Parsing not implemented.") def load_from_xml(self, xml_txt): @@ -56,12 +58,8 @@ class DescribeCoverage(ogc_common.XMLNode): identifiers = tl.List(trait=tl.Instance(klass=Identifier)) def validate(self): - assert ( - self.service == "WCS" - ), "WCS Request validation error: service should be WCS" - assert self.version.startswith( - "1.0.0" - ), "WCS Request validation error: version should be 1.0.0" + assert self.service == "WCS", WCS_VALIDATION_ERROR + assert self.version.startswith("1.0.0"), "WCS Request validation error: version should be 1.0.0" for obj in self.identifiers: obj.validate() @@ -71,11 +69,7 @@ def _load_from_kv(self, args): self.service = args["service"].upper() self.version = args["version"] - identifiers = [ - Identifier(value=identifier.strip()) - for identifier in args["coverage"].split(",") - ] - # assert len(identifiers) == 1, 'Multiple identifiers not yet supported: ' + repr(identifiers) + identifiers = [Identifier(value=identifier.strip()) for identifier in args["coverage"].split(",")] self.identifiers = identifiers @@ -90,9 +84,7 @@ class GetCapabilities(ogc_common.XMLNode): accept_formats = tl.List(trait=tl.Instance(klass=ogc_common.OutputFormat)) def validate(self): - assert ( - self.service == "WCS" - ), "WCS Request validation error: service should be WCS" + assert self.service == "WCS", WCS_VALIDATION_ERROR for obj in self.accept_formats: obj.validate() @@ -120,9 +112,7 @@ def _load_xml_doc(self, xml_doc): if tag == "AcceptFormats": for eelement in element: assert lxml.etree.QName(eelement.tag).localname == "OutputFormat" - self.accept_formats.append( - ogc_common.OutputFormat(value=eelement.text) - ) + self.accept_formats.append(ogc_common.OutputFormat(value=eelement.text)) else: logger.warn("Tag %s not known." % tag) @@ -155,14 +145,10 @@ class GetCoverage(ogc_common.XMLNode): height = tl.Int() def validate(self): - assert ( - self.service == "WCS" - ), "WCS Request validation error: service should be WCS" + assert self.service == "WCS", WCS_VALIDATION_ERROR assert self.identifier, "WCS Request validation error: no coverage specified" - assert ( - self.domain_subset_bbox - ), "WCS Request validation error: no bounding box specified" + assert self.domain_subset_bbox, "WCS Request validation error: no bounding box specified" self.output_format.validate(), "WCS Request validation error: output format" assert self.height, "WCS Request validation error: no height specified" assert self.width, "WCS Request validation error: no width specified" @@ -174,9 +160,7 @@ def validate(self): self.domain_subset_bbox.lower_corner[1], self.domain_subset_bbox.upper_corner[1], ] - if any([abs(l) > 361.000 for l in lons]) or any( - [abs(l) > 91.000 for l in lats] - ): + if any([abs(l) > 361.000 for l in lons]) or any([abs(l) > 91.000 for l in lats]): raise ogc_common.WCSException( exception_code="InvalidParameterValue", locator="BBOX", @@ -200,16 +184,14 @@ def _load_from_kv(self, args): self.crs = args["request_crs"].lower() if "crs" in list(args.keys()): - assert ( - args["crs"].lower() in settings.WCS_CRS - ), "SRS not supported [CRS]: %s (%s are supported.)" % ( + assert args["crs"].lower() in settings.WCS_CRS, "SRS not supported [CRS]: %s (%s are supported.)" % ( args["crs"], str(settings.WCS_CRS), ) self.crs = args["crs"].lower() bbox = args["bbox"].replace(" ", "").split(",") - # BBOX = minx, miny, maxx, maxy, minz, maxz + # BBOX : [minx, miny, maxx, maxy, minz, maxz] self.domain_subset_bbox = ogc_common.BoundingBox( lower_corner=(float(bbox[0]), float(bbox[1])), upper_corner=(float(bbox[2]), float(bbox[3])), @@ -225,9 +207,9 @@ def _load_from_kv(self, args): self.output_format = ogc_common.OutputFormat(value=args["format"]) if "time" in args: - # TIME = time1, time2,... + # TIME : time1, time2,... # or - # TIME = min / max / res, ... + # TIME : min / max / res, ... if "," in args["time"]: raise ogc_common.WCSException( exception_code="InvalidParameterValue", diff --git a/ogc/wcs_response_1_0_0.py b/ogc/wcs_response_1_0_0.py index dd9385e..04a9d75 100755 --- a/ogc/wcs_response_1_0_0.py +++ b/ogc/wcs_response_1_0_0.py @@ -49,17 +49,13 @@ def _identifer_default(self): def _title_default(self): if self.layer: - return "({}) {}".format( - self.constraints_abbreviated, repr(self.layer._style) - ) + return "({}) {}".format(self.constraints_abbreviated, repr(self.layer._style)) abstract = tl.Unicode(default_value=None, allow_none=True) def _abstract_default(self): if self.layer: - abstract = "({}) OGC Layer: {}".format( - self.constraints_abbreviated, repr(self.layer._style) - ) + abstract = "({}) OGC Layer: {}".format(self.constraints_abbreviated, repr(self.layer._style)) if self.layer._style.is_enumerated: abstract += " Layer represents an enumerated (i.e., categorical/non-scalar) quantity." return abstract @@ -91,6 +87,110 @@ def _wgs84_bounding_box_upper_corner_lat_lon_default(self): class CoverageDescription(ogc_common.XMLNode): coverages = tl.List(trait=tl.Instance(klass=Coverage)) + def coverage_offering(self, coverage): + xml = """ """ + if coverage.identifier: + xml += " {coverage.identifier}\n".format(coverage=coverage) + if coverage.title: + xml += " {coverage.title}\n".format(coverage=coverage) + if coverage.abstract: + xml += " {coverage.abstract}\n".format(coverage=coverage) + temporal_domain = "" + if hasattr(coverage.layer, "valid_times"): + if coverage.layer.all_times_valid: + temporal_domain += " \n" + temporal_domain += " \n" + temporal_domain += " {}".format( + datetime.datetime.min + ) + temporal_domain += " {}".format( + datetime.datetime.max + ) + temporal_domain += " \n" + temporal_domain += " \n" + + elif coverage.layer.valid_times and coverage.layer.valid_times is not tl.Undefined: + temporal_domain += " \n" + temporal_domain += "".join( + [ + " {}\n".format(dt.isoformat()) + for dt in coverage.layer.valid_times + ] + ) + temporal_domain += " \n" + + if coverage.wgs84_bounding_box_lower_corner_lat_lon or coverage.wgs84_bounding_box_upper_corner_lat_lon: + xml += """ + {coverage.wgs84_bounding_box_lower_corner_lat_lon[1]} {coverage.wgs84_bounding_box_lower_corner_lat_lon[0]} + {coverage.wgs84_bounding_box_upper_corner_lat_lon[1]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[0]} + """.format( + coverage=coverage + ) + + xml += """\ + + + + {coverage.wgs84_bounding_box_lower_corner_lat_lon[1]} {coverage.wgs84_bounding_box_lower_corner_lat_lon[0]} + {coverage.wgs84_bounding_box_upper_corner_lat_lon[1]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[0]} + + + + + 0 0 + {coverage.grid_coordinates.x_size} {coverage.grid_coordinates.y_size} + + + x + y + + {coverage.grid_coordinates.geotransform[0]} {coverage.grid_coordinates.geotransform[3]} + + {coverage.grid_coordinates.geotransform[1]} {coverage.grid_coordinates.geotransform[2]} + {coverage.grid_coordinates.geotransform[4]} {coverage.grid_coordinates.geotransform[5]} + + + {temporal_domain} + + + + {coverage.identifier} + {coverage.title} + + + Band + Band + + 1 + + + + + + +""".format( + coverage=coverage, + temporal_domain=temporal_domain, + epsg=NATIVE_PROJECTION.upper(), + ) + xml += "\n".join( + [ + " {epsg}".format(epsg=epsg.upper()) + for epsg in list(settings.WCS_CRS.keys()) + ] + ) + xml += """ + + +""" + xml += """\ + + GeoTIFF + + +""" + return xml + def to_xml(self): xml = """\ @@ -105,123 +205,7 @@ def to_xml(self): """ for coverage in self.coverages: - xml += """ """ - if coverage.identifier: - xml += " {coverage.identifier}\n".format( - coverage=coverage - ) - if coverage.title: - xml += " {coverage.title}\n".format( - coverage=coverage - ) - if coverage.abstract: - xml += " {coverage.abstract}\n".format( - coverage=coverage - ) - temporal_domain = "" - if hasattr(coverage.layer, "valid_times"): - if coverage.layer.all_times_valid: - temporal_domain += " \n" - temporal_domain += " \n" - temporal_domain += " {}".format( - datetime.datetime.min - ) - temporal_domain += " {}".format( - datetime.datetime.max - ) - temporal_domain += " \n" - temporal_domain += " \n" - - elif ( - coverage.layer.valid_times - and coverage.layer.valid_times is not tl.Undefined - ): - temporal_domain += " \n" - temporal_domain += "".join( - [ - " {}\n".format( - dt.isoformat() - ) - for dt in coverage.layer.valid_times - ] - ) - temporal_domain += " \n" - - if ( - coverage.wgs84_bounding_box_lower_corner_lat_lon - or coverage.wgs84_bounding_box_upper_corner_lat_lon - ): - xml += """ - {coverage.wgs84_bounding_box_lower_corner_lat_lon[1]} {coverage.wgs84_bounding_box_lower_corner_lat_lon[0]} - {coverage.wgs84_bounding_box_upper_corner_lat_lon[1]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[0]} - """.format( - coverage=coverage - ) - - xml += """\ - - - - {coverage.wgs84_bounding_box_lower_corner_lat_lon[1]} {coverage.wgs84_bounding_box_lower_corner_lat_lon[0]} - {coverage.wgs84_bounding_box_upper_corner_lat_lon[1]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[0]} - - - - - 0 0 - {coverage.grid_coordinates.x_size} {coverage.grid_coordinates.y_size} - - - x - y - - {coverage.grid_coordinates.geotransform[0]} {coverage.grid_coordinates.geotransform[3]} - - {coverage.grid_coordinates.geotransform[1]} {coverage.grid_coordinates.geotransform[2]} - {coverage.grid_coordinates.geotransform[4]} {coverage.grid_coordinates.geotransform[5]} - - - {temporal_domain} - - - - {coverage.identifier} - {coverage.title} - - - Band - Band - - 1 - - - - - - -""".format( - coverage=coverage, - temporal_domain=temporal_domain, - epsg=NATIVE_PROJECTION.upper(), - ) - xml += "\n".join( - [ - " {epsg}".format( - epsg=epsg.upper() - ) - for epsg in list(settings.WCS_CRS.keys()) - ] - ) - xml += """ - - -""" - xml += """\ - - GeoTIFF - - -""" + xml += self.coverage_offering(coverage) xml += """\ """ @@ -256,9 +240,7 @@ def service(self): self=self, constraints=settings.CONSTRAINTS ) - base_url = tl.Unicode( - default_value=None, allow_none=True - ) # e.g., http://hostname:port/path? + base_url = tl.Unicode(default_value=None, allow_none=True) # e.g., http://hostname:port/path? def capability(self): return """\ @@ -300,9 +282,7 @@ def capability(self): self=self ) - coverages = tl.List( - tl.Instance(klass=Coverage) - ) # is populated via Traits in constructor + coverages = tl.List(tl.Instance(klass=Coverage)) # is populated via Traits in constructor # Check if list of layers available should be trimmed layer_subset = [] @@ -318,36 +298,21 @@ def contents(self): # If configured, trim layers list to layers specified in settings if self.limit_layers: - self.coverages = [ - layer - for layer in self.coverages - if layer.identifier in self.layer_subset - ] + self.coverages = [layer for layer in self.coverages if layer.identifier in self.layer_subset] for coverage in self.coverages: xml += " \n" if coverage.abstract: - xml += " {coverage.abstract}\n".format( - coverage=coverage - ) + xml += " {coverage.abstract}\n".format(coverage=coverage) if coverage.identifier: # required - xml += ( - " {coverage.identifier}\n".format( - coverage=coverage - ) - ) + xml += " {coverage.identifier}\n".format(coverage=coverage) else: logger.info("Invalid layer. Missing name.") if coverage.title: # required - xml += " {coverage.title}\n".format( - coverage=coverage - ) + xml += " {coverage.title}\n".format(coverage=coverage) else: logger.info("Invalid layer. Missing label.") - if ( - coverage.wgs84_bounding_box_lower_corner_lat_lon - or coverage.wgs84_bounding_box_upper_corner_lat_lon - ): + if coverage.wgs84_bounding_box_lower_corner_lat_lon or coverage.wgs84_bounding_box_upper_corner_lat_lon: xml += """ {coverage.wgs84_bounding_box_lower_corner_lat_lon[1]} {coverage.wgs84_bounding_box_lower_corner_lat_lon[0]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[1]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[0]} diff --git a/ogc/wms_request_1_3_0.py b/ogc/wms_request_1_3_0.py index 3333688..6c34ee6 100755 --- a/ogc/wms_request_1_3_0.py +++ b/ogc/wms_request_1_3_0.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__file__) WMS_VALIDATION_ERROR = "WMS Request validation error: service should be WMS" + class GetCapabilities(ogc_common.XMLNode): """ Request to a WMS server to perform the GetCapabilities operation. @@ -25,9 +26,7 @@ class GetCapabilities(ogc_common.XMLNode): accept_formats = tl.List(trait=tl.Instance(klass=ogc_common.OutputFormat)) def validate(self): - assert ( - self.service == "WMS" - ), WMS_VALIDATION_ERROR + assert self.service == "WMS", WMS_VALIDATION_ERROR for obj in self.accept_formats: obj.validate() @@ -62,15 +61,11 @@ class GetMap(ogc_common.XMLNode): ) def validate(self): - assert ( - self.service == "WMS" - ), WMS_VALIDATION_ERROR + assert self.service == "WMS", WMS_VALIDATION_ERROR assert self.layer, "WMS Request validation error: no coverage specified" assert self.bbox, "WMS Request validation error: no bounding box specified" - assert ( - self.output_format - ), "WMS Request validation error: no output format specified" + assert self.output_format, "WMS Request validation error: no output format specified" assert self.height, "WMS Request validation error: no height specified" assert self.width, "WMS Request validation error: no width specified" lons = [self.bbox.lower_corner[0], self.bbox.upper_corner[0]] @@ -92,16 +87,14 @@ def _load_from_kv(self, args): self.layer = Identifier(value=args["layers"]) if "crs" in list(args.keys()): - assert ( - args["crs"].lower() in settings.WMS_CRS - ), "SRS not supported [CRS]: %s (%s are supported.)" % ( + assert args["crs"].lower() in settings.WMS_CRS, "SRS not supported [CRS]: %s (%s are supported.)" % ( args["crs"], str(settings.WMS_CRS), ) self.crs = args["crs"].lower() bbox = args["bbox"].split(",") - # BBOX = minx, miny, maxx, maxy, minz, maxz + # BBOX : [minx, miny, maxx, maxy, minz, maxz] self.bbox = ogc_common.BoundingBox( lower_corner=(float(bbox[0]), float(bbox[1])), upper_corner=(float(bbox[2]), float(bbox[3])), @@ -116,9 +109,9 @@ def _load_from_kv(self, args): ) if "time" in args: - # TIME = time1, time2,... + # TIME : time1, time2,... # or - # TIME = min / max / res, ... + # TIME : min / max / res, ... assert "," not in args["time"], "error loading time from request" self.time = args["time"] @@ -142,17 +135,13 @@ class GetLegendGraphic(ogc_common.XMLNode): service = tl.Unicode(default_value=None, allow_none=True) version = tl.Unicode(default_value=None, allow_none=True) - output_format = tl.Enum( - values=["image/png", "image/png; mode=8bit", "image/png;mode=8-bit"] - ) + output_format = tl.Enum(values=["image/png", "image/png; mode=8bit", "image/png;mode=8-bit"]) layer = tl.Instance(klass=Identifier) # Limited to one crs = tl.Enum(values=list(settings.WMS_CRS.keys())) def validate(self): - assert ( - self.service == "WMS" - ), WMS_VALIDATION_ERROR + assert self.service == "WMS", WMS_VALIDATION_ERROR assert self.layer, "WMS Request validation error: no coverage specified" def _load_from_kv(self, args): diff --git a/ogc/wms_response_1_3_0.py b/ogc/wms_response_1_3_0.py index 9e584e9..0991798 100755 --- a/ogc/wms_response_1_3_0.py +++ b/ogc/wms_response_1_3_0.py @@ -58,12 +58,10 @@ def service(self): self=self, constraints=settings.CONSTRAINTS, maxWidthWMS=int(np.sqrt(settings.MAX_GRID_COORDS_REQUEST_SIZE)), - maxHeightWMS=int(np.sqrt(settings.MAX_GRID_COORDS_REQUEST_SIZE)) + maxHeightWMS=int(np.sqrt(settings.MAX_GRID_COORDS_REQUEST_SIZE)), ) - base_url = tl.Unicode( - default_value=None, allow_none=True - ) # e.g., http://hostname:port/path? + base_url = tl.Unicode(default_value=None, allow_none=True) # e.g., http://hostname:port/path? def request(self): return """\ @@ -100,9 +98,7 @@ def exception(self): """ - coverages = tl.List( - trait=tl.Instance(klass=Coverage) - ) # is populated via Traits in constructor + coverages = tl.List(trait=tl.Instance(klass=Coverage)) # is populated via Traits in constructor # Check if list of layers available should be trimmed layer_subset = [] @@ -113,6 +109,97 @@ def exception(self): except Exception as e: logger.info("Layer limiting settings not enabled: {}".format(e)) + def coverage_times_list(self, coverage, default_time): + # Build list of times to display + display_times = [] + for time in reversed(coverage.layer.valid_times): + if (default_time - time).days < settings.PAST_DAYS_INCLUDED: + display_times.append(time) + else: + # Stop looking once the list has reached too far in the past + break + + return display_times + + def coverage_layer(self, coverage): + xml = """ \n""" + if coverage.identifier: + xml += f" {coverage.identifier}\n" + if coverage.title: + xml += f" {coverage.title}\n" + else: + logger.info("Invalid layer. Missing title.") + if coverage.abstract: + xml += f" {coverage.abstract}\n" + + xml += self._get_CRS_and_BoundingBox() + + if ( + hasattr(coverage.layer, "valid_times") + and coverage.layer.valid_times is not tl.Undefined + and len(coverage.layer.valid_times) > 0 + ): + min_time = coverage.layer.valid_times[0] + max_time = coverage.layer.valid_times[-1] + time_dimension_str = ( + """ {times}\n""" + ) + + # Find last time with seconds == 0 + try: + latest_lis_time = next( + (t for t in reversed(coverage.layer.valid_times) if t.second == 0), + None, + ) + except AttributeError: + latest_lis_time = next((t for t in reversed(coverage.layer.valid_times)), None) + + if latest_lis_time is not None: + # default to latest LIS time, if available + default_time = latest_lis_time + else: + # otherwise default to first available time + default_time = min_time + + if settings.USE_TIMES_LIST: + display_times = self.coverage_times_list(coverage, default_time) + times_available_str = ",".join([time.isoformat() + "Z" for time in reversed(display_times)]) + else: + times_available_str = "{min_time}/{max_time}/P3H".format( + min_time=min_time.isoformat() + "Z", + max_time=max_time.isoformat() + "Z", + ) + + xml += time_dimension_str.format( + times=times_available_str, + default_time=default_time.isoformat() + "Z", + ) + + legend_graphic_width = coverage.layer.legend_graphic_width + legend_graphic_height = coverage.layer.legend_graphic_height + + legend_link = "{base}SERVICE={service}&VERSION={version}&REQUEST=GetLegendGraphic&LAYER={layer}&STYLE=default&FORMAT=image/png; mode=8bit".format( + base=self.base_url if self.base_url.endswith("?") else self.base_url + "?", + service=self.service_type, + version=self.version, + layer=coverage.identifier, + ) + + # Write the style section + xml += """ \n""" + xml += """ \n""" + + return xml + def layers(self): xml = " \n" xml += " {}\n".format(self.service_group_title) @@ -120,107 +207,11 @@ def layers(self): # If configured, trim layers list to layers specified in settings if self.limit_layers: - self.coverages = [ - layer - for layer in self.coverages - if layer.identifier in self.layer_subset - ] + self.coverages = [layer for layer in self.coverages if layer.identifier in self.layer_subset] for coverage in self.coverages: - xml += """ \n""" - if coverage.identifier: - xml += f" {coverage.identifier}\n" - if coverage.title: - xml += f" {coverage.title}\n" - else: - logger.info("Invalid layer. Missing title.") - if coverage.abstract: - xml += f" {coverage.abstract}\n" - - xml += self._get_CRS_and_BoundingBox() - - if ( - hasattr(coverage.layer, "valid_times") - and coverage.layer.valid_times is not tl.Undefined - and len(coverage.layer.valid_times) > 0 - ): - min_time = coverage.layer.valid_times[0] - max_time = coverage.layer.valid_times[-1] - time_dimension_str = """ {times}\n""" - - # Find last time with seconds == 0 - try: - latest_lis_time = next( - ( - t - for t in reversed(coverage.layer.valid_times) - if t.second == 0 - ), - None, - ) - except AttributeError: - latest_lis_time = next( - (t for t in reversed(coverage.layer.valid_times)), None - ) - - if latest_lis_time is not None: - # default to latest LIS time, if available - default_time = latest_lis_time - else: - # otherwise default to first available time - default_time = min_time - - if settings.USE_TIMES_LIST: - # Build list of times to display - display_times = [] - for time in reversed(coverage.layer.valid_times): - if (default_time - time).days < settings.PAST_DAYS_INCLUDED: - display_times.append(time) - else: - # Stop looking once the list has reached too far in the past - break - - times_available_str = ",".join( - [time.isoformat() + "Z" for time in reversed(display_times)] - ) - - else: - times_available_str = "{min_time}/{max_time}/P3H".format( - min_time=min_time.isoformat() + "Z", - max_time=max_time.isoformat() + "Z", - ) - - xml += time_dimension_str.format( - times=times_available_str, - default_time=default_time.isoformat() + "Z", - ) + xml += self.coverage_layer(coverage) - legend_graphic_width = coverage.layer.legend_graphic_width - legend_graphic_height = coverage.layer.legend_graphic_height - - legend_link = "{base}SERVICE={service}&VERSION={version}&REQUEST=GetLegendGraphic&LAYER={layer}&STYLE=default&FORMAT=image/png; mode=8bit".format( - base=self.base_url - if self.base_url.endswith("?") - else self.base_url + "?", - service=self.service_type, - version=self.version, - layer=coverage.identifier, - ) - - # Write the style section - xml += """ \n""" - xml += """ \n""" # end layers list xml += """ """ diff --git a/setup.py b/setup.py index 0f079d9..380d362 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ class PostDevelopCommand(develop): def run(self): try: subprocess.check_call(["pre-commit", "install"]) - except subprocess.CalledProcessError as e: + except subprocess.CalledProcessError: print("Failed to install pre-commit hook") develop.run(self) From 3daba3b138c4d3de5ecc44462846e30ce00f49f9 Mon Sep 17 00:00:00 2001 From: scranford1 Date: Thu, 25 Sep 2025 09:59:06 -0400 Subject: [PATCH 18/69] Feature/sonarqube coverage report (#10) Sonarqube coverage reporting --- .devcontainer/dev_container.dockerfile | 2 +- .github/workflows/github-python-workflow.yml | 91 +++++++++++++++++--- pyproject.toml | 10 ++- sonar-project.properties | 6 ++ 4 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 sonar-project.properties diff --git a/.devcontainer/dev_container.dockerfile b/.devcontainer/dev_container.dockerfile index 741b275..07e7dd0 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.11-slim +ARG BASE_URL=python:3.8-slim FROM ${BASE_URL} USER root diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml index b9056a1..9f09f67 100644 --- a/.github/workflows/github-python-workflow.yml +++ b/.github/workflows/github-python-workflow.yml @@ -1,16 +1,15 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - +# This workflow handles linting, testing, and SonarQube scanning for the OGC repository. name: Python Workflow on: push: branches: [main, develop] pull_request: - branches: [main, develop] + types: [opened, synchronize, reopened] jobs: - test: + lint: + name: Lint runs-on: ubuntu-latest steps: @@ -18,21 +17,91 @@ jobs: with: fetch-depth: 0 fetch-tags: true - - name: Set up Python 3.11 + + - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.11" + python-version: "3.8" + - name: Install dependencies run: | pip install --upgrade pip setuptools && pip install .[dev] + - name: Run flake8 lint checks run: | # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --output-file=flake8-report.txt # exit-zero treats all errors as warnings - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics --output-file=flake8-report.txt + - name: Run black format checks - run: black --check --diff ogc example || true # For now pass even if files need formatting + run: black --check --diff ogc example > black-report.txt || true # For now pass even if files need formatting + + - name: Artifact flake8 report + uses: actions/upload-artifact@v4 + with: + name: flake8-report + path: flake8-report.txt + retention-days: 1 + + - name: Artifact black report + uses: actions/upload-artifact@v4 + with: + name: black-report + path: black-report.txt + retention-days: 1 + + unit_testing: + name: Unit Testing + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.8" + + - name: Install dependencies + run: | + pip install --upgrade pip setuptools && pip install .[dev] + - name: Run pytest with coverage run: | - pytest -v --cov=ogc + coverage run --data-file=coverage.bin --branch \ + -m pytest --continue-on-collection-errors || \ + { echo "WARNING: Some tests have failed. (Temporarily ignored)"; true; } + coverage xml --data-file=coverage.bin -o coverage.xml + + - name: Artifact coverage document + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.xml + retention-days: 1 + + sonarqube_scan: + name: SonarQube Scan + needs: [unit_testing] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Download coverage artifact + uses: actions/download-artifact@v4 + with: + name: coverage + path: . + + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@fd88b7d7ccbaefd23d8f36f73b59db7a3d246602 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/pyproject.toml b/pyproject.toml index 3411a4d..0b9a391 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,7 @@ name = "OGC" dynamic = ["version"] description = "OGC WCS and WMS server" authors = [{ name = "Creare" }] -license = "Apache-2.0" # Creare's preferred license -license-files = ["LICENSE.txt"] +license = { file = "LICENSE" } readme = "README.md" classifiers = [ # How mature is this project? Common values are @@ -50,10 +49,10 @@ dev = [ # TESTING "pytest", "pytest-mock", - "pytest-cov", "pytest-html", "pytest-remotedata", "recommonmark", + "coverage", # FORMATTING "pre_commit", "black", @@ -68,3 +67,8 @@ testpaths = ["ogc"] [tool.black] line-length = 120 + +[tool.coverage.run] +relative_files = true +source = ["ogc/"] +branch = true diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..43c8211 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,6 @@ + +sonar.projectKey=creare-com_ogc +sonar.organization=creare-com +sonar.qualitygate.wait=true +sonar.python.version=3.8 +sonar.python.coverage.reportPaths=coverage.xml \ No newline at end of file From aa077ca9c04d6e9ae7ffb355cb2e33bb38384d60 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 26 Nov 2025 15:49:21 +0000 Subject: [PATCH 19/69] 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 20/69] 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 21/69] 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 22/69] 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 23/69] 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 24/69] 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 25/69] 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 26/69] 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 27/69] 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 28/69] 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 ) From 3922eedd773460aae0c3c89e72e64aa4df0fd4a2 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 18 Dec 2025 22:52:39 +0000 Subject: [PATCH 29/69] Allow format specification regardless of case --- ogc/servers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ogc/servers.py b/ogc/servers.py index 8b4932e..b7b859e 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -288,11 +288,15 @@ def wrapper(*args, **kwargs) -> Response: allowed_chars = r'-A-Za-z0-9 +.,_/:*\{\}\(\)\[\]"' match_one_unallowed_char = "[^%s]" % allowed_chars filtered_args = { + # Convert keys to lower-case. # Find every unallowed char in the value and replace it # with nothing (remove it). - k: re.sub(match_one_unallowed_char, "", str(v)) + k.lower(): re.sub(match_one_unallowed_char, "", str(v)) for (k, v) in request.args.items() } + # Replace format with its lowercase version to match pygeoapi expectations + if filtered_args.get("f", None): + filtered_args["f"] = filtered_args["f"].lower() # Replace the arguments with the filtered option request.args = ImmutableMultiDict(filtered_args) pygeoapi_request = APIRequest.from_flask(request, ["en"]) From 46c760eefe4a2f6f7f7eb63158ebeeacf2d968e3 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 18 Dec 2025 22:54:37 +0000 Subject: [PATCH 30/69] Fix configuration errors due to pickling failure --- ogc/edr/edr_config.py | 8 ++- ogc/edr/edr_provider.py | 50 +++++++++++++--- ogc/edr/edr_routes.py | 3 +- ogc/edr/test/test_edr_provider.py | 94 ++++++++++++++++++++++++------- 4 files changed, 123 insertions(+), 32 deletions(-) diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 100b262..5bdd87d 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -51,12 +51,12 @@ def get_configuration(base_url: str, layers: List[pogc.Layer]) -> Dict[str, Any] # Add the data resources and provider information resources = configuration.get("resources", {}) - configuration["resources"] = resources | EdrConfig._resources_definition(layers) + configuration["resources"] = resources | EdrConfig._resources_definition(base_url, layers) return configuration @staticmethod - def _resources_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: + def _resources_definition(base_url: str, 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. @@ -66,6 +66,8 @@ def _resources_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: Parameters ---------- + base_url : str + The base URL used as an identifier for the given layers. layers : List[pogc.Layer] The layers which define the data sources for the EDR server. @@ -98,7 +100,7 @@ def _resources_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: "default": True, "name": "ogc.edr.edr_provider.EdrProvider", "data": group_name, - "layers": group_layers, + "base_url": base_url, "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 897d12a..b30b03b 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -4,6 +4,7 @@ import zipfile import traitlets as tl from datetime import datetime +from collections import defaultdict from typing import List, Dict, Tuple, Any from shapely.geometry.base import BaseGeometry from pygeoapi.provider.base import ProviderConnectionError, ProviderInvalidQueryError @@ -17,6 +18,37 @@ class EdrProvider(BaseEDRProvider): """Custom provider to be used with layer data sources.""" + _layers_dict = defaultdict(list) + + @classmethod + def set_layers(cls, base_url: str, layers: List[pogc.Layer]): + """Set the layer resources which will be available to the provider. + + Parameters + ---------- + base_url : str + The base URL that the layers are available on. + layers : List[pogc.Layer] + The layers which the provider will have access to. + """ + cls._layers_dict[base_url] = layers + + @classmethod + def get_layers(cls, base_url: str) -> List[pogc.Layer]: + """Get the layer resources for a specific base URL. + + Parameters + ---------- + base_url : str + The base URL for the layers. + + Returns + ------- + List[pogc.Layer] + The layers associated with the base URL. + """ + return cls._layers_dict.get(base_url, []) + def __init__(self, provider_def: Dict[str, Any]): """Construct the provider using the provider definition. @@ -30,7 +62,7 @@ def __init__(self, provider_def: Dict[str, Any]): ProviderConnectionError Raised if the specified collection is not found within any layers. ProviderConnectionError - Raised if the provider does not specify any data sources. + Raised if the provider does not specify any base URL. """ super().__init__(provider_def) collection_id = provider_def.get("data", None) @@ -39,9 +71,9 @@ 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.") + self.base_url = provider_def.get("base_url", None) + if self.base_url is None: + raise ProviderConnectionError("Valid URL identifier not found for the data.") @property def parameters(self) -> Dict[str, pogc.Layer]: @@ -54,7 +86,9 @@ def parameters(self) -> Dict[str, pogc.Layer]: 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} + return { + layer.identifier: layer for layer in self.get_layers(self.base_url) 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. @@ -150,7 +184,8 @@ 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(requested_native_coordinates.crs if requested_native_coordinates else None) - return self.to_coverage_json(self.layers, dataset, crs) + layers = self.get_layers(self.base_url) + return self.to_coverage_json(layers, dataset, crs) else: return self.to_geotiff_response(dataset, self.collection_id) @@ -288,7 +323,8 @@ def instances(self, **kwargs) -> List[str]: The instances available in the collection. """ instances = set() - for layer in self.layers: + layers = self.get_layers(self.base_url) + for layer in layers: if layer.group == self.collection_id: instances.update(layer.time_instances()) return list(instances) diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index 1c752da..30b4625 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -15,6 +15,7 @@ from ogc import podpac as pogc from .edr_config import EdrConfig +from .edr_provider import EdrProvider class EdrRoutes(tl.HasTraits): @@ -51,7 +52,7 @@ def create_api(self) -> pygeoapi.api.API: # 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"] = "" - + EdrProvider.set_layers(self.base_url, self.layers) config = EdrConfig.get_configuration(self.base_url, self.layers) open_api = get_oas(config, fail_on_invalid_collection=False) return pygeoapi.api.API(config=deepcopy(config), openapi=open_api) diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index a7e89b6..369297e 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -10,7 +10,7 @@ from pygeoapi.provider.base import ProviderInvalidQueryError -def get_provider_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: +def get_provider_definition(base_url: str) -> Dict[str, Any]: """Define the provider definition which is typically handled by pygeoapi. Parameters @@ -28,7 +28,7 @@ def get_provider_definition(layers: List[pogc.Layer]) -> Dict[str, Any]: "default": True, "name": "ogc.edr.edr_provider.EdrProvider", "data": "Layers", - "layers": layers, + "base_url": base_url, "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"}, } @@ -42,12 +42,32 @@ def test_edr_provider_resources(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ + base_url = "/" identifiers = [layer.identifier for layer in layers] - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) - assert len(provider.layers) == len(layers) - assert all(layer.identifier in identifiers for layer in provider.layers) + assert len(provider.get_layers(base_url)) == len(layers) + assert all(layer.identifier in identifiers for layer in provider.get_layers(base_url)) + + +def test_edr_provider_resources_limited_by_url(layers: List[pogc.Layer]): + """Test the available resources of the EDR Provider class are limited by URL. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + """ + base_url = "/" + invalid_url = "/invalid" + + provider = EdrProvider(provider_def=get_provider_definition(invalid_url)) + provider.set_layers(base_url, layers) + + assert len(provider.get_layers(invalid_url)) == 0 + assert len(provider.get_layers(base_url)) == len(layers) def test_edr_provider_get_instance_valid_id(layers: List[pogc.Layer]): @@ -58,9 +78,11 @@ def test_edr_provider_get_instance_valid_id(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ + base_url = "/" time_instance = next(iter(layers[0].time_instances())) - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) assert provider.get_instance(time_instance) == time_instance @@ -73,7 +95,9 @@ def test_edr_provider_get_instance_invalid_id(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ - provider = EdrProvider(provider_def=get_provider_definition(layers)) + base_url = "/" + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) assert provider.get_instance("invalid") is None @@ -86,9 +110,11 @@ def test_edr_provider_parameter_keys(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ + base_url = "/" identifiers = [layer.identifier for layer in layers] - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) parameters = provider.parameters assert len(list(parameters.keys())) == len(layers) @@ -103,10 +129,12 @@ def test_edr_provider_instances(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ + base_url = "/" instance_sets = [layer.time_instances() for layer in layers] time_instances = set().union(*instance_sets) - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) instances = provider.instances() assert len(instances) == len(time_instances) @@ -121,9 +149,11 @@ def test_edr_provider_get_fields(layers: List[pogc.Layer]): layers : List[pogc.Layer] Layers provided by a test fixture. """ + base_url = "/" identifiers = [layer.identifier for layer in layers] - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) fields = provider.get_fields() assert len(fields.keys()) == len(layers) @@ -143,12 +173,14 @@ def test_edr_provider_position_request_valid_wkt( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" 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] - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) response = provider.position(**args) @@ -173,11 +205,13 @@ def test_edr_provider_position_request_invalid_wkt( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal del args["bbox"] args["wkt"] = "invalid" - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) with pytest.raises(ProviderInvalidQueryError): provider.position(**args) @@ -196,10 +230,12 @@ def test_edr_provider_position_request_invalid_property( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal args["select_properties"] = "invalid" - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) with pytest.raises(ProviderInvalidQueryError): provider.position(**args) @@ -218,10 +254,12 @@ def test_edr_provider_cube_request_valid_bbox( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal parameter_name = single_layer_cube_args_internal["select_properties"][0] - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) response = provider.cube(**args) @@ -246,10 +284,12 @@ def test_edr_provider_cube_request_invalid_bbox( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal args["bbox"] = "invalid" - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) with pytest.raises(ProviderInvalidQueryError): provider.cube(**args) @@ -268,10 +308,12 @@ def test_edr_provider_cube_request_invalid_altitude( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal args["z"] = "invalid" - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) with pytest.raises(ProviderInvalidQueryError): provider.position(**args) @@ -288,12 +330,14 @@ def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_la single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" 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] - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) response = provider.area(**args) @@ -318,11 +362,13 @@ def test_edr_provider_area_request_invalid_wkt( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal del args["bbox"] args["wkt"] = "invalid" - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) with pytest.raises(ProviderInvalidQueryError): provider.area(**args) @@ -341,10 +387,12 @@ def test_edr_provider_cube_request_invalid_datetime( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal args["datetime_"] = "10_24/2025" - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) with pytest.raises(ProviderInvalidQueryError): provider.cube(**args) @@ -363,11 +411,13 @@ def test_edr_provider_cube_request_valid_geotiff_format( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal args["format_"] = "geotiff" parameter_name = single_layer_cube_args_internal["select_properties"][0] - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) response = provider.cube(**args) @@ -388,6 +438,7 @@ def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters( single_layer_cube_args_internal : Dict[str, Any] Single layer arguments with internal pygeoapi keys provided by a test fixture. """ + base_url = "/" args = single_layer_cube_args_internal args["format_"] = "geotiff" @@ -396,7 +447,8 @@ 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 - provider = EdrProvider(provider_def=get_provider_definition(layers)) + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) response = provider.cube(**args) buffer = io.BytesIO(base64.b64decode(response["fp"])) From ad247267bfaabf4f071a63fed98648b80527a5fe Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 18 Dec 2025 22:55:29 +0000 Subject: [PATCH 31/69] Fix numpy array errors in JSON encoding --- ogc/edr/edr_provider.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index b30b03b..91bf3d1 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -575,6 +575,10 @@ def to_coverage_json( coordinates = next(iter(dataset.values())).coords x_arr, y_arr = EdrProvider.crs_converter(coordinates["lon"].values, coordinates["lat"].values, crs) + # Convert numpy array coordinates to a flattened list. + x_arr = list(x_arr.flatten()) + y_arr = list(y_arr.flatten()) + coverage_json = { "type": "Coverage", "domain": { @@ -582,13 +586,13 @@ def to_coverage_json( "domainType": "Grid", "axes": { "x": { - "start": x_arr[0], - "stop": x_arr[-1], + "start": x_arr[0] if len(x_arr) > 0 else None, + "stop": x_arr[-1] if len(x_arr) > 0 else None, "num": len(x_arr), }, "y": { - "start": y_arr[0], - "stop": y_arr[-1], + "start": y_arr[0] if len(y_arr) > 0 else None, + "stop": y_arr[-1] if len(y_arr) > 0 else None, "num": len(y_arr), }, }, From 58d5775f3d72eaac1f384d0e090874f42fc61601 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 19 Dec 2025 02:50:07 +0000 Subject: [PATCH 32/69] Remove time offsets in output data --- ogc/edr/edr_provider.py | 27 ++++++++++++++++++--------- ogc/edr/test/test_edr_provider.py | 12 +++--------- ogc/edr/test/test_edr_routes.py | 2 +- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 91bf3d1..3024d23 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -177,6 +177,14 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): dataset = {} for requested_parameter, layer in parameters_filtered.items(): units_data_array = layer.node.eval(requested_native_coordinates) + # Recombine stacked temporal dimensions if necessary. + # The temporal output should always be stacked, based on stacked input. + if "time_forecastOffsetHr" in units_data_array.dims: + forecast_offsets = units_data_array.forecastOffsetHr.data.copy() + time_data = units_data_array.time.data.copy() + units_data_array = units_data_array.drop_vars({"time", "time_forecastOffsetHr", "forecastOffsetHr"}) + units_data_array = units_data_array.rename(time_forecastOffsetHr="time") + units_data_array = units_data_array.assign_coords(time=time_data + forecast_offsets) dataset[requested_parameter] = units_data_array self.check_query_condition(len(dataset) == 0, "No matching parameters found.") @@ -737,8 +745,8 @@ def get_native_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. + """Find the intersecting latitude and longitude coordinates between the source and target. + Convert time instances to stacked time and forecast offsets for node evalutation. Parameters ---------- @@ -754,6 +762,9 @@ def get_native_coordinates( podpac.Coordinates The converted coordinates source coordinates intersecting with the target coordinates. """ + # Find intersections with target keeping source crs + source_intersection_coordinates = target_coordinates.intersect(source_coordinates, dims=["lat", "lon"]) + source_intersection_coordinates = source_intersection_coordinates.transform(source_coordinates.crs) # Handle conversion from times and instance to time and offsets if ( "forecastOffsetHr" in target_coordinates.udims @@ -768,14 +779,12 @@ def get_native_coordinates( # This modifies the time coordinates to account for the new forecast offset hour new_coordinates = podpac.Coordinates( - [[source_time_instance], time_deltas], - ["time", "forecastOffsetHr"], + [[[source_time_instance] * len(time_deltas), 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) + source_intersection_coordinates = podpac.coordinates.merge_dims( + [source_intersection_coordinates.udrop(["time", "forecastOffsetHr"]), new_coordinates] + ) return source_intersection_coordinates diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 369297e..12ae5c1 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -184,9 +184,7 @@ def test_edr_provider_position_request_valid_wkt( response = provider.position(**args) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set( - layers[0].node.find_coordinates()[0].dims - ) + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set(["lat", "lon", "time"]) assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -263,9 +261,7 @@ def test_edr_provider_cube_request_valid_bbox( response = provider.cube(**args) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set( - layers[0].node.find_coordinates()[0].dims - ) + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set(["lat", "lon", "time"]) assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -341,9 +337,7 @@ def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_la response = provider.area(**args) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set( - layers[0].node.find_coordinates()[0].dims - ) + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set(["lat", "lon", "time"]) assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index caa5cd6..1431c33 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -226,7 +226,7 @@ def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube assert status == HTTPStatus.OK - assert content["domain"]["ranges"][parameter_name]["axisNames"] == list(layers[0].node.find_coordinates()[0].dims) + assert set(content["domain"]["ranges"][parameter_name]["axisNames"]) == set(["lat", "lon", "time"]) assert np.prod(np.array(content["domain"]["ranges"][parameter_name]["shape"])) == len( content["domain"]["ranges"][parameter_name]["values"] ) From e8d7eb310fd48f5490b40fdf7664f5c729b049be Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 19 Dec 2025 14:29:23 +0000 Subject: [PATCH 33/69] Fix error messages not reaching user --- ogc/edr/edr_provider.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 3024d23..dad1b4e 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -170,7 +170,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): ) self.check_query_condition( - requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE, + bool(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, ) @@ -225,11 +225,13 @@ def position(self, **kwargs): crs = EdrProvider.interpret_crs(crs) if not isinstance(wkt, BaseGeometry): - raise ProviderInvalidQueryError("Invalid wkt provided.") + msg = "Invalid WKT string provided for the position query." + raise ProviderInvalidQueryError(msg, user_msg=msg) elif wkt.geom_type == "Point": lon, lat = EdrProvider.crs_converter([wkt.x], [wkt.y], crs) else: - raise ProviderInvalidQueryError("Unknown WKT Type (Use Point).") + msg = "Unknown WKT string type for the position query (use Point)." + raise ProviderInvalidQueryError(msg, user_msg=msg) requested_coordinates = podpac.Coordinates([lat, lon], dims=["lat", "lon"], crs=crs) @@ -260,7 +262,8 @@ def cube(self, **kwargs): crs = EdrProvider.interpret_crs(crs) if not isinstance(bbox, List) or len(bbox) != 4: - raise ProviderInvalidQueryError("Invalid bounding box provided.") + msg = "Invalid bounding box provided, expected bounding box of (minx, miny, maxx, maxy)." + raise ProviderInvalidQueryError(msg, user_msg=msg) xmin, ymin, xmax, ymax = bbox lon, lat = EdrProvider.crs_converter([xmin, xmax], [ymin, ymax], crs) @@ -297,11 +300,13 @@ def area(self, **kwargs): crs = EdrProvider.interpret_crs(crs) if not isinstance(wkt, BaseGeometry): - raise ProviderInvalidQueryError("Invalid wkt provided.") + msg = "Invalid WKT string provided for the area query." + raise ProviderInvalidQueryError(msg, user_msg=msg) 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).") + msg = "Unknown WKT string type for the area query (use Polygon)." + raise ProviderInvalidQueryError(msg, user_msg=msg) requested_coordinates = podpac.Coordinates([lat, lon], dims=["lat", "lon"], crs=crs) @@ -426,7 +431,8 @@ def interpret_crs(crs: str | None) -> str: 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.") + msg = f"Invalid CRS provided, expected one of {', '.join(settings.EDR_CRS.keys())}" + raise ProviderInvalidQueryError(msg, user_msg=msg) return crs @@ -683,7 +689,7 @@ def check_query_condition(conditional: bool, message: str): Raised if the conditional provided is true. """ if conditional: - raise ProviderInvalidQueryError(message) + raise ProviderInvalidQueryError(message, user_msg=message) @staticmethod def validate_datetime(datetime_string: str) -> bool: From da5a637ae518bc1d11ab74f4d8a7636153dff351 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 19 Dec 2025 15:19:35 +0000 Subject: [PATCH 34/69] Resolve sonarqube findings --- ogc/edr/test/test_edr_provider.py | 6 +++--- ogc/edr/test/test_edr_routes.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 12ae5c1..6797baa 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -184,7 +184,7 @@ def test_edr_provider_position_request_valid_wkt( response = provider.position(**args) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set(["lat", "lon", "time"]) + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -261,7 +261,7 @@ def test_edr_provider_cube_request_valid_bbox( response = provider.cube(**args) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set(["lat", "lon", "time"]) + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -337,7 +337,7 @@ def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_la response = provider.area(**args) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == set(["lat", "lon", "time"]) + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index 1431c33..a1cd183 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -226,7 +226,7 @@ def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube assert status == HTTPStatus.OK - assert set(content["domain"]["ranges"][parameter_name]["axisNames"]) == set(["lat", "lon", "time"]) + assert set(content["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} assert np.prod(np.array(content["domain"]["ranges"][parameter_name]["shape"])) == len( content["domain"]["ranges"][parameter_name]["values"] ) From 3910e9b175c3e70c50d11573a156b51e3985c791 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 5 Jan 2026 16:15:50 +0000 Subject: [PATCH 35/69] Update valid CRS formats for EDR --- ogc/edr/edr_config.py | 10 +++++----- ogc/edr/edr_provider.py | 4 ++-- ogc/edr/test/test_edr_provider.py | 7 ++++--- ogc/settings.py | 8 ++++---- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 5bdd87d..488f83b 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -102,8 +102,8 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, "data": group_name, "base_url": base_url, "crs": [ - "https://www.opengis.net/def/crs/OGC/1.3/CRS84", - "https://www.opengis.net/def/crs/EPSG/0/4326", + settings.crs_84_uri_format, + settings.epsg_4326_uri_format, ], "format": { "name": "geotiff", @@ -163,14 +163,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": "https://www.opengis.net/def/crs/OGC/1.3/CRS84", + "crs": settings.crs_84_uri_format, }, **( { "temporal": { "begin": datetime.fromisoformat(time_range[0]), # start datetime in RFC3339 "end": datetime.fromisoformat(time_range[-1]), # end datetime in RFC3339 - "trs": "https://www.opengis.net/def/uom/ISO-8601/0/Gregorian", + "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", } } if time_range is not None @@ -200,5 +200,5 @@ def _wgs84_bounding_box(layer: pogc.Layer) -> Tuple[float, float, float, float]: layer.grid_coordinates.URC.lat, ) except Exception: - crs_extents = settings.EDR_CRS["crs:84"] + crs_extents = settings.EDR_CRS[settings.crs_84_uri_format] 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 index dad1b4e..dd1530d 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -427,8 +427,8 @@ def interpret_crs(crs: str | None) -> str: ProviderInvalidQueryError Raised if the provided CRS string is unknown. """ - if crs is None or crs.lower() == "crs:84": - return settings.crs_84_pyproj_format # Pyproj acceptable format + if crs is None: + return settings.crs_84_uri_format # Pyproj acceptable format if crs.lower() not in [key.lower() for key in settings.EDR_CRS.keys()]: msg = f"Invalid CRS provided, expected one of {', '.join(settings.EDR_CRS.keys())}" diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 6797baa..3c635af 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -29,7 +29,7 @@ def get_provider_definition(base_url: str) -> Dict[str, Any]: "name": "ogc.edr.edr_provider.EdrProvider", "data": "Layers", "base_url": base_url, - "crs": ["https://www.opengis.net/def/crs/OGC/1.3/CRS84", "https://www.opengis.net/def/crs/EPSG/0/4326"], + "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"}, } @@ -573,12 +573,13 @@ def test_edr_provider_altitude_invalid_string(): 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" + assert EdrProvider.interpret_crs(None) == "http://www.opengis.net/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" + crs = "http://www.opengis.net/def/crs/EPSG/0/4326" + assert EdrProvider.interpret_crs(crs) == crs def test_edr_provider_crs_interpreter_invalid_value(): diff --git a/ogc/settings.py b/ogc/settings.py index a9df028..61f5843 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -10,8 +10,9 @@ # 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" +crs_84_uri_format = "http://www.opengis.net/def/crs/OGC/1.3/CRS84" +epsg_4326_uri_format = "http://www.opengis.net/def/crs/EPSG/0/4326" # Default/Supported WMS CRS/SRS WMS_CRS = { @@ -32,9 +33,8 @@ 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}, - crs_84_pyproj_format: {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, + epsg_4326_uri_format: {"minx": -90.0, "miny": -180.0, "maxx": 90.0, "maxy": 180.0}, + crs_84_uri_format: {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, } # WMS Capabilities timestamp format From afeabcde0477f1f5b2dc833a9f8d14477dbd37be Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 6 Jan 2026 16:13:03 +0000 Subject: [PATCH 36/69] Add EDR links to default home endpoint --- ogc/servers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ogc/servers.py b/ogc/servers.py index b7b859e..d72d8a8 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -54,6 +54,13 @@ def home(endpoint):
  • WMS GetLegend Example (PNG) (v1.3.0)
  • +
  • EDR: Open Geospatial Consortium (OGC) Environmental Data Retrieval (EDR) (v1.0.1) + +
  • """ From 8abf700e87c5e54fb91753cec238ace2925a4f1f Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 7 Jan 2026 09:21:33 -0500 Subject: [PATCH 37/69] Update the EDR configuration server url with request urls --- ogc/edr/edr_routes.py | 38 +++++++++++++++++++++++++++++++++++++- ogc/servers.py | 3 +++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index 30b4625..bdc618f 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -21,7 +21,7 @@ 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/") + base_url = tl.Unicode(default_value="http://127.0.0.1:5000/edr") layers = tl.List(trait=tl.Instance(pogc.Layer)) def __init__(self, **kwargs): @@ -40,6 +40,17 @@ def layers_change(self, change: Dict[str, Any]): """ self.api = self.create_api() + @tl.observe("base_url") + def base_url_change(self, change: Dict[str, Any]): + """Monitor the base url 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. @@ -61,6 +72,24 @@ def clean_configuration_cache(self): """Clean a pygeoapi internal translation cache so that multiple configurations can be used simultaneously.""" pygeoapi.l10n._cfg_cache = {} + def update_configuration_base_url(self, request: pygeoapi.api.APIRequest): + """Update the EDR configuration base URL based on the provided request. + The EDR configuration base URL does not necessarily match the full path of the request base URL. + + Parameters + ---------- + request : pygeoapi.api.APIRequest + The API request containing a base URL. + The base URL should include scheme, host, and path for the request. + """ + request_base_url = request.params.get("base_url", "") + # Limit the base URL from the request to the EDR subdirectory + base_url_partitioned = request_base_url.partition("/edr") + if len(base_url_partitioned[0]) > 0: + configuration_base_url = base_url_partitioned[0] + base_url_partitioned[1] + if configuration_base_url != self.base_url: + self.base_url = configuration_base_url + 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. @@ -75,6 +104,7 @@ def static_files(self, request: pygeoapi.api.APIRequest, file_path: str) -> Tupl Headers, HTTP Status, and Content returned as a tuple to make the server response. """ self.clean_configuration_cache() + self.update_configuration_base_url(request) 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) @@ -102,6 +132,7 @@ def landing_page(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str Headers, HTTP Status, and Content returned as a tuple to make the server response. """ self.clean_configuration_cache() + self.update_configuration_base_url(request) return pygeoapi.api.landing_page(self.api, request) def openapi(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: @@ -118,6 +149,7 @@ def openapi(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | by Headers, HTTP Status, and Content returned as a tuple to make the server response. """ self.clean_configuration_cache() + self.update_configuration_base_url(request) return pygeoapi.api.openapi_(self.api, request) def conformance(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: @@ -134,6 +166,7 @@ def conformance(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str Headers, HTTP Status, and Content returned as a tuple to make the server response. """ self.clean_configuration_cache() + self.update_configuration_base_url(request) return pygeoapi.api.conformance(self.api, request) def describe_collections( @@ -156,6 +189,7 @@ def describe_collections( Headers, HTTP Status, and Content returned as a tuple to make the server response. """ self.clean_configuration_cache() + self.update_configuration_base_url(request) return pygeoapi.api.describe_collections(self.api, request, collection_id) def describe_instances( @@ -181,6 +215,7 @@ def describe_instances( Headers, HTTP Status, and Content returned as a tuple to make the server response. """ self.clean_configuration_cache() + self.update_configuration_base_url(request) return pygeoedr.get_collection_edr_instances(self.api, request, collection_id, instance_id=instance_id) def collection_query( @@ -209,6 +244,7 @@ def collection_query( Headers, HTTP Status, and Content returned as a tuple to make the server response. """ self.clean_configuration_cache() + self.update_configuration_base_url(request) headers, http_status, content = pygeoedr.get_collection_edr_query( self.api, request, collection_id, instance_id, query_type=query_type, location_id=None ) diff --git a/ogc/servers.py b/ogc/servers.py index d72d8a8..fe8fda8 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -304,6 +304,9 @@ def wrapper(*args, **kwargs) -> Response: # Replace format with its lowercase version to match pygeoapi expectations if filtered_args.get("f", None): filtered_args["f"] = filtered_args["f"].lower() + + filtered_args["base_url"] = request.base_url + # Replace the arguments with the filtered option request.args = ImmutableMultiDict(filtered_args) pygeoapi_request = APIRequest.from_flask(request, ["en"]) From 6a4708a28bcc1d7ba6e528318390a43124dd662f Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 8 Jan 2026 15:31:12 +0000 Subject: [PATCH 38/69] Update default python version to 3.12 --- .devcontainer/dev_container.dockerfile | 2 +- .github/workflows/github-python-workflow.yml | 4 ++-- sonar-project.properties | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.devcontainer/dev_container.dockerfile b/.devcontainer/dev_container.dockerfile index 741b275..f602887 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.11-slim +ARG BASE_URL=python:3.12-slim FROM ${BASE_URL} USER root diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml index 69a4f81..45ab8bc 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.11" + python-version: "3.12" - name: Install dependencies run: | @@ -64,7 +64,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v3 with: - python-version: "3.11" + python-version: "3.12" - name: Install dependencies run: | diff --git a/sonar-project.properties b/sonar-project.properties index e0b3003..810a66f 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.11 +sonar.python.version=3.12 sonar.python.coverage.reportPaths=coverage.xml \ No newline at end of file From 47ca81dd9574ef4aff01e42e4d145a4ed2a81cf4 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 8 Jan 2026 17:41:05 +0000 Subject: [PATCH 39/69] Fix mapping and add default css --- ogc/edr/config/default.json | 2 +- ogc/edr/static/css/default.css | 86 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 ogc/edr/static/css/default.css diff --git a/ogc/edr/config/default.json b/ogc/edr/config/default.json index c827b04..cf2a734 100644 --- a/ogc/edr/config/default.json +++ b/ogc/edr/config/default.json @@ -16,7 +16,7 @@ "admin": false, "map": { "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - "attribution": "'© OpenStreetMap contributors'" + "attribution": "© OpenStreetMap contributors" } }, "logging": { diff --git a/ogc/edr/static/css/default.css b/ogc/edr/static/css/default.css new file mode 100644 index 0000000..36f9261 --- /dev/null +++ b/ogc/edr/static/css/default.css @@ -0,0 +1,86 @@ +.flat { + border: 0px; +} + +header { + display: inline-block; +} + +main { + background-color: white; +} + +.crumbs { + background-color: rgb(230, 230, 230); + padding: 6px; +} + +.crumbs a { + padding: 0px 6px; + color: black; + text-decoration: none; + /* text-transform: capitalize;*/ +} + +#items-map, +#collection-map { + width: 100%; + height: 400px; +} + +#coverages-map { + width: 100%; + height: 80vh; +} + +.c3-tooltip-container { + z-index: 300; +} + +/* cancel mini-css header>button uppercase */ +header button, +header [type="button"], +header .button, +header [role="button"] { + text-transform: none; +} + +html { + background-color: #fff; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: #fff; +} + +footer.sticky-bottom { + margin-top: auto; +} + +main { + padding-bottom: 65px; + /* prevent from falling under the footer */ +} + +table:not(.horizontal) { + max-height: none; +} + +mark.successful { + background-color: green; +} + +mark.accepted { + background-color: default; +} + +mark.failed { + background-color: red; +} + +mark.running { + background-color: orange; +} \ No newline at end of file From 77226db2db128cfddc0396033323848bc7d6791d Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 8 Jan 2026 18:10:12 +0000 Subject: [PATCH 40/69] Add URL configuration change test case --- ogc/edr/test/test_edr_routes.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index a1cd183..caa0cbd 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -24,7 +24,7 @@ def mock_request(request_args: Dict[str, Any] = {}) -> APIRequest: APIRequest Mock API request for route testing. """ - environ = create_environ(base_url="http://127.0.0.1:5000/ogc/") + environ = create_environ(base_url="http://127.0.0.1:5000/ogc/edr") request = Request(environ) request.args = ImmutableMultiDict(request_args.items()) return APIRequest(request, ["en"]) @@ -336,3 +336,16 @@ def test_edr_routes_collection_query_missing_parameter( ) assert status == HTTPStatus.BAD_REQUEST + + +def test_edr_routes_request_url_updates_configuration_url(): + """Test the EDR routes request base URL updates the configuration URL.""" + request_url = "http://test:5000/ogc/edr/static/img/logo.png" + expected_config_url = "http://test:5000/ogc/edr" + request = mock_request({"base_url": request_url}) + edr_routes = EdrRoutes(layers=[]) + + _, status, _ = edr_routes.static_files(request, "img/logo.png") + + assert status == HTTPStatus.OK + assert edr_routes.api.config["server"]["url"] == expected_config_url From 63c99a7ecde3762162fc812f9ee5850e155376d3 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 8 Jan 2026 18:59:11 +0000 Subject: [PATCH 41/69] Convert additional formats to lowercase --- ogc/edr/edr_routes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index bdc618f..35160cd 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -61,8 +61,8 @@ def create_api(self) -> pygeoapi.api.API: """ # 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"] = "" + pygeoapi.plugin.PLUGINS["formatter"]["geotiff"] = "" + pygeoapi.plugin.PLUGINS["formatter"]["coveragejson"] = "" EdrProvider.set_layers(self.base_url, self.layers) config = EdrConfig.get_configuration(self.base_url, self.layers) open_api = get_oas(config, fail_on_invalid_collection=False) From 2353f696530426cfc37c5a2c5adbc7b2e4164d76 Mon Sep 17 00:00:00 2001 From: scranford1 Date: Fri, 6 Feb 2026 10:09:00 -0500 Subject: [PATCH 42/69] Feature/edr compliance (#14) * Specify media type for query requests * Force log level to be set from configuration * Improve default formatting for requests * Fix coverage metadata based on coordinates * Improve EDR based on compliance testing * HTML support to prepare for future availability * Add custom formatters for Geotiff and CoverageJSON * Use abstract only for description * CoverageJSON parameters match metadata parameters * Resolve sonarqube cognitive complexity issues * Testing addition and altitude/time change * Update formatters to use a common base class * Add coverage for get altitudes method * Additional CRS addition for altitude --- ogc/edr/edr_api.py | 338 ++++++++++++++++++++ ogc/edr/edr_config.py | 92 ++++-- ogc/edr/edr_formatter.py | 69 +++++ ogc/edr/edr_provider.py | 492 ++++++++++++++++++++---------- ogc/edr/edr_routes.py | 20 +- ogc/edr/test/test_edr_config.py | 2 +- ogc/edr/test/test_edr_provider.py | 79 +++-- ogc/edr/test/test_edr_routes.py | 8 +- ogc/podpac.py | 39 ++- ogc/servers.py | 12 +- ogc/settings.py | 9 + 11 files changed, 935 insertions(+), 225 deletions(-) create mode 100644 ogc/edr/edr_api.py create mode 100644 ogc/edr/edr_formatter.py diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py new file mode 100644 index 0000000..bcdf36b --- /dev/null +++ b/ogc/edr/edr_api.py @@ -0,0 +1,338 @@ +import json +import numpy as np +import pygeoapi.api +import pygeoapi.api.environmental_data_retrieval as pygeoedr +from http import HTTPStatus +from datetime import datetime, timezone +from typing import Tuple, List, Dict, Any, Union +from ogc import podpac as pogc +from pygeoapi.plugin import load_plugin +from pygeoapi.util import filter_dict_by_key_value, to_json, get_provider_by_type +from pygeoapi.api import API, APIRequest +from pygeoapi.linked_data import jsonldify +from .edr_provider import EdrProvider + + +class EdrAPI: + """Used to modify the default responses before returning data to the user.""" + + @jsonldify + @staticmethod + def landing_page(api: API, request: APIRequest) -> Tuple[dict, int, str]: + """Provide the API landing page. + + Parameters + ---------- + api : API + The API which handles the request. + request : APIRequest + The request object. + + Returns + ------- + Tuple[dict, int, str] + Headers, HTTP Status, and Content returned as a tuple. + """ + return pygeoapi.api.landing_page(api, request) + + @staticmethod + def openapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]: + """Provide the OpenAPI documentation. + + Parameters + ---------- + api : API + The API which handles the request. + request : APIRequest + The request object. + + Returns + ------- + Tuple[dict, int, str] + Headers, HTTP Status, and Content returned as a tuple. + """ + return pygeoapi.api.openapi_(api, request) + + @staticmethod + def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: + """Provide the conformance definition. + + Parameters + ---------- + api : API + The API which handles the request. + request : APIRequest + The request object. + + Returns + ------- + Tuple[dict, int, str] + Headers, HTTP Status, and Content returned as a tuple. + """ + return pygeoapi.api.conformance(api, request) + + @jsonldify + @staticmethod + def describe_collections(api: API, request: APIRequest, dataset: str | None = None) -> Tuple[dict, int, str]: + """Provide the collection/collections metadata. + + Overrides default functionality to append additional metadata to collections. + + Parameters + ---------- + api : API + The API which handles the request. + request : APIRequest + The request object. + dataset : str | None, optional + The dataset (collection) to be described or None for all collections, by default None. + + Returns + ------- + Tuple[dict, int, str] + Headers, HTTP Status, and Content returned as a tuple. + """ + headers, status, content = pygeoapi.api.describe_collections(api, request, dataset) + if request.format != pygeoapi.api.F_JSON or status != HTTPStatus.OK: + return headers, status, content + + collection_description = json.loads(content) + collection_configuration = filter_dict_by_key_value(api.config["resources"], "type", "collection") + collections = [collection_description] if dataset is not None else collection_description.get("collections", []) + + for collection in collections: + collection_id = collection["id"] + provider = get_provider_by_type(collection_configuration[collection_id]["providers"], "edr") + provider_plugin = load_plugin("provider", provider) + provider_parameters = provider_plugin.get_fields() + + extents = collection.get("extent", {}) + if "vertical" in collection_configuration[collection_id]["extents"]: + extents = extents | {"vertical": collection_configuration[collection_id]["extents"]["vertical"]} + if "temporal" in collection_configuration[collection_id]["extents"]: + times = collection_configuration[collection_id]["extents"]["temporal"].get("values", []) + trs = collection_configuration[collection_id]["extents"]["temporal"].get("trs") + temporal_extents = EdrAPI._temporal_extents(times, trs) + extents = extents | temporal_extents + collection["extent"] = extents + + collection["output_formats"] = collection_configuration[collection_id].get("output_formats", []) + + height_units = collection_configuration[collection_id].get("height_units", []) + for query_type in collection["data_queries"]: + data_query_additions = { + "query_type": query_type, + **({"height_units": height_units} if query_type == "cube" else {}), + } + variables = collection["data_queries"][query_type]["link"].get("variables", {}) + collection["data_queries"][query_type]["link"]["variables"] = variables | data_query_additions + + for parameter in collection["parameter_names"]: + collection["parameter_names"][parameter] = collection["parameter_names"][parameter] | { + "description": provider_parameters[parameter].get("description", "") + } + + return headers, status, to_json(collection_description, api.pretty_print) + + @staticmethod + def get_collection_edr_instances( + api: API, request: APIRequest, dataset: str, instance_id: str | None = None + ) -> Tuple[dict, int, str]: + """Provide the instance/instances metadata. + + Overrides default functionality to append additional metadata to instances. + + Parameters + ---------- + api : API + The API which handles the request. + request : APIRequest + The request object. + dataset : str + The dataset (collection) to be described. + instance_id : str | None, optional + The instance to be described or None for all instances, by default None. + + Returns + ------- + Tuple[dict, int, str] + Headers, HTTP Status, and Content returned as a tuple. + """ + headers, status, content = pygeoedr.get_collection_edr_instances(api, request, dataset, instance_id) + if request.format != pygeoapi.api.F_JSON or status != HTTPStatus.OK: + return headers, status, content + + instance_description = json.loads(content) + collection_configuration = filter_dict_by_key_value(api.config["resources"], "type", "collection") + provider = get_provider_by_type(collection_configuration[dataset]["providers"], "edr") + provider_plugin = load_plugin("provider", provider) + provider_parameters = provider_plugin.get_fields() + instances = [instance_description] if instance_id is not None else instance_description.get("instances", []) + collection_layers = EdrProvider.get_layers(provider["base_url"], dataset) + + for instance in instances: + extents = instance.get("extent", {}) + if "vertical" in collection_configuration[dataset]["extents"]: + extents = extents | {"vertical": collection_configuration[dataset]["extents"]["vertical"]} + + times = EdrProvider.get_datetimes(collection_layers, instance["id"]) + if len(times) > 0: + trs = collection_configuration[dataset]["extents"]["temporal"].get("trs") + temporal_extents = EdrAPI._temporal_extents(times, trs) + extents = extents | temporal_extents + + bbox = collection_configuration[dataset]["extents"]["spatial"]["bbox"] + if not isinstance(bbox[0], list): + bbox = [bbox] + crs = collection_configuration[dataset]["extents"]["spatial"].get("crs") + spatial_extents = EdrAPI._spatial_extents(bbox, crs) + extents = extents | spatial_extents + instance["extent"] = extents + + instance["output_formats"] = collection_configuration[dataset].get("output_formats", []) + + height_units = collection_configuration[dataset].get("height_units") + for query_type in instance["data_queries"]: + data_query_additions = { + "query_type": query_type, + **({"height_units": height_units} if query_type == "cube" else {}), + } + variables = instance["data_queries"][query_type]["link"].get("variables", {}) + instance["data_queries"][query_type]["link"]["variables"] = variables | data_query_additions + + instance["parameter_names"] = EdrAPI._instance_parameters( + collection_layers, provider_parameters, instance["id"] + ) + + return headers, status, to_json(instance_description, api.pretty_print) + + @staticmethod + def get_collection_edr_query( + api: API, + request: APIRequest, + dataset: str, + instance: str | None, + query_type: str, + location_id: str | None = None, + ) -> Tuple[dict, int, str]: + """Query the collection or instance. + + Parameters + ---------- + api : API + The API which handles the request. + request : APIRequest + The request object. + dataset : str + The dataset (collection) to be queried. + instance_id : str | None + The instance to be queried or None if querying a collection. + query_type : str + The query type. + location_id : str | None, optional + Location identifier for location queries or None, by default None. + + Returns + ------- + Tuple[dict, int, str] + Headers, HTTP Status, and Content returned as a tuple. + """ + return pygeoedr.get_collection_edr_query(api, request, dataset, instance, query_type, location_id) + + @staticmethod + def _temporal_extents(times: List[Union[np.datetime64, datetime]], trs: str | None) -> Dict[str, Any]: + """Get the temporal extents for the provided times and reference system. + + Parameters + ---------- + times : List[Union[np.datetime64, datetime]] + Times used to create the temporal extent. + trs : str | None + The reference system for the times. + + Returns + ------- + Dict[str, Any] + The temporal extent object. + """ + iso_times = [] + for time in sorted(times): + dt = time.astype("datetime64[ms]").astype(datetime) if isinstance(time, np.datetime64) else time + if dt.tzinfo is None: + time_utc = dt.replace(tzinfo=timezone.utc) + else: + time_utc = dt.astimezone(timezone.utc) + iso_times.append(time_utc.isoformat().replace("+00:00", "Z")) + + return { + "temporal": { + "interval": [iso_times[0], iso_times[-1]] if len(iso_times) > 0 else [], + "values": iso_times, + "trs": trs, + } + } + + @staticmethod + def _spatial_extents(bbox: List[float], crs: str | None) -> Dict[str, Any]: + """Get the spatial extents for the provided bbox and reference system. + + Parameters + ---------- + bbox : List[float] + Bounding box used to create the spatial extent. + crs : str | None + The reference system for the bounding box. + + Returns + ------- + Dict[str, Any] + The spatial extent object. + """ + return { + "spatial": { + "bbox": bbox, + **({"crs": crs} if crs is not None else {}), + } + } + + @staticmethod + def _instance_parameters( + collection_layers: List[pogc.Layer], provider_parameters: Dict[str, Any], instance: str + ) -> Dict[str, Any]: + """Get the parameter metadata for the specific instance provided. + + Parameters + ---------- + collection_layers : List[pogc.Layer] + The layers available in the collection. + provider_parameters: Dict[str, Any] + The metadata for all available parameters in the collection from provider fields. + instance: str + The instance to determine parameters for. + + Returns + ------- + Dict[str, Any] + The metadata for available parameters in the instance. + """ + instance_parameters = {"parameter_names": {}} + for key, value in provider_parameters.items(): + layer = next((layer for layer in collection_layers if layer.identifier == key), None) + if layer is not None and instance in layer.time_instances(): + instance_parameters[key] = { + "id": key, + "type": "Parameter", + "name": value["title"], + "observedProperty": { + "label": {"id": key, "en": value["title"]}, + }, + "description": value["description"], + "unit": { + "label": {"en": value["title"]}, + "symbol": { + "value": value["x-ogc-unit"], + "type": "http://www.opengis.net/def/uom/UCUM/", + }, + }, + } + return instance_parameters diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 488f83b..4aa2a0b 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -1,8 +1,7 @@ import os import json -import numpy as np +import logging 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 @@ -53,6 +52,11 @@ def get_configuration(base_url: str, layers: List[pogc.Layer]) -> Dict[str, Any] resources = configuration.get("resources", {}) configuration["resources"] = resources | EdrConfig._resources_definition(base_url, layers) + # Force the log level based on the configuration as it is loaded, otherwise it is ignored + if configuration.get("logging", {}).get("level"): + api_logger = logging.getLogger("pygeoapi") + api_logger.setLevel(configuration["logging"]["level"]) + return configuration @staticmethod @@ -94,6 +98,8 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, "description": f"Collection of data related to {group_name}", "keywords": ["podpac"], "extents": EdrConfig._generate_extents(group_layers), + "height_units": EdrConfig._vertical_units(group_layers), + "output_formats": settings.EDR_QUERY_FORMATS, "providers": [ { "type": "edr", @@ -101,16 +107,23 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, "name": "ogc.edr.edr_provider.EdrProvider", "data": group_name, "base_url": base_url, - "crs": [ - settings.crs_84_uri_format, - settings.epsg_4326_uri_format, - ], + "crs": list(settings.EDR_CRS.keys()), "format": { - "name": "geotiff", + "name": settings.GEOTIFF, "mimetype": "image/tiff", }, } ], + "formatters": [ + { + "name": "ogc.edr.edr_formatter.GeoTiffFormatter", + "mimetype": "image/tiff", + }, + { + "name": "ogc.edr.edr_formatter.CoverageJsonFormatter", + "mimetype": "application/prs.coverage+json", + }, + ], } } resources.update(resource) @@ -132,8 +145,8 @@ def _generate_extents(layers: List[pogc.Layer]) -> 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 + time_range = set() + vertical_range = set() # 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) @@ -145,20 +158,15 @@ def _generate_extents(layers: List[pogc.Layer]) -> Dict[str, Any]: urc_lon = max(urc_lon, urc_lon_tmp) urc_lat = max(urc_lat, urc_lat_tmp) + coordinates_list = layer.get_coordinates_list() + if len(coordinates_list) > 0 and "alt" in coordinates_list[0].udims: + vertical_range.update(coordinates_list[0]["alt"].coordinates) + 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(), - ] + time_range.update(layer.valid_times) + + sorted_time_range = sorted(time_range) + sorted_vertical_range = sorted(vertical_range) return { "spatial": { @@ -168,12 +176,24 @@ def _generate_extents(layers: List[pogc.Layer]) -> Dict[str, Any]: **( { "temporal": { - "begin": datetime.fromisoformat(time_range[0]), # start datetime in RFC3339 - "end": datetime.fromisoformat(time_range[-1]), # end datetime in RFC3339 + "begin": sorted_time_range[0], # start datetime in RFC3339 + "end": sorted_time_range[-1], # end datetime in RFC3339 + "values": sorted_time_range, "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", } } - if time_range is not None + if len(sorted_time_range) > 0 + else {} + ), + **( + { + "vertical": { + "interval": [sorted_vertical_range[0], sorted_vertical_range[-1]], + "values": sorted_vertical_range, + "vrs": "http://www.opengis.net/def/uom/EPSG/0/9001", + } + } + if len(sorted_vertical_range) > 0 else {} ), } @@ -202,3 +222,25 @@ def _wgs84_bounding_box(layer: pogc.Layer) -> Tuple[float, float, float, float]: except Exception: crs_extents = settings.EDR_CRS[settings.crs_84_uri_format] return (crs_extents["minx"], crs_extents["miny"], crs_extents["maxx"], crs_extents["maxy"]) + + @staticmethod + def _vertical_units(layers: List[pogc.Layer]) -> List[str]: + """Retrieve the vertical units for the layers. + + Parameters + ---------- + layer : List[pogc.Layer] + The layers from which to get the vertical units. + + Returns + ------- + str | None + The vertical units available for the layers. + """ + vertical_units = set() + for layer in layers: + coordinates_list = layer.get_coordinates_list() + if len(coordinates_list) > 0 and coordinates_list[0].alt_units: + vertical_units.add(coordinates_list[0].alt_units) + + return list(vertical_units) diff --git a/ogc/edr/edr_formatter.py b/ogc/edr/edr_formatter.py new file mode 100644 index 0000000..e3f3aab --- /dev/null +++ b/ogc/edr/edr_formatter.py @@ -0,0 +1,69 @@ +from pygeoapi.formatter.base import BaseFormatter +from pygeoapi.util import to_json + + +class BaseEdrFormatter(BaseFormatter): + """Base formatter for EDR data.""" + + def __init__(self, formatter_def: dict): + """Initialize the formatter. + + Parameters + ---------- + formatter_def : dict + The formatter definition. + """ + + super().__init__(formatter_def) + self.mimetype = formatter_def["mimetype"] + + def write(self, options: dict = {}, data: dict | None = None) -> str: + """Generate data in the specified format. + + Parameters + ---------- + options : dict, optional + Formatting options, by default {}. + data : dict | None, optional + Dictionary representation of the data, by default None. + + Returns + ------- + str + String representation of the data. + """ + return to_json(data, True) if data is not None else "" + + +class GeoTiffFormatter(BaseEdrFormatter): + """Formatter for GeoTIFF data. Defined for format link information to be populated.""" + + def __init__(self, formatter_def: dict): + """Initialize the formatter. + + Parameters + ---------- + formatter_def : dict + The formatter definition. + """ + + super().__init__(formatter_def) + self.f = "geotiff" + self.extension = "tiff" + + +class CoverageJsonFormatter(BaseEdrFormatter): + """Formatter for CoverageJSON data. Defined for format link information to be populated.""" + + def __init__(self, formatter_def: dict): + """Initialize the formatter. + + Parameters + ---------- + formatter_def : dict + The formatter definition. + """ + + super().__init__(formatter_def) + self.f = "coveragejson" + self.extension = "json" diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index dd1530d..ada92fe 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -2,7 +2,6 @@ import io import numpy as np import zipfile -import traitlets as tl from datetime import datetime from collections import defaultdict from typing import List, Dict, Tuple, Any @@ -34,20 +33,27 @@ def set_layers(cls, base_url: str, layers: List[pogc.Layer]): cls._layers_dict[base_url] = layers @classmethod - def get_layers(cls, base_url: str) -> List[pogc.Layer]: - """Get the layer resources for a specific base URL. + def get_layers(cls, base_url: str, group: str | None = None) -> List[pogc.Layer]: + """Get the layer resources for a specific base URL and group. Parameters ---------- base_url : str The base URL for the layers. + group : str | None, optional + Optional group to filter layers, by default None. + Returns ------- List[pogc.Layer] The layers associated with the base URL. """ - return cls._layers_dict.get(base_url, []) + layers = cls._layers_dict.get(base_url, []) + if group is not None: + return [layer for layer in layers if layer.group.lower() == group.lower()] + else: + return layers def __init__(self, provider_def: Dict[str, Any]): """Construct the provider using the provider definition. @@ -65,14 +71,15 @@ def __init__(self, provider_def: Dict[str, Any]): Raised if the provider does not specify any base URL. """ super().__init__(provider_def) - collection_id = provider_def.get("data", None) + collection_id = provider_def.get("data") if collection_id is None: raise ProviderConnectionError("Data not found.") self.collection_id = str(collection_id) + self.native_format = provider_def["format"]["name"] - self.base_url = provider_def.get("base_url", None) - if self.base_url is None: + self.base_url = provider_def.get("base_url", "") + if not self.base_url: raise ProviderConnectionError("Valid URL identifier not found for the data.") @property @@ -86,9 +93,7 @@ def parameters(self) -> Dict[str, pogc.Layer]: Dict[str, pogc.Layer] The parameters as a dictionary of layer identifiers and layer objects. """ - return { - layer.identifier: layer for layer in self.get_layers(self.base_url) if layer.group == self.collection_id - } + return {layer.identifier: layer for layer in self.get_layers(self.base_url, self.collection_id)} def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): """Handle the requests to the EDR server at the specified requested coordinates. @@ -116,18 +121,26 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): Raises ------ + ProviderInvalidQueryError + Raised if an invalid instance is provided. + ProviderInvalidQueryError + Raised if an invalid parameter is provided. + ProviderInvalidQueryError + Raised if an invalid output format is provided. 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 the parameters are invalid. + Raised if a GeoTIFF request includes multiple time bands. ProviderInvalidQueryError - Raised if an instance is provided and it is invalid. + Raised if a GeoTIFF request includes multiple vertical bands. ProviderInvalidQueryError Raised if native coordinates could not be found. ProviderInvalidQueryError Raised if the request queries for native coordinates exceeding the max allowable size. + ProviderInvalidQueryError + Raised if no parameters could not be evaluated. """ instance = kwargs.get("instance") requested_parameters = kwargs.get("select_properties") @@ -135,39 +148,34 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): datetime_arg = kwargs.get("datetime_") z_arg = kwargs.get("z") - 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 - } + instance = self.validate_instance(instance) + requested_parameters = self.validate_parameters(requested_parameters) + output_format = self.validate_output_format(output_format) - 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." + available_times = self.get_datetimes(list(self.parameters.values()), instance) + available_altitudes = self.get_altitudes(list(self.parameters.values())) + time_coords = self.interpret_time_coordinates( + available_times, datetime_arg, instance, requested_coordinates.crs ) + altitude_coords = self.interpret_altitude_coordinates(available_altitudes, z_arg, requested_coordinates.crs) if time_coords is not None: + self.check_query_condition( + len(time_coords["time"].coordinates) > 1 and output_format == settings.GEOTIFF.lower(), + "GeoTIFF output currently only supports single time requests.", + ) requested_coordinates = podpac.coordinates.merge_dims([time_coords, requested_coordinates]) if altitude_coords is not None: + self.check_query_condition( + len(altitude_coords["alt"].coordinates) > 1 and output_format == settings.GEOTIFF.lower(), + "GeoTIFF output currently only supports single altitude requests.", + ) requested_coordinates = podpac.coordinates.merge_dims([altitude_coords, requested_coordinates]) # Handle defining native coordinates for the query, these should match between each layer - coordinates_list = next(iter(parameters_filtered.values())).node.find_coordinates() - + coordinates_list = next(iter(requested_parameters.values())).get_coordinates_list() self.check_query_condition(len(coordinates_list) == 0, "Native coordinates not found.") - - requested_native_coordinates = self.get_native_coordinates( - requested_coordinates, coordinates_list[0], np.datetime64(instance) - ) + requested_native_coordinates = self.get_native_coordinates(requested_coordinates, coordinates_list[0]) self.check_query_condition( bool(requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE), @@ -175,27 +183,23 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): ) dataset = {} - for requested_parameter, layer in parameters_filtered.items(): - units_data_array = layer.node.eval(requested_native_coordinates) - # Recombine stacked temporal dimensions if necessary. - # The temporal output should always be stacked, based on stacked input. - if "time_forecastOffsetHr" in units_data_array.dims: - forecast_offsets = units_data_array.forecastOffsetHr.data.copy() - time_data = units_data_array.time.data.copy() - units_data_array = units_data_array.drop_vars({"time", "time_forecastOffsetHr", "forecastOffsetHr"}) - units_data_array = units_data_array.rename(time_forecastOffsetHr="time") - units_data_array = units_data_array.assign_coords(time=time_data + forecast_offsets) - dataset[requested_parameter] = units_data_array + for requested_parameter, layer in requested_parameters.items(): + units_data_array = EdrProvider.evaluate_layer(requested_native_coordinates, layer) + if units_data_array is not None: + 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": + if ( + output_format == settings.COVERAGE_JSON.lower() + or output_format == settings.JSON.lower() + or output_format == settings.HTML.lower() + ): crs = self.interpret_crs(requested_native_coordinates.crs if requested_native_coordinates else None) - layers = self.get_layers(self.base_url) + layers = self.get_layers(self.base_url, self.collection_id) return self.to_coverage_json(layers, dataset, crs) - else: - return self.to_geotiff_response(dataset, self.collection_id) + + return self.to_geotiff_response(dataset, self.collection_id) def position(self, **kwargs): """Handles requests for the position query type. @@ -261,13 +265,22 @@ def cube(self, **kwargs): crs = kwargs.get("crs") crs = EdrProvider.interpret_crs(crs) - if not isinstance(bbox, List) or len(bbox) != 4: - msg = "Invalid bounding box provided, expected bounding box of (minx, miny, maxx, maxy)." + if not isinstance(bbox, List) or (len(bbox) != 4 and len(bbox) != 6): + msg = ( + "Invalid bounding box provided, " + "expected bounding box of (minx, miny, maxx, maxy) or (minx, miny, minz, maxx, maxy, maxz)." + ) raise ProviderInvalidQueryError(msg, user_msg=msg) - xmin, ymin, xmax, ymax = bbox - lon, lat = EdrProvider.crs_converter([xmin, xmax], [ymin, ymax], crs) + if len(bbox) == 6: + xmin, ymin, zmin, xmax, ymax, zmax = bbox + # Set the z argument if not specified using a closed interval from the bounding box data + if kwargs.get("z") is None: + kwargs["z"] = f"{zmin}/{zmax}" + else: + 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) @@ -336,10 +349,9 @@ def instances(self, **kwargs) -> List[str]: The instances available in the collection. """ instances = set() - layers = self.get_layers(self.base_url) - for layer in layers: - if layer.group == self.collection_id: - instances.update(layer.time_instances()) + collection_layers = self.get_layers(self.base_url, self.collection_id) + for layer in collection_layers: + instances.update(layer.time_instances()) return list(instances) def get_fields(self) -> Dict[str, Any]: @@ -352,14 +364,148 @@ def get_fields(self) -> Dict[str, Any]: """ 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, + "description": layer.abstract, + "x-ogc-unit": layer.get_units(), } return fields + def validate_output_format(self, output_format: str | None) -> str: + """Validate the output format for a query. + + If None provided, return the default. + If the provided output format is invalid, raise an error. + + Parameters + ---------- + output_format : str | None + The specified output format which needs to be validated. + + Returns + ------- + str + Output format string. + + Raises + ------ + ProviderInvalidQueryError + Raised if the provided output format is invalid. + """ + if output_format is None: + return self.native_format.lower() + + if output_format.lower() not in [key.lower() for key in settings.EDR_QUERY_FORMATS]: + msg = f"Invalid format provided, expected one of {', '.join(settings.EDR_QUERY_FORMATS)}" + raise ProviderInvalidQueryError(msg, user_msg=msg) + + return output_format.lower() + + def validate_instance(self, instance: str | None) -> str | None: + """Validate the instance for a query. + + If None provided, the collection is being queried. + If the instance is invalid, raise an error. + + Parameters + ---------- + instance : str | None + The instance which needs to be validated. + + Returns + ------- + str | None + The validated instance or None if the collection is being queried. + + Raises + ------ + ProviderInvalidQueryError + Raised if the provided instance is invalid. + """ + if instance is None: + return None + + if instance not in self.instances(): + msg = "Invalid instance provided." + raise ProviderInvalidQueryError(msg, user_msg=msg) + + return instance + + def validate_parameters(self, parameters: List[str] | None) -> Dict[str, pogc.Layer]: + """Validate the parameters for a query. + + If None provided or an list is empty, return all parameters. + If the provided parameter list is invalid, raise an error. + + Parameters + ---------- + parameters : List[str] | None + The specified parameters for a query. + + Returns + ------- + Dict[str, pogc.Layer] + The validated parameters dictionary containing associated layers. + + Raises + ------ + ProviderInvalidQueryError + Raised if the provided parameters are invalid. + """ + if parameters is None or len(parameters) == 0: + return self.parameters + + parameters_lower = [param.lower() for param in parameters] + parameters_filtered = { + key: value + for key, value in self.parameters.items() + if key.lower() in parameters_lower and value is not None + } + if len(parameters_filtered) != len(parameters): + msg = "Invalid parameters provided." + raise ProviderInvalidQueryError(msg, user_msg=msg) + + return parameters_filtered + + @staticmethod + def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) -> podpac.UnitsDataArray | None: + """Evaluate a layer using the requested coordinates. + + Temporal coordinates are ignored if the layer does not include them. + + Parameters + ---------- + requested_coordinates : podpac.Coordinates + The requested coordinates for the evaluation. + layer : pogc.Layer + The layer to evaluate. + + Returns + ------- + podpac.UnitsDataArray + The units data array returned from evaluation or None if the node was not found. + """ + coordinates = layer.get_coordinates_list() + layer_requested_coordinates = requested_coordinates + units_data_array = None + if len(coordinates) > 0 and "time" not in coordinates[0].udims: + layer_requested_coordinates = layer_requested_coordinates.udrop( + ["time", "forecastOffsetHr"], ignore_missing=True + ) + if layer.node is not None: + units_data_array = layer.node.eval(layer_requested_coordinates) + if "forecastOffsetHr" in units_data_array.dims or "time_forecastOffsetHr" in units_data_array.dims: + if "time_forecastOffsetHr" not in units_data_array.dims: + units_data_array = units_data_array.stack(time_forecastOffsetHr=("time", "forecastOffsetHr")) + forecast_offsets = units_data_array.forecastOffsetHr.data.copy() + time_data = units_data_array.time.data.copy() + units_data_array = units_data_array.drop_vars({"time", "time_forecastOffsetHr", "forecastOffsetHr"}) + units_data_array = units_data_array.rename(time_forecastOffsetHr="time") + units_data_array = units_data_array.assign_coords(time=time_data + forecast_offsets) + + return units_data_array + @staticmethod def get_altitudes(layers: List[pogc.Layer]) -> List[float]: """The list of available altitudes for the provided layers. @@ -377,31 +523,43 @@ def get_altitudes(layers: List[pogc.Layer]) -> List[float]: available_altitudes = set() for layer in layers: - coordinates_list = layer.node.find_coordinates() + coordinates_list = layer.get_coordinates_list() if len(coordinates_list) > 0 and "alt" in coordinates_list[0].udims: available_altitudes.update(coordinates_list[0]["alt"].coordinates) return list(available_altitudes) @staticmethod - def get_datetimes(layers: List[pogc.Layer]) -> List[str]: + def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[np.datetime64]: """The list of available times for the provided layers. Parameters ---------- layers : List[pogc.Layer] The list of layers to determine datetimes for. - + instance_time: str | None + The optional instance time which forecast datetimes are being requested for. Returns ------- - List[str] - Available time strings for the provider layers. + List[np.datetime64] + Available time values 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) + coordinates_list = layer.get_coordinates_list() + if len(coordinates_list) > 0 and "time" in coordinates_list[0].udims: + if instance_time in layer.time_instances() and "forecastOffsetHr" in coordinates_list[0].udims: + # Retrieve available forecastOffSetHr and instance time combinations + instance_datetime = np.datetime64(instance_time) + instance_coordinates = coordinates_list[0].select({"time": [instance_datetime, instance_datetime]}) + selected_offset_coordinates = instance_coordinates["forecastOffsetHr"].coordinates + available_times.update( + [np.datetime64(instance_time) + offset for offset in selected_offset_coordinates] + ) + elif not instance_time and "forecastOffsetHr" not in coordinates_list[0].udims: + # Retrieve layer times for non-instance requests + available_times.update(coordinates_list[0]["time"].coordinates) return list(available_times) @@ -414,7 +572,7 @@ def interpret_crs(crs: str | None) -> str: Parameters ---------- - crs : str + crs : str | None The input CRS id string which needs to be validated/converted. Returns @@ -454,7 +612,7 @@ def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]: Tuple[Any, Any] The X,Y as Longitude/Latitude data. """ - if crs.lower() == "epsg:4326": + if crs.lower() == settings.epsg_4326_uri_format.lower(): return (y, x) return (x, y) @@ -463,7 +621,7 @@ def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]: 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. + """Interpret the altitude string into altitude coordinates using known formats. Specification: single-level = level @@ -483,14 +641,22 @@ def interpret_altitude_coordinates( Returns ------- podpac.Coordinates | None - Altitude coordinates for the request or None if conversion fails. + Altitude coordinates for the request or None if no matching altitudes were found. + + Raises + ------ + ProviderInvalidQueryError + Raised if the provided altitude string is invalid. """ - if not altitude_string or len(available_altitudes) == 0: + + if not altitude_string and len(available_altitudes) == 0: return None try: altitudes = None - if "/" in altitude_string: + if not altitude_string: + altitudes = available_altitudes + elif "/" in altitude_string: altitudes_split = altitude_string.split("/") if len(altitudes_split) == 2: minimum = float(altitudes_split[0]) @@ -504,15 +670,20 @@ def interpret_altitude_coordinates( else: altitudes = [float(alt) for alt in altitude_string.split(",")] except ValueError: - return None + msg = "Invalid vertical level requested." + raise ProviderInvalidQueryError(msg, user_msg=msg) + + if altitudes is None: + msg = "Invalid vertical level requested." + raise ProviderInvalidQueryError(msg, user_msg=msg) 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 + available_times: List[np.datetime64], time_string: str | None, instance_time: str | None, crs: str | None ) -> podpac.Coordinates | None: - """Interpret the string into a list of times using known formats. + """Interpret the time string and instance into time coordinates using known formats. Specification: interval-closed = date-time "/" date-time @@ -523,46 +694,63 @@ def interpret_time_coordinates( Parameters ---------- - available_times: List[str] + available_times: List[np.datetime64] The available times for interpretation. time_string : str | None The string representation of the requested times. + instance_time: str | None + The string representation of the requested instance time. crs : str The CRS that the coordinates need to match. Returns ------- podpac.Coordinates | None - Time coordinates for the request or None if conversion fails. + Time coordinates for the request or None if no matching times were found. + + Raises + ------ + ProviderInvalidQueryError + Raised if the provided time string is invalid. """ - if not time_string or len(available_times) == 0: + if not time_string and len(available_times) == 0: return None try: times = None - np_available_times = [np.datetime64(time) for time in available_times] - if "/" in time_string: + if not time_string: + times = available_times + elif "/" 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)] + times = [time for time in available_times if time <= np.datetime64(maximum)] elif maximum == "..": - times = [time for time in np_available_times if time >= np.datetime64(minimum)] + times = [time for time in available_times if time >= np.datetime64(minimum)] else: times = [ - time - for time in np_available_times - if np.datetime64(minimum) <= time <= np.datetime64(maximum) + time for time in available_times if np.datetime64(minimum) <= time <= np.datetime64(maximum) ] else: times = [np.datetime64(time_string)] except ValueError: - return None + msg = "Invalid datetime requested." + raise ProviderInvalidQueryError(msg, user_msg=msg) + + if times is None: + msg = "Invalid datetime requested." + raise ProviderInvalidQueryError(msg, user_msg=msg) - return podpac.Coordinates([times], dims=["time"], crs=crs) if times is not None else None + if instance_time: + offsets = [np.timedelta64(time - np.datetime64(instance_time), "h") for time in times] + return podpac.Coordinates( + [[[instance_time] * len(offsets), offsets]], dims=[["time", "forecastOffsetHr"]], crs=crs + ) + + return podpac.Coordinates([times], dims=["time"], crs=crs) @staticmethod def to_coverage_json( @@ -612,16 +800,32 @@ def to_coverage_json( }, "referencing": [ { - "coordinates": ["lon", "lat"], + "coordinates": ["x", "y"], "system": {"type": "GeographicCRS", "id": crs}, }, - { - "coordinates": ["t"], - "system": { - "type": "TemporalRS", - "calendar": "Gregorian", - }, - }, + *( + [ + { + "coordinates": ["t"], + "system": { + "type": "TemporalRS", + "calendar": "Gregorian", + }, + } + ] + if "time" in coordinates.dims + else [] + ), + *( + [ + { + "coordinates": ["z"], + "system": {"type": "VerticalCRS"}, + } + ] + if "alt" in coordinates.dims + else [] + ), ], "parameters": {}, "ranges": {}, @@ -636,28 +840,29 @@ def to_coverage_json( } 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, + layer = next((layer for layer in layers if layer.identifier == param), None) + if layer is not None: + units = layer.get_units() + parameter_definition = { + param: { + "type": "Parameter", + "observedProperty": { + "label": { + "id": param, + "en": param, + } }, - }, - "unit": { - "label": {"en": units}, - "symbol": { - "value": units, - "type": None, + "description": layer.abstract, + "unit": { + "label": {"en": param}, + "symbol": { + "value": units, + "type": None, + }, }, - }, + } } - } - coverage_json["domain"]["parameters"].update(parameter_definition) + coverage_json["domain"]["parameters"].update(parameter_definition) coverage_json["domain"]["ranges"].update( { param: { @@ -691,26 +896,6 @@ def check_query_condition(conditional: bool, message: str): if conditional: raise ProviderInvalidQueryError(message, user_msg=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 geotiff of the data for the provided parameters. @@ -749,10 +934,8 @@ def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id def get_native_coordinates( source_coordinates: podpac.Coordinates, target_coordinates: podpac.Coordinates, - source_time_instance: np.datetime64 | None, ) -> podpac.Coordinates: """Find the intersecting latitude and longitude coordinates between the source and target. - Convert time instances to stacked time and forecast offsets for node evalutation. Parameters ---------- @@ -760,8 +943,6 @@ def get_native_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 ------- @@ -769,28 +950,11 @@ def get_native_coordinates( The converted coordinates source coordinates intersecting with the target coordinates. """ # Find intersections with target keeping source crs - source_intersection_coordinates = target_coordinates.intersect(source_coordinates, dims=["lat", "lon"]) + target_spatial_coordinates = podpac.Coordinates( + [target_coordinates["lat"], target_coordinates["lon"]], dims=["lat", "lon"], crs=target_coordinates.crs + ) + source_intersection_coordinates = target_spatial_coordinates.intersect(source_coordinates, dims=["lat", "lon"]) source_intersection_coordinates = source_intersection_coordinates.transform(source_coordinates.crs) - # 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] * len(time_deltas), time_deltas]], - [["time", "forecastOffsetHr"]], - crs=source_coordinates.crs, - ) - source_intersection_coordinates = podpac.coordinates.merge_dims( - [source_intersection_coordinates.udrop(["time", "forecastOffsetHr"]), new_coordinates] - ) - - return source_intersection_coordinates + return podpac.coordinates.merge_dims( + [source_intersection_coordinates, source_coordinates.udrop(["lat", "lon"], ignore_missing=True)] + ) diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index 35160cd..e883418 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -7,13 +7,13 @@ import pygeoapi.l10n import pygeoapi.plugin import pygeoapi.api -import pygeoapi.api.environmental_data_retrieval as pygeoedr 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_api import EdrAPI from .edr_config import EdrConfig from .edr_provider import EdrProvider @@ -133,7 +133,7 @@ def landing_page(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str """ self.clean_configuration_cache() self.update_configuration_base_url(request) - return pygeoapi.api.landing_page(self.api, request) + return EdrAPI.landing_page(self.api, request) def openapi(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: """Handle API documentation requests for the server. @@ -150,7 +150,7 @@ def openapi(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | by """ self.clean_configuration_cache() self.update_configuration_base_url(request) - return pygeoapi.api.openapi_(self.api, request) + return EdrAPI.openapi_(self.api, request) def conformance(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str | bytes]: """Handle conformance requests for the server. @@ -167,7 +167,7 @@ def conformance(self, request: pygeoapi.api.APIRequest) -> Tuple[dict, int, str """ self.clean_configuration_cache() self.update_configuration_base_url(request) - return pygeoapi.api.conformance(self.api, request) + return EdrAPI.conformance(self.api, request) def describe_collections( self, @@ -190,7 +190,7 @@ def describe_collections( """ self.clean_configuration_cache() self.update_configuration_base_url(request) - return pygeoapi.api.describe_collections(self.api, request, collection_id) + return EdrAPI.describe_collections(self.api, request, collection_id) def describe_instances( self, @@ -216,7 +216,7 @@ def describe_instances( """ self.clean_configuration_cache() self.update_configuration_base_url(request) - return pygeoedr.get_collection_edr_instances(self.api, request, collection_id, instance_id=instance_id) + return EdrAPI.get_collection_edr_instances(self.api, request, collection_id, instance_id=instance_id) def collection_query( self, @@ -245,16 +245,22 @@ def collection_query( """ self.clean_configuration_cache() self.update_configuration_base_url(request) - headers, http_status, content = pygeoedr.get_collection_edr_query( + headers, http_status, content = EdrAPI.get_collection_edr_query( self.api, request, collection_id, instance_id, query_type=query_type, location_id=None ) + if "text/html" in headers.get("Content-Type", ""): + return headers, http_status, content + 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-Type"] = "image/tiff" 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"])) + else: + headers["Content-Type"] = "application/prs.coverage+json" return headers, http_status, content diff --git a/ogc/edr/test/test_edr_config.py b/ogc/edr/test/test_edr_config.py index 0a1a710..e651c49 100644 --- a/ogc/edr/test/test_edr_config.py +++ b/ogc/edr/test/test_edr_config.py @@ -23,7 +23,7 @@ def test_edr_configuration_contains_layer_groups(layers: List[pogc.Layer]): assert len(group_keys) > 0 for key in group_keys: - assert configuration["resources"].get(key, None) is not None + assert configuration["resources"].get(key) is not None def test_edr_configuration_contains_spatial_extent(layers: List[pogc.Layer], single_layer_cube_args: Dict[str, Any]): diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 3c635af..0ee0dfa 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -3,8 +3,10 @@ import zipfile import base64 import io +import podpac from shapely import Point, Polygon from typing import Dict, List, Any +from ogc import settings from ogc import podpac as pogc from ogc.edr.edr_provider import EdrProvider from pygeoapi.provider.base import ProviderInvalidQueryError @@ -29,7 +31,7 @@ def get_provider_definition(base_url: str) -> Dict[str, Any]: "name": "ogc.edr.edr_provider.EdrProvider", "data": "Layers", "base_url": base_url, - "crs": ["http://www.opengis.net/def/crs/OGC/1.3/CRS84", "http://www.opengis.net/def/crs/EPSG/0/4326"], + "crs": list(settings.EDR_CRS.keys()), "format": {"name": "GeoJSON", "mimetype": "application/json"}, } @@ -230,6 +232,8 @@ def test_edr_provider_position_request_invalid_property( """ base_url = "/" args = single_layer_cube_args_internal + del args["bbox"] + args["wkt"] = Point(5.2, 52.1) args["select_properties"] = "invalid" provider = EdrProvider(provider_def=get_provider_definition(base_url)) @@ -291,6 +295,30 @@ def test_edr_provider_cube_request_invalid_bbox( provider.cube(**args) +def test_edr_provider_cube_request_invalid_instance( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the cube method of the EDR Provider class with an invalid instance. + + 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. + """ + base_url = "/" + args = single_layer_cube_args_internal + args["instance"] = "invalid" + + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) + + 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] ): @@ -459,9 +487,10 @@ 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])] + available_times = [np.datetime64(time) for time in available_times] + expected_times = available_times[0] - time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None, None) assert time_coords is not None np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) @@ -471,9 +500,10 @@ 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]] + available_times = [np.datetime64(time) for time in available_times] + expected_times = available_times[0:3] - time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None, None) assert time_coords is not None np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) @@ -483,9 +513,10 @@ 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]] + available_times = [np.datetime64(time) for time in available_times] + expected_times = available_times[0:4] - time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None, None) assert time_coords is not None np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) @@ -495,9 +526,10 @@ 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:]] + available_times = [np.datetime64(time) for time in available_times] + expected_times = available_times[1:] - time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) + time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None, None) assert time_coords is not None np.testing.assert_array_equal(time_coords["time"].coordinates, expected_times) @@ -507,10 +539,23 @@ 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"] + available_times = [np.datetime64(time) for time in available_times] + + with pytest.raises(ProviderInvalidQueryError): + EdrProvider.interpret_time_coordinates(available_times, time_string, None, None) + - time_coords = EdrProvider.interpret_time_coordinates(available_times, time_string, None) +def test_edr_provider_get_altitudes(): + """Test the get altitudes method of the EDR Provider class with a layer containing altitude data.""" + latitude = np.arange(1, 5) + longitude = np.arange(1, 5) + altitude = np.arange(1, 10) + data = np.random.default_rng(1).random((len(latitude), len(longitude), len(altitude))) + coords = podpac.Coordinates([latitude, longitude, altitude], dims=["lat", "lon", "alt"]) + node = podpac.data.Array(source=data, coordinates=coords) + layer = pogc.Layer(node=node, identifier="Test") - assert time_coords is None + np.testing.assert_array_equal(EdrProvider.get_altitudes([layer]), altitude) def test_edr_provider_altitude_single_value(): @@ -566,20 +611,18 @@ def test_edr_provider_altitude_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 + with pytest.raises(ProviderInvalidQueryError): + EdrProvider.interpret_altitude_coordinates(available_altitudes, altitude_string, 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) == "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + assert EdrProvider.interpret_crs(None) == settings.crs_84_uri_format def test_edr_provider_crs_interpreter_valid_value(): """Test the CRS interpretation returns a valid value when the argument is acceptable.""" - crs = "http://www.opengis.net/def/crs/EPSG/0/4326" - assert EdrProvider.interpret_crs(crs) == crs + assert EdrProvider.interpret_crs(settings.epsg_4326_uri_format) == settings.epsg_4326_uri_format def test_edr_provider_crs_interpreter_invalid_value(): @@ -597,4 +640,4 @@ def test_edr_provider_crs_converter(): lon = y lat = x - assert EdrProvider.crs_converter(x, y, "epsg:4326") == (lon, lat) + assert EdrProvider.crs_converter(x, y, crs=settings.epsg_4326_uri_format) == (lon, lat) diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index caa0cbd..249eac4 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -213,7 +213,6 @@ def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube collection_id = layers[0].group 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) @@ -314,7 +313,7 @@ def test_edr_routes_collection_query_invalid_bbox(layers: List[pogc.Layer], sing 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. + """Test the EDR colletion query with a missing parameter. All parameters are expected to be returned. Parameters ---------- @@ -328,14 +327,15 @@ def test_edr_routes_collection_query_missing_parameter( request = mock_request(single_layer_cube_args) edr_routes = EdrRoutes(layers=layers) - _, status, _ = edr_routes.collection_query( + _, status, content = edr_routes.collection_query( request, collection_id=layers[0].group, instance_id=next(iter(layers[0].time_instances())), query_type="cube", ) - assert status == HTTPStatus.BAD_REQUEST + assert status == HTTPStatus.OK + assert content["domain"]["ranges"].keys() == {layer.identifier for layer in layers} def test_edr_routes_request_url_updates_configuration_url(): diff --git a/ogc/podpac.py b/ogc/podpac.py index e64a41f..4ef1e3e 100755 --- a/ogc/podpac.py +++ b/ogc/podpac.py @@ -16,6 +16,7 @@ import json import textwrap import re +from datetime import datetime def _uppercase_for_dict_keys(lower_dict): @@ -64,7 +65,7 @@ def time_instances(self) -> List[str]: List of available time instances as a strings. """ time_instances = set() - coordinates_list = self.node.find_coordinates() + coordinates_list = self.get_coordinates_list() # Time instances are created if a node has both time and offsets. if ( @@ -72,10 +73,44 @@ def time_instances(self) -> List[str]: 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]) + time_instances.update( + [ + time.astype("datetime64[ms]").astype(datetime).isoformat() + for time in coordinates_list[0]["time"].coordinates + ] + ) return list(time_instances) + def get_coordinates_list(self) -> List[Coordinates]: + """Retrieve the coordinates list from the node. + + Returns + ------- + List[Coordinates] + List of coordinates from the node. + """ + if self.node is None: + return [] + + return self.node.find_coordinates() + + def get_units(self) -> str | None: + """Retrieve the units from the node. + + Returns + ------- + str | None + The units from the node. + """ + units = None + if self.node is not None and self.node.units is not None: + units = self.node.units + elif self.node is not None and self.node.style is not None: + units = self.node.style.units + + return units + def get_node(self, args): return self.node diff --git a/ogc/servers.py b/ogc/servers.py index fe8fda8..efa6213 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -17,6 +17,7 @@ from ogc.ogc_common import WCSException from pygeoapi.api import APIRequest from pygeoapi.util import get_api_rules +from . import settings logger = logging.getLogger(__name__) @@ -193,7 +194,7 @@ def method(): self.add_url_rule( f"/{endpoint}/edr/collections//instances//", endpoint=f"{endpoint}_instance_query", - view_func=self.edr_render(ogc.edr_routes.collection_query), + view_func=self.edr_render(ogc.edr_routes.collection_query, default_format=settings.GEOTIFF), methods=["GET"], strict_slashes=strict_slashes, ) @@ -258,7 +259,7 @@ def ogc_render(self, ogc_idx): ee = WCSException() return respond_xml(ee.to_xml(), status=500) - def edr_render(self, callable: Callable) -> Callable: + def edr_render(self, callable: Callable, default_format: str | None = "json") -> Callable: """Function which returns a wrapper for the provided callable. Filters arguments and handles any necessary exceptions. @@ -266,6 +267,8 @@ def edr_render(self, callable: Callable) -> Callable: ---------- callable : Callable The callable request handler to be wrapped. + default_format: str | None + The optional default data output format, by default "json". Returns ------- @@ -302,8 +305,9 @@ def wrapper(*args, **kwargs) -> Response: for (k, v) in request.args.items() } # Replace format with its lowercase version to match pygeoapi expectations - if filtered_args.get("f", None): - filtered_args["f"] = filtered_args["f"].lower() + format_argument = filtered_args.get("f", default_format) + if format_argument is not None: + filtered_args["f"] = format_argument.lower() filtered_args["base_url"] = request.base_url diff --git a/ogc/settings.py b/ogc/settings.py index 61f5843..8bdf166 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -12,6 +12,7 @@ crs_84 = "crs:84" epsg_4326 = "epsg:4326" crs_84_uri_format = "http://www.opengis.net/def/crs/OGC/1.3/CRS84" +crs_84h_uri_format = "http://www.opengis.net/def/crs/OGC/0/CRS84h" epsg_4326_uri_format = "http://www.opengis.net/def/crs/EPSG/0/4326" # Default/Supported WMS CRS/SRS @@ -35,8 +36,16 @@ EDR_CRS = { epsg_4326_uri_format: {"minx": -90.0, "miny": -180.0, "maxx": 90.0, "maxy": 180.0}, crs_84_uri_format: {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, + crs_84h_uri_format: {"minx": -180.0, "miny": -90.0, "maxx": 180.0, "maxy": 90.0}, } +# EDR query output formats +GEOTIFF = "GeoTIFF" +JSON = "JSON" +COVERAGE_JSON = "CoverageJSON" +HTML = "HTML" +EDR_QUERY_FORMATS = [GEOTIFF, COVERAGE_JSON, JSON, HTML] + # WMS Capabilities timestamp format USE_TIMES_LIST = False PAST_DAYS_INCLUDED = 7 From 75ad465ec93fef93311a71594a31074bc40fe23f Mon Sep 17 00:00:00 2001 From: scranford1 Date: Wed, 18 Feb 2026 15:11:54 -0500 Subject: [PATCH 43/69] Feature/edr conformance (#15) Supporting EDR 1.1.0 with custom API documentation and resolution parameters --- ogc/edr/edr_api.py | 331 +++++++++++++++++++++++++++++- ogc/edr/edr_provider.py | 94 ++++++++- ogc/edr/edr_routes.py | 1 + ogc/edr/test/test_edr_provider.py | 35 ++++ ogc/edr/test/test_edr_routes.py | 4 +- 5 files changed, 458 insertions(+), 7 deletions(-) diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index bcdf36b..8c27dfb 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -16,6 +16,15 @@ class EdrAPI: """Used to modify the default responses before returning data to the user.""" + CONFORMANCE_CLASSES = sorted( + { + "https://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", + "https://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections", + "https://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/core", + } + ) + SCHEMA_CLASS = "https://schemas.opengis.net/ogcapi/edr/1.1/openapi" + @jsonldify @staticmethod def landing_page(api: API, request: APIRequest) -> Tuple[dict, int, str]: @@ -51,7 +60,24 @@ def openapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]: Tuple[dict, int, str] Headers, HTTP Status, and Content returned as a tuple. """ - return pygeoapi.api.openapi_(api, request) + html_path = "openapi/redoc.html" if request._args.get("ui") == "redoc" else "openapi/swagger.html" + headers = request.get_response_headers(**api.api_headers) + + if request.format == pygeoapi.api.F_HTML: + data = {"openapi-document-path": f"{api.base_url}/openapi"} + content = pygeoapi.api.render_j2_template( + api.tpl_config, api.config["server"]["templates"], html_path, data, request.locale + ) + + return headers, HTTPStatus.OK, content + + headers["Content-Type"] = "application/vnd.oai.openapi+json;version=3.0" + + if isinstance(api.openapi, dict): + openapi = EdrAPI._openapi_update(api.openapi) + return headers, HTTPStatus.OK, to_json(openapi, api.pretty_print) + else: + return headers, HTTPStatus.OK, api.openapi @staticmethod def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: @@ -69,7 +95,18 @@ def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]: Tuple[dict, int, str] Headers, HTTP Status, and Content returned as a tuple. """ - return pygeoapi.api.conformance(api, request) + html_path = "conformance.html" + conformance = {"conformsTo": list(EdrAPI.CONFORMANCE_CLASSES)} + + headers = request.get_response_headers(**api.api_headers) + if request.format == pygeoapi.api.F_HTML: + content = pygeoapi.api.render_j2_template( + api.tpl_config, api.config["server"]["templates"], html_path, conformance, request.locale + ) + + return headers, HTTPStatus.OK, content + + return headers, HTTPStatus.OK, to_json(conformance, api.pretty_print) @jsonldify @staticmethod @@ -336,3 +373,293 @@ def _instance_parameters( }, } return instance_parameters + + @staticmethod + def _openapi_update(api: Dict[str, Any]) -> Dict[str, Any]: + """Update the default OpenAPI definition to a custom format. + + Parameters + ---------- + api : Dict[str, Any] + The OpenAPI definition to be updated. + + Returns + ------- + Dict[str, Any] + The customized OpenAPI definition. + """ + server_tag = "Server" + collection_tag = "Collection Information" + instance_tag = "Instance Information" + query_tag = "Query" + + resource_not_found_error = {"description": "Resource not found."} + internal_application_error = { + "description": "Internal application error", + "content": { + "application/xml": { + "schema": { + "type": "object", + "format": "xml", + "xml": {"name": "ExceptionReport"}, + }, + "example": ( + '' + "" + '' + "Internal application error" + "" + "" + ), + } + }, + } + + query_base_parameters = [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/crs.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/datetime.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/parameter-name.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/z.yaml"}, + ] + query_responses = { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/200.yaml", + }, + "400": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/400.yaml", + }, + "404": resource_not_found_error, + "default": internal_application_error, + } + + openapi = {} + openapi["openapi"] = api.get("openapi") + openapi["info"] = api.get("info") + openapi["servers"] = api.get("servers") + openapi["tags"] = [server_tag, collection_tag, instance_tag, query_tag] + openapi["paths"] = { + "/": { + "get": { + "summary": "Landing Page", + "description": "Landing page of the API.", + "tags": [server_tag], + "operationId": "getLandingPage", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/core/landingPage.yaml", + }, + "default": internal_application_error, + }, + }, + }, + "/api": { + "get": { + "summary": "Capabilities of the API.", + "description": "API", + "tags": [server_tag], + "operationId": "getApi", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "description": "API capabilities", + }, + "default": internal_application_error, + }, + }, + }, + "/conformance": { + "get": { + "summary": "Conformance classes defining standard compliance of the API.", + "description": "Conformance Classes", + "tags": [server_tag], + "operationId": "getConformance", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/core/conformance.yaml", + }, + "default": internal_application_error, + }, + }, + }, + "/collections": { + "get": { + "summary": "Collection information for all available collections.", + "description": "Collections", + "tags": [collection_tag], + "operationId": "getCollections", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/collections/collections.yaml", + }, + "default": internal_application_error, + }, + }, + }, + "/collections/{collectionId}": { + "get": { + "summary": "Collection information for a single collection.", + "description": "Collection", + "tags": [collection_tag], + "operationId": "getCollection", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/collections/collection.yaml", + }, + "404": resource_not_found_error, + "default": internal_application_error, + }, + }, + }, + "/collections/{collectionId}/area": { + "get": { + "summary": "Query a collection for an area.", + "description": "Collection Area Query", + "tags": [query_tag], + "operationId": "getCollectionArea", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/areaCoords.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/cube": { + "get": { + "summary": "Query a collection for a cube.", + "description": "Collection Cube Query", + "tags": [query_tag], + "operationId": "getCollectionCube", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/bbox.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/position": { + "get": { + "summary": "Query a collection for a position.", + "description": "Collection Position Query", + "tags": [query_tag], + "operationId": "getCollectionPosition", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/positionCoords.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/instances/": { + "get": { + "summary": "Instance information for all available instances in a collection.", + "description": "Collection Instances", + "tags": [instance_tag], + "operationId": "getCollectionInstances", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/instances.yaml", + }, + "404": resource_not_found_error, + "default": internal_application_error, + }, + }, + }, + "/collections/{collectionId}/instances/{instanceId}": { + "get": { + "summary": "Instance information for a single instance in a collection.", + "description": "Collection Instance", + "tags": [instance_tag], + "operationId": "getCollectionInstance", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/f.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"}, + ], + "responses": { + "200": { + "$ref": f"{EdrAPI.SCHEMA_CLASS}/responses/queries/instances.yaml", + }, + "404": resource_not_found_error, + "default": internal_application_error, + }, + }, + }, + "/collections/{collectionId}/instances/{instanceId}/area": { + "get": { + "summary": "Query a collection instance for an area.", + "description": "Instance Area Query", + "tags": [query_tag], + "operationId": "getCollectionInstanceArea", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/areaCoords.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/instances/{instanceId}/cube": { + "get": { + "summary": "Query a collection instance for a cube.", + "description": "Instance Cube Query", + "tags": [query_tag], + "operationId": "getCollectionInstanceCube", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/bbox.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-x.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/resolution-y.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + "/collections/{collectionId}/instances/{instanceId}/position": { + "get": { + "summary": "Query a collection instance for a position.", + "description": "Instance Position Query", + "tags": [query_tag], + "operationId": "getCollectionInstancePosition", + "parameters": [ + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/core/positionCoords.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/collections/collectionId.yaml"}, + {"$ref": f"{EdrAPI.SCHEMA_CLASS}/parameters/queries/instanceId.yaml"}, + *query_base_parameters, + ], + "responses": query_responses, + }, + }, + } + + return openapi diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index ada92fe..1c14d6b 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -18,6 +18,18 @@ class EdrProvider(BaseEDRProvider): """Custom provider to be used with layer data sources.""" _layers_dict = defaultdict(list) + _extra_args = defaultdict() + + @classmethod + def set_extra_query_args(cls, args: Dict[str, Any]): + """Set the extra arguments which will be available to the provider on a request. + + Parameters + ---------- + args : Dict[str, Any] + The extra arguments which are not included in the default requests. + """ + cls._extra_args = args @classmethod def set_layers(cls, base_url: str, layers: List[pogc.Layer]): @@ -147,11 +159,15 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): output_format = kwargs.get("format_") datetime_arg = kwargs.get("datetime_") z_arg = kwargs.get("z") + resolution_x = self._extra_args.get("resolution-x") + resolution_y = self._extra_args.get("resolution-y") instance = self.validate_instance(instance) requested_parameters = self.validate_parameters(requested_parameters) output_format = self.validate_output_format(output_format) + resolution_x, resolution_y = self.validate_resolution(resolution_x, resolution_y) + crs = self.interpret_crs(requested_coordinates.crs) available_times = self.get_datetimes(list(self.parameters.values()), instance) available_altitudes = self.get_altitudes(list(self.parameters.values())) time_coords = self.interpret_time_coordinates( @@ -175,7 +191,10 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): # Handle defining native coordinates for the query, these should match between each layer coordinates_list = next(iter(requested_parameters.values())).get_coordinates_list() self.check_query_condition(len(coordinates_list) == 0, "Native coordinates not found.") - requested_native_coordinates = self.get_native_coordinates(requested_coordinates, coordinates_list[0]) + resolution_lon, resolution_lat = self.crs_converter(resolution_x, resolution_y, crs) + requested_native_coordinates = self.get_native_coordinates( + requested_coordinates, coordinates_list[0], resolution_lat, resolution_lon + ) self.check_query_condition( bool(requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE), @@ -195,7 +214,6 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): or output_format == settings.JSON.lower() or output_format == settings.HTML.lower() ): - crs = self.interpret_crs(requested_native_coordinates.crs if requested_native_coordinates else None) layers = self.get_layers(self.base_url, self.collection_id) return self.to_coverage_json(layers, dataset, crs) @@ -402,6 +420,44 @@ def validate_output_format(self, output_format: str | None) -> str: return output_format.lower() + def validate_resolution(self, resolution_x: str | None, resolution_y: str | None) -> Tuple[int, int]: + """Validate the resolutions and return the values as integers. + + If no resolution is provided in a specific direction a zero value should be used to indicate native resolution. + + Parameters + ---------- + resolution_x : str | None + The resolution in the x-direction or None. + resolution_y : str | None + The resolution in the y-direction or None. + + Returns + ------- + Tuple[int, int] + Resolution x and y as integers. + + Raises + ------ + ProviderInvalidQueryError + Raised if either of the provided resolutions is invalid. + """ + valid_resolutions = True + validated_resolution_x = 0 + validated_resolution_y = 0 + try: + validated_resolution_x = int(0 if resolution_x is None else resolution_x) + validated_resolution_y = int(0 if resolution_y is None else resolution_y) + valid_resolutions = validated_resolution_x >= 0 and validated_resolution_y >= 0 + except ValueError: + valid_resolutions = False + + if not valid_resolutions: + msg = "Invalid resolution provided, expected positive integer." + raise ProviderInvalidQueryError(msg, user_msg=msg) + + return validated_resolution_x, validated_resolution_y + def validate_instance(self, instance: str | None) -> str | None: """Validate the instance for a query. @@ -503,6 +559,13 @@ def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) units_data_array = units_data_array.drop_vars({"time", "time_forecastOffsetHr", "forecastOffsetHr"}) units_data_array = units_data_array.rename(time_forecastOffsetHr="time") units_data_array = units_data_array.assign_coords(time=time_data + forecast_offsets) + if units_data_array.attrs.get("bounds", None): + filtered_bounds = { + coord: bnd + for coord, bnd in units_data_array.attrs["bounds"].items() + if coord in units_data_array.coords.dims + } + units_data_array.attrs["bounds"] = filtered_bounds return units_data_array @@ -934,6 +997,8 @@ def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id def get_native_coordinates( source_coordinates: podpac.Coordinates, target_coordinates: podpac.Coordinates, + resolution_lat: int, + resolution_lon: int, ) -> podpac.Coordinates: """Find the intersecting latitude and longitude coordinates between the source and target. @@ -943,15 +1008,38 @@ def get_native_coordinates( The source coordinates to be converted. target_coordinates : podpac.Coordinates The target coordinates to find intersections on. + resolution_lat: int + The desired resolution in the latitudinal direction, with a zero value using native resolution. + resolution_lon: int + The desired resolution in the longitudinal direction, with a zero value using native resolution. Returns ------- podpac.Coordinates The converted coordinates source coordinates intersecting with the target coordinates. """ + latitudes = ( + target_coordinates["lat"].coordinates + if resolution_lat == 0 + else np.linspace( + np.min(source_coordinates["lat"].coordinates), + np.max(source_coordinates["lat"].coordinates), + resolution_lat, + ) + ) + longitudes = ( + target_coordinates["lon"].coordinates + if resolution_lon == 0 + else np.linspace( + np.min(source_coordinates["lon"].coordinates), + np.max(source_coordinates["lon"].coordinates), + resolution_lon, + ) + ) + # Find intersections with target keeping source crs target_spatial_coordinates = podpac.Coordinates( - [target_coordinates["lat"], target_coordinates["lon"]], dims=["lat", "lon"], crs=target_coordinates.crs + [latitudes, longitudes], dims=["lat", "lon"], crs=target_coordinates.crs ) source_intersection_coordinates = target_spatial_coordinates.intersect(source_coordinates, dims=["lat", "lon"]) source_intersection_coordinates = source_intersection_coordinates.transform(source_coordinates.crs) diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index e883418..827a0cd 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -245,6 +245,7 @@ def collection_query( """ self.clean_configuration_cache() self.update_configuration_base_url(request) + EdrProvider.set_extra_query_args(request._args) headers, http_status, content = EdrAPI.get_collection_edr_query( self.api, request, collection_id, instance_id, query_type=query_type, location_id=None ) diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 0ee0dfa..fe68e97 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -271,6 +271,41 @@ def test_edr_provider_cube_request_valid_bbox( ) +def test_edr_provider_cube_request_valid_bbox_with_resolution( + 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 and a specific resolution. + + The tested node is adjusted to ensure interpolation is used. + + 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. + """ + base_url = "/" + args = single_layer_cube_args_internal + parameter_name = single_layer_cube_args_internal["select_properties"][0] + resolution_x = 15 + resolution_y = 20 + + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + layers[0].node = layers[0].node.interpolate() + provider.set_layers(base_url, layers) + provider.set_extra_query_args({"resolution-x": resolution_x, "resolution-y": resolution_y}) + + response = provider.cube(**args) + + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == resolution_x * resolution_y + 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] ): diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index 249eac4..ca0aaaf 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -89,7 +89,7 @@ def test_edr_routes_conformance(layers: List[pogc.Layer]): 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"] + assert "https://www.opengis.net/spec/ogcapi-edr-1/1.1/conf/core" in response["conformsTo"] def test_edr_routes_api(): @@ -102,7 +102,7 @@ def test_edr_routes_api(): assert status == HTTPStatus.OK assert response["paths"]["/"] - assert response["paths"]["/openapi"] + assert response["paths"]["/api"] assert response["paths"]["/conformance"] assert response["paths"]["/collections"] From cf6294493862292c0c26c13c0d37a3f47d672fe7 Mon Sep 17 00:00:00 2001 From: scranford1 Date: Fri, 6 Mar 2026 17:27:42 -0500 Subject: [PATCH 44/69] Feature/query formats (#16) Enhancements to the EDR endpoints. --- ogc/edr/edr_api.py | 36 +++++- ogc/edr/edr_config.py | 32 +++-- ogc/edr/edr_provider.py | 201 +++++++++++++++++++++--------- ogc/edr/edr_routes.py | 81 ++++++++++-- ogc/edr/test/test_edr_provider.py | 95 ++++++++++++-- ogc/edr/test/test_edr_routes.py | 36 +++++- ogc/podpac.py | 37 +++--- ogc/servers.py | 20 ++- ogc/settings.py | 11 +- pyproject.toml | 2 +- 10 files changed, 427 insertions(+), 124 deletions(-) diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index 8c27dfb..ff13604 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -155,10 +155,18 @@ def describe_collections(api: API, request: APIRequest, dataset: str | None = No collection["output_formats"] = collection_configuration[collection_id].get("output_formats", []) + collection_queryable = EdrProvider.is_collection_queryable(provider["base_url"], collection_id) + if not collection_queryable: + collection_without_queryables = EdrAPI._remove_query_metadata(collection) + collection["links"] = collection_without_queryables["links"] + collection["data_queries"] = collection_without_queryables["data_queries"] + height_units = collection_configuration[collection_id].get("height_units", []) + query_formats = collection_configuration[collection_id].get("query_formats", {}) for query_type in collection["data_queries"]: data_query_additions = { "query_type": query_type, + **(query_formats.get(query_type) if query_formats.get(query_type) is not None else {}), **({"height_units": height_units} if query_type == "cube" else {}), } variables = collection["data_queries"][query_type]["link"].get("variables", {}) @@ -229,9 +237,11 @@ def get_collection_edr_instances( instance["output_formats"] = collection_configuration[dataset].get("output_formats", []) height_units = collection_configuration[dataset].get("height_units") + query_formats = collection_configuration[dataset].get("query_formats", {}) for query_type in instance["data_queries"]: data_query_additions = { "query_type": query_type, + **(query_formats.get(query_type) if query_formats.get(query_type) is not None else {}), **({"height_units": height_units} if query_type == "cube" else {}), } variables = instance["data_queries"][query_type]["link"].get("variables", {}) @@ -352,7 +362,7 @@ def _instance_parameters( Dict[str, Any] The metadata for available parameters in the instance. """ - instance_parameters = {"parameter_names": {}} + instance_parameters = {} for key, value in provider_parameters.items(): layer = next((layer for layer in collection_layers if layer.identifier == key), None) if layer is not None and instance in layer.time_instances(): @@ -374,6 +384,30 @@ def _instance_parameters( } return instance_parameters + @staticmethod + def _remove_query_metadata(data: Dict[str, Any]) -> Dict[str, Any]: + """Remove metadata from the dictionary which relates to query types such as position and cube. + + Parameters + ---------- + data : Dict[str, Any] + The dictionary to remove query metadata from. + + Returns + ------- + Dict[str, Any] + The updated dictionary with metadata removed. + """ + filtered_data = data.copy() + data_queries = data.get("data_queries", {}) + filtered_data["data_queries"] = {"instances": data_queries.get("instances")} + filtered_data["links"] = [] + for link in data["links"]: + if link.get("rel") != "data" or "/instances" in link.get("href", ""): + filtered_data["links"].append(link) + + return filtered_data + @staticmethod def _openapi_update(api: Dict[str, Any]) -> Dict[str, Any]: """Update the default OpenAPI definition to a custom format. diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 4aa2a0b..58a032a 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -99,7 +99,8 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, "keywords": ["podpac"], "extents": EdrConfig._generate_extents(group_layers), "height_units": EdrConfig._vertical_units(group_layers), - "output_formats": settings.EDR_QUERY_FORMATS, + "output_formats": list({item for values in settings.EDR_QUERY_FORMATS.values() for item in values}), + "query_formats": EdrConfig.data_query_formats(), "providers": [ { "type": "edr", @@ -158,9 +159,9 @@ def _generate_extents(layers: List[pogc.Layer]) -> Dict[str, Any]: urc_lon = max(urc_lon, urc_lon_tmp) urc_lat = max(urc_lat, urc_lat_tmp) - coordinates_list = layer.get_coordinates_list() - if len(coordinates_list) > 0 and "alt" in coordinates_list[0].udims: - vertical_range.update(coordinates_list[0]["alt"].coordinates) + coordinates = layer.get_coordinates() + if coordinates is not None and "alt" in coordinates.udims: + vertical_range.update(coordinates["alt"].coordinates) if hasattr(layer, "valid_times") and layer.valid_times is not tl.Undefined and len(layer.valid_times) > 0: time_range.update(layer.valid_times) @@ -239,8 +240,25 @@ def _vertical_units(layers: List[pogc.Layer]) -> List[str]: """ vertical_units = set() for layer in layers: - coordinates_list = layer.get_coordinates_list() - if len(coordinates_list) > 0 and coordinates_list[0].alt_units: - vertical_units.add(coordinates_list[0].alt_units) + coordinates = layer.get_coordinates() + if coordinates is not None and coordinates.alt_units: + vertical_units.add(coordinates.alt_units) return list(vertical_units) + + @staticmethod + def data_query_formats() -> Dict[str, Any]: + """Get data related to the available query output formats and the default format. + + Returns + ------- + Dict[str, Any] + Query format data for each query type. + """ + query_formats = {} + for query_type, formats in settings.EDR_QUERY_FORMATS.items(): + query_formats[query_type] = { + "output_formats": formats, + "default_output_format": settings.EDR_QUERY_DEFAULTS.get(query_type), + } + return query_formats diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 1c14d6b..a1766a3 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -1,10 +1,12 @@ -import base64 -import io +import json +import tempfile import numpy as np import zipfile +import pyproj from datetime import datetime from collections import defaultdict from typing import List, Dict, Tuple, Any +from pyproj.exceptions import CRSError from shapely.geometry.base import BaseGeometry from pygeoapi.provider.base import ProviderConnectionError, ProviderInvalidQueryError from pygeoapi.provider.base_edr import BaseEDRProvider @@ -67,6 +69,29 @@ def get_layers(cls, base_url: str, group: str | None = None) -> List[pogc.Layer] else: return layers + @classmethod + def is_collection_queryable(cls, base_url: str, group: str) -> bool: + """Determine whether a collection contains directly queryable data or is only queryable through instances. + + Parameters + ---------- + base_url : str + The base URL for the layers. + group : str + Collection to check if direct querying is possible. + + Returns + ------- + bool + True if the collection can be queried directly, false otherwise. + """ + layers = cls.get_layers(base_url, group) + for layer in layers: + coordinates = layer.get_coordinates() + if coordinates is not None and "forecastOffsetHr" not in coordinates.udims: + return True + return False + def __init__(self, provider_def: Dict[str, Any]): """Construct the provider using the provider definition. @@ -88,7 +113,6 @@ def __init__(self, provider_def: Dict[str, Any]): raise ProviderConnectionError("Data not found.") self.collection_id = str(collection_id) - self.native_format = provider_def["format"]["name"] self.base_url = provider_def.get("base_url", "") if not self.base_url: @@ -125,6 +149,10 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): The requested datetime/datetimes for data retrieval. z : str The requested vertical level/levels for data retrieval. + resolution-x : str + The number of requested data points, as a string, in the x-direction. + resolution-y : str + The number of requested data points, as a string, in the y-direction. Returns ------- @@ -137,8 +165,6 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): Raised if an invalid instance is provided. ProviderInvalidQueryError Raised if an invalid parameter is provided. - ProviderInvalidQueryError - Raised if an invalid output format is provided. ProviderInvalidQueryError Raised if a datetime string is provided but cannot be interpreted. ProviderInvalidQueryError @@ -159,12 +185,11 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): output_format = kwargs.get("format_") datetime_arg = kwargs.get("datetime_") z_arg = kwargs.get("z") - resolution_x = self._extra_args.get("resolution-x") - resolution_y = self._extra_args.get("resolution-y") + resolution_x = kwargs.get("resolution-x") + resolution_y = kwargs.get("resolution-y") instance = self.validate_instance(instance) requested_parameters = self.validate_parameters(requested_parameters) - output_format = self.validate_output_format(output_format) resolution_x, resolution_y = self.validate_resolution(resolution_x, resolution_y) crs = self.interpret_crs(requested_coordinates.crs) @@ -189,16 +214,16 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): requested_coordinates = podpac.coordinates.merge_dims([altitude_coords, requested_coordinates]) # Handle defining native coordinates for the query, these should match between each layer - coordinates_list = next(iter(requested_parameters.values())).get_coordinates_list() - self.check_query_condition(len(coordinates_list) == 0, "Native coordinates not found.") + coordinates = next(iter(requested_parameters.values())).get_coordinates() + self.check_query_condition(coordinates is None, "Native coordinates not found.") resolution_lon, resolution_lat = self.crs_converter(resolution_x, resolution_y, crs) requested_native_coordinates = self.get_native_coordinates( - requested_coordinates, coordinates_list[0], resolution_lat, resolution_lon + requested_coordinates, coordinates, resolution_lat, resolution_lon ) self.check_query_condition( bool(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, + "Coordinates size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE, ) dataset = {} @@ -215,7 +240,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): or output_format == settings.HTML.lower() ): layers = self.get_layers(self.base_url, self.collection_id) - return self.to_coverage_json(layers, dataset, crs) + return self.to_coverage_json(layers, dataset, self.collection_id, crs) return self.to_geotiff_response(dataset, self.collection_id) @@ -226,8 +251,8 @@ def position(self, **kwargs): ---------- wkt : shapely.geometry WKT geometry - crs : str - The requested CRS for the return coordinates and data. + format_ : str + The requested output format of the data. Returns ------- @@ -236,6 +261,8 @@ def position(self, **kwargs): Raises ------ + ProviderInvalidQueryError + Raised if an invalid output format is provided. ProviderInvalidQueryError Raised if the wkt string is not provided. ProviderInvalidQueryError @@ -243,8 +270,9 @@ def position(self, **kwargs): """ lat, lon = [], [] wkt = kwargs.get("wkt") - crs = kwargs.get("crs") + crs = self._extra_args.get("crs") crs = EdrProvider.interpret_crs(crs) + kwargs["format_"] = self.validate_output_format(kwargs["format_"], "position") if not isinstance(wkt, BaseGeometry): msg = "Invalid WKT string provided for the position query." @@ -266,8 +294,8 @@ def cube(self, **kwargs): ---------- bbox : List[float] Bbox geometry (for cube queries) - crs : str - The requested CRS for the return coordinates and data. + format_ : str + The requested output format of the data. Returns ------- @@ -276,12 +304,17 @@ def cube(self, **kwargs): Raises ------ + ProviderInvalidQueryError + Raised if an invalid output format is provided. ProviderInvalidQueryError Raised if the bounding box is invalid. """ bbox = kwargs.get("bbox") - crs = kwargs.get("crs") + crs = self._extra_args.get("crs") crs = EdrProvider.interpret_crs(crs) + kwargs["format_"] = self.validate_output_format(kwargs["format_"], "cube") + kwargs["resolution-x"] = self._extra_args.get("resolution-x") + kwargs["resolution-y"] = self._extra_args.get("resolution-y") if not isinstance(bbox, List) or (len(bbox) != 4 and len(bbox) != 6): msg = ( @@ -310,8 +343,8 @@ def area(self, **kwargs): ---------- wkt : shapely.geometry WKT geometry - crs : str - The requested CRS for the return coordinates and data. + format_ : str + The requested output format of the data. Returns ------- @@ -320,6 +353,8 @@ def area(self, **kwargs): Raises ------ + ProviderInvalidQueryError + Raised if an invalid output format is provided. ProviderInvalidQueryError Raised if the wkt string is not provided. ProviderInvalidQueryError @@ -327,8 +362,11 @@ def area(self, **kwargs): """ lat, lon = [], [] wkt = kwargs.get("wkt") - crs = kwargs.get("crs") + crs = self._extra_args.get("crs") crs = EdrProvider.interpret_crs(crs) + kwargs["format_"] = self.validate_output_format(kwargs["format_"], "area") + kwargs["resolution-x"] = self._extra_args.get("resolution-x") + kwargs["resolution-y"] = self._extra_args.get("resolution-y") if not isinstance(wkt, BaseGeometry): msg = "Invalid WKT string provided for the area query." @@ -390,7 +428,7 @@ def get_fields(self) -> Dict[str, Any]: } return fields - def validate_output_format(self, output_format: str | None) -> str: + def validate_output_format(self, output_format: str | None, query_type: str) -> str: """Validate the output format for a query. If None provided, return the default. @@ -400,6 +438,8 @@ def validate_output_format(self, output_format: str | None) -> str: ---------- output_format : str | None The specified output format which needs to be validated. + query_type: str + The query type to validate output formats against. Returns ------- @@ -412,10 +452,12 @@ def validate_output_format(self, output_format: str | None) -> str: Raised if the provided output format is invalid. """ if output_format is None: - return self.native_format.lower() + return settings.EDR_QUERY_DEFAULTS.get(query_type, "") - if output_format.lower() not in [key.lower() for key in settings.EDR_QUERY_FORMATS]: - msg = f"Invalid format provided, expected one of {', '.join(settings.EDR_QUERY_FORMATS)}" + if output_format.lower() not in [key.lower() for key in settings.EDR_QUERY_FORMATS.get(query_type, [])]: + msg = ( + f"Invalid format provided, expected one of {', '.join(settings.EDR_QUERY_FORMATS.get(query_type, []))}" + ) raise ProviderInvalidQueryError(msg, user_msg=msg) return output_format.lower() @@ -528,8 +570,6 @@ def validate_parameters(self, parameters: List[str] | None) -> Dict[str, pogc.La def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) -> podpac.UnitsDataArray | None: """Evaluate a layer using the requested coordinates. - Temporal coordinates are ignored if the layer does not include them. - Parameters ---------- requested_coordinates : podpac.Coordinates @@ -542,10 +582,18 @@ def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) podpac.UnitsDataArray The units data array returned from evaluation or None if the node was not found. """ - coordinates = layer.get_coordinates_list() + coordinates = layer.get_coordinates() layer_requested_coordinates = requested_coordinates units_data_array = None - if len(coordinates) > 0 and "time" not in coordinates[0].udims: + if coordinates is None: + return units_data_array + + layer_has_instances = "forecastOffsetHr" in coordinates.udims + request_has_instances = "forecastOffsetHr" in requested_coordinates.udims + if layer_has_instances ^ request_has_instances: + return units_data_array + + if "time" not in coordinates[0].udims: layer_requested_coordinates = layer_requested_coordinates.udrop( ["time", "forecastOffsetHr"], ignore_missing=True ) @@ -586,9 +634,9 @@ def get_altitudes(layers: List[pogc.Layer]) -> List[float]: available_altitudes = set() for layer in layers: - coordinates_list = layer.get_coordinates_list() - if len(coordinates_list) > 0 and "alt" in coordinates_list[0].udims: - available_altitudes.update(coordinates_list[0]["alt"].coordinates) + coordinates = layer.get_coordinates() + if coordinates is not None and "alt" in coordinates.udims: + available_altitudes.update(coordinates["alt"].coordinates) return list(available_altitudes) @@ -610,25 +658,25 @@ def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[n available_times = set() for layer in layers: - coordinates_list = layer.get_coordinates_list() - if len(coordinates_list) > 0 and "time" in coordinates_list[0].udims: - if instance_time in layer.time_instances() and "forecastOffsetHr" in coordinates_list[0].udims: + coordinates = layer.get_coordinates() + if coordinates is not None and "time" in coordinates.udims: + if instance_time in layer.time_instances() and "forecastOffsetHr" in coordinates.udims: # Retrieve available forecastOffSetHr and instance time combinations instance_datetime = np.datetime64(instance_time) - instance_coordinates = coordinates_list[0].select({"time": [instance_datetime, instance_datetime]}) + instance_coordinates = coordinates.select({"time": [instance_datetime, instance_datetime]}) selected_offset_coordinates = instance_coordinates["forecastOffsetHr"].coordinates available_times.update( [np.datetime64(instance_time) + offset for offset in selected_offset_coordinates] ) - elif not instance_time and "forecastOffsetHr" not in coordinates_list[0].udims: + elif not instance_time and "forecastOffsetHr" not in coordinates.udims: # Retrieve layer times for non-instance requests - available_times.update(coordinates_list[0]["time"].coordinates) + available_times.update(coordinates["time"].coordinates) return list(available_times) @staticmethod def interpret_crs(crs: str | None) -> str: - """Interpret the CRS id string into a valid PyProj CRS format. + """Interpret the CRS id string into a valid WKT CRS format. If None provided, return the default. If the provided CRS is invalid, raise an error. @@ -641,7 +689,7 @@ def interpret_crs(crs: str | None) -> str: Returns ------- str - Pyproj CRS string. + WKT CRS string. Raises ------ @@ -649,13 +697,20 @@ def interpret_crs(crs: str | None) -> str: Raised if the provided CRS string is unknown. """ if crs is None: - return settings.crs_84_uri_format # Pyproj acceptable format + return pyproj.CRS(settings.crs_84_uri_format).to_wkt() # Pyproj acceptable format - if crs.lower() not in [key.lower() for key in settings.EDR_CRS.keys()]: - msg = f"Invalid CRS provided, expected one of {', '.join(settings.EDR_CRS.keys())}" - raise ProviderInvalidQueryError(msg, user_msg=msg) + try: + wkt_crs = pyproj.CRS(crs).to_wkt() + wkt_options = [pyproj.CRS(key).to_wkt() for key in settings.EDR_CRS.keys()] + except CRSError: + wkt_crs = None + wkt_options = [] - return crs + if wkt_crs is None or wkt_crs not in wkt_options: + error_msg = msg = f"Invalid CRS provided, expected one of {', '.join(settings.EDR_CRS.keys())}" + raise ProviderInvalidQueryError(msg, user_msg=error_msg) + + return wkt_crs @staticmethod def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]: @@ -675,7 +730,9 @@ def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]: Tuple[Any, Any] The X,Y as Longitude/Latitude data. """ - if crs.lower() == settings.epsg_4326_uri_format.lower(): + wkt_crs = pyproj.CRS(crs).to_wkt() + wkt_epsg_4326 = pyproj.CRS(settings.epsg_4326_uri_format).to_wkt() + if wkt_crs == wkt_epsg_4326: return (y, x) return (x, y) @@ -817,23 +874,27 @@ def interpret_time_coordinates( @staticmethod def to_coverage_json( - layers: List[pogc.Layer], dataset: Dict[str, podpac.UnitsDataArray], crs: str + layers: List[pogc.Layer], dataset: Dict[str, podpac.UnitsDataArray], collection_id: str, crs: str ) -> Dict[str, Any]: """Generate a CoverageJSON of the data for the provided parameters. + The returned object must be serializable, so a temporary file is returned to reference the data. + 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. + collection_id : str + The collection id of the data used in naming the output file. crs : str The CRS associated with the requested coordinates and data response. Returns ------- Dict[str, Any] - A dictionary of the CoverageJSON data. + A dictionary of the desired output file name and data path. """ # Determine the bounding coordinates, assume they all are the same @@ -844,6 +905,9 @@ def to_coverage_json( x_arr = list(x_arr.flatten()) y_arr = list(y_arr.flatten()) + lon_map_value, lat_map_value = EdrProvider.crs_converter("x", "y", crs) + dimension_map = {"time": "t", "alt": "z", "lon": lon_map_value, "lat": lat_map_value} + coverage_json = { "type": "Coverage", "domain": { @@ -926,19 +990,26 @@ def to_coverage_json( } } coverage_json["domain"]["parameters"].update(parameter_definition) + + data = [x if np.isfinite(x) else None for x in data_array.values.flatten()] coverage_json["domain"]["ranges"].update( { param: { "type": "NdArray", "dataType": "float", - "axisNames": list(data_array.coords.keys()), + "axisNames": [dimension_map.get(str(key), str(key)) for key in data_array.coords.keys()], "shape": data_array.shape, - "values": list(data_array.values.flatten()), # Row Major Order + "values": data, # Row Major Order } } ) - return coverage_json + encoder = json.JSONEncoder() + with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as named_file: + for chunk in encoder.iterencode(coverage_json): + named_file.write(chunk) + + return {"fp": named_file.name, "fn": f"{collection_id}.json"} @staticmethod def check_query_condition(conditional: bool, message: str): @@ -963,6 +1034,8 @@ def check_query_condition(conditional: bool, message: str): def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id: str) -> Dict[str, Any]: """Generate a geotiff of the data for the provided parameters. + The returned object must be serializable, so a temporary file is returned to reference the data. + Parameters ---------- dataset : Dict[str, podpac.UnitsDataArray] @@ -973,25 +1046,26 @@ def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id Returns ------- Dict[str, Any] - A dictionary the file name and data with a Base64 encoding. + A dictionary of the desired output file name and data path. """ if len(dataset) == 1: units_data_array = next(iter(dataset.values())) geotiff_bytes = units_data_array.to_format("geotiff").read() + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".tif", delete=False) as named_file: + named_file.write(geotiff_bytes) return { - "fp": base64.b64encode(geotiff_bytes).decode("utf-8"), + "fp": named_file.name, "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()) + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".zip", delete=False) as named_file: + with zipfile.ZipFile(named_file, "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"} + return {"fp": named_file.name, "fn": f"{collection_id}.zip"} @staticmethod def get_native_coordinates( @@ -1018,6 +1092,9 @@ def get_native_coordinates( podpac.Coordinates The converted coordinates source coordinates intersecting with the target coordinates. """ + if len(source_coordinates["lat"].coordinates) == 1 and len(source_coordinates["lon"].coordinates) == 1: + return source_coordinates + latitudes = ( target_coordinates["lat"].coordinates if resolution_lat == 0 diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index 827a0cd..b03435b 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -1,13 +1,12 @@ import os import mimetypes import json -import base64 -import io import traitlets as tl +import weakref import pygeoapi.l10n import pygeoapi.plugin import pygeoapi.api -from typing import Tuple, Any, Dict +from typing import Tuple, Any, Dict, Generator from http import HTTPStatus from copy import deepcopy from pygeoapi.openapi import get_oas @@ -255,13 +254,73 @@ def collection_query( 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-Type"] = "image/tiff" - 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"])) - else: - headers["Content-Type"] = "application/prs.coverage+json" + data_path = content["fp"] + binary_mode = "rb" + text_mode = "r" + mode = text_mode + if data_path.endswith(".tif"): + mode = binary_mode + headers["Content-Type"] = "image/tiff" + elif data_path.endswith(".zip"): + mode = binary_mode + headers["Content-Type"] = "application/zip" + elif data_path.endswith(".json"): + mode = text_mode + headers["Content-Type"] = "application/prs.coverage+json" + + headers["Content-Disposition"] = f"attachment; filename={content["fn"]}" + content = self.file_generator_with_cleanup(data_path, mode) return headers, http_status, content + + @staticmethod + def file_generator_with_cleanup(path: str, mode: str, chunk_size=1024 * 1024) -> Generator[str | bytes, None, None]: + """Create a generator for file data with cleanup. + + Parameters + ---------- + path : str + The path to the file. + mode : str + The mode to open the file with. + chunk_size : int, optional + The character or byte size of each chunk read from the file, by default 1024*1024 + + Yields + ------ + Generator[str | bytes, None, None] + Chunks of data from the file. + """ + + def _cleanup(path: str): + """Cleanup a file if it exists. + + Parameters + ---------- + path : str + The path to cleanup. + """ + if os.path.exists(path): + os.remove(path) + + def _generator() -> Generator[str | bytes, None, None]: + """Create the generator for reading file data. + + Yields + ------ + Generator[str | bytes, None, None] + Chunks of file data. + """ + try: + with open(path, mode) as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield chunk + finally: + _cleanup(path) + + generator = _generator() + weakref.finalize(generator, _cleanup, path) # Ensure file removal even if generator is never used + return generator diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index fe68e97..fddd6bc 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -1,9 +1,11 @@ import pytest import numpy as np import zipfile -import base64 import io +import json +import os import podpac +import pyproj from shapely import Point, Polygon from typing import Dict, List, Any from ogc import settings @@ -12,6 +14,46 @@ from pygeoapi.provider.base import ProviderInvalidQueryError +def get_json_with_cleanup(path: str) -> Dict[str, Any]: + """Get JSON data from a path and remove the file after retrieval. + + Parameters + ---------- + path : str + The JSON file path. + + Returns + ------- + Dict[str, Any] + JSON data from the path. + """ + with open(path, "r") as f: + response = json.load(f) + + os.remove(path) + return response + + +def get_bytes_with_cleanup(path: str) -> bytes: + """Get byte data from a path and remove the file after retrieval. + + Parameters + ---------- + path : str + The file path. + + Returns + ------- + bytes + Binary data from the path. + """ + with open(path, "rb") as f: + response = f.read() + + os.remove(path) + return response + + def get_provider_definition(base_url: str) -> Dict[str, Any]: """Define the provider definition which is typically handled by pygeoapi. @@ -185,8 +227,9 @@ def test_edr_provider_position_request_valid_wkt( provider.set_layers(base_url, layers) response = provider.position(**args) + response = get_json_with_cleanup(response["fp"]) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -217,6 +260,32 @@ def test_edr_provider_position_request_invalid_wkt( provider.position(**args) +def test_edr_provider_position_request_invalid_format( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the position method of the EDR Provider class with an invalid format. + + 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. + """ + base_url = "/" + args = single_layer_cube_args_internal + del args["bbox"] + args["wkt"] = Point(5.2, 52.1) + args["format_"] = settings.GEOTIFF + + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) + + 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] ): @@ -264,8 +333,9 @@ def test_edr_provider_cube_request_valid_bbox( provider.set_layers(base_url, layers) response = provider.cube(**args) + response = get_json_with_cleanup(response["fp"]) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -298,8 +368,9 @@ def test_edr_provider_cube_request_valid_bbox_with_resolution( provider.set_extra_query_args({"resolution-x": resolution_x, "resolution-y": resolution_y}) response = provider.cube(**args) + response = get_json_with_cleanup(response["fp"]) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == resolution_x * resolution_y assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] @@ -399,8 +470,9 @@ def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_la provider.set_layers(base_url, layers) response = provider.area(**args) + response = get_json_with_cleanup(response["fp"]) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -477,9 +549,10 @@ def test_edr_provider_cube_request_valid_geotiff_format( provider.set_layers(base_url, layers) response = provider.cube(**args) + data = get_bytes_with_cleanup(response["fp"]) assert response["fn"] == f"{parameter_name}.tif" - assert len(base64.b64decode(response["fp"])) > 0 + assert len(data) > 0 def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters( @@ -508,8 +581,7 @@ def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters( provider.set_layers(base_url, layers) response = provider.cube(**args) - buffer = io.BytesIO(base64.b64decode(response["fp"])) - + buffer = io.BytesIO(get_bytes_with_cleanup(response["fp"])) assert response["fn"] == f"{group}.zip" assert zipfile.is_zipfile(buffer) with zipfile.ZipFile(buffer, "r") as zf: @@ -652,12 +724,15 @@ def test_edr_provider_altitude_invalid_string(): 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) == settings.crs_84_uri_format + + assert EdrProvider.interpret_crs(None) == pyproj.CRS(settings.crs_84_uri_format).to_wkt() 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(settings.epsg_4326_uri_format) == settings.epsg_4326_uri_format + assert ( + EdrProvider.interpret_crs(settings.epsg_4326_uri_format) == pyproj.CRS(settings.epsg_4326_uri_format).to_wkt() + ) def test_edr_provider_crs_interpreter_invalid_value(): diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index ca0aaaf..e9eb11f 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -1,5 +1,7 @@ +import os import json import numpy as np +import tempfile from pygeoapi.api import APIRequest from http import HTTPStatus from typing import Dict, List, Any @@ -149,7 +151,7 @@ def test_edr_routes_describe_collection(layers: List[pogc.Layer]): 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"] + assert list(response["data_queries"].keys()) == ["instances"] def test_edr_routes_describe_instances(layers: List[pogc.Layer]): @@ -222,10 +224,11 @@ def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube instance_id=instance_id, query_type="cube", ) + content = json.loads("".join(content)) assert status == HTTPStatus.OK - assert set(content["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(content["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(content["domain"]["ranges"][parameter_name]["shape"])) == len( content["domain"]["ranges"][parameter_name]["values"] ) @@ -333,6 +336,7 @@ def test_edr_routes_collection_query_missing_parameter( instance_id=next(iter(layers[0].time_instances())), query_type="cube", ) + content = json.loads("".join(content)) assert status == HTTPStatus.OK assert content["domain"]["ranges"].keys() == {layer.identifier for layer in layers} @@ -349,3 +353,31 @@ def test_edr_routes_request_url_updates_configuration_url(): assert status == HTTPStatus.OK assert edr_routes.api.config["server"]["url"] == expected_config_url + + +def test_edr_routes_file_cleanup_unused_generator(): + """Test the EDR routes properly cleans up files if the generator goes unused.""" + named_file = tempfile.NamedTemporaryFile(mode="w+", delete=False) + generator = EdrRoutes.file_generator_with_cleanup(named_file.name, "r") + + assert os.path.exists(named_file.name) + + del generator + + assert not os.path.exists(named_file.name) + + +def test_edr_routes_file_cleanup_generator_error(): + """Test the EDR routes properly cleans up files if the generator has an error.""" + named_file = tempfile.NamedTemporaryFile(mode="w+", delete=False) + generator = EdrRoutes.file_generator_with_cleanup(named_file.name, "r") + + assert os.path.exists(named_file.name) + + try: + next(generator) + generator.close() + except StopIteration: + pass + + assert not os.path.exists(named_file.name) diff --git a/ogc/podpac.py b/ogc/podpac.py index 4ef1e3e..d9fe213 100755 --- a/ogc/podpac.py +++ b/ogc/podpac.py @@ -65,35 +65,40 @@ def time_instances(self) -> List[str]: List of available time instances as a strings. """ time_instances = set() - coordinates_list = self.get_coordinates_list() + coordinates = self.get_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 - ): + if coordinates is not None and "time" in coordinates.udims and "forecastOffsetHr" in coordinates.udims: time_instances.update( - [ - time.astype("datetime64[ms]").astype(datetime).isoformat() - for time in coordinates_list[0]["time"].coordinates - ] + [time.astype("datetime64[ms]").astype(datetime).isoformat() for time in coordinates["time"].coordinates] ) return list(time_instances) - def get_coordinates_list(self) -> List[Coordinates]: - """Retrieve the coordinates list from the node. + def get_coordinates(self) -> Coordinates | None: + """Retrieve the coordinates from the node. Returns ------- - List[Coordinates] - List of coordinates from the node. + Coordinates | None + Coordinates from the node or None if not found. """ if self.node is None: - return [] + return None - return self.node.find_coordinates() + coordinates_list = self.node.find_coordinates() + dimension_set = set() + coordinates = None + + for coords in coordinates_list: + dimension_set.update(coords.udims) + if coordinates is None or len(coords.udims) > len(coordinates.udims): + coordinates = coords + + if coordinates is not None and not all(dim in coordinates.udims for dim in dimension_set): + raise ValueError("Not all node coordinate dimensions contained in the layer coordinates.") + + return coordinates def get_units(self) -> str | None: """Retrieve the units from the node. diff --git a/ogc/servers.py b/ogc/servers.py index efa6213..4b93993 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -194,7 +194,7 @@ def method(): self.add_url_rule( f"/{endpoint}/edr/collections//instances//", endpoint=f"{endpoint}_instance_query", - view_func=self.edr_render(ogc.edr_routes.collection_query, default_format=settings.GEOTIFF), + view_func=self.edr_render(ogc.edr_routes.collection_query), methods=["GET"], strict_slashes=strict_slashes, ) @@ -259,7 +259,7 @@ def ogc_render(self, ogc_idx): ee = WCSException() return respond_xml(ee.to_xml(), status=500) - def edr_render(self, callable: Callable, default_format: str | None = "json") -> Callable: + def edr_render(self, callable: Callable) -> Callable: """Function which returns a wrapper for the provided callable. Filters arguments and handles any necessary exceptions. @@ -267,9 +267,6 @@ def edr_render(self, callable: Callable, default_format: str | None = "json") -> ---------- callable : Callable The callable request handler to be wrapped. - default_format: str | None - The optional default data output format, by default "json". - Returns ------- Callable @@ -305,6 +302,10 @@ def wrapper(*args, **kwargs) -> Response: for (k, v) in request.args.items() } # Replace format with its lowercase version to match pygeoapi expectations + query_type = kwargs.get("query_type") + default_format = settings.JSON + if query_type is not None: + default_format = settings.EDR_QUERY_DEFAULTS.get(query_type, default_format) format_argument = filtered_args.get("f", default_format) if format_argument is not None: filtered_args["f"] = format_argument.lower() @@ -319,14 +320,7 @@ def wrapper(*args, **kwargs) -> Response: 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 + return response except Exception as e: logger.error("OGC: server.edr_render Exception: %s", str(e), exc_info=True) ee = WCSException() diff --git a/ogc/settings.py b/ogc/settings.py index 8bdf166..f6ef46a 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -44,7 +44,16 @@ JSON = "JSON" COVERAGE_JSON = "CoverageJSON" HTML = "HTML" -EDR_QUERY_FORMATS = [GEOTIFF, COVERAGE_JSON, JSON, HTML] +EDR_QUERY_FORMATS = { + "cube": [GEOTIFF, COVERAGE_JSON, JSON, HTML], + "area": [GEOTIFF, COVERAGE_JSON, JSON, HTML], + "position": [COVERAGE_JSON, JSON, HTML], +} +EDR_QUERY_DEFAULTS = { + "cube": GEOTIFF, + "area": GEOTIFF, + "position": COVERAGE_JSON, +} # WMS Capabilities timestamp format USE_TIMES_LIST = False diff --git a/pyproject.toml b/pyproject.toml index 2cf8ea1..e104951 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ 'lxml', 'webob', 'traitlets', - 'pygeoapi', + 'pygeoapi==0.22.0', 'shapely', 'numpy', 'traitlets', From 1db90ef34c32c67dc482025793bf6062df8ef689 Mon Sep 17 00:00:00 2001 From: scranford1 Date: Mon, 23 Mar 2026 15:18:41 -0400 Subject: [PATCH 45/69] Bugfix/extent generation (#17) * Dynamic extent generation and coordinate updates --- ogc/edr/config/default.json | 11 +-- ogc/edr/edr_api.py | 192 ++++++++++++++++++++++++++++-------- ogc/edr/edr_config.py | 135 ++----------------------- ogc/edr/edr_provider.py | 16 +-- ogc/edr/test/conftest.py | 4 +- ogc/settings.py | 6 +- 6 files changed, 174 insertions(+), 190 deletions(-) diff --git a/ogc/edr/config/default.json b/ogc/edr/config/default.json index cf2a734..6aea353 100644 --- a/ogc/edr/config/default.json +++ b/ogc/edr/config/default.json @@ -24,12 +24,9 @@ }, "metadata": { "identification": { - "title": "OGC Server", - "description": "An example OGC Server", - "keywords": [ - "geospatial", - "podpac" - ], + "title": "EDR Server", + "description": "Environmental Data Retrieval 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" @@ -47,4 +44,4 @@ "url": "https://github.com/creare-com" } } -} \ No newline at end of file +} diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index ff13604..ed18663 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -1,4 +1,5 @@ import json +import pyproj import numpy as np import pygeoapi.api import pygeoapi.api.environmental_data_retrieval as pygeoedr @@ -11,6 +12,7 @@ from pygeoapi.api import API, APIRequest from pygeoapi.linked_data import jsonldify from .edr_provider import EdrProvider +from .. import settings class EdrAPI: @@ -142,26 +144,17 @@ def describe_collections(api: API, request: APIRequest, dataset: str | None = No provider = get_provider_by_type(collection_configuration[collection_id]["providers"], "edr") provider_plugin = load_plugin("provider", provider) provider_parameters = provider_plugin.get_fields() - - extents = collection.get("extent", {}) - if "vertical" in collection_configuration[collection_id]["extents"]: - extents = extents | {"vertical": collection_configuration[collection_id]["extents"]["vertical"]} - if "temporal" in collection_configuration[collection_id]["extents"]: - times = collection_configuration[collection_id]["extents"]["temporal"].get("values", []) - trs = collection_configuration[collection_id]["extents"]["temporal"].get("trs") - temporal_extents = EdrAPI._temporal_extents(times, trs) - extents = extents | temporal_extents - collection["extent"] = extents - + collection_layers = EdrProvider.get_layers(provider["base_url"], collection_id) + collection["extent"] = EdrAPI._generate_extents(collection_layers, None) collection["output_formats"] = collection_configuration[collection_id].get("output_formats", []) collection_queryable = EdrProvider.is_collection_queryable(provider["base_url"], collection_id) + collection_without_queryables = EdrAPI._remove_query_metadata(collection) + collection["links"] = collection_without_queryables["links"] # Always remove unnecessary query links if not collection_queryable: - collection_without_queryables = EdrAPI._remove_query_metadata(collection) - collection["links"] = collection_without_queryables["links"] collection["data_queries"] = collection_without_queryables["data_queries"] - height_units = collection_configuration[collection_id].get("height_units", []) + height_units = EdrAPI._vertical_units(collection_layers) query_formats = collection_configuration[collection_id].get("query_formats", {}) for query_type in collection["data_queries"]: data_query_additions = { @@ -216,27 +209,11 @@ def get_collection_edr_instances( collection_layers = EdrProvider.get_layers(provider["base_url"], dataset) for instance in instances: - extents = instance.get("extent", {}) - if "vertical" in collection_configuration[dataset]["extents"]: - extents = extents | {"vertical": collection_configuration[dataset]["extents"]["vertical"]} - - times = EdrProvider.get_datetimes(collection_layers, instance["id"]) - if len(times) > 0: - trs = collection_configuration[dataset]["extents"]["temporal"].get("trs") - temporal_extents = EdrAPI._temporal_extents(times, trs) - extents = extents | temporal_extents - - bbox = collection_configuration[dataset]["extents"]["spatial"]["bbox"] - if not isinstance(bbox[0], list): - bbox = [bbox] - crs = collection_configuration[dataset]["extents"]["spatial"].get("crs") - spatial_extents = EdrAPI._spatial_extents(bbox, crs) - extents = extents | spatial_extents - instance["extent"] = extents - + instance_id = instance["id"] + instance["extent"] = EdrAPI._generate_extents(collection_layers, instance_id) instance["output_formats"] = collection_configuration[dataset].get("output_formats", []) - height_units = collection_configuration[dataset].get("height_units") + height_units = EdrAPI._vertical_units(collection_layers) query_formats = collection_configuration[dataset].get("query_formats", {}) for query_type in instance["data_queries"]: data_query_additions = { @@ -300,7 +277,7 @@ def _temporal_extents(times: List[Union[np.datetime64, datetime]], trs: str | No Returns ------- Dict[str, Any] - The temporal extent object. + The temporal extent object or an empty dictionary.. """ iso_times = [] for time in sorted(times): @@ -311,13 +288,45 @@ def _temporal_extents(times: List[Union[np.datetime64, datetime]], trs: str | No time_utc = dt.astimezone(timezone.utc) iso_times.append(time_utc.isoformat().replace("+00:00", "Z")) - return { - "temporal": { - "interval": [iso_times[0], iso_times[-1]] if len(iso_times) > 0 else [], - "values": iso_times, - "trs": trs, + return ( + { + "temporal": { + "interval": [iso_times[0], iso_times[-1]], + "values": iso_times, + "trs": trs, + } } - } + if len(iso_times) > 0 + else {} + ) + + @staticmethod + def _vertical_extents(vertical_levels: List[float], vrs: str | None) -> Dict[str, Any]: + """Get the vertical extents for the provided levels and reference system. + + Parameters + ---------- + vertical_levels : List[float] + Vertical levels used to create the vertical extent. + vrs : str | None + The reference system for the vertical levels. + + Returns + ------- + Dict[str, Any] + The vertical extent object or an empty dictionary. + """ + return ( + { + "vertical": { + "interval": [vertical_levels[0], vertical_levels[-1]], + "values": vertical_levels, + "vrs": vrs, + } + } + if len(vertical_levels) > 0 + else {} + ) @staticmethod def _spatial_extents(bbox: List[float], crs: str | None) -> Dict[str, Any]: @@ -342,6 +351,109 @@ def _spatial_extents(bbox: List[float], crs: str | None) -> Dict[str, Any]: } } + @staticmethod + def _vertical_units(layers: List[pogc.Layer]) -> List[str]: + """Retrieve the vertical units for the layers. + + Parameters + ---------- + layer : List[pogc.Layer] + The layers from which to get the vertical units. + + Returns + ------- + List[str] + The vertical units available for the layers. + """ + vertical_units = set() + for layer in layers: + coordinates = layer.get_coordinates() + if coordinates is not None and coordinates.alt_units: + vertical_units.add(coordinates.alt_units) + + return list(vertical_units) + + @staticmethod + def _crs84_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[settings.crs_84_uri_format] + return (crs_extents["minx"], crs_extents["miny"], crs_extents["maxx"], crs_extents["maxy"]) + + @staticmethod + def _generate_extents(layers: List[pogc.Layer], instance: str | None) -> Dict[str, Any]: + """Generate the extents for the provided layers. + + Parameters + ---------- + layers : List[pogc.Layer] + The layers for temporal and spatial extent generation. + instance : str | None + The instance for extent generation or None for collection extents. + + Returns + ------- + Dict[str, Any] + The extents dictionary for the provided layers. + """ + bbox = [] + crs = pyproj.CRS(settings.crs_84_uri_format).to_wkt() + + time_range = set() + vertical_range = set() + + for layer in layers: + coordinates = layer.get_coordinates() + if coordinates is not None: + llc_lon_tmp, llc_lat_tmp, urc_lon_tmp, urc_lat_tmp = EdrAPI._crs84_bounding_box(layer) + if len(bbox) != 4: + bbox = [llc_lon_tmp, llc_lat_tmp, urc_lon_tmp, urc_lat_tmp] + else: + llc_lon = min(bbox[0], llc_lon_tmp) + llc_lat = min(bbox[1], llc_lat_tmp) + urc_lon = max(bbox[2], urc_lon_tmp) + urc_lat = max(bbox[3], urc_lat_tmp) + bbox = [llc_lon, llc_lat, urc_lon, urc_lat] + + if "alt" in coordinates.udims: + vertical_range.update(coordinates["alt"].coordinates) + + if "time" in coordinates.udims: + if instance in layer.time_instances() and "forecastOffsetHr" in coordinates.udims: + instance_datetime = np.datetime64(instance) + instance_coordinates = coordinates.select({"time": [instance_datetime, instance_datetime]}) + selected_offset_coordinates = instance_coordinates["forecastOffsetHr"].coordinates + time_range.update([instance_datetime + offset for offset in selected_offset_coordinates]) + elif not instance and "forecastOffsetHr" not in coordinates.udims: + time_range.update(coordinates["time"].coordinates) + + sorted_time_range = sorted(time_range) + sorted_vertical_range = sorted(vertical_range) + + return { + **(EdrAPI._spatial_extents(bbox, crs)), + **(EdrAPI._temporal_extents(sorted_time_range, "https://www.opengis.net/def/uom/ISO-8601/0/Gregorian")), + **(EdrAPI._vertical_extents(sorted_vertical_range, "https://www.opengis.net/def/uom/EPSG/0/9001")), + } + @staticmethod def _instance_parameters( collection_layers: List[pogc.Layer], provider_parameters: Dict[str, Any], instance: str diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 58a032a..2d9716f 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -1,9 +1,7 @@ import os import json import logging -import traitlets as tl -from collections import defaultdict -from typing import List, Dict, Tuple, Any +from typing import List, Dict, Any from ogc import podpac as pogc from .. import settings @@ -82,14 +80,10 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, """ resources = {} - groups = defaultdict(list) - - # Organize the data into groups - for layer in layers: - groups[layer.group].append(layer) + groups = {layer.group for layer in layers} # Generate collection resources based on groups - for group_name, group_layers in groups.items(): + for group_name in groups: resource = { group_name: { "type": "collection", @@ -97,8 +91,12 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, "title": group_name, "description": f"Collection of data related to {group_name}", "keywords": ["podpac"], - "extents": EdrConfig._generate_extents(group_layers), - "height_units": EdrConfig._vertical_units(group_layers), + "extents": { + "spatial": { + "bbox": [-180, -90, 180, 90], # Placeholder extents + "crs": settings.crs_84_uri_format, + } + }, "output_formats": list({item for values in settings.EDR_QUERY_FORMATS.values() for item in values}), "query_formats": EdrConfig.data_query_formats(), "providers": [ @@ -131,121 +129,6 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, 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 - time_range = set() - vertical_range = set() - # 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) - - coordinates = layer.get_coordinates() - if coordinates is not None and "alt" in coordinates.udims: - vertical_range.update(coordinates["alt"].coordinates) - - if hasattr(layer, "valid_times") and layer.valid_times is not tl.Undefined and len(layer.valid_times) > 0: - time_range.update(layer.valid_times) - - sorted_time_range = sorted(time_range) - sorted_vertical_range = sorted(vertical_range) - - return { - "spatial": { - "bbox": [llc_lon, llc_lat, urc_lon, urc_lat], # minx, miny, maxx, maxy - "crs": settings.crs_84_uri_format, - }, - **( - { - "temporal": { - "begin": sorted_time_range[0], # start datetime in RFC3339 - "end": sorted_time_range[-1], # end datetime in RFC3339 - "values": sorted_time_range, - "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", - } - } - if len(sorted_time_range) > 0 - else {} - ), - **( - { - "vertical": { - "interval": [sorted_vertical_range[0], sorted_vertical_range[-1]], - "values": sorted_vertical_range, - "vrs": "http://www.opengis.net/def/uom/EPSG/0/9001", - } - } - if len(sorted_vertical_range) > 0 - 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[settings.crs_84_uri_format] - return (crs_extents["minx"], crs_extents["miny"], crs_extents["maxx"], crs_extents["maxy"]) - - @staticmethod - def _vertical_units(layers: List[pogc.Layer]) -> List[str]: - """Retrieve the vertical units for the layers. - - Parameters - ---------- - layer : List[pogc.Layer] - The layers from which to get the vertical units. - - Returns - ------- - str | None - The vertical units available for the layers. - """ - vertical_units = set() - for layer in layers: - coordinates = layer.get_coordinates() - if coordinates is not None and coordinates.alt_units: - vertical_units.add(coordinates.alt_units) - - return list(vertical_units) - @staticmethod def data_query_formats() -> Dict[str, Any]: """Get data related to the available query output formats and the default format. diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index a1766a3..0b94842 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -202,7 +202,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): if time_coords is not None: self.check_query_condition( - len(time_coords["time"].coordinates) > 1 and output_format == settings.GEOTIFF.lower(), + any(dimension > 1 for dimension in time_coords.shape) and output_format == settings.GEOTIFF.lower(), "GeoTIFF output currently only supports single time requests.", ) requested_coordinates = podpac.coordinates.merge_dims([time_coords, requested_coordinates]) @@ -234,11 +234,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): self.check_query_condition(len(dataset) == 0, "No matching parameters found.") - if ( - output_format == settings.COVERAGE_JSON.lower() - or output_format == settings.JSON.lower() - or output_format == settings.HTML.lower() - ): + if output_format == settings.COVERAGE_JSON.lower(): layers = self.get_layers(self.base_url, self.collection_id) return self.to_coverage_json(layers, dataset, self.collection_id, crs) @@ -665,9 +661,7 @@ def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[n instance_datetime = np.datetime64(instance_time) instance_coordinates = coordinates.select({"time": [instance_datetime, instance_datetime]}) selected_offset_coordinates = instance_coordinates["forecastOffsetHr"].coordinates - available_times.update( - [np.datetime64(instance_time) + offset for offset in selected_offset_coordinates] - ) + available_times.update([instance_datetime + offset for offset in selected_offset_coordinates]) elif not instance_time and "forecastOffsetHr" not in coordinates.udims: # Retrieve layer times for non-instance requests available_times.update(coordinates["time"].coordinates) @@ -866,9 +860,7 @@ def interpret_time_coordinates( if instance_time: offsets = [np.timedelta64(time - np.datetime64(instance_time), "h") for time in times] - return podpac.Coordinates( - [[[instance_time] * len(offsets), offsets]], dims=[["time", "forecastOffsetHr"]], crs=crs - ) + return podpac.Coordinates([[instance_time], offsets], dims=["time", "forecastOffsetHr"], crs=crs) return podpac.Coordinates([times], dims=["time"], crs=crs) diff --git a/ogc/edr/test/conftest.py b/ogc/edr/test/conftest.py index b87b3e5..2c36dd2 100644 --- a/ogc/edr/test/conftest.py +++ b/ogc/edr/test/conftest.py @@ -59,7 +59,7 @@ def single_layer_cube_args() -> Dict[str, Any]: """ return { - "f": "json", + "f": "coveragejson", "bbox": "-180, -90, 180, 90", "datetime": str(time[0]), "parameter-name": [layer1.identifier], @@ -77,7 +77,7 @@ def single_layer_cube_args_internal() -> Dict[str, Any]: """ return { - "format_": "json", + "format_": "coveragejson", "instance": str(time[0]), "bbox": [-180, -90, 180, 90], "datetime_": str(time[0]), diff --git a/ogc/settings.py b/ogc/settings.py index f6ef46a..6859455 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -45,9 +45,9 @@ COVERAGE_JSON = "CoverageJSON" HTML = "HTML" EDR_QUERY_FORMATS = { - "cube": [GEOTIFF, COVERAGE_JSON, JSON, HTML], - "area": [GEOTIFF, COVERAGE_JSON, JSON, HTML], - "position": [COVERAGE_JSON, JSON, HTML], + "cube": [GEOTIFF, COVERAGE_JSON], + "area": [GEOTIFF, COVERAGE_JSON], + "position": [COVERAGE_JSON], } EDR_QUERY_DEFAULTS = { "cube": GEOTIFF, From 199b7c4019ff053ea6fbc1e902c5db2d3a8f6464 Mon Sep 17 00:00:00 2001 From: John Walthour Date: Tue, 7 Apr 2026 10:46:00 -0400 Subject: [PATCH 46/69] Convert coverage report to absolute paths --- .github/workflows/github-python-workflow.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml index 45ab8bc..e3ed9c4 100644 --- a/.github/workflows/github-python-workflow.yml +++ b/.github/workflows/github-python-workflow.yml @@ -76,7 +76,7 @@ jobs: -m pytest --continue-on-collection-errors || \ { echo "WARNING: Some tests have failed. (Temporarily ignored)"; true; } coverage xml --data-file=coverage.bin -o coverage.xml - + sed -i 's,.*/ogc,ogc,' coverage.xml - name: Artifact coverage document uses: actions/upload-artifact@v4 with: diff --git a/pyproject.toml b/pyproject.toml index e104951..25dd3de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,6 @@ testpaths = ["ogc"] line-length = 120 [tool.coverage.run] -relative_files = true +relative_files = false source = ["ogc/"] branch = true From 0b4c4c975fa943ad01272c622dc8952e36cb61da Mon Sep 17 00:00:00 2001 From: John Walthour Date: Tue, 7 Apr 2026 12:01:41 -0400 Subject: [PATCH 47/69] Added comment linking to PR with explanation --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 25dd3de..28c2688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,6 @@ testpaths = ["ogc"] line-length = 120 [tool.coverage.run] -relative_files = false +relative_files = false # For reasoning, see https://github.com/creare-com/ogc/pull/18 source = ["ogc/"] branch = true From c2ad77d00ac9527dfb356f49b88af825af3bc84c Mon Sep 17 00:00:00 2001 From: John Walthour Date: Tue, 14 Apr 2026 19:03:36 -0400 Subject: [PATCH 48/69] Removed unused imports --- example/app.py | 1 - ogc/core.py | 3 --- ogc/podpac.py | 2 -- ogc/servers.py | 1 - ogc/version.py | 2 +- ogc/wcs_response_1_0_0.py | 1 - ogc/wms_request_1_3_0.py | 2 -- ogc/wms_response_1_3_0.py | 1 - 8 files changed, 1 insertion(+), 12 deletions(-) diff --git a/example/app.py b/example/app.py index 8394228..f0ddf19 100755 --- a/example/app.py +++ b/example/app.py @@ -2,7 +2,6 @@ Demo Flask app using PODPAC Node backed layers. """ -from flask import Flask from datetime import datetime import re diff --git a/ogc/core.py b/ogc/core.py index 570188b..cbbb175 100755 --- a/ogc/core.py +++ b/ogc/core.py @@ -13,7 +13,6 @@ from . import wms_request_1_3_0 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 @@ -116,8 +115,6 @@ def get_coverage_wcs(self, args, wcs_request): coverage = self.get_coverage_from_id(get_coverage.identifier.value) - from dateutil.parser import parse - if get_coverage.width == 0: raise WCSException( exception_code="InvalidParameterValue", diff --git a/ogc/podpac.py b/ogc/podpac.py index d9fe213..b136a30 100755 --- a/ogc/podpac.py +++ b/ogc/podpac.py @@ -10,9 +10,7 @@ from matplotlib import pyplot as plt import matplotlib as mpl import io -from PIL import Image import numpy as np -import xarray as xr import json import textwrap import re diff --git a/ogc/servers.py b/ogc/servers.py index 4b93993..d56a2f0 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -9,7 +9,6 @@ import re from flask import Flask, request, Response, make_response, send_file import six -import traceback import logging from typing import Callable from werkzeug.datastructures import ImmutableMultiDict diff --git a/ogc/version.py b/ogc/version.py index ff67c79..25703f5 100755 --- a/ogc/version.py +++ b/ogc/version.py @@ -61,7 +61,7 @@ def version(): git = "/usr/bin/git" try: subprocess.check_output([git, "--version"]) - except Exception as e: + except Exception: return version_full version_full = ( diff --git a/ogc/wcs_response_1_0_0.py b/ogc/wcs_response_1_0_0.py index 04a9d75..6e281c1 100755 --- a/ogc/wcs_response_1_0_0.py +++ b/ogc/wcs_response_1_0_0.py @@ -1,7 +1,6 @@ import logging import datetime -import lxml, lxml.etree import traitlets as tl from ogc import GridCoordinates diff --git a/ogc/wms_request_1_3_0.py b/ogc/wms_request_1_3_0.py index 6c34ee6..37b9d95 100755 --- a/ogc/wms_request_1_3_0.py +++ b/ogc/wms_request_1_3_0.py @@ -1,7 +1,5 @@ import logging -import lxml, lxml.etree -import dateutil.parser import traitlets as tl from . import ogc_common diff --git a/ogc/wms_response_1_3_0.py b/ogc/wms_response_1_3_0.py index 0991798..cc56a14 100755 --- a/ogc/wms_response_1_3_0.py +++ b/ogc/wms_response_1_3_0.py @@ -1,6 +1,5 @@ import logging -import lxml, lxml.etree import traitlets as tl import numpy as np From 9d8c86a25793cd39bd77788d1c2a187c4822c932 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 17 Apr 2026 17:07:50 -0400 Subject: [PATCH 49/69] ENH: Allow OGC formats to be optionally supported --- ogc/core.py | 6 +- ogc/edr/test/conftest.py | 6 ++ ogc/servers.py | 200 ++++++++++++++++++++++----------------- ogc/settings.py | 6 ++ 4 files changed, 130 insertions(+), 88 deletions(-) diff --git a/ogc/core.py b/ogc/core.py index cbbb175..a9c0885 100755 --- a/ogc/core.py +++ b/ogc/core.py @@ -62,7 +62,11 @@ 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) + self.edr_routes = ( + EdrRoutes(base_url=f"{self.server_address}{self.endpoint}/edr", layers=layers) + if settings.EDR_ENABLED + else None + ) def get_coverage_from_id(self, identifier): for coverage in self.wcs_capabilities.coverages: diff --git a/ogc/edr/test/conftest.py b/ogc/edr/test/conftest.py index 2c36dd2..6aae719 100644 --- a/ogc/edr/test/conftest.py +++ b/ogc/edr/test/conftest.py @@ -1,3 +1,4 @@ +import os import pytest import numpy as np import datetime @@ -36,6 +37,11 @@ ) +@pytest.fixture(scope="session", autouse=True) +def set_env_vars(): + os.environ["OGC_SUPPORTED_FORMATS"] = "edr" + + @pytest.fixture() def layers() -> List[pogc.Layer]: """List of test layers. diff --git a/ogc/servers.py b/ogc/servers.py index d56a2f0..3efea7c 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -34,10 +34,9 @@ def home(endpoint): """Example API home page. Developers should make their own page similar to this one.""" test_layer = "testLayerName" test_layer_time = "12:59:59" # HH:MM:SS - return f"""

    OGC Server API

    -

    This is the API endpoint served at {endpoint}. Add example usage here for your users.

    -
      + wcs_list_item = ( + f"""
    • WCS: Open Geospatial Consortium (OGC) Web Coverage Service (WCS) (v1.0.0)
    • + """ + if settings.WCS_ENABLED + else "" + ) + + wms_list_item = ( + f"""
    • WMS: Open Geospatial Consortium (OGC) Web Map Service (WMS) (v1.3.0)
    • + """ + if settings.WMS_ENABLED + else "" + ) + + edr_list_item = ( + f"""
    • EDR: Open Geospatial Consortium (OGC) Environmental Data Retrieval (EDR) (v1.0.1)
    • + """ + if settings.EDR_ENABLED + else "" + ) + + return f"""

      OGC Server API

      +

      This is the API endpoint served at {endpoint}. Add example usage here for your users.

      + +
        + {wcs_list_item} + {wms_list_item} + {edr_list_item}
      """ @@ -115,88 +140,89 @@ 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 - strict_slashes = get_api_rules(ogc.edr_routes.api.config).strict_slashes - self.add_url_rule( - 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/", - 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", - 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", - 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", - 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", - 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/", - 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", - 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/", - 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//", - 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//", - endpoint=f"{endpoint}_instance_query", - view_func=self.edr_render(ogc.edr_routes.collection_query), - methods=["GET"], - strict_slashes=strict_slashes, - ) + # Set up the EDR endpoints for the server if routes are available + if settings.EDR_ENABLED: + strict_slashes = get_api_rules(ogc.edr_routes.api.config).strict_slashes + self.add_url_rule( + 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/", + 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", + 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", + 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", + 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", + 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/", + 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", + 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/", + 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//", + 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//", + 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): logger.info("OGC server.ogc_render %i", ogc_idx) @@ -230,9 +256,9 @@ def ogc_render(self, ogc_idx): else: args["base_url"] = None ogc_response = None - if args["service"].lower() == "wcs": + if args["service"].lower() == "wcs" and settings.WCS_ENABLED: ogc_response = ogc.handle_wcs_kv(args) - elif args["service"].lower() == "wms": + elif args["service"].lower() == "wms" and settings.WMS_ENABLED: ogc_response = ogc.handle_wms_kv(args) if ogc_response is not None: if isinstance(ogc_response, six.string_types): diff --git a/ogc/settings.py b/ogc/settings.py index 6859455..52a92e8 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -87,3 +87,9 @@ EDR_CONFIGURATION_PATH = os.environ["EDR_CONFIGURATION_PATH"] except Exception: EDR_CONFIGURATION_PATH = None + +# get supported formats +OGC_SUPPORTED_FORMATS = os.environ.get("OGC_SUPPORTED_FORMATS", "wms,wcs") +WMS_ENABLED = "wms" in OGC_SUPPORTED_FORMATS.lower() +WCS_ENABLED = "wcs" in OGC_SUPPORTED_FORMATS.lower() +EDR_ENABLED = "edr" in OGC_SUPPORTED_FORMATS.lower() From 355034b3f6eb7355b38ea002bbefed14bb5a1fb4 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 21 Apr 2026 13:34:49 +0000 Subject: [PATCH 50/69] FIX: Black formatting fixes --- ogc/ogc_common.py | 4 +--- ogc/version.py | 10 ++-------- ogc/wcs_response_1_0_0.py | 16 ++++------------ ogc/wms_response_1_3_0.py | 4 +--- 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/ogc/ogc_common.py b/ogc/ogc_common.py index 20b3424..fd0856d 100755 --- a/ogc/ogc_common.py +++ b/ogc/ogc_common.py @@ -123,7 +123,5 @@ def to_xml(self): {exception_text} -""".format( - self=self, exception_text=exception_text - ) +""".format(self=self, exception_text=exception_text) return xml diff --git a/ogc/version.py b/ogc/version.py index 25703f5..b3dac1b 100755 --- a/ogc/version.py +++ b/ogc/version.py @@ -64,14 +64,8 @@ def version(): except Exception: return version_full - version_full = ( - subprocess.check_output([git, "describe", "--always"], cwd=CWD) - .strip() - .decode("ascii") - ) - version_full = version_full.replace("-", "+", 1).replace( - "-", "." - ) # Make this consistent with PEP440 + version_full = subprocess.check_output([git, "describe", "--always"], cwd=CWD).strip().decode("ascii") + version_full = version_full.replace("-", "+", 1).replace("-", ".") # Make this consistent with PEP440 except Exception as e: print("Could not determine Project version from git repo.\n" + str(e)) diff --git a/ogc/wcs_response_1_0_0.py b/ogc/wcs_response_1_0_0.py index 6e281c1..d90813f 100755 --- a/ogc/wcs_response_1_0_0.py +++ b/ogc/wcs_response_1_0_0.py @@ -122,9 +122,7 @@ def coverage_offering(self, coverage): xml += """ {coverage.wgs84_bounding_box_lower_corner_lat_lon[1]} {coverage.wgs84_bounding_box_lower_corner_lat_lon[0]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[1]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[0]} - """.format( - coverage=coverage - ) + """.format(coverage=coverage) xml += """\ @@ -235,9 +233,7 @@ def service(self): UNAVAILABLE {constraints} -""".format( - self=self, constraints=settings.CONSTRAINTS - ) +""".format(self=self, constraints=settings.CONSTRAINTS) base_url = tl.Unicode(default_value=None, allow_none=True) # e.g., http://hostname:port/path? @@ -277,9 +273,7 @@ def capability(self): application/vnd.ogc.se_xml - """.format( - self=self - ) + """.format(self=self) coverages = tl.List(tl.Instance(klass=Coverage)) # is populated via Traits in constructor @@ -316,9 +310,7 @@ def contents(self): {coverage.wgs84_bounding_box_lower_corner_lat_lon[1]} {coverage.wgs84_bounding_box_lower_corner_lat_lon[0]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[1]} {coverage.wgs84_bounding_box_upper_corner_lat_lon[0]} -""".format( - coverage=coverage - ) +""".format(coverage=coverage) xml += " \n" xml += " \n" diff --git a/ogc/wms_response_1_3_0.py b/ogc/wms_response_1_3_0.py index cc56a14..b20f7a1 100755 --- a/ogc/wms_response_1_3_0.py +++ b/ogc/wms_response_1_3_0.py @@ -86,9 +86,7 @@ def request(self): - """.format( - self=self - ) + """.format(self=self) def exception(self): return """\ From 19092f0474ffeba281bb62d1561dfe5aad14183a Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 21 Apr 2026 13:49:46 +0000 Subject: [PATCH 51/69] FIX: E741 ambiguous single-letter variable names --- example/app.py | 2 +- ogc/wcs_request_1_0_0.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/app.py b/example/app.py index f0ddf19..48c0908 100755 --- a/example/app.py +++ b/example/app.py @@ -115,7 +115,7 @@ def check_layers(layer): if match_object: clean_layer = match_object.group(0) - if clean_layer in [l.identifier for l in all_layers]: + if clean_layer in [available_layer.identifier for available_layer in all_layers]: return "{} is an available layer id".format(clean_layer) else: return "No layer available with that id" diff --git a/ogc/wcs_request_1_0_0.py b/ogc/wcs_request_1_0_0.py index 36db9f0..cb84ced 100755 --- a/ogc/wcs_request_1_0_0.py +++ b/ogc/wcs_request_1_0_0.py @@ -160,7 +160,7 @@ def validate(self): self.domain_subset_bbox.lower_corner[1], self.domain_subset_bbox.upper_corner[1], ] - if any([abs(l) > 361.000 for l in lons]) or any([abs(l) > 91.000 for l in lats]): + if any([abs(lon) > 361.000 for lon in lons]) or any([abs(lat) > 91.000 for lat in lats]): raise ogc_common.WCSException( exception_code="InvalidParameterValue", locator="BBOX", From 12140ec6e92f67e6b519e7fdb19a02fca159b3a7 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 21 Apr 2026 13:51:12 +0000 Subject: [PATCH 52/69] FIX: E266 too many leading '#' for block comment --- ogc/version.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ogc/version.py b/ogc/version.py index b3dac1b..1af9b30 100755 --- a/ogc/version.py +++ b/ogc/version.py @@ -12,13 +12,13 @@ import os from collections import OrderedDict -######################### -## UPDATE VERSION HERE ## -######################### +####################### +# UPDATE VERSION HERE # +####################### MAJOR = 0 MINOR = 4 HOTFIX = 2 -######################### +####################### VERSION_INFO = OrderedDict([("MAJOR", MAJOR), ("MINOR", MINOR), ("HOTFIX", HOTFIX)]) From fff9a488b5432141022154ec5212944e59c10526 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 21 Apr 2026 13:53:01 +0000 Subject: [PATCH 53/69] FIX: E401 multiple imports on one line --- ogc/ogc_common.py | 3 ++- ogc/wcs_request_1_0_0.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ogc/ogc_common.py b/ogc/ogc_common.py index fd0856d..74a0270 100755 --- a/ogc/ogc_common.py +++ b/ogc/ogc_common.py @@ -1,6 +1,7 @@ import logging -import lxml, lxml.etree +import lxml +import lxml.etree import numpy as np import traitlets as tl diff --git a/ogc/wcs_request_1_0_0.py b/ogc/wcs_request_1_0_0.py index cb84ced..7c500b3 100755 --- a/ogc/wcs_request_1_0_0.py +++ b/ogc/wcs_request_1_0_0.py @@ -1,6 +1,7 @@ import logging -import lxml, lxml.etree +import lxml +import lxml.etree import dateutil.parser import traitlets as tl From d38bea7e604ab00de6dee4ad514e282ca75a4fb8 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 21 Apr 2026 14:49:03 +0000 Subject: [PATCH 54/69] CHORE: Remove passing formatting checks on failure --- .github/workflows/github-python-workflow.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml index e3ed9c4..b332f3f 100644 --- a/.github/workflows/github-python-workflow.yml +++ b/.github/workflows/github-python-workflow.yml @@ -3,9 +3,9 @@ name: Python Workflow on: push: - branches: [main, develop] + branches: [ main, develop ] pull_request: - types: [opened, synchronize, reopened] + types: [ opened, synchronize, reopened ] jobs: lint: @@ -30,12 +30,10 @@ jobs: - name: Run flake8 lint checks run: | # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --output-file=flake8-report.txt - # exit-zero treats all errors as warnings - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics --output-file=flake8-report.txt + flake8 . --count --select=E9,F --show-source --statistics --max-line-length=120 --output-file=flake8-report.txt - name: Run black format checks - run: black --check --diff ogc example > black-report.txt || true # For now pass even if files need formatting + run: black --check --diff ogc example > black-report.txt - name: Artifact flake8 report uses: actions/upload-artifact@v4 @@ -86,7 +84,7 @@ jobs: sonarqube_scan: name: SonarQube Scan - needs: [unit_testing] + needs: [ unit_testing ] runs-on: ubuntu-latest steps: From ef72d7bcf71736f2fdff7e0c270a2ff6981cc630 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 21 Apr 2026 14:52:28 +0000 Subject: [PATCH 55/69] FIX: Sonarqube remove list comprehension with any --- ogc/wcs_request_1_0_0.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ogc/wcs_request_1_0_0.py b/ogc/wcs_request_1_0_0.py index 7c500b3..c9dcf1a 100755 --- a/ogc/wcs_request_1_0_0.py +++ b/ogc/wcs_request_1_0_0.py @@ -161,7 +161,7 @@ def validate(self): self.domain_subset_bbox.lower_corner[1], self.domain_subset_bbox.upper_corner[1], ] - if any([abs(lon) > 361.000 for lon in lons]) or any([abs(lat) > 91.000 for lat in lats]): + if any(abs(lon) > 361.000 for lon in lons) or any(abs(lat) > 91.000 for lat in lats): raise ogc_common.WCSException( exception_code="InvalidParameterValue", locator="BBOX", From ff8a77d1e82fabfb66518d988d41c180e2b78cc0 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 21 Apr 2026 16:05:26 +0000 Subject: [PATCH 56/69] Add testing for WCS/WMS supported formats --- ogc/edr/test/conftest.py | 1 + ogc/test/test_servers.py | 74 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/ogc/edr/test/conftest.py b/ogc/edr/test/conftest.py index 6aae719..22b5192 100644 --- a/ogc/edr/test/conftest.py +++ b/ogc/edr/test/conftest.py @@ -39,6 +39,7 @@ @pytest.fixture(scope="session", autouse=True) def set_env_vars(): + """Setup the environmental variables for the session to support EDR.""" os.environ["OGC_SUPPORTED_FORMATS"] = "edr" diff --git a/ogc/test/test_servers.py b/ogc/test/test_servers.py index 27f620c..9d96229 100644 --- a/ogc/test/test_servers.py +++ b/ogc/test/test_servers.py @@ -1,12 +1,40 @@ from ogc import servers from ogc import core from ogc import podpac as pogc +from ogc import settings +from unittest.mock import patch +from typing import Callable +import importlib import podpac import pytest import numpy as np +@pytest.fixture +def supported_formats() -> Callable[[str], None]: + """Fixture used to patch OGC supported formats. + + Returns + ------- + Callable[[str], None] + A function which patches the OGC supported formats based on input string. + """ + + def _supported_formats(formats: str): + """Patch the supported formats setting. + + Parameters + ---------- + formats : str + The formats which should be supported by the server as a string. + """ + with patch.dict("os.environ", {"OGC_SUPPORTED_FORMATS": formats}): + importlib.reload(settings) + + return _supported_formats + + @pytest.fixture def client(): """ @@ -96,3 +124,49 @@ def test_server_ogc_render_invalid_request(client): """ response = client.get("/ogc?service=WCS&request=InvalidRequest") assert response.status_code == 400 + + +def test_server_with_default_supported_services(client): + """ + Test the server with the default supported services. + """ + response = client.get("/ogc?service=WMS&request=GetCapabilities") + assert response.status_code == 200 + + response = client.get("/ogc?service=WCS&request=GetCapabilities") + assert response.status_code == 200 + + response = client.get("/ogc/edr") + assert response.status_code == 404 + + +def test_server_without_wcs_supported_service(supported_formats, client): + """ + Test the WCS service is unavailable when WCS is not a supported format. + """ + supported_formats("wms") + + response = client.get("/ogc?service=WMS&request=GetCapabilities") + assert response.status_code == 200 + + response = client.get("/ogc?service=WCS&request=GetCapabilities") + assert response.status_code == 400 + + response = client.get("/ogc/edr") + assert response.status_code == 404 + + +def test_server_without_wms_supported_service(supported_formats, client): + """ + Test the WMS service is unavailable when WMS is not a supported format. + """ + supported_formats("wcs") + + response = client.get("/ogc?service=WCS&request=GetCapabilities") + assert response.status_code == 200 + + response = client.get("/ogc?service=WMS&request=GetCapabilities") + assert response.status_code == 400 + + response = client.get("/ogc/edr") + assert response.status_code == 404 From e74fce3108f773b15367c927b965cb2d5ef67336 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 24 Apr 2026 12:41:15 +0000 Subject: [PATCH 57/69] Improve linting and formatting checks --- .github/workflows/github-python-workflow.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-python-workflow.yml b/.github/workflows/github-python-workflow.yml index b332f3f..3e3e5a9 100644 --- a/.github/workflows/github-python-workflow.yml +++ b/.github/workflows/github-python-workflow.yml @@ -30,10 +30,10 @@ jobs: - name: Run flake8 lint checks run: | # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F --show-source --statistics --max-line-length=120 --output-file=flake8-report.txt + flake8 . --count --ignore=E,W,D,I,N806,N815,N818,Q000,Q001,Q002,S001,B008,B028 --show-source --statistics --max-line-length=120 --output-file=flake8-report.txt - name: Run black format checks - run: black --check --diff ogc example > black-report.txt + run: black --check --diff -l 120 ogc example > black-report.txt - name: Artifact flake8 report uses: actions/upload-artifact@v4 From 4cef1c2d1415fc1e704de2f8b492d10db60e17f1 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 24 Apr 2026 12:42:43 +0000 Subject: [PATCH 58/69] Add flake8 bugbear and builtins --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 28c2688..e7d6cc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,8 @@ dev = [ "pre_commit", "black", "flake8", + "flake8-bugbear", + "flake8-builtins", ] [tool.setuptools.packages.find] From b875cc71064af8b67863e3c514965b64ba825188 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 24 Apr 2026 13:00:41 +0000 Subject: [PATCH 59/69] FIX: B006 Remove mutable data structures as argument defaults --- ogc/core.py | 3 ++- ogc/edr/edr_formatter.py | 4 ++-- ogc/edr/test/test_edr_routes.py | 7 ++++--- ogc/servers.py | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/ogc/core.py b/ogc/core.py index cbbb175..dbb40b3 100755 --- a/ogc/core.py +++ b/ogc/core.py @@ -38,8 +38,9 @@ class OGC(tl.HasTraits): def base_url(self): return "{}{}?".format(self.server_address, self.endpoint) - def __init__(self, layers=[], **kwargs): + def __init__(self, layers=None, **kwargs): super().__init__(**kwargs) + layers = layers if layers is not None else [] coverages = [ wcs_response_1_0_0.Coverage( layer=layer, diff --git a/ogc/edr/edr_formatter.py b/ogc/edr/edr_formatter.py index e3f3aab..104d570 100644 --- a/ogc/edr/edr_formatter.py +++ b/ogc/edr/edr_formatter.py @@ -17,13 +17,13 @@ def __init__(self, formatter_def: dict): super().__init__(formatter_def) self.mimetype = formatter_def["mimetype"] - def write(self, options: dict = {}, data: dict | None = None) -> str: + def write(self, options: dict | None = None, data: dict | None = None) -> str: """Generate data in the specified format. Parameters ---------- options : dict, optional - Formatting options, by default {}. + Formatting options, by default None. data : dict | None, optional Dictionary representation of the data, by default None. diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index e9eb11f..3b98258 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -12,20 +12,21 @@ from ogc.edr.edr_routes import EdrRoutes -def mock_request(request_args: Dict[str, Any] = {}) -> APIRequest: +def mock_request(request_args: Dict[str, Any] | None = None) -> APIRequest: """Creates a mock request for EDR routes to use. Parameters ---------- - request_args: Dict[str, Any], optional - The dictionary for query string arguments. + request_args: Dict[str, Any] | None, optional + The dictionary for query string arguments, by default None. Returns ------- APIRequest Mock API request for route testing. """ + request_args = request_args if request_args is not None else {} environ = create_environ(base_url="http://127.0.0.1:5000/ogc/edr") request = Request(environ) request.args = ImmutableMultiDict(request_args.items()) diff --git a/ogc/servers.py b/ogc/servers.py index d56a2f0..e437c45 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -343,6 +343,6 @@ class FastAPI(object): https://mangum.io/asgi-frameworks/ """ - def __init__(self, *args, ogcs=[]): + def __init__(self, *args, ogcs): super().__init__(*args) raise NotImplementedError From ced82ee3516dfe76952c485742091d1aff90bd9e Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 24 Apr 2026 13:05:49 +0000 Subject: [PATCH 60/69] FIX: A002 Name shadowing --- ogc/servers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ogc/servers.py b/ogc/servers.py index e437c45..2b93f15 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -258,13 +258,13 @@ def ogc_render(self, ogc_idx): ee = WCSException() return respond_xml(ee.to_xml(), status=500) - def edr_render(self, callable: Callable) -> Callable: + def edr_render(self, request_handler: Callable) -> Callable: """Function which returns a wrapper for the provided callable. Filters arguments and handles any necessary exceptions. Parameters ---------- - callable : Callable + request_handler : Callable The callable request handler to be wrapped. Returns ------- @@ -315,7 +315,7 @@ def wrapper(*args, **kwargs) -> Response: request.args = ImmutableMultiDict(filtered_args) pygeoapi_request = APIRequest.from_flask(request, ["en"]) # Build the flask response - headers, status, content = callable(pygeoapi_request, *args, **kwargs) + headers, status, content = request_handler(pygeoapi_request, *args, **kwargs) response = make_response(content, status) if headers: response.headers = headers From 118f5396964d8bdfc29502b2d939b5090d9b6b19 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 24 Apr 2026 13:13:09 +0000 Subject: [PATCH 61/69] FIX: B023 Function definition does not bind loop variable --- ogc/servers.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ogc/servers.py b/ogc/servers.py index 2b93f15..f12ca5a 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -98,16 +98,17 @@ def __init__(self, *args, ogcs=None, home_func=None): ogcs = [] self.ogcs = ogcs + + def make_method(idx): + def method(): + return self.ogc_render(idx) + + return method + for idx, ogc in enumerate(ogcs): endpoint = ogc.endpoint # should be a string, e.g. "/ogc" method_name = "render" + endpoint.replace("/", "_") # e.g. "render_ogc" - def make_method(idx): - def method(): - return self.ogc_render(idx) - - return method - method = make_method(idx) setattr(self, method_name, method) method = getattr(self, method_name) From 3b3e7f0583940ad97314e5298eb79828473c55bb Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 24 Apr 2026 13:14:40 +0000 Subject: [PATCH 62/69] FIX: A002 Name shadowing --- ogc/wms_response_1_3_0.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ogc/wms_response_1_3_0.py b/ogc/wms_response_1_3_0.py index b20f7a1..4e195f1 100755 --- a/ogc/wms_response_1_3_0.py +++ b/ogc/wms_response_1_3_0.py @@ -239,11 +239,11 @@ def to_xml(self): ) @staticmethod - def _format_number(input, float_decimals=9): - if isinstance(input, float): - return "{number:.{decimals}f}".format(number=input, decimals=float_decimals) + def _format_number(input_number, float_decimals=9): + if isinstance(input_number, float): + return "{number:.{decimals}f}".format(number=input_number, decimals=float_decimals) else: - return "{}".format(input) + return "{}".format(input_number) def _get_CRS_and_BoundingBox(self, depth=3): indent = " " From a823a1b1215c8b89b55e5e5c6c9259e777e6b18a Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 24 Apr 2026 13:20:09 +0000 Subject: [PATCH 63/69] FIX: B042 Exception class with should pass all args... --- ogc/ogc_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ogc/ogc_common.py b/ogc/ogc_common.py index 74a0270..4bf3dd4 100755 --- a/ogc/ogc_common.py +++ b/ogc/ogc_common.py @@ -103,7 +103,7 @@ def __init__( """ exception_code: 'NoApplicableCode', 'InvalidFormat', 'CoverageNotDefined', 'MissingParameterValue', 'InvalidParameterValue' """ - super(WCSException, self).__init__(exception_text) + super().__init__(exception_text, exception_code, locator) self.exception_text = exception_text self.exception_code = exception_code From 885a4b5a9b08e3e3b00e44ff3a374e81102b41bf Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 24 Apr 2026 13:22:08 +0000 Subject: [PATCH 64/69] FIX: B018 Found useless Tuple expression --- ogc/wcs_request_1_0_0.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ogc/wcs_request_1_0_0.py b/ogc/wcs_request_1_0_0.py index c9dcf1a..0f7fcc9 100755 --- a/ogc/wcs_request_1_0_0.py +++ b/ogc/wcs_request_1_0_0.py @@ -150,7 +150,7 @@ def validate(self): assert self.identifier, "WCS Request validation error: no coverage specified" assert self.domain_subset_bbox, "WCS Request validation error: no bounding box specified" - self.output_format.validate(), "WCS Request validation error: output format" + self.output_format.validate() # WCS Request validation error: output format assert self.height, "WCS Request validation error: no height specified" assert self.width, "WCS Request validation error: no width specified" lons = [ From e5001b10dfc3a58ee6712d1bcdfafc132608dcda Mon Sep 17 00:00:00 2001 From: BAyotte Date: Wed, 29 Apr 2026 08:18:28 -0400 Subject: [PATCH 65/69] checking input characters validity and escaping html --- ogc/servers.py | 30 +++++++++- ogc/settings.py | 3 + ogc/test/test_input_security.py | 97 +++++++++++++++++++++++++++++++++ ogc/wcs_response_1_0_0.py | 20 ++++--- ogc/wms_response_1_3_0.py | 13 +++-- 5 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 ogc/test/test_input_security.py diff --git a/ogc/servers.py b/ogc/servers.py index b0844bb..691bd9d 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -7,6 +7,7 @@ """ import re +import xml.sax.saxutils from flask import Flask, request, Response, make_response, send_file import six import logging @@ -21,6 +22,16 @@ logger = logging.getLogger(__name__) +def _check_query_string(raw_qs: bytes) -> None: + """Raise WCSException if the raw query string exceeds the maximum allowed length or contains invalid UTF-8.""" + if len(raw_qs) > settings.MAX_QUERY_STRING_BYTES: + raise WCSException("Request query string exceeds maximum allowed length.") + try: + raw_qs.decode("utf-8") + except UnicodeDecodeError: + raise WCSException("Request contains invalid UTF-8 encoding.") + + def respond_xml(doc, status=200): # First, validate that XML can be parsed. from lxml import etree @@ -231,6 +242,12 @@ def ogc_render(self, ogc_idx): return respond_xml("

      Only GET supported

      ", status=405) ogc = self.ogcs[ogc_idx] + + try: + _check_query_string(request.query_string) + except WCSException as e: + return respond_xml(e.to_xml(), status=400) + if not request.args: return self.home_func(ogc.endpoint) try: @@ -253,7 +270,7 @@ def ogc_render(self, ogc_idx): } if request.base_url: - args["base_url"] = request.base_url + "?" + args["base_url"] = xml.sax.saxutils.escape(request.base_url, {'"': '"'}) + "?" else: args["base_url"] = None ogc_response = None @@ -310,6 +327,12 @@ def wrapper(*args, **kwargs) -> Response: logger.info("OGC server.edr_render") if request.method != "GET": return respond_xml("

      Only GET supported

      ", status=405) + + try: + _check_query_string(request.query_string) + except WCSException as e: + return respond_xml(e.to_xml(), status=400) + try: # We'll filter out any characters from URl parameter values that # are not in the allowlist. @@ -336,7 +359,7 @@ def wrapper(*args, **kwargs) -> Response: if format_argument is not None: filtered_args["f"] = format_argument.lower() - filtered_args["base_url"] = request.base_url + filtered_args["base_url"] = xml.sax.saxutils.escape(request.base_url, {'"': '"'}) if request.base_url else None # Replace the arguments with the filtered option request.args = ImmutableMultiDict(filtered_args) @@ -347,6 +370,9 @@ def wrapper(*args, **kwargs) -> Response: if headers: response.headers = headers return response + except WCSException as e: + logger.error("OGC: server.edr_render WCSException: %s", str(e), exc_info=True) + return respond_xml(e.to_xml(), status=400) except Exception as e: logger.error("OGC: server.edr_render Exception: %s", str(e), exc_info=True) ee = WCSException() diff --git a/ogc/settings.py b/ogc/settings.py index 52a92e8..d1b09c3 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -62,6 +62,9 @@ # Max WCS/WMS response size MAX_GRID_COORDS_REQUEST_SIZE = 1024 * 1024 +# HTTP input size limits +MAX_QUERY_STRING_BYTES = 8192 + # WMS Capabilities limit layers WMS_LIMIT_LAYERS = False diff --git a/ogc/test/test_input_security.py b/ogc/test/test_input_security.py new file mode 100644 index 0000000..0a9fbce --- /dev/null +++ b/ogc/test/test_input_security.py @@ -0,0 +1,97 @@ +""" +Tests verifying that the server rejects oversized query strings. +""" + +import pytest +import numpy as np +import podpac + +from ogc import servers, core, settings +from ogc import podpac as pogc +from ogc.ogc_common import WCSException +from ogc.servers import _check_query_string + + +# --------------------------------------------------------------------------- +# Shared fixture (mirrors test_servers.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client(): + 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"]) + node = podpac.data.Array(source=data, coordinates=coords) + layer = pogc.Layer( + node=node, + identifier="layer1", + title="Layer 1", + abstract="Layer 1 Data", + group="Layers", + ) + ogc_instance = core.OGC(layers=[layer]) + app = servers.FlaskServer(__name__, ogcs=[ogc_instance]) + app.config["TESTING"] = True + yield app.test_client() + + +# --------------------------------------------------------------------------- +# 1. _check_query_string unit tests — exercises the real production function +# --------------------------------------------------------------------------- + + +def test_check_query_string_overflow(): + """Raises WCSException when byte length exceeds MAX_QUERY_STRING_BYTES.""" + oversized = b"A" * (settings.MAX_QUERY_STRING_BYTES + 1) + with pytest.raises(WCSException, match="maximum allowed length"): + _check_query_string(oversized) + + +def test_check_query_string_valid(): + """Does not raise for a normal ASCII query string.""" + _check_query_string(b"SERVICE=WCS&REQUEST=GetCapabilities&VERSION=1.0.0") + + +def test_check_query_string_exactly_at_limit(): + """Does not raise when the query string is exactly at the limit.""" + at_limit = b"A" * settings.MAX_QUERY_STRING_BYTES + _check_query_string(at_limit) + + +def test_check_query_string_invalid_utf8(): + """Raises WCSException when the query string contains raw non-UTF-8 bytes.""" + with pytest.raises(WCSException, match="invalid UTF-8 encoding"): + _check_query_string(b"SERVICE=WCS&COVERAGE=\xff\xfe") + + +def test_check_query_string_valid_percent_encoded(): + """Does not raise for percent-encoded non-ASCII (all bytes are ASCII in the raw QS).""" + _check_query_string(b"SERVICE=WCS&COVERAGE=%C3%A9") + + +# --------------------------------------------------------------------------- +# 2. HTTP integration — overflow rejected via test client +# --------------------------------------------------------------------------- + + +def test_ogc_render_overflow_returns_400(client): + """A query string over the limit returns a 400 WCSException response.""" + oversized = "A=" + "B" * (settings.MAX_QUERY_STRING_BYTES + 1) + response = client.get("/ogc", environ_overrides={"QUERY_STRING": oversized}) + assert response.status_code == 400 + assert b"ExceptionReport" in response.data + assert b"maximum allowed length" in response.data + + +# --------------------------------------------------------------------------- +# 3. Regression — valid WCS request still works end-to-end +# --------------------------------------------------------------------------- + + +def test_valid_wcs_get_capabilities_unchanged(client): + """Normal GetCapabilities request is unaffected by the new guards.""" + response = client.get("/ogc?SERVICE=WCS&REQUEST=GetCapabilities&VERSION=1.0.0") + assert response.status_code == 200 + assert b"WCS_Capabilities" in response.data diff --git a/ogc/wcs_response_1_0_0.py b/ogc/wcs_response_1_0_0.py index d90813f..0b657db 100755 --- a/ogc/wcs_response_1_0_0.py +++ b/ogc/wcs_response_1_0_0.py @@ -1,5 +1,6 @@ import logging import datetime +from xml.sax.saxutils import escape import traitlets as tl from ogc import GridCoordinates @@ -89,11 +90,11 @@ class CoverageDescription(ogc_common.XMLNode): def coverage_offering(self, coverage): xml = """ """ if coverage.identifier: - xml += " {coverage.identifier}\n".format(coverage=coverage) + xml += " {}\n".format(escape(coverage.identifier)) if coverage.title: - xml += " {coverage.title}\n".format(coverage=coverage) + xml += " {}\n".format(escape(coverage.title)) if coverage.abstract: - xml += " {coverage.abstract}\n".format(coverage=coverage) + xml += " {}\n".format(escape(coverage.abstract)) temporal_domain = "" if hasattr(coverage.layer, "valid_times"): if coverage.layer.all_times_valid: @@ -226,14 +227,15 @@ class Capabilities(ogc_common.XMLNode): version = tl.Unicode(default_value=SERVICE_VERSION) def service(self): + title = escape(self.service_title) if self.service_title else "" return """\ - {self.service_title} - {self.service_title} + {title} + {title} UNAVAILABLE {constraints} -""".format(self=self, constraints=settings.CONSTRAINTS) +""".format(title=title, constraints=settings.CONSTRAINTS) base_url = tl.Unicode(default_value=None, allow_none=True) # e.g., http://hostname:port/path? @@ -296,13 +298,13 @@ def contents(self): for coverage in self.coverages: xml += " \n" if coverage.abstract: - xml += " {coverage.abstract}\n".format(coverage=coverage) + xml += " {}\n".format(escape(coverage.abstract)) if coverage.identifier: # required - xml += " {coverage.identifier}\n".format(coverage=coverage) + xml += " {}\n".format(escape(coverage.identifier)) else: logger.info("Invalid layer. Missing name.") if coverage.title: # required - xml += " {coverage.title}\n".format(coverage=coverage) + xml += " {}\n".format(escape(coverage.title)) else: logger.info("Invalid layer. Missing label.") if coverage.wgs84_bounding_box_lower_corner_lat_lon or coverage.wgs84_bounding_box_upper_corner_lat_lon: diff --git a/ogc/wms_response_1_3_0.py b/ogc/wms_response_1_3_0.py index 4e195f1..5edadde 100755 --- a/ogc/wms_response_1_3_0.py +++ b/ogc/wms_response_1_3_0.py @@ -1,4 +1,5 @@ import logging +from xml.sax.saxutils import escape import traitlets as tl import numpy as np @@ -43,10 +44,11 @@ class Capabilities(ogc_common.XMLNode): version = tl.Unicode(default_value=SERVICE_VERSION) def service(self): + title = escape(self.service_title) if self.service_title else "" return """\ WMS - {self.service_title} + {title} {constraints} 1 @@ -54,6 +56,7 @@ def service(self): {maxHeightWMS} """.format( + title=title, self=self, constraints=settings.CONSTRAINTS, maxWidthWMS=int(np.sqrt(settings.MAX_GRID_COORDS_REQUEST_SIZE)), @@ -121,13 +124,13 @@ def coverage_times_list(self, coverage, default_time): def coverage_layer(self, coverage): xml = """ \n""" if coverage.identifier: - xml += f" {coverage.identifier}\n" + xml += f" {escape(coverage.identifier)}\n" if coverage.title: - xml += f" {coverage.title}\n" + xml += f" {escape(coverage.title)}\n" else: logger.info("Invalid layer. Missing title.") if coverage.abstract: - xml += f" {coverage.abstract}\n" + xml += f" {escape(coverage.abstract)}\n" xml += self._get_CRS_and_BoundingBox() @@ -199,7 +202,7 @@ def coverage_layer(self, coverage): def layers(self): xml = " \n" - xml += " {}\n".format(self.service_group_title) + xml += " {}\n".format(escape(self.service_group_title) if self.service_group_title else "") xml += self._get_CRS_and_BoundingBox(depth=2) # If configured, trim layers list to layers specified in settings From ba7e14b181454307397ef3f9a708934449df87b5 Mon Sep 17 00:00:00 2001 From: BAyotte Date: Wed, 29 Apr 2026 14:17:49 +0000 Subject: [PATCH 66/69] updating black formatting --- ogc/servers.py | 6 ++++-- ogc/test/test_input_security.py | 1 - ogc/wms_response_1_3_0.py | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ogc/servers.py b/ogc/servers.py index 691bd9d..2c1093f 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -270,7 +270,7 @@ def ogc_render(self, ogc_idx): } if request.base_url: - args["base_url"] = xml.sax.saxutils.escape(request.base_url, {'"': '"'}) + "?" + args["base_url"] = xml.sax.saxutils.escape(request.base_url, {'"': """}) + "?" else: args["base_url"] = None ogc_response = None @@ -359,7 +359,9 @@ def wrapper(*args, **kwargs) -> Response: if format_argument is not None: filtered_args["f"] = format_argument.lower() - filtered_args["base_url"] = xml.sax.saxutils.escape(request.base_url, {'"': '"'}) if request.base_url else None + filtered_args["base_url"] = ( + xml.sax.saxutils.escape(request.base_url, {'"': """}) if request.base_url else None + ) # Replace the arguments with the filtered option request.args = ImmutableMultiDict(filtered_args) diff --git a/ogc/test/test_input_security.py b/ogc/test/test_input_security.py index 0a9fbce..3093681 100644 --- a/ogc/test/test_input_security.py +++ b/ogc/test/test_input_security.py @@ -11,7 +11,6 @@ from ogc.ogc_common import WCSException from ogc.servers import _check_query_string - # --------------------------------------------------------------------------- # Shared fixture (mirrors test_servers.py) # --------------------------------------------------------------------------- diff --git a/ogc/wms_response_1_3_0.py b/ogc/wms_response_1_3_0.py index 5edadde..813a487 100755 --- a/ogc/wms_response_1_3_0.py +++ b/ogc/wms_response_1_3_0.py @@ -202,7 +202,9 @@ def coverage_layer(self, coverage): def layers(self): xml = " \n" - xml += " {}\n".format(escape(self.service_group_title) if self.service_group_title else "") + xml += " {}\n".format( + escape(self.service_group_title) if self.service_group_title else "" + ) xml += self._get_CRS_and_BoundingBox(depth=2) # If configured, trim layers list to layers specified in settings From 569f48a1555bd7ebe0c3e22203afc3d843fc9e4f Mon Sep 17 00:00:00 2001 From: scranford1 Date: Wed, 29 Apr 2026 12:18:49 -0400 Subject: [PATCH 67/69] Feature/time dimension (#21) * ENH: Move to a custom referenceTime dimension --- example/app.py | 7 ++-- ogc/edr/edr_api.py | 12 ++++--- ogc/edr/edr_provider.py | 55 +++++++++++++++++-------------- ogc/edr/test/conftest.py | 50 +++++++++++++++++++++++++--- ogc/edr/test/test_edr_provider.py | 28 ++++++++++++++++ ogc/edr/test/test_edr_routes.py | 42 +++++++++++++++++++++++ ogc/podpac.py | 10 ++++-- ogc/settings.py | 3 ++ 8 files changed, 167 insertions(+), 40 deletions(-) diff --git a/example/app.py b/example/app.py index 48c0908..b8e11e4 100755 --- a/example/app.py +++ b/example/app.py @@ -8,12 +8,13 @@ from ogc import servers from ogc import core from ogc import podpac as pogc +from ogc.settings import EDR_TIME_INSTANCE_DIMENSION import podpac import numpy as np # Setup new dimension -podpac.core.coordinates.utils.add_valid_dimension("forecastOffsetHr") +podpac.core.coordinates.utils.add_valid_dimension(EDR_TIME_INSTANCE_DIMENSION) # create some podpac nodes data = np.random.default_rng(1).random((11, 21)) @@ -26,8 +27,8 @@ node2 = podpac.data.Array(source=data2, coordinates=coords) time = np.array(["2025-10-24T12:00:00"], dtype="datetime64") -offsets = [np.timedelta64(0, "h")] -coords = podpac.Coordinates([lat, lon, time, offsets], dims=["lat", "lon", "time", "forecastOffsetHr"]) +instance = np.array(["2025-10-24T00:00:00"], dtype="datetime64") +coords = podpac.Coordinates([lat, lon, time, instance], dims=["lat", "lon", "time", EDR_TIME_INSTANCE_DIMENSION]) data3 = np.random.default_rng(1).random((11, 21, 1, 1)) node3 = podpac.data.Array(source=data3, coordinates=coords) diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index ed18663..7758e1e 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -437,12 +437,14 @@ def _generate_extents(layers: List[pogc.Layer], instance: str | None) -> Dict[st vertical_range.update(coordinates["alt"].coordinates) if "time" in coordinates.udims: - if instance in layer.time_instances() and "forecastOffsetHr" in coordinates.udims: + if instance in layer.time_instances() and settings.EDR_TIME_INSTANCE_DIMENSION in coordinates.udims: instance_datetime = np.datetime64(instance) - instance_coordinates = coordinates.select({"time": [instance_datetime, instance_datetime]}) - selected_offset_coordinates = instance_coordinates["forecastOffsetHr"].coordinates - time_range.update([instance_datetime + offset for offset in selected_offset_coordinates]) - elif not instance and "forecastOffsetHr" not in coordinates.udims: + instance_coordinates = coordinates.select( + {settings.EDR_TIME_INSTANCE_DIMENSION: [instance_datetime, instance_datetime]} + ) + selected_time_coordinates = instance_coordinates["time"].coordinates + time_range.update(selected_time_coordinates) + elif not instance and settings.EDR_TIME_INSTANCE_DIMENSION not in coordinates.udims: time_range.update(coordinates["time"].coordinates) sorted_time_range = sorted(time_range) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 0b94842..4167200 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -88,7 +88,7 @@ def is_collection_queryable(cls, base_url: str, group: str) -> bool: layers = cls.get_layers(base_url, group) for layer in layers: coordinates = layer.get_coordinates() - if coordinates is not None and "forecastOffsetHr" not in coordinates.udims: + if coordinates is not None and settings.EDR_TIME_INSTANCE_DIMENSION not in coordinates.udims: return True return False @@ -584,25 +584,26 @@ def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) if coordinates is None: return units_data_array - layer_has_instances = "forecastOffsetHr" in coordinates.udims - request_has_instances = "forecastOffsetHr" in requested_coordinates.udims + layer_has_instances = settings.EDR_TIME_INSTANCE_DIMENSION in coordinates.udims + request_has_instances = settings.EDR_TIME_INSTANCE_DIMENSION in requested_coordinates.udims if layer_has_instances ^ request_has_instances: return units_data_array - if "time" not in coordinates[0].udims: - layer_requested_coordinates = layer_requested_coordinates.udrop( - ["time", "forecastOffsetHr"], ignore_missing=True - ) + if "time" not in coordinates.udims: + layer_requested_coordinates = layer_requested_coordinates.udrop(["time"], ignore_missing=True) if layer.node is not None: units_data_array = layer.node.eval(layer_requested_coordinates) - if "forecastOffsetHr" in units_data_array.dims or "time_forecastOffsetHr" in units_data_array.dims: - if "time_forecastOffsetHr" not in units_data_array.dims: - units_data_array = units_data_array.stack(time_forecastOffsetHr=("time", "forecastOffsetHr")) - forecast_offsets = units_data_array.forecastOffsetHr.data.copy() - time_data = units_data_array.time.data.copy() - units_data_array = units_data_array.drop_vars({"time", "time_forecastOffsetHr", "forecastOffsetHr"}) - units_data_array = units_data_array.rename(time_forecastOffsetHr="time") - units_data_array = units_data_array.assign_coords(time=time_data + forecast_offsets) + units_data_array = units_data_array.unstack() + if settings.EDR_TIME_INSTANCE_DIMENSION in units_data_array.dims: + # Use scalar selection to drop the extra dimension + units_data_array = units_data_array.sel( + { + settings.EDR_TIME_INSTANCE_DIMENSION: layer_requested_coordinates[ + settings.EDR_TIME_INSTANCE_DIMENSION + ].coordinates[0] + } + ) + units_data_array = units_data_array.drop_vars({settings.EDR_TIME_INSTANCE_DIMENSION}) if units_data_array.attrs.get("bounds", None): filtered_bounds = { coord: bnd @@ -645,7 +646,7 @@ def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[n layers : List[pogc.Layer] The list of layers to determine datetimes for. instance_time: str | None - The optional instance time which forecast datetimes are being requested for. + The optional instance time to get datetimes for, if not provided collection datetimes are returned. Returns ------- List[np.datetime64] @@ -656,14 +657,17 @@ def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[n for layer in layers: coordinates = layer.get_coordinates() if coordinates is not None and "time" in coordinates.udims: - if instance_time in layer.time_instances() and "forecastOffsetHr" in coordinates.udims: - # Retrieve available forecastOffSetHr and instance time combinations + if ( + instance_time in layer.time_instances() + and settings.EDR_TIME_INSTANCE_DIMENSION in coordinates.udims + ): instance_datetime = np.datetime64(instance_time) - instance_coordinates = coordinates.select({"time": [instance_datetime, instance_datetime]}) - selected_offset_coordinates = instance_coordinates["forecastOffsetHr"].coordinates - available_times.update([instance_datetime + offset for offset in selected_offset_coordinates]) - elif not instance_time and "forecastOffsetHr" not in coordinates.udims: - # Retrieve layer times for non-instance requests + instance_coordinates = coordinates.select( + {settings.EDR_TIME_INSTANCE_DIMENSION: [instance_datetime, instance_datetime]} + ) + selected_time_coordinates = instance_coordinates["time"].coordinates + available_times.update(selected_time_coordinates) + elif not instance_time and settings.EDR_TIME_INSTANCE_DIMENSION not in coordinates.udims: available_times.update(coordinates["time"].coordinates) return list(available_times) @@ -859,8 +863,9 @@ def interpret_time_coordinates( raise ProviderInvalidQueryError(msg, user_msg=msg) if instance_time: - offsets = [np.timedelta64(time - np.datetime64(instance_time), "h") for time in times] - return podpac.Coordinates([[instance_time], offsets], dims=["time", "forecastOffsetHr"], crs=crs) + return podpac.Coordinates( + [times, [instance_time]], dims=["time", settings.EDR_TIME_INSTANCE_DIMENSION], crs=crs + ) return podpac.Coordinates([times], dims=["time"], crs=crs) diff --git a/ogc/edr/test/conftest.py b/ogc/edr/test/conftest.py index 22b5192..d566273 100644 --- a/ogc/edr/test/conftest.py +++ b/ogc/edr/test/conftest.py @@ -5,16 +5,19 @@ import podpac from ogc import podpac as pogc from typing import Dict, List, Any +from ogc.settings import EDR_TIME_INSTANCE_DIMENSION # Setup new dimension -podpac.core.coordinates.utils.add_valid_dimension("forecastOffsetHr") +podpac.core.coordinates.utils.add_valid_dimension(EDR_TIME_INSTANCE_DIMENSION) lat = np.linspace(90, -90, 11) lon = np.linspace(-180, 180, 21) time = np.array(["2025-10-24T12:00:00"], dtype="datetime64") -offsets = [np.timedelta64(0, "h")] +instance = np.array(["2025-10-24T00:00:00"], dtype="datetime64") data = np.random.default_rng(1).random((11, 21, 1, 1)) -coords = podpac.Coordinates([lat, lon, time, offsets], dims=["lat", "lon", "time", "forecastOffsetHr"]) +coords = podpac.Coordinates([lat, lon, time, instance], dims=["lat", "lon", "time", EDR_TIME_INSTANCE_DIMENSION]) +data_without_instance = np.random.default_rng(1).random((11, 21, 1)) +coords_without_instance = podpac.Coordinates([lat, lon, time], dims=["lat", "lon", "time"]) # Define test layers using sample data and coordinates node1 = podpac.data.Array(source=data, coordinates=coords) @@ -35,6 +38,15 @@ group="Layers", valid_times=[dt.astype(datetime.datetime) for dt in time], ) +node3 = podpac.data.Array(source=data_without_instance, coordinates=coords_without_instance) +layer3 = pogc.Layer( + node=node3, + identifier="layer3", + title="Layer 3", + abstract="Layer3 Data (No instance)", + group="Layers", + valid_times=[dt.astype(datetime.datetime) for dt in time], +) @pytest.fixture(scope="session", autouse=True) @@ -55,6 +67,18 @@ def layers() -> List[pogc.Layer]: return [layer1, layer2] +@pytest.fixture() +def layers_no_instance() -> List[pogc.Layer]: + """List of test layers without instances. + + Returns + ------- + List[pogc.Layer] + The test layers. + """ + return [layer3] + + @pytest.fixture() def single_layer_cube_args() -> Dict[str, Any]: """Dictionary of valid request arguments that align to a single test layer cube request. @@ -85,8 +109,26 @@ def single_layer_cube_args_internal() -> Dict[str, Any]: return { "format_": "coveragejson", - "instance": str(time[0]), + "instance": str(instance[0]), "bbox": [-180, -90, 180, 90], "datetime_": str(time[0]), "select_properties": [layer1.identifier], } + + +@pytest.fixture() +def single_layer_cube_args_no_instance_internal() -> Dict[str, Any]: + """Dictionary of valid arguments that align to a single non-instance test layer request with internal pygeoapi keys. + + Returns + ------- + Dict[str, Any] + Valid internal cube arguments for a single non-instance test layer. + """ + + return { + "format_": "coveragejson", + "bbox": [-180, -90, 180, 90], + "datetime_": str(time[0]), + "select_properties": [layer3.identifier], + } diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index fddd6bc..c081781 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -401,6 +401,34 @@ def test_edr_provider_cube_request_invalid_bbox( provider.cube(**args) +def test_edr_provider_cube_request_without_instance( + layers_no_instance: List[pogc.Layer], single_layer_cube_args_no_instance_internal: Dict[str, Any] +): + """Test the cube method of the EDR Provider class with a valid layer without an instance. + + Parameters + ---------- + layers_no_instance : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_no_instance_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + base_url = "/" + args = single_layer_cube_args_no_instance_internal + parameter_name = single_layer_cube_args_no_instance_internal["select_properties"][0] + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers_no_instance) + + response = provider.cube(**args) + response = get_json_with_cleanup(response["fp"]) + + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} + 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_instance( layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] ): diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index 3b98258..4931f99 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -155,6 +155,29 @@ def test_edr_routes_describe_collection(layers: List[pogc.Layer]): assert list(response["data_queries"].keys()) == ["instances"] +def test_edr_routes_describe_collection_without_instance(layers_no_instance: List[pogc.Layer]): + """Test the EDR collection description for a response without instances. + The instance data query should still be available so it can return empty. + + Parameters + ---------- + layers_no_instance : List[pogc.Layer] + Layers provided by a test fixture. + """ + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=layers_no_instance) + collection_id = layers_no_instance[0].group + collection_layers = [layer for layer in layers_no_instance 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 set(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. @@ -181,6 +204,25 @@ def test_edr_routes_describe_instances(layers: List[pogc.Layer]): assert response_time_instances_ids == list(time_instances) +def test_edr_routes_describe_instances_without_instances(layers_no_instance: List[pogc.Layer]): + """Test the EDR instances description for a response when no instances are available. + + Parameters + ---------- + layers_no_instance : List[pogc.Layer] + Layers provided by a test fixture. + """ + request = mock_request({"f": "json"}) + edr_routes = EdrRoutes(layers=layers_no_instance) + collection_id = layers_no_instance[0].group + + _, 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"]) == 0 + + def test_edr_routes_describe_instance(layers: List[pogc.Layer]): """Test the EDR instance description for a response. diff --git a/ogc/podpac.py b/ogc/podpac.py index b136a30..e2103cd 100755 --- a/ogc/podpac.py +++ b/ogc/podpac.py @@ -15,6 +15,7 @@ import textwrap import re from datetime import datetime +from ogc.settings import EDR_TIME_INSTANCE_DIMENSION def _uppercase_for_dict_keys(lower_dict): @@ -65,10 +66,13 @@ def time_instances(self) -> List[str]: time_instances = set() coordinates = self.get_coordinates() - # Time instances are created if a node has both time and offsets. - if coordinates is not None and "time" in coordinates.udims and "forecastOffsetHr" in coordinates.udims: + # Time instances are created if a node has a time instance dimension. + if coordinates is not None and EDR_TIME_INSTANCE_DIMENSION in coordinates.udims: time_instances.update( - [time.astype("datetime64[ms]").astype(datetime).isoformat() for time in coordinates["time"].coordinates] + [ + time.astype("datetime64[ms]").astype(datetime).isoformat() + for time in coordinates[EDR_TIME_INSTANCE_DIMENSION].coordinates + ] ) return list(time_instances) diff --git a/ogc/settings.py b/ogc/settings.py index 52a92e8..8ff7fa1 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -55,6 +55,9 @@ "position": COVERAGE_JSON, } +# The dimension associated with time instances of a collection +EDR_TIME_INSTANCE_DIMENSION = "referenceTime" + # WMS Capabilities timestamp format USE_TIMES_LIST = False PAST_DAYS_INCLUDED = 7 From 52e8ba1f0e9f80a757be882a2dbd531c49dbaa34 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 29 Apr 2026 16:38:17 +0000 Subject: [PATCH 68/69] Release v0.5.0 --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1873b22 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog +## 0.5.0 + +### Introduction +Support for Environmental Data Retrieval (EDR) was added in this version to extend OGC capabilities. +EDR is optional and can be enabled via the OGC_SUPPORTED_FORMATS environmental variable. +The currently supported version of EDR is v1.1.0. Additionally, the repository was modernized by +adding a development container, pyproject.toml, and Github Actions for linting, formatting, +unit testing and scanning. + +### Features +* Added support for EDR v1.1.0. + +### Maintenance +* Added unit testing for code coverage requirements. +* Added development container and pyproject.toml file. +* Added Github Actions for linting, formatting, unit testing, and scanning. + +### Bugfixes +* Fixed formatting, linting, and SonarQube errors to pass CI/CD scans. \ No newline at end of file From 96a6eff944e337c665234cf938ca6950be5d88bc Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 29 Apr 2026 21:41:16 +0000 Subject: [PATCH 69/69] Increment version for release 0.5.0 --- ogc/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ogc/version.py b/ogc/version.py index 1af9b30..8509ca4 100755 --- a/ogc/version.py +++ b/ogc/version.py @@ -16,8 +16,8 @@ # UPDATE VERSION HERE # ####################### MAJOR = 0 -MINOR = 4 -HOTFIX = 2 +MINOR = 5 +HOTFIX = 0 #######################