From 805819867e28866f515e7f000d6182f6421fdea8 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 14:44:41 -0700 Subject: [PATCH 01/53] Port #1926 Port #1926 with updates for CovJSON Formatting --- pygeoapi/formatter/csv_.py | 67 ++++++++++--- tests/formatter/test_csv__formatter.py | 134 ++++++++++++++++++------- 2 files changed, 147 insertions(+), 54 deletions(-) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index 2dd8c9dfb..c34ac8b1d 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -31,6 +31,8 @@ import io import logging +from shapely.geometry import shape as geojson_to_geom + from pygeoapi.formatter.base import BaseFormatter, FormatterSerializationError LOGGER = logging.getLogger(__name__) @@ -60,12 +62,28 @@ def write(self, options: dict = {}, data: dict = None) -> str: Generate data in CSV format :param options: CSV formatting options - :param data: dict of GeoJSON data + :param data: dict of data :returns: string representation of format """ + type = data.get('type') or '' + LOGGER.debug(f'Formatting CSV from data type: {type}') + + if 'Feature' in type or 'features' in data: + return self._write_from_geojson(options, data) + + def _write_from_geojson( + self, options: dict = {}, data: dict = None, is_point=False + ) -> str: + """ + Generate GeoJSON data in CSV format - is_point = False + :param options: CSV formatting options + :param data: dict of GeoJSON data + :param is_point: whether the features are point geometries + + :returns: string representation of format + """ try: fields = list(data['features'][0]['properties'].keys()) except IndexError: @@ -75,27 +93,44 @@ def write(self, options: dict = {}, data: dict = None) -> str: if self.geom: LOGGER.debug('Including point geometry') if data['features'][0]['geometry']['type'] == 'Point': - fields.insert(0, 'x') - fields.insert(1, 'y') + LOGGER.debug('point geometry detected, adding x,y columns') + fields.extend(['x', 'y']) is_point = True else: - # TODO: implement wkt geometry serialization - LOGGER.debug('not a point geometry, skipping') + LOGGER.debug('not a point geometry, adding wkt column') + fields.append('wkt') LOGGER.debug(f'CSV fields: {fields}') + output = io.StringIO() + writer = csv.DictWriter(output, fields) + writer.writeheader() - try: - output = io.StringIO() - writer = csv.DictWriter(output, fields) - writer.writeheader() + for feature in data['features']: + self._add_feature(writer, feature, is_point) - for feature in data['features']: - fp = feature['properties'] + return output.getvalue().encode('utf-8') + + def _add_feature( + self, writer: csv.DictWriter, feature: dict, is_point: bool + ) -> None: + """ + Add feature data to CSV writer + + :param writer: CSV DictWriter + :param feature: dict of GeoJSON feature + :param is_point: whether the feature is a point geometry + """ + fp = feature['properties'] + try: + if self.geom: if is_point: - fp['x'] = feature['geometry']['coordinates'][0] - fp['y'] = feature['geometry']['coordinates'][1] - LOGGER.debug(fp) - writer.writerow(fp) + [fp['x'], fp['y']] = feature['geometry']['coordinates'] + else: + geom = geojson_to_geom(feature['geometry']) + fp['wkt'] = geom.wkt + + LOGGER.debug(f'Writing feature to row: {fp}') + writer.writerow(fp) except ValueError as err: LOGGER.error(err) raise FormatterSerializationError('Error writing CSV output') diff --git a/tests/formatter/test_csv__formatter.py b/tests/formatter/test_csv__formatter.py index c01e23c24..8b082bc3f 100644 --- a/tests/formatter/test_csv__formatter.py +++ b/tests/formatter/test_csv__formatter.py @@ -27,56 +27,114 @@ # # ================================================================= -import csv -import io +from csv import DictReader +from io import StringIO +import json + import pytest +from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.formatter.csv_ import CSVFormatter +from ..util import get_test_file_path -@pytest.fixture() -def fixture(): - data = { - 'features': [{ - 'geometry': { - 'type': 'Point', - 'coordinates': [ - -130.44472222222223, - 54.28611111111111 - ] - }, - 'type': 'Feature', - 'properties': { - 'id': 1972, - 'foo': 'bar', - 'title': None, - }, - 'id': 48693 - }] + +@pytest.fixture +def data(): + data_path = get_test_file_path('data/items.geojson') + with open(data_path, 'r', encoding='utf-8') as fh: + return json.load(fh) + + +@pytest.fixture(scope='function') +def csv_reader_geom_enabled(data): + """csv_reader with geometry enabled""" + formatter = CSVFormatter({'geom': True}) + output = formatter.write(data=data) + return DictReader(StringIO(output.decode('utf-8'))) + + +@pytest.fixture +def invalid_geometry_data(): + return { + 'features': [ + { + 'id': 1, + 'type': 'Feature', + 'properties': { + 'id': 1, + 'title': 'Invalid Point Feature' + }, + 'geometry': { + 'type': 'Point', + 'coordinates': [-130.44472222222223] + } + } + ] } - return data +def test_write_with_geometry_enabled(csv_reader_geom_enabled): + """Test CSV output with geometry enabled""" + rows = list(csv_reader_geom_enabled) + + # Verify the header + header = list(csv_reader_geom_enabled.fieldnames) + assert len(header) == 4 -def test_csv__formatter(fixture): - f = CSVFormatter({'geom': True}) - f_csv = f.write(data=fixture) + # Verify number of rows + assert len(rows) == 9 - buffer = io.StringIO(f_csv.decode('utf-8')) - reader = csv.DictReader(buffer) - header = list(reader.fieldnames) +def test_write_without_geometry(data): + formatter = CSVFormatter({'geom': False}) + output = formatter.write(data=data) + csv_reader = DictReader(StringIO(output.decode('utf-8'))) + + """Test CSV output with geometry disabled""" + rows = list(csv_reader) + + # Verify headers don't include geometry + headers = csv_reader.fieldnames + assert 'geometry' not in headers + + # Verify data + first_row = rows[0] + assert first_row['uri'] == \ + 'http://localhost:5000/collections/objects/items/1' + assert first_row['name'] == 'LineString' + + +def test_write_empty_features(): + """Test handling of empty feature collection""" + formatter = CSVFormatter({'geom': True}) + data = { + 'features': [] + } + output = formatter.write(data=data) + assert output == '' + - assert f.mimetype == 'text/csv; charset=utf-8' +@pytest.mark.parametrize( + 'row_index,expected_wkt', + [ + (2, 'POINT (-85 33)'), + (3, 'MULTILINESTRING ((10 10, 20 20, 10 40), (40 40, 30 30, 40 20, 30 10))'), # noqa + (4, 'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))'), + (5, 'POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10), (20 30, 35 35, 30 20, 20 30))'), # noqa + (6, 'MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)), ((15 5, 40 10, 10 20, 5 10, 15 5)))') # noqa + ] +) +def test_wkt(csv_reader_geom_enabled, row_index, expected_wkt): + """Test CSV output of multi-point geometry""" + rows = list(csv_reader_geom_enabled) - assert len(header) == 5 + # Verify data + geometry_row = rows[row_index] + assert geometry_row['wkt'] == expected_wkt - assert 'x' in header - assert 'y' in header - data = next(reader) - assert data['x'] == '-130.44472222222223' - assert data['y'] == '54.28611111111111' - assert data['id'] == '1972' - assert data['foo'] == 'bar' - assert data['title'] == '' +def test_invalid_geometry_data(invalid_geometry_data): + formatter = CSVFormatter({'geom': True}) + with pytest.raises(FormatterSerializationError): + formatter.write(data=invalid_geometry_data) From 1bb3c250e1e2bb804e9c142e4a564d120cc7ebd5 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 16:21:19 -0700 Subject: [PATCH 02/53] Add CovJSON CSV Formatter --- pygeoapi/formatter/csv_.py | 74 ++++++++++++++++++++++++++ tests/formatter/test_csv__formatter.py | 58 ++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index c34ac8b1d..1c42b1c64 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -71,6 +71,8 @@ def write(self, options: dict = {}, data: dict = None) -> str: if 'Feature' in type or 'features' in data: return self._write_from_geojson(options, data) + elif 'Coverage' in type or 'coverages' in data: + return self._write_from_covjson(options, data) def _write_from_geojson( self, options: dict = {}, data: dict = None, is_point=False @@ -135,7 +137,79 @@ def _add_feature( LOGGER.error(err) raise FormatterSerializationError('Error writing CSV output') + def _write_from_covjson( + self, options: dict = {}, data: dict = None + ) -> str: + """ + Generate CovJSON data in CSV format + + :param options: CSV formatting options + :param data: dict of CovJSON data + + :returns: string representation of format + """ + LOGGER.debug('Processing CovJSON data for CSV output') + units = {} + for p, v in data['parameters'].items(): + unit = v['unit']['symbol'] + if isinstance(unit, dict): + unit = unit.get('value') + + units[p] = unit + + fields = ['parameter', 'datetime', 'value', 'unit', 'x', 'y'] + LOGGER.debug(f'CSV fields: {fields}') + output = io.StringIO() + writer = csv.DictWriter(output, fields) + writer.writeheader() + + if data['type'] == 'Coverage': + is_point = 'point' in data['domain']['domainType'].lower() + self._add_coverage(writer, units, data, is_point) + else: + [ + self._add_coverage(writer, units, coverage, True) + for coverage in data['coverages'] + if 'point' in coverage['domain']['domainType'].lower() + ] return output.getvalue().encode('utf-8') + @staticmethod + def _add_coverage( + writer: csv.DictWriter, units: dict, data: dict, is_point: bool = False + ) -> None: + """ + Add coverage data to CSV writer + + :param writer: CSV DictWriter + :param units: dict of parameter units + :param data: dict of CovJSON coverage data + :param is_point: whether the coverage is a point coverage + """ + + if is_point is False: + LOGGER.warning('Non-point coverages not supported for CSV output') + return + + axes = data['domain']['axes'] + time_range = range(len(axes['t']['values'])) + + try: + [ + writer.writerow({ + 'parameter': parameter, + 'datetime': axes['t']['values'][time_value], + 'value': data['ranges'][parameter]['values'][time_value], + 'unit': units[parameter], + 'x': axes['x']['values'][-1], + 'y': axes['y']['values'][-1] + }) + for parameter in data['ranges'] + for time_value in time_range + ] + except ValueError as err: + LOGGER.error(err) + raise FormatterSerializationError('Error writing CSV output') + def __repr__(self): return f' {self.name}' diff --git a/tests/formatter/test_csv__formatter.py b/tests/formatter/test_csv__formatter.py index 8b082bc3f..c2fbe98da 100644 --- a/tests/formatter/test_csv__formatter.py +++ b/tests/formatter/test_csv__formatter.py @@ -138,3 +138,61 @@ def test_invalid_geometry_data(invalid_geometry_data): formatter = CSVFormatter({'geom': True}) with pytest.raises(FormatterSerializationError): formatter.write(data=invalid_geometry_data) + + +@pytest.fixture +def point_coverage_data(): + return { + 'type': 'Coverage', + 'domain': { + 'type': 'Domain', + 'domainType': 'PointSeries', + 'axes': { + 'x': {'values': [-10.1]}, + 'y': {'values': [-40.2]}, + 't': {'values': [ + '2013-01-01', '2013-01-02', '2013-01-03', + '2013-01-04', '2013-01-05', '2013-01-06']} + } + }, + 'parameters': { + 'PSAL': { + 'type': 'Parameter', + 'description': {'en': 'The measured salinity'}, + 'unit': {'symbol': 'psu'}, + 'observedProperty': { + 'id': 'http://vocab.nerc.ac.uk/standard_name/sea_water_salinity/', # noqa + 'label': {'en': 'Sea Water Salinity'} + } + } + }, + 'ranges': { + 'PSAL': { + 'axisNames': ['t'], + 'shape': [6], + 'values': [ + 43.9599, 43.9599, 43.9640, 43.9640, 43.9679, 43.987 + ] + } + } + } + + +def test_point_coverage_csv(point_coverage_data): + """Test CSV output of point coverage data""" + formatter = CSVFormatter({'geom': True}) + output = formatter.write(data=point_coverage_data) + csv_reader = DictReader(StringIO(output.decode('utf-8'))) + rows = list(csv_reader) + + # Verify number of rows + assert len(rows) == 6 + + # Verify data + first_row = rows[0] + assert first_row['parameter'] == 'PSAL' + assert first_row['datetime'] == '2013-01-01' + assert first_row['value'] == '43.9599' + assert first_row['unit'] == 'psu' + assert first_row['x'] == '-10.1' + assert first_row['y'] == '-40.2' From 615ba4509eec007295569d5efa847ad2811afe81 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 16:42:30 -0700 Subject: [PATCH 03/53] Update EDR Content Type --- pygeoapi/api/environmental_data_retrieval.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py index 7e1ef1f51..f3b580893 100644 --- a/pygeoapi/api/environmental_data_retrieval.py +++ b/pygeoapi/api/environmental_data_retrieval.py @@ -494,8 +494,14 @@ def get_collection_edr_query(api: API, request: APIRequest, HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, 'NoApplicableCode', msg) + headers['Content-Type'] = formatter.mimetype + if formatter.attachment: - filename = f'{dataset}.{formatter.extension}' + if p.filename is None: + filename = f'{dataset}.{formatter.extension}' + else: + filename = f'{p.filename}' + cd = f'attachment; filename="{filename}"' headers['Content-Disposition'] = cd From b1ab0626856d065f4eef559b4a4d10f5a7c00359 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 16:58:21 -0700 Subject: [PATCH 04/53] Add original tests back --- tests/formatter/test_csv__formatter.py | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/formatter/test_csv__formatter.py b/tests/formatter/test_csv__formatter.py index c2fbe98da..2bb3cb013 100644 --- a/tests/formatter/test_csv__formatter.py +++ b/tests/formatter/test_csv__formatter.py @@ -39,6 +39,30 @@ from ..util import get_test_file_path +@pytest.fixture() +def fixture(): + data = { + 'features': [{ + 'geometry': { + 'type': 'Point', + 'coordinates': [ + -130.44472222222223, + 54.28611111111111 + ] + }, + 'type': 'Feature', + 'properties': { + 'id': 1972, + 'foo': 'bar', + 'title': None, + }, + 'id': 48693 + }] + } + + return data + + @pytest.fixture def data(): data_path = get_test_file_path('data/items.geojson') @@ -74,6 +98,30 @@ def invalid_geometry_data(): } +def test_csv__formatter(fixture): + f = CSVFormatter({'geom': True}) + f_csv = f.write(data=fixture) + + buffer = StringIO(f_csv.decode('utf-8')) + reader = DictReader(buffer) + + header = list(reader.fieldnames) + + assert f.mimetype == 'text/csv; charset=utf-8' + + assert len(header) == 5 + + assert 'x' in header + assert 'y' in header + + data = next(reader) + assert data['x'] == '-130.44472222222223' + assert data['y'] == '54.28611111111111' + assert data['id'] == '1972' + assert data['foo'] == 'bar' + assert data['title'] == '' + + def test_write_with_geometry_enabled(csv_reader_geom_enabled): """Test CSV output with geometry enabled""" rows = list(csv_reader_geom_enabled) From be45c52e5477600e72cf5aeaf643cedf308ae214 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 18:26:43 -0700 Subject: [PATCH 05/53] Do not throw error on additional keys --- pygeoapi/formatter/csv_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index 1c42b1c64..aeb4f5491 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -104,7 +104,7 @@ def _write_from_geojson( LOGGER.debug(f'CSV fields: {fields}') output = io.StringIO() - writer = csv.DictWriter(output, fields) + writer = csv.DictWriter(output, fields, extrasaction='ignore') writer.writeheader() for feature in data['features']: From 4d47d419470ba026fcf183d500217da46bd35bd8 Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:55:06 -0500 Subject: [PATCH 06/53] Change x,y columns order to be at the start --- pygeoapi/formatter/csv_.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index aeb4f5491..7171f3584 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -96,7 +96,8 @@ def _write_from_geojson( LOGGER.debug('Including point geometry') if data['features'][0]['geometry']['type'] == 'Point': LOGGER.debug('point geometry detected, adding x,y columns') - fields.extend(['x', 'y']) + fields.insert(0, 'x') + fields.insert(1, 'y') is_point = True else: LOGGER.debug('not a point geometry, adding wkt column') @@ -126,7 +127,8 @@ def _add_feature( try: if self.geom: if is_point: - [fp['x'], fp['y']] = feature['geometry']['coordinates'] + fp['x'] = feature['geometry']['coordinates'][0] + fp['y'] = feature['geometry']['coordinates'][1] else: geom = geojson_to_geom(feature['geometry']) fp['wkt'] = geom.wkt From 096c5778aad18dbb7eeadd18720d2dd2b1217873 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sun, 22 Feb 2026 21:20:28 -0500 Subject: [PATCH 07/53] add resolution/default to collection extents.temporal configuration (#2260) (#2261) --- docs/source/configuration.rst | 2 ++ pygeoapi/api/__init__.py | 6 ++++++ pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml | 6 ++++++ tests/api/test_api.py | 8 ++++++-- tests/pygeoapi-test-config.yml | 4 +++- 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 8cab94f52..d718e85e6 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -243,6 +243,8 @@ default. begin: 2000-10-30T18:24:39Z # start datetime in RFC3339 end: 2007-10-30T08:57:29Z # end datetime in RFC3339 trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian # TRS + resolution: P1D # ISO 8601 duration + default: 2000-10-30T18:24:39Z # default time # additional extents can be added as desired (1..n) foo: url: https://example.org/def # required URL of the extent diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 9116737a4..55a1d4e1f 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -1008,6 +1008,12 @@ def describe_collections(api: API, request: APIRequest, } if 'trs' in t_ext: collection['extent']['temporal']['trs'] = t_ext['trs'] + if 'resolution' in t_ext: + collection['extent']['temporal']['grid'] = { + 'resolution': t_ext['resolution'] + } + if 'default' in t_ext: + collection['extent']['temporal']['default'] = t_ext['default'] _ = extents.pop('spatial', None) _ = extents.pop('temporal', None) diff --git a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml index d772f000b..19c18f5dc 100644 --- a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml +++ b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml @@ -477,6 +477,12 @@ properties: type: string description: temporal reference system of features default: 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian' + resolution: + type: string + description: temporal resolution + default: + type: string + description: default time value patternProperties: "^(?!spatial$|temporal$).*": type: object diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 816fe8178..763fdc77d 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -6,7 +6,7 @@ # Bernhard Mallinger # Francesco Bartoli # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2026 Francesco Bartoli # @@ -635,7 +635,11 @@ def test_describe_collections(config, api_): 'interval': [ ['2000-10-30T18:24:39+00:00', '2007-10-30T08:57:29+00:00'] ], - 'trs': 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian' + 'trs': 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian', + 'grid': { + 'resolution': 'P1D' + }, + 'default': '2000-10-30T18:24:39+00:00' } } diff --git a/tests/pygeoapi-test-config.yml b/tests/pygeoapi-test-config.yml index 13dc63aa9..e64afbf28 100644 --- a/tests/pygeoapi-test-config.yml +++ b/tests/pygeoapi-test-config.yml @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2019 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -142,6 +142,8 @@ resources: begin: 2000-10-30T18:24:39Z end: 2007-10-30T08:57:29Z trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + resolution: P1D + default: 2000-10-30T18:24:39Z providers: - type: feature name: CSV From dabbea77ee96843c90e1931ebefb4623859ef9e8 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sun, 22 Feb 2026 22:15:21 -0500 Subject: [PATCH 08/53] remove deprecated license and fix test cmdclass in setup.py (#2262) --- setup.py | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/setup.py b/setup.py index 161941e21..efd7dd99c 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -84,26 +84,7 @@ def finalize_options(self): def run(self): import subprocess - errno = subprocess.call(['pytest', 'tests/test_api.py']) - raise SystemExit(errno) - - -class PyCoverage(Command): - user_options = [] - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - import subprocess - - errno = subprocess.call(['coverage', 'run', '--source=pygeoapi', - '-m', 'unittest', - 'pygeoapi.tests.run_tests']) - errno = subprocess.call(['coverage', 'report', '-m']) + errno = subprocess.call(['pytest', 'tests/api/test_api.py']) raise SystemExit(errno) @@ -169,14 +150,12 @@ def get_package_version(): 'Environment :: Console', 'Intended Audience :: Developers', 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Topic :: Scientific/Engineering :: GIS' ], cmdclass={ 'test': PyTest, - 'coverage': PyCoverage, 'cleanbuild': PyCleanBuild } ) From 42798417886eb0f9a0de8cdb978747b2f8dcc07f Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:01:30 -0500 Subject: [PATCH 09/53] Support SQL connection string (#2251) * Support SQL connection string Modify SQL provider logic to support connection string * Add test for MySQL * Fix flake8 * Remove pg8000 test * Add funcstrings * Synchronize Postgres manager * Update LOGGER statements --- docs/source/publishing/ogcapi-features.rst | 12 ++ pygeoapi/process/manager/postgresql.py | 45 +++---- pygeoapi/provider/sql.py | 139 +++++++++++++------- tests/provider/test_mysql_provider.py | 64 ++++----- tests/provider/test_postgresql_provider.py | 144 +++++++++++++-------- 5 files changed, 246 insertions(+), 158 deletions(-) diff --git a/docs/source/publishing/ogcapi-features.rst b/docs/source/publishing/ogcapi-features.rst index 5646f1a7b..df40d27bd 100644 --- a/docs/source/publishing/ogcapi-features.rst +++ b/docs/source/publishing/ogcapi-features.rst @@ -627,6 +627,18 @@ Must have PostGIS installed. geom_field: foo_geom count: true # Optional; Default true; Enable/disable count for improved performance. +This can be represented as a connection dictionary or as a connection string as follows: + +.. code-block:: yaml + + providers: + - type: feature + name: PostgreSQL + data: postgresql://postgres:postgres@127.0.0.1:3010/test + id_field: osm_id + table: hotosm_bdi_waterways + geom_field: foo_geom + A number of database connection options can be also configured in the provider in order to adjust properly the sqlalchemy engine client. These are optional and if not specified, the default from the engine will be used. Please see also `SQLAlchemy docs `_. diff --git a/pygeoapi/process/manager/postgresql.py b/pygeoapi/process/manager/postgresql.py index bf5033eef..82ed5eb91 100644 --- a/pygeoapi/process/manager/postgresql.py +++ b/pygeoapi/process/manager/postgresql.py @@ -46,7 +46,6 @@ from typing import Any, Tuple from sqlalchemy import insert, update, delete -from sqlalchemy.engine import make_url from sqlalchemy.orm import Session from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD @@ -56,7 +55,9 @@ ProcessorGenericError ) from pygeoapi.process.manager.base import BaseManager -from pygeoapi.provider.sql import get_engine, get_table_model +from pygeoapi.provider.sql import ( + get_engine, get_table_model, store_db_parameters +) from pygeoapi.util import JobStatus @@ -66,13 +67,15 @@ class PostgreSQLManager(BaseManager): """PostgreSQL Manager""" + default_port = 5432 + def __init__(self, manager_def: dict): """ Initialize object :param manager_def: manager definition - :returns: `pygeoapi.process.manager.postgresqs.PostgreSQLManager` + :returns: `pygeoapi.process.manager.postgresql.PostgreSQLManager` """ super().__init__(manager_def) @@ -81,30 +84,18 @@ def __init__(self, manager_def: dict): self.supports_subscribing = True self.connection = manager_def['connection'] - try: - self.db_search_path = tuple(self.connection.get('search_path', - ['public'])) - except Exception: - self.db_search_path = ('public',) - - try: - LOGGER.debug('Connecting to database') - if isinstance(self.connection, str): - _url = make_url(self.connection) - self._engine = get_engine( - 'postgresql+psycopg2', - _url.host, - _url.port, - _url.database, - _url.username, - _url.password) - else: - self._engine = get_engine('postgresql+psycopg2', - **self.connection) - except Exception as err: - msg = 'Test connecting to DB failed' - LOGGER.error(f'{msg}: {err}') - raise ProcessorGenericError(msg) + options = manager_def.get('options', {}) + store_db_parameters(self, manager_def['connection'], options) + self._engine = get_engine( + 'postgresql+psycopg2', + self.db_host, + self.db_port, + self.db_name, + self.db_user, + self._db_password, + self.db_conn, + **self.db_options + ) try: LOGGER.debug('Getting table model') diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index a955f06db..19cc35ca8 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -39,25 +39,12 @@ # # ================================================================= -# Testing local postgis with docker: -# docker run --name "postgis" \ -# -v postgres_data:/var/lib/postgresql -p 5432:5432 \ -# -e ALLOW_IP_RANGE=0.0.0.0/0 \ -# -e POSTGRES_USER=postgres \ -# -e POSTGRES_PASS=postgres \ -# -e POSTGRES_DBNAME=test \ -# -d -t kartoza/postgis - -# Import dump: -# gunzip < tests/data/hotosm_bdi_waterways.sql.gz | -# psql -U postgres -h 127.0.0.1 -p 5432 test - from copy import deepcopy from datetime import datetime from decimal import Decimal import functools import logging -from typing import Optional +from typing import Optional, Any from geoalchemy2 import Geometry # noqa - this isn't used explicitly but is needed to process Geometry columns from geoalchemy2.functions import ST_MakeEnvelope, ST_Intersects @@ -73,7 +60,7 @@ desc, delete ) -from sqlalchemy.engine import URL +from sqlalchemy.engine import URL, Engine from sqlalchemy.exc import ( ConstraintColumnNotFoundError, InvalidRequestError, @@ -82,6 +69,7 @@ from sqlalchemy.ext.automap import automap_base from sqlalchemy.orm import Session, load_only from sqlalchemy.sql.expression import and_ +from sqlalchemy.schema import Table from pygeoapi.crs import get_transform_from_spec, get_srid from pygeoapi.provider.base import ( @@ -135,8 +123,8 @@ def __init__( LOGGER.debug(f'Configured Storage CRS: {self.storage_crs}') # Read table information from database - options = provider_def.get('options', {}) - self._store_db_parameters(provider_def['data'], options) + options = provider_def.get('options', {}) | extra_conn_args + store_db_parameters(self, provider_def['data'], options) self._engine = get_engine( driver_name, self.db_host, @@ -144,13 +132,13 @@ def __init__( self.db_name, self.db_user, self._db_password, - **self.db_options | extra_conn_args + self.db_conn, + **self.db_options ) self.table_model = get_table_model( self.table, self.id_field, self.db_search_path, self._engine ) - LOGGER.debug(f'DB connection: {repr(self._engine.url)}') self.get_fields() def query( @@ -426,22 +414,6 @@ def delete(self, identifier): return result.rowcount > 0 - def _store_db_parameters(self, parameters, options): - self.db_user = parameters.get('user') - self.db_host = parameters.get('host') - self.db_port = parameters.get('port', self.default_port) - self.db_name = parameters.get('dbname') - # db_search_path gets converted to a tuple here in order to ensure it - # is hashable - which allows us to use functools.cache() when - # reflecting the table definition from the DB - self.db_search_path = tuple(parameters.get('search_path', ['public'])) - self._db_password = parameters.get('password') - self.db_options = { - k: v - for k, v in options.items() - if not isinstance(v, dict) - } - def _sqlalchemy_to_feature(self, item, crs_transform_out=None, select_properties=[]): """ @@ -602,6 +574,48 @@ def _select_properties_clause(self, select_properties, skip_geometry): return selected_properties_clause +def store_db_parameters( + self: GenericSQLProvider | Any, + connection_data: str | dict[str], + options: dict[str, str] +) -> None: + """ + Store database connection parameters + + :self: instance of provider or manager class + :param connection_data: connection string or dict of connection params + :param options: additional connection options + + :returns: None + """ + if isinstance(connection_data, str): + self.db_conn = connection_data + connection_data = {} + else: + self.db_conn = None + # OR + self.db_user = connection_data.get('user') + self.db_host = connection_data.get('host') + self.db_port = connection_data.get('port', self.default_port) + self.db_name = ( + connection_data.get('dbname') or connection_data.get('database') + ) + self.db_query = connection_data.get('query') + self._db_password = connection_data.get('password') + # db_search_path gets converted to a tuple here in order to ensure it + # is hashable - which allows us to use functools.cache() when + # reflecting the table definition from the DB + self.db_search_path = tuple( + connection_data.get('search_path') or + options.pop('search_path', ['public']) + ) + self.db_options = { + k: v + for k, v in options.items() + if not isinstance(v, dict) + } + + @functools.cache def get_engine( driver_name: str, @@ -610,20 +624,38 @@ def get_engine( database: str, user: str, password: str, + conn_str: Optional[str] = None, **connect_args -): - """Create SQL Alchemy engine.""" - conn_str = URL.create( - drivername=driver_name, - username=user, - password=password, - host=host, - port=int(port), - database=database - ) +) -> Engine: + """ + Get SQL Alchemy engine. + + :param driver_name: database driver name + :param host: database host + :param port: database port + :param database: database name + :param user: database user + :param password: database password + :param conn_str: optional connection URL + :param connect_args: custom connection arguments to pass to create_engine() + + :returns: SQL Alchemy engine + """ + if conn_str is None: + conn_str = URL.create( + drivername=driver_name, + username=user, + password=password, + host=host, + port=int(port), + database=database + ) + engine = create_engine( conn_str, connect_args=connect_args, pool_pre_ping=True ) + + LOGGER.debug(f'Created engine for {repr(engine.url)}.') return engine @@ -632,14 +664,25 @@ def get_table_model( table_name: str, id_field: str, db_search_path: tuple[str], - engine -): - """Reflect table.""" + engine: Engine +) -> Table: + """ + Reflect table using SQLAlchemy Automap. + + :param table_name: name of table to reflect + :param id_field: name of primary key field + :param db_search_path: tuple of database schemas to search for the table + :param engine: SQLAlchemy engine to use for reflection + + :returns: SQLAlchemy model of the reflected table + """ + LOGGER.debug('Reflecting table definition from database') metadata = MetaData() # Look for table in the first schema in the search path schema = db_search_path[0] try: + LOGGER.debug(f'Looking for table {table_name} in schema {schema}') metadata.reflect( bind=engine, schema=schema, only=[table_name], views=True ) diff --git a/tests/provider/test_mysql_provider.py b/tests/provider/test_mysql_provider.py index 0f470d750..1ac10e1c1 100644 --- a/tests/provider/test_mysql_provider.py +++ b/tests/provider/test_mysql_provider.py @@ -37,44 +37,45 @@ PASSWORD = os.environ.get('MYSQL_PASSWORD', 'mysql') -""" -For local testing, a MySQL database can be spun up with docker -compose as follows: - -services: - - mysql: - image: mysql:8 - ports: - - 3306:3306 - environment: - MYSQL_ROOT_PASSWORD: mysql - MYSQL_USER: pygeoapi - MYSQL_PASSWORD: mysql - MYSQL_DATABASE: test_geo_app - volumes: - - ./tests/data/mysql_data.sql:/docker-entrypoint-initdb.d/init.sql:ro -""" - - -@pytest.fixture() -def config(): - return { +# Testing local MySQL with docker: +''' +docker run --name mysql-test \ + -e MYSQL_ROOT_PASSWORD=mysql \ + -e MYSQL_USER=pygeoapi \ + -e MYSQL_PASSWORD=mysql \ + -e MYSQL_DATABASE=test_geo_app \ + -p 3306:3306 \ + -v ./tests/data/mysql_data.sql:/docker-entrypoint-initdb.d/init.sql:ro \ + -d mysql:8 +''' + + +@pytest.fixture(params=['default', 'connection_string']) +def config(request): + config_ = { 'name': 'MySQL', 'type': 'feature', - 'data': { + 'options': {'connect_timeout': 10}, + 'id_field': 'locationID', + 'table': 'location', + 'geom_field': 'locationCoordinates' + } + if request.param == 'default': + config_['data'] = { 'host': 'localhost', 'dbname': 'test_geo_app', 'user': 'root', 'port': 3306, 'password': PASSWORD, 'search_path': ['test_geo_app'] - }, - 'options': {'connect_timeout': 10}, - 'id_field': 'locationID', - 'table': 'location', - 'geom_field': 'locationCoordinates' - } + } + elif request.param == 'connection_string': + config_['data'] = ( + f'mysql+pymysql://root:{PASSWORD}@localhost:3306/test_geo_app' + ) + config_['options']['search_path'] = ['test_geo_app'] + + return config_ def test_valid_connection_options(config): @@ -87,7 +88,8 @@ def test_valid_connection_options(config): 'keepalives', 'keepalives_idle', 'keepalives_count', - 'keepalives_interval' + 'keepalives_interval', + 'search_path' ] diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index c27660caf..eb0b8760c 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -37,7 +37,17 @@ # ================================================================= # Needs to be run like: python3 -m pytest -# See pygeoapi/provider/postgresql.py for instructions on setting up +# Testing local postgis with docker: +''' +docker run --name postgis \ + --rm \ + -p 5432:5432 \ + -e ALLOW_IP_RANGE=0.0.0.0/0 \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASS=postgres \ + -e POSTGRES_DBNAME=test \ + -d -t kartoza/postgis +''' # test database in Docker from http import HTTPStatus @@ -69,44 +79,58 @@ PASSWORD = os.environ.get('POSTGRESQL_PASSWORD', 'postgres') -@pytest.fixture() -def config(): - return { +@pytest.fixture(params=['default', 'connection_string']) +def config(request): + config_ = { 'name': 'PostgreSQL', 'type': 'feature', - 'data': {'host': '127.0.0.1', - 'dbname': 'test', - 'user': 'postgres', - 'password': PASSWORD, - 'search_path': ['osm', 'public'] - }, - 'options': { - 'connect_timeout': 10 - }, + 'options': {'connect_timeout': 10}, 'id_field': 'osm_id', 'table': 'hotosm_bdi_waterways', 'geom_field': 'foo_geom' } + if request.param == 'default': + config_['data'] = { + 'host': '127.0.0.1', + 'dbname': 'test', + 'user': 'postgres', + 'password': PASSWORD, + 'search_path': ['osm', 'public'] + } + elif request.param == 'connection_string': + config_['data'] = ( + f'postgresql://postgres:{PASSWORD}@127.0.0.1:5432/test' + ) + config_['options']['search_path'] = ['osm', 'public'] + return config_ -@pytest.fixture() -def config_types(): - return { + +@pytest.fixture(params=['default', 'connection_string']) +def config_types(request): + config_ = { 'name': 'PostgreSQL', 'type': 'feature', - 'data': {'host': '127.0.0.1', - 'dbname': 'test', - 'user': 'postgres', - 'password': PASSWORD, - 'search_path': ['public'] - }, - 'options': { - 'connect_timeout': 10 - }, + 'options': {'connect_timeout': 10}, 'id_field': 'id', 'table': 'foo', 'geom_field': 'the_geom' } + if request.param == 'default': + config_['data'] = { + 'host': '127.0.0.1', + 'dbname': 'test', + 'user': 'postgres', + 'password': PASSWORD, + 'search_path': ['public', 'osm'] + } + elif request.param == 'connection_string': + config_['data'] = ( + f'postgresql://postgres:{PASSWORD}@127.0.0.1:5432/test' + ) + config_['options']['search_path'] = ['public', 'osm'] + + return config_ @pytest.fixture() @@ -148,14 +172,20 @@ def test_valid_connection_options(config): for key in keys: assert key in ['connect_timeout', 'tcp_user_timeout', 'keepalives', 'keepalives_idle', 'keepalives_count', - 'keepalives_interval'] + 'keepalives_interval', 'search_path'] def test_schema_path_search(config): - config['data']['search_path'] = ['public', 'osm'] + if isinstance(config['data'], dict): + config['data']['search_path'] = ['public', 'osm'] + else: + config['options']['search_path'] = ['public', 'osm'] PostgreSQLProvider(config) - config['data']['search_path'] = ['public', 'notosm'] + if isinstance(config['data'], dict): + config['data']['search_path'] = ['public', 'notosm'] + else: + config['options']['search_path'] = ['public', 'notosm'] with pytest.raises(ProviderQueryError): PostgreSQLProvider(config) @@ -189,13 +219,13 @@ def test_query_materialised_view(config): provider = PostgreSQLProvider(config_materialised_view) # Only ID, width and depth properties should be available - assert set(provider.get_fields().keys()) == {"osm_id", "width", "depth"} + assert set(provider.get_fields().keys()) == {'osm_id', 'width', 'depth'} def test_query_with_property_filter(config): """Test query valid features when filtering by property""" p = PostgreSQLProvider(config) - feature_collection = p.query(properties=[("waterway", "stream")]) + feature_collection = p.query(properties=[('waterway', 'stream')]) features = feature_collection.get('features') stream_features = list( filter(lambda feature: feature['properties']['waterway'] == 'stream', @@ -246,19 +276,19 @@ def test_query_with_config_properties(config): feature = result.get('features')[0] properties = feature.get('properties') for property_name in properties.keys(): - assert property_name in config["properties"] + assert property_name in config['properties'] -@pytest.mark.parametrize("property_filter, expected", [ +@pytest.mark.parametrize('property_filter, expected', [ ([], 14776), - ([("waterway", "stream")], 13930), - ([("waterway", "this does not exist")], 0), + ([('waterway', 'stream')], 13930), + ([('waterway', 'this does not exist')], 0), ]) def test_query_hits_with_property_filter(config, property_filter, expected): """Test query resulttype=hits""" provider = PostgreSQLProvider(config) - results = provider.query(properties=property_filter, resulttype="hits") - assert results["numberMatched"] == expected + results = provider.query(properties=property_filter, resulttype='hits') + assert results['numberMatched'] == expected def test_query_bbox(config): @@ -337,7 +367,7 @@ def test_get_with_config_properties(config): result = provider.get(80835483) properties = result.get('properties') for property_name in properties.keys(): - assert property_name in config["properties"] + assert property_name in config['properties'] def test_get_not_existing_item_raise_exception(config): @@ -376,7 +406,7 @@ def test_query_cql(config, cql, expected_ids): assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') - ids = [feature["id"] for feature in features] + ids = [feature['id'] for feature in features] assert ids == expected_ids @@ -385,7 +415,7 @@ def test_query_cql_properties_bbox_filters(config): # Arrange properties = [('waterway', 'stream')] bbox = [29, -2.8, 29.2, -2.9] - filterq = parse("osm_id BETWEEN 80800000 AND 80900000") + filterq = parse('osm_id BETWEEN 80800000 AND 80900000') expected_ids = [80835470] # Act @@ -395,7 +425,7 @@ def test_query_cql_properties_bbox_filters(config): bbox=bbox) # Assert - ids = [feature["id"] for feature in feature_collection.get('features')] + ids = [feature['id'] for feature in feature_collection.get('features')] assert ids == expected_ids @@ -457,9 +487,9 @@ def test_instantiation(config): provider = PostgreSQLProvider(config) # Assert - assert provider.name == "PostgreSQL" - assert provider.table == "hotosm_bdi_waterways" - assert provider.id_field == "osm_id" + assert provider.name == 'PostgreSQL' + assert provider.table == 'hotosm_bdi_waterways' + assert provider.id_field == 'osm_id' @pytest.mark.parametrize('bad_data, exception, match', [ @@ -484,8 +514,14 @@ def test_instantiation_with_bad_config(config, bad_data, exception, match): def test_instantiation_with_bad_credentials(config): # Arrange - config['data'].update({'user': 'bad_user'}) - match = r'Could not connect to .*bad_user:\*\*\*@' + if isinstance(config['data'], dict): + config['data'].update({'user': 'bad_user'}) + match = r'Could not connect to .*bad_user:\*\*\*@' + + else: + config['data'] = config['data'].replace('postgres:', 'bad_user:') + match = r'Could not connect to .*bad_user:\*\*\*@' + # Make sure we don't use a cached connection in the tests postgresql_provider_module._ENGINE_STORE = {} @@ -505,7 +541,7 @@ def test_engine_and_table_model_stores(config): # Same database connection details, but different table different_table = config.copy() - different_table.update(table="hotosm_bdi_drains") + different_table.update(table='hotosm_bdi_drains') provider2 = PostgreSQLProvider(different_table) assert repr(provider2._engine) == repr(provider0._engine) assert provider2._engine is provider0._engine @@ -515,7 +551,11 @@ def test_engine_and_table_model_stores(config): # and also a different table_model, as two databases may have different # tables with the same name different_host = config.copy() - different_host["data"]["host"] = "localhost" + if isinstance(config['data'], dict): + different_host['data']['host'] = 'localhost' + else: + different_host['data'] = config['data'].replace( + '127.0.0.1', 'localhost') provider3 = PostgreSQLProvider(different_host) assert provider3._engine is not provider0._engine assert provider3.table_model is not provider0.table_model @@ -584,7 +624,7 @@ def test_get_collection_items_postgresql_cql_invalid_filter_language(pg_api_): assert error_response['description'] == 'Invalid filter language' -@pytest.mark.parametrize("bad_cql", [ +@pytest.mark.parametrize('bad_cql', [ 'id IN (1, ~)', 'id EATS (1, 2)', # Valid CQL relations only 'id IN (1, 2' # At some point this may return UnexpectedEOF @@ -664,7 +704,7 @@ def test_get_collection_items_postgresql_cql_json_invalid_filter_language(pg_api """ # Arrange # CQL should never be parsed - cql = {"in": {"value": {"property": "id"}, "list": [1, 2]}} + cql = {'in': {'value': {'property': 'id'}, 'list': [1, 2]}} headers = {'CONTENT_TYPE': 'application/query-cql-json'} # Act @@ -681,9 +721,9 @@ def test_get_collection_items_postgresql_cql_json_invalid_filter_language(pg_api assert error_response['description'] == 'Bad CQL JSON' -@pytest.mark.parametrize("bad_cql", [ +@pytest.mark.parametrize('bad_cql', [ # Valid CQL relations only - {"eats": {"value": {"property": "id"}, "list": [1, 2]}}, + {'eats': {'value': {'property': 'id'}, 'list': [1, 2]}}, # At some point this may return UnexpectedEOF '{"in": {"value": {"property": "id"}, "list": [1, 2}}' ]) @@ -939,7 +979,7 @@ def test_provider_count_false_with_resulttype_hits(config): provider = PostgreSQLProvider(config) # Act - results = provider.query(resulttype="hits") + results = provider.query(resulttype='hits') # Assert assert results['numberMatched'] == 14776 From b777b16d81670fca43a75db2f04d5f802bbf5155 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Tue, 24 Feb 2026 08:36:52 -0500 Subject: [PATCH 10/53] PubSub: add support for Kafka (#2259) (#2258) --- docs/source/pubsub.rst | 27 ++++++++++ pygeoapi/plugin.py | 1 + pygeoapi/pubsub/http.py | 4 +- pygeoapi/pubsub/kafka.py | 109 +++++++++++++++++++++++++++++++++++++++ pygeoapi/pubsub/mqtt.py | 4 +- requirements-pubsub.txt | 1 + 6 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 pygeoapi/pubsub/kafka.py diff --git a/docs/source/pubsub.rst b/docs/source/pubsub.rst index da8570f97..739a50d93 100644 --- a/docs/source/pubsub.rst +++ b/docs/source/pubsub.rst @@ -85,6 +85,11 @@ Brokers The following protocols are supported: +.. note:: + + Pub/Sub client dependencies will vary based on the selected broker. ``requirements-pubsub.txt`` contains all requirements for supported brokers, as a reference point. + + MQTT ^^^^ @@ -99,6 +104,23 @@ Example directive: channel: messages/a/data # optional hidden: false # default +Kafka +^^^^^ + +Example directive: + +.. code-block:: yaml + + pubsub: + name: Kafka + broker: + url: tcp://localhost:9092 + channel: messages-a-data + # if using authentication: + # sasl_mechanism: PLAIN # default PLAIN + # sasl_security_protocol: SASL_PLAINTEXT # default SASL_PLAINTEXT + hidden: true # default false + HTTP ^^^^ @@ -113,12 +135,16 @@ Example directive: channel: messages-a-data # optional hidden: true # default false +Additional information +---------------------- + .. note:: For any Pub/Sub endpoints requiring authentication, encode the ``url`` value as follows: * ``mqtt://username:password@localhost:1883`` * ``https://username:password@localhost`` + * ``tcp://username:password@localhost:9092`` As with any section of the pygeoapi configuration, environment variables may be used as needed, for example to set username/password information in a URL. If ``pubsub.broker.url`` contains authentication, and @@ -131,5 +157,6 @@ Example directive: If a ``channel`` is not defined, only the relevant OGC API endpoint is used. + .. _`OGC API Publish-Subscribe Workflow - Part 1: Core`: https://docs.ogc.org/DRAFTS/25-030.html .. _`AsyncAPI`: https://www.asyncapi.com diff --git a/pygeoapi/plugin.py b/pygeoapi/plugin.py index 19795be15..32292c895 100644 --- a/pygeoapi/plugin.py +++ b/pygeoapi/plugin.py @@ -88,6 +88,7 @@ }, 'pubsub': { 'HTTP': 'pygeoapi.pubsub.http.HTTPPubSubClient', + 'Kafka': 'pygeoapi.pubsub.kafka.KafkaPubSubClient', 'MQTT': 'pygeoapi.pubsub.mqtt.MQTTPubSubClient' } } diff --git a/pygeoapi/pubsub/http.py b/pygeoapi/pubsub/http.py index a07c600ca..c19accc7d 100644 --- a/pygeoapi/pubsub/http.py +++ b/pygeoapi/pubsub/http.py @@ -41,7 +41,7 @@ class HTTPPubSubClient(BasePubSubClient): """HTTP client""" - def __init__(self, broker_url): + def __init__(self, publisher_def): """ Initialize object @@ -50,7 +50,7 @@ def __init__(self, broker_url): :returns: pygeoapi.pubsub.http.HTTPPubSubClient """ - super().__init__(broker_url) + super().__init__(publisher_def) self.name = 'HTTP' self.type = 'http' self.auth = None diff --git a/pygeoapi/pubsub/kafka.py b/pygeoapi/pubsub/kafka.py new file mode 100644 index 000000000..20033dea8 --- /dev/null +++ b/pygeoapi/pubsub/kafka.py @@ -0,0 +1,109 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import logging + +from kafka import errors, KafkaProducer + +from pygeoapi.pubsub.base import BasePubSubClient, PubSubClientConnectionError +from pygeoapi.util import to_json + +LOGGER = logging.getLogger(__name__) + + +class KafkaPubSubClient(BasePubSubClient): + """Kafka client""" + + def __init__(self, publisher_def): + """ + Initialize object + + :param publisher_def: provider definition + + :returns: pygeoapi.pubsub.kafka.KafkaPubSubClient + """ + + super().__init__(publisher_def) + self.name = 'Kafka' + self.type = 'kafka' + self.sasl_mechanism = publisher_def.get('sasl.mechanism', 'PLAIN') + self.security_protocol = publisher_def.get('security.protocol', 'SASL_SSL') # noqa + + msg = f'Initializing to broker {self.broker_safe_url} with id {self.client_id}' # noqa + LOGGER.debug(msg) + + def connect(self) -> None: + """ + Connect to an Kafka broker + + :returns: None + """ + + args = { + 'bootstrap_servers': f'{self.broker_url.hostname}:{self.broker_url.port}', # noqa + 'client_id': self.client_id, + 'value_serializer': lambda v: to_json(v).encode('utf-8') + } + if None not in [self.broker_url.username, self.broker_url.password]: + args.update({ + 'security.protocol': self.security_protocol, + 'sasl.mechanism': self.sasl_mechanism, + 'sasl.username': self.broker_url.username, + 'sasl.password': self.broker_url.password + }) + + LOGGER.debug('Creating Kafka producer') + try: + self.producer = KafkaProducer(**args) + except errors.NoBrokersAvailable as err: + raise PubSubClientConnectionError(err) + + def pub(self, channel: str, message: str) -> bool: + """ + Publish a message to a broker/channel + + :param channel: `str` of topic + :param message: `str` of message + + :returns: `bool` of publish result + """ + + LOGGER.debug(f'Publishing to broker {self.broker_safe_url}') + LOGGER.debug(f'Channel: {channel}') + LOGGER.debug(f'Message: {message}') + LOGGER.debug('Sanitizing channel for HTTP') + channel = channel.replace('/', '-') + channel = channel.replace(':', '-') + LOGGER.debug(f'Sanitized channel for Kafka: {channel}') + + self.producer.send(channel, value=message) + self.producer.flush() + + def __repr__(self): + return f' {self.broker_safe_url}' diff --git a/pygeoapi/pubsub/mqtt.py b/pygeoapi/pubsub/mqtt.py index 2afd04087..0f88d670b 100644 --- a/pygeoapi/pubsub/mqtt.py +++ b/pygeoapi/pubsub/mqtt.py @@ -39,7 +39,7 @@ class MQTTPubSubClient(BasePubSubClient): """MQTT client""" - def __init__(self, broker_url): + def __init__(self, publisher_def): """ Initialize object @@ -48,7 +48,7 @@ def __init__(self, broker_url): :returns: pycsw.pubsub.mqtt.MQTTPubSubClient """ - super().__init__(broker_url) + super().__init__(publisher_def) self.type = 'mqtt' self.port = self.broker_url.port diff --git a/requirements-pubsub.txt b/requirements-pubsub.txt index 8579e8b22..1e32c725e 100644 --- a/requirements-pubsub.txt +++ b/requirements-pubsub.txt @@ -1 +1,2 @@ +kafka-python paho-mqtt From e4d8ced809533c8aee7f22f5d850a9f6bd3aa5ac Mon Sep 17 00:00:00 2001 From: Colton Loftus <70598503+C-Loftus@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:35:33 -0500 Subject: [PATCH 11/53] Fix `transform_bbox` CRS types (#2265) * Fix transform_bbox CRS types * manually fix formatting * manually fix formatting --- pygeoapi/crs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index 0c2ff7b48..188414fa1 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -278,7 +278,8 @@ def crs_transform_feature(feature: dict, transform_func: Callable): ) -def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: +def transform_bbox(bbox: list, from_crs: Union[str, pyproj.CRS], + to_crs: Union[str, pyproj.CRS]) -> list: """ helper function to transform a bounding box (bbox) from a source to a target CRS. CRSs in URI str format. @@ -286,7 +287,7 @@ def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: :param bbox: list of coordinates in 'from_crs' projection :param from_crs: CRS to transform from - :param to_crs: CRSto transform to + :param to_crs: CRS to transform to :raises `CRSError`: Error raised if no CRS could be identified from an URI. From 247ebf775db61efb73e27daed977390d92a44f3d Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sun, 1 Mar 2026 15:00:15 -0500 Subject: [PATCH 12/53] prevent form fields from being submitted on form click/submit (#2263) --- pygeoapi/templates/collections/items/index.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pygeoapi/templates/collections/items/index.html b/pygeoapi/templates/collections/items/index.html index 5db56046d..3d325c445 100644 --- a/pygeoapi/templates/collections/items/index.html +++ b/pygeoapi/templates/collections/items/index.html @@ -52,7 +52,7 @@

{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en
- +
@@ -192,6 +192,7 @@

{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en document.getElementById("q").addEventListener("keydown", function(event) { if (event.key === "Enter") { + event.preventDefault(); submitForm(); } }); @@ -288,6 +289,9 @@

{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en setRectangle(map.getBounds().pad(-0.95)); } } + + var form = document.getElementById("searchForm"); + form.addEventListener("submit", submitForm); {% endif %} {% endif %} From a09fdac6dd5c97bdad9484355592748b1b88ef70 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 2 Mar 2026 11:18:00 -0500 Subject: [PATCH 13/53] enable skipping of errorneous collections in /collections (#2267) --- pygeoapi/api/__init__.py | 488 +----------------- pygeoapi/api/admin.py | 5 +- pygeoapi/api/collection.py | 475 +++++++++++++++++ pygeoapi/api/coverages.py | 3 +- pygeoapi/api/environmental_data_retrieval.py | 4 +- pygeoapi/api/itemtypes.py | 6 +- pygeoapi/api/maps.py | 5 +- pygeoapi/api/processes.py | 6 +- pygeoapi/api/stac.py | 5 +- pygeoapi/api/tiles.py | 7 +- pygeoapi/formats.py | 51 ++ pygeoapi/process/manager/mongodb_.py | 4 +- pygeoapi/process/manager/postgresql.py | 4 +- pygeoapi/process/manager/tinydb_.py | 4 +- tests/api/test_api.py | 38 +- tests/api/test_itemtypes.py | 6 +- tests/api/test_processes.py | 4 +- tests/api/test_stac.py | 4 +- tests/api/test_tiles.py | 4 +- ...ygeoapi-test-config-failing-collection.yml | 206 ++++++++ 20 files changed, 824 insertions(+), 505 deletions(-) create mode 100644 pygeoapi/api/collection.py create mode 100644 pygeoapi/formats.py create mode 100644 tests/pygeoapi-test-config-failing-collection.yml diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 55a1d4e1f..c98cac3c1 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -40,7 +40,7 @@ Returns content from plugins and sets responses. """ -from collections import ChainMap, OrderedDict +from collections import ChainMap from copy import deepcopy from datetime import datetime from functools import partial @@ -56,21 +56,19 @@ import pytz from pygeoapi import __version__, l10n -from pygeoapi.crs import DEFAULT_STORAGE_CRS, get_supported_crs_list +from pygeoapi.api.collection import gen_collection, OGC_RELTYPES_BASE +from pygeoapi.formats import FORMAT_TYPES, F_GZIP, F_HTML, F_JSON, F_JSONLD from pygeoapi.linked_data import jsonldify, jsonldify_collection from pygeoapi.log import setup_logger from pygeoapi.plugin import load_plugin from pygeoapi.process.manager.base import get_manager -from pygeoapi.provider import ( - filter_providers_by_type, get_provider_by_type, get_provider_default) -from pygeoapi.provider.base import ( - ProviderConnectionError, ProviderGenericError, ProviderTypeError) +from pygeoapi.provider import filter_providers_by_type, get_provider_by_type +from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError from pygeoapi.util import ( - TEMPLATESDIR, UrlPrefetcher, dategetter, - filter_dict_by_key_value, get_api_rules, get_base_url, get_typed_value, - render_j2_template, to_json, get_choice_from_headers, get_from_headers, - get_dataset_formatters + TEMPLATESDIR, UrlPrefetcher, filter_dict_by_key_value, get_api_rules, + get_base_url, get_typed_value, render_j2_template, to_json, + get_choice_from_headers, get_from_headers ) LOGGER = logging.getLogger(__name__) @@ -82,26 +80,6 @@ } CHARSET = ['utf-8'] -F_JSON = 'json' -F_COVERAGEJSON = 'json' -F_HTML = 'html' -F_JSONLD = 'jsonld' -F_GZIP = 'gzip' -F_PNG = 'png' -F_JPEG = 'jpeg' -F_MVT = 'mvt' -F_NETCDF = 'NetCDF' - -#: Formats allowed for ?f= requests (order matters for complex MIME types) -FORMAT_TYPES = OrderedDict(( - (F_HTML, 'text/html'), - (F_JSONLD, 'application/ld+json'), - (F_JSON, 'application/json'), - (F_PNG, 'image/png'), - (F_JPEG, 'image/jpeg'), - (F_MVT, 'application/vnd.mapbox-vector-tile'), - (F_NETCDF, 'application/x-netcdf'), -)) #: Locale used for system responses (e.g. exceptions) SYSTEM_LOCALE = l10n.Locale('en', 'US') @@ -115,8 +93,6 @@ 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30' ] -OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' - def all_apis() -> dict: """ @@ -511,7 +487,7 @@ def get_response_headers(self, force_lang: l10n.Locale | None = None, if F_GZIP in FORMAT_TYPES: if force_encoding: headers['Content-Encoding'] = force_encoding - elif F_GZIP in get_from_headers(self._headers, 'accept-encoding'): + elif F_GZIP in get_from_headers(self._headers, 'accept-encoding'): # noqa headers['Content-Encoding'] = F_GZIP return headers @@ -950,9 +926,7 @@ def describe_collections(api: API, request: APIRequest, HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) if dataset is not None: - collections_dict = { - k: v for k, v in collections.items() if k == dataset - } + collections_dict = {dataset: api.config['resources'][dataset]} else: collections_dict = collections @@ -961,439 +935,21 @@ def describe_collections(api: API, request: APIRequest, if v.get('visibility', 'default') == 'hidden': LOGGER.debug(f'Skipping hidden layer: {k}') continue - collection_data = get_provider_default(v['providers']) - collection_data_type = collection_data['type'] - - collection_data_format = None - - if 'format' in collection_data: - collection_data_format = collection_data['format'] - - is_vector_tile = (collection_data_type == 'tile' and - collection_data_format['name'] not - in [F_PNG, F_JPEG]) - - collection = { - 'id': k, - 'title': l10n.translate(v['title'], request.locale), - 'description': l10n.translate(v['description'], request.locale), # noqa - 'keywords': l10n.translate(v['keywords'], request.locale), - 'links': [] - } - - extents = deepcopy(v['extents']) - - bbox = extents['spatial']['bbox'] - LOGGER.debug('Setting spatial extents from configuration') - # The output should be an array of bbox, so if the user only - # provided a single bbox, wrap it in a array. - if not isinstance(bbox[0], list): - bbox = [bbox] - collection['extent'] = { - 'spatial': { - 'bbox': bbox - } - } - if 'crs' in extents['spatial']: - collection['extent']['spatial']['crs'] = \ - extents['spatial']['crs'] - - t_ext = extents.get('temporal', {}) - if t_ext: - LOGGER.debug('Setting temporal extents from configuration') - begins = dategetter('begin', t_ext) - ends = dategetter('end', t_ext) - collection['extent']['temporal'] = { - 'interval': [[begins, ends]] - } - if 'trs' in t_ext: - collection['extent']['temporal']['trs'] = t_ext['trs'] - if 'resolution' in t_ext: - collection['extent']['temporal']['grid'] = { - 'resolution': t_ext['resolution'] - } - if 'default' in t_ext: - collection['extent']['temporal']['default'] = t_ext['default'] - - _ = extents.pop('spatial', None) - _ = extents.pop('temporal', None) - - for ek, ev in extents.items(): - LOGGER.debug(f'Adding extent {ek}') - collection['extent'][ek] = { - 'definition': ev['url'], - 'interval': [ev['range']] - } - if 'units' in ev: - collection['extent'][ek]['unit'] = ev['units'] - - if 'values' in ev: - collection['extent'][ek]['grid'] = { - 'cellsCount': len(ev['values']), - 'coordinates': ev['values'] - } - - LOGGER.debug('Processing configured collection links') - for link in l10n.translate(v.get('links', []), request.locale): - lnk = { - 'type': link['type'], - 'rel': link['rel'], - 'title': l10n.translate(link['title'], request.locale), - 'href': l10n.translate(link['href'], request.locale), - } - if 'hreflang' in link: - lnk['hreflang'] = l10n.translate( - link['hreflang'], request.locale) - content_length = link.get('length', 0) - - if lnk['rel'] == 'enclosure' and content_length == 0: - # Issue HEAD request for enclosure links without length - lnk_headers = api.prefetcher.get_headers(lnk['href']) - content_length = int(lnk_headers.get('content-length', 0)) - content_type = lnk_headers.get('content-type', lnk['type']) - if content_length == 0: - # Skip this (broken) link - LOGGER.debug(f"Enclosure {lnk['href']} is invalid") - continue - if content_type != lnk['type']: - # Update content type if different from specified - lnk['type'] = content_type - LOGGER.debug( - f"Fixed media type for enclosure {lnk['href']}") - - if content_length > 0: - lnk['length'] = content_length - - collection['links'].append(lnk) - - # TODO: provide translations - LOGGER.debug('Adding JSON and HTML link relations') - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSON], - 'rel': 'root', - 'title': l10n.translate('The landing page of this server as JSON', request.locale), # noqa - 'href': f"{api.base_url}?f={F_JSON}" - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': 'root', - 'title': l10n.translate('The landing page of this server as HTML', request.locale), # noqa - 'href': f"{api.base_url}?f={F_HTML}" - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSON], - 'rel': request.get_linkrel(F_JSON), - 'title': l10n.translate('This document as JSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_JSON}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSONLD], - 'rel': request.get_linkrel(F_JSONLD), - 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_JSONLD}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': request.get_linkrel(F_HTML), - 'title': l10n.translate('This document as HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_HTML}' - }) - - if collection_data_type == 'record': - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSON], - 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', - 'title': l10n.translate('Record catalogue as JSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_JSON}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', - 'title': l10n.translate('Record catalogue as HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}?f={F_HTML}' - }) - - if collection_data_type in ['feature', 'coverage', 'record']: - collection['links'].append({ - 'type': 'application/schema+json', - 'rel': f'{OGC_RELTYPES_BASE}/schema', - 'title': l10n.translate('Schema of collection in JSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/schema?f={F_JSON}' # noqa - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': f'{OGC_RELTYPES_BASE}/schema', - 'title': l10n.translate('Schema of collection in HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/schema?f={F_HTML}' # noqa - }) - - if is_vector_tile or collection_data_type in ['feature', 'record']: - # TODO: translate - collection['itemType'] = collection_data_type - LOGGER.debug('Adding feature/record based links') - collection['links'].append({ - 'type': 'application/schema+json', - 'rel': f'{OGC_RELTYPES_BASE}/queryables', - 'title': l10n.translate('Queryables for this collection as JSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_JSON}' # noqa - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': f'{OGC_RELTYPES_BASE}/queryables', - 'title': l10n.translate('Queryables for this collection as HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_HTML}' # noqa - }) - collection['links'].append({ - 'type': 'application/geo+json', - 'rel': 'items', - 'title': l10n.translate('Items as GeoJSON', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/items?f={F_JSON}' # noqa - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSONLD], - 'rel': 'items', - 'title': l10n.translate('Items as RDF (GeoJSON-LD)', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/items?f={F_JSONLD}' # noqa - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': 'items', - 'title': l10n.translate('Items as HTML', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/items?f={F_HTML}' # noqa - }) - - for key, value in get_dataset_formatters(v).items(): - collection['links'].append({ - 'type': value.mimetype, - 'rel': 'items', - 'title': l10n.translate(f'Items as {key}', request.locale), # noqa - 'href': f'{api.get_collections_url()}/{k}/items?f={value.f}' # noqa - }) - - # OAPIF Part 2 - list supported CRSs and StorageCRS - if collection_data_type in ['edr', 'feature']: - collection['crs'] = get_supported_crs_list(collection_data) - collection['storageCrs'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa - if 'storage_crs_coordinate_epoch' in collection_data: - collection['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa - - elif collection_data_type == 'coverage': - # TODO: translate - LOGGER.debug('Adding coverage based links') - collection['links'].append({ - 'type': 'application/prs.coverage+json', - 'rel': f'{OGC_RELTYPES_BASE}/coverage', - 'title': l10n.translate('Coverage data', request.locale), - 'href': f'{api.get_collections_url()}/{k}/coverage?f={F_JSON}' # noqa - }) - if collection_data_format is not None: - title_ = l10n.translate('Coverage data as', request.locale) # noqa - title_ = f"{title_} {collection_data_format['name']}" - collection['links'].append({ - 'type': collection_data_format['mimetype'], - 'rel': f'{OGC_RELTYPES_BASE}/coverage', - 'title': title_, - 'href': f"{api.get_collections_url()}/{k}/coverage?f={collection_data_format['name']}" # noqa - }) - if dataset is not None: - LOGGER.debug('Creating extended coverage metadata') - try: - provider_def = get_provider_by_type( - api.config['resources'][k]['providers'], - 'coverage') - p = load_plugin('provider', provider_def) - except ProviderConnectionError: - msg = 'connection error (check logs)' - return api.get_exception( - HTTPStatus.INTERNAL_SERVER_ERROR, - headers, request.format, - 'NoApplicableCode', msg) - except ProviderTypeError: - pass - else: - collection['extent']['spatial']['grid'] = [{ - 'cellsCount': p._coverage_properties['width'], - 'resolution': p._coverage_properties['resx'] - }, { - 'cellsCount': p._coverage_properties['height'], - 'resolution': p._coverage_properties['resy'] - }] - if 'time_range' in p._coverage_properties: - collection['extent']['temporal'] = { - 'interval': [p._coverage_properties['time_range']] - } - if 'restime' in p._coverage_properties: - collection['extent']['temporal']['grid'] = { - 'resolution': p._coverage_properties['restime'] # noqa - } - if 'uad' in p._coverage_properties: - collection['extent'].update(p._coverage_properties['uad']) # noqa try: - tile = get_provider_by_type(v['providers'], 'tile') - p = load_plugin('provider', tile) - except ProviderConnectionError: - msg = 'connection error (check logs)' - return api.get_exception( - HTTPStatus.INTERNAL_SERVER_ERROR, - headers, request.format, - 'NoApplicableCode', msg) - except ProviderTypeError: - tile = None - - if tile: - # TODO: translate - - LOGGER.debug('Adding tile links') - collection['links'].append({ - 'type': FORMAT_TYPES[F_JSON], - 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', - 'title': l10n.translate('Tiles as JSON', request.locale), - 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_JSON}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', - 'title': l10n.translate('Tiles as HTML', request.locale), - 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_HTML}' - }) - - try: - map_ = get_provider_by_type(v['providers'], 'map') - p = load_plugin('provider', map_) - except ProviderTypeError: - map_ = None - - if map_: - LOGGER.debug('Adding map links') - - map_mimetype = map_['format']['mimetype'] - map_format = map_['format']['name'] - - title_ = l10n.translate('Map as', request.locale) - title_ = f'{title_} {map_format}' - - collection['links'].append({ - 'type': map_mimetype, - 'rel': f'{OGC_RELTYPES_BASE}/map', - 'title': title_, - 'href': f'{api.get_collections_url()}/{k}/map?f={map_format}' - }) - - if p._fields: - schema_reltype = f'{OGC_RELTYPES_BASE}/schema', - schema_links = [s for s in collection['links'] if - schema_reltype in s] - - if not schema_links: - title_ = l10n.translate('Schema of collection in JSON', request.locale) # noqa - collection['links'].append({ - 'type': 'application/schema+json', - 'rel': f'{OGC_RELTYPES_BASE}/schema', - 'title': title_, - 'href': f'{api.get_collections_url()}/{k}/schema?f=json' # noqa - }) - title_ = l10n.translate('Schema of collection in HTML', request.locale) # noqa - collection['links'].append({ - 'type': 'text/html', - 'rel': f'{OGC_RELTYPES_BASE}/schema', - 'title': title_, - 'href': f'{api.get_collections_url()}/{k}/schema?f=html' # noqa - }) + fcm['collections'].append( + gen_collection(api, request, k, request.locale)) + except Exception as err: + LOGGER.warning(f'Error generating collection {k}: {err}') + if dataset is None: + LOGGER.debug('Skipping failed dataset') + else: + return api.get_exception( + HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, + 'NoApplicableCode', 'Error generating collection') - try: - edr = get_provider_by_type(v['providers'], 'edr') - p = load_plugin('provider', edr) - except ProviderConnectionError: - msg = 'connection error (check logs)' - return api.get_exception( - HTTPStatus.INTERNAL_SERVER_ERROR, headers, - request.format, 'NoApplicableCode', msg) - except ProviderTypeError: - edr = None - - if edr: - # TODO: translate - LOGGER.debug('Adding EDR links') - collection['data_queries'] = {} - parameters = p.get_fields() - if parameters: - collection['parameter_names'] = {} - for key, value in parameters.items(): - collection['parameter_names'][key] = { - 'id': key, - 'type': 'Parameter', - 'name': value['title'], - 'observedProperty': { - 'label': { - 'id': key, - 'en': value['title'] - }, - }, - 'unit': { - 'label': { - 'en': value['title'] - }, - 'symbol': { - 'value': value['x-ogc-unit'], - 'type': 'http://www.opengis.net/def/uom/UCUM/' # noqa - } - } - } - - collection['parameter_names'][key].update({ - 'description': value['description']} - if 'description' in value else {} - ) - - for qt in p.get_query_types(): - data_query = { - 'link': { - 'href': f'{api.get_collections_url()}/{k}/{qt}', - 'rel': 'data', - 'variables': { - 'query_type': qt - } - } - } - - if request.format is not None and request.format == 'json': - data_query['link']['type'] = 'application/vnd.cov+json' - - collection['data_queries'][qt] = data_query - - title1 = l10n.translate('query for this collection as JSON', request.locale) # noqa - title1 = f'{qt} {title1}' - title2 = l10n.translate('query for this collection as HTML', request.locale) # noqa - title2 = f'{qt} {title2}' - - collection['links'].append({ - 'type': 'application/json', - 'rel': 'data', - 'title': title1, - 'href': f'{api.get_collections_url()}/{k}/{qt}?f={F_JSON}' - }) - collection['links'].append({ - 'type': FORMAT_TYPES[F_HTML], - 'rel': 'data', - 'title': title2, - 'href': f'{api.get_collections_url()}/{k}/{qt}?f={F_HTML}' - }) - - for key, value in get_dataset_formatters(v).items(): - title3 = f'{qt} query for this collection as {key}' - collection['links'].append({ - 'type': value.mimetype, - 'rel': 'data', - 'title': title3, - 'href': f'{api.get_collections_url()}/{k}/{qt}?f={value.f}' # noqa - }) - - if dataset is not None and k == dataset: - fcm = collection - break - - fcm['collections'].append(collection) + if dataset is not None: + fcm = fcm['collections'][0] if dataset is None: # TODO: translate diff --git a/pygeoapi/api/admin.py b/pygeoapi/api/admin.py index bf485515b..a971e1f25 100644 --- a/pygeoapi/api/admin.py +++ b/pygeoapi/api/admin.py @@ -3,7 +3,7 @@ # Authors: Tom Kralidis # Benjamin Webb # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2023 Benjamin Webb # # Permission is hereby granted, free of charge, to any person @@ -39,8 +39,9 @@ from jsonpatch import make_patch from jsonschema.exceptions import ValidationError -from pygeoapi.api import API, APIRequest, F_HTML +from pygeoapi.api import API, APIRequest from pygeoapi.config import get_config, validate_config +from pygeoapi.formats import F_HTML from pygeoapi.openapi import get_oas from pygeoapi.util import to_json, render_j2_template, yaml_dump diff --git a/pygeoapi/api/collection.py b/pygeoapi/api/collection.py new file mode 100644 index 000000000..2f3c7fea1 --- /dev/null +++ b/pygeoapi/api/collection.py @@ -0,0 +1,475 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# Francesco Bartoli +# Sander Schaminee +# John A Stevenson +# Colin Blackburn +# Ricardo Garcia Silva +# +# Copyright (c) 2026 Tom Kralidis +# Copyright (c) 2026 Francesco Bartoli +# Copyright (c) 2022 John A Stevenson and Colin Blackburn +# Copyright (c) 2023 Ricardo Garcia Silva +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +from copy import deepcopy +import logging + +from pygeoapi import l10n +from pygeoapi.formats import (F_JSON, F_JSONLD, F_HTML, F_JPEG, + F_PNG, FORMAT_TYPES) +from pygeoapi.crs import DEFAULT_STORAGE_CRS, get_supported_crs_list +from pygeoapi.plugin import load_plugin +from pygeoapi.provider import get_provider_by_type, get_provider_default +from pygeoapi.provider.base import ProviderConnectionError, ProviderTypeError +from pygeoapi.util import dategetter, get_dataset_formatters + +LOGGER = logging.getLogger(__name__) + +OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' + + +def gen_collection(api, request, dataset: str, + locale_: str) -> dict: + """ + Generate OGC API Collection description + + :param api: `APIRequest` object + :param dataset: `str` of dataset name + :param locale_: `str` of requested locale + + :returns: `dict` of OGC API Collection description + """ + + config = api.config['resources'][dataset] + + data = { + 'id': dataset, + 'links': [] + } + + collection_data = get_provider_default(config['providers']) + collection_data_type = collection_data['type'] + + collection_data_format = None + + if 'format' in collection_data: + collection_data_format = collection_data['format'] + + is_vector_tile = (collection_data_type == 'tile' and + collection_data_format['name'] not + in [F_PNG, F_JPEG]) + + data.update({ + 'title': l10n.translate(config['title'], locale_), + 'description': l10n.translate(config['description'], locale_), + 'keywords': l10n.translate(config['keywords'], locale_), + }) + + extents = deepcopy(config['extents']) + + bbox = extents['spatial']['bbox'] + LOGGER.debug('Setting spatial extents from configuration') + # The output should be an array of bbox, so if the user only + # provided a single bbox, wrap it in a array. + if not isinstance(bbox[0], list): + bbox = [bbox] + + data['extent'] = { + 'spatial': { + 'bbox': bbox + } + } + + if 'crs' in extents['spatial']: + data['extent']['spatial']['crs'] = extents['spatial']['crs'] + + t_ext = extents.get('temporal', {}) + if t_ext: + LOGGER.debug('Setting temporal extents from configuration') + begins = dategetter('begin', t_ext) + ends = dategetter('end', t_ext) + data['extent']['temporal'] = { + 'interval': [[begins, ends]] + } + if 'trs' in t_ext: + data['extent']['temporal']['trs'] = t_ext['trs'] + if 'resolution' in t_ext: + data['extent']['temporal']['grid'] = { + 'resolution': t_ext['resolution'] + } + if 'default' in t_ext: + data['extent']['temporal']['default'] = t_ext['default'] + + _ = extents.pop('spatial', None) + _ = extents.pop('temporal', None) + + for ek, ev in extents.items(): + LOGGER.debug(f'Adding extent {ek}') + data['extent'][ek] = { + 'definition': ev['url'], + 'interval': [ev['range']] + } + if 'units' in ev: + data['extent'][ek]['unit'] = ev['units'] + + if 'values' in ev: + data['extent'][ek]['grid'] = { + 'cellsCount': len(ev['values']), + 'coordinates': ev['values'] + } + + LOGGER.debug('Processing configured collection links') + for link in l10n.translate(config.get('links', []), locale_): + lnk = { + 'type': link['type'], + 'rel': link['rel'], + 'title': l10n.translate(link['title'], locale_), + 'href': l10n.translate(link['href'], locale_), + } + if 'hreflang' in link: + lnk['hreflang'] = l10n.translate( + link['hreflang'], locale_) + content_length = link.get('length', 0) + + if lnk['rel'] == 'enclosure' and content_length == 0: + # Issue HEAD request for enclosure links without length + lnk_headers = api.prefetcher.get_headers(lnk['href']) + content_length = int(lnk_headers.get('content-length', 0)) + content_type = lnk_headers.get('content-type', lnk['type']) + if content_length == 0: + # Skip this (broken) link + LOGGER.debug(f"Enclosure {lnk['href']} is invalid") + continue + if content_type != lnk['type']: + # Update content type if different from specified + lnk['type'] = content_type + LOGGER.debug( + f"Fixed media type for enclosure {lnk['href']}") + + if content_length > 0: + lnk['length'] = content_length + + data['links'].append(lnk) + + # TODO: provide translations + LOGGER.debug('Adding JSON and HTML link relations') + data['links'].extend([{ + 'type': FORMAT_TYPES[F_JSON], + 'rel': 'root', + 'title': l10n.translate('The landing page of this server as JSON', locale_), # noqa + 'href': f"{api.base_url}?f={F_JSON}" + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': 'root', + 'title': l10n.translate('The landing page of this server as HTML', locale_), # noqa + 'href': f"{api.base_url}?f={F_HTML}" + }, { + 'type': FORMAT_TYPES[F_JSON], + 'rel': request.get_linkrel(F_JSON), + 'title': l10n.translate('This document as JSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_JSONLD], + 'rel': request.get_linkrel(F_JSONLD), + 'title': l10n.translate('This document as RDF (JSON-LD)', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_JSONLD}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': request.get_linkrel(F_HTML), + 'title': l10n.translate('This document as HTML', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_HTML}' + }]) + + if collection_data_type == 'record': + data['links'].extend([{ + 'type': FORMAT_TYPES[F_JSON], + 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', + 'title': l10n.translate('Record catalogue as JSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', + 'title': l10n.translate('Record catalogue as HTML', locale_), + 'href': f'{api.get_collections_url()}/{dataset}?f={F_HTML}' + }]) + + if collection_data_type in ['feature', 'coverage', 'record']: + data['links'].extend([{ + 'type': 'application/schema+json', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': l10n.translate('Schema of collection in JSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/schema?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': l10n.translate('Schema of collection in HTML', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/schema?f={F_HTML}' + }]) + + if is_vector_tile or collection_data_type in ['feature', 'record']: + # TODO: translate + data['itemType'] = collection_data_type + LOGGER.debug('Adding feature/record based links') + data['links'].extend([{ + 'type': 'application/schema+json', + 'rel': f'{OGC_RELTYPES_BASE}/queryables', + 'title': l10n.translate('Queryables for this collection as JSON', locale_), # noqa + 'href': f'{api.get_collections_url()}/{dataset}/queryables?f={F_JSON}' # noqa + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': f'{OGC_RELTYPES_BASE}/queryables', + 'title': l10n.translate('Queryables for this collection as HTML', locale_), # noqa + 'href': f'{api.get_collections_url()}/{dataset}/queryables?f={F_HTML}' # noqa + }, { + 'type': 'application/geo+json', + 'rel': 'items', + 'title': l10n.translate('Items as GeoJSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/items?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_JSONLD], + 'rel': 'items', + 'title': l10n.translate('Items as RDF (GeoJSON-LD)', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/items?f={F_JSONLD}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': 'items', + 'title': l10n.translate('Items as HTML', locale_), # noqa + 'href': f'{api.get_collections_url()}/{dataset}/items?f={F_HTML}' + }]) + + for key, value in get_dataset_formatters(config).items(): + data['links'].append({ + 'type': value.mimetype, + 'rel': 'items', + 'title': l10n.translate(f'Items as {key}', locale_), # noqa + 'href': f'{api.get_collections_url()}/{dataset}/items?f={value.f}' # noqa + }) + + # OAPIF Part 2 - list supported CRSs and StorageCRS + if collection_data_type in ['edr', 'feature']: + data['crs'] = get_supported_crs_list(collection_data) + data['storageCrs'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa + if 'storage_crs_coordinate_epoch' in collection_data: + data['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa + + elif collection_data_type == 'coverage': + LOGGER.debug('Adding coverage based links') + data['links'].append({ + 'type': 'application/prs.coverage+json', + 'rel': f'{OGC_RELTYPES_BASE}/coverage', + 'title': l10n.translate('Coverage data', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/coverage?f={F_JSON}' # noqa + }) + if collection_data_format is not None: + title_ = l10n.translate('Coverage data as', locale_) + title_ = f"{title_} {collection_data_format['name']}" + data['links'].append({ + 'type': collection_data_format['mimetype'], + 'rel': f'{OGC_RELTYPES_BASE}/coverage', + 'title': title_, + 'href': f"{api.get_collections_url()}/{dataset}/coverage?f={collection_data_format['name']}" # noqa + }) + if dataset is not None: + LOGGER.debug('Creating extended coverage metadata') + try: + provider_def = get_provider_by_type( + api.config['resources'][dataset]['providers'], + 'coverage') + p = load_plugin('provider', provider_def) + except ProviderConnectionError: + raise + except ProviderTypeError: + pass + else: + data['extent']['spatial']['grid'] = [{ + 'cellsCount': p._coverage_properties['width'], + 'resolution': p._coverage_properties['resx'] + }, { + 'cellsCount': p._coverage_properties['height'], + 'resolution': p._coverage_properties['resy'] + }] + if 'time_range' in p._coverage_properties: + data['extent']['temporal'] = { + 'interval': [p._coverage_properties['time_range']] + } + if 'restime' in p._coverage_properties: + data['extent']['temporal']['grid'] = { + 'resolution': p._coverage_properties['restime'] + } + if 'uad' in p._coverage_properties: + data['extent'].update(p._coverage_properties['uad']) + + try: + tile = get_provider_by_type(config['providers'], 'tile') + p = load_plugin('provider', tile) + except ProviderConnectionError: + raise + except ProviderTypeError: + tile = None + + if tile: + LOGGER.debug('Adding tile links') + data['links'].extend([{ + 'type': FORMAT_TYPES[F_JSON], + 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', + 'title': l10n.translate('Tiles as JSON', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_JSON}' + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', + 'title': l10n.translate('Tiles as HTML', locale_), + 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_HTML}' + }]) + + try: + map_ = get_provider_by_type(config['providers'], 'map') + p = load_plugin('provider', map_) + except ProviderTypeError: + map_ = None + + if map_: + LOGGER.debug('Adding map links') + + map_mimetype = map_['format']['mimetype'] + map_format = map_['format']['name'] + + title_ = l10n.translate('Map as', locale_) + title_ = f'{title_} {map_format}' + + data['links'].append({ + 'type': map_mimetype, + 'rel': f'{OGC_RELTYPES_BASE}/map', + 'title': title_, + 'href': f'{api.get_collections_url()}/{dataset}/map?f={map_format}' + }) + + if p._fields: + schema_reltype = f'{OGC_RELTYPES_BASE}/schema', + schema_links = [s for s in data['links'] if + schema_reltype in s] + + if not schema_links: + title_ = l10n.translate('Schema of collection in JSON', locale_) # noqa + data['links'].append({ + 'type': 'application/schema+json', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': title_, + 'href': f'{api.get_collections_url()}/{dataset}/schema?f=json' # noqa + }) + title_ = l10n.translate('Schema of collection in HTML', locale_) # noqa + data['links'].append({ + 'type': 'text/html', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': title_, + 'href': f'{api.get_collections_url()}/{dataset}/schema?f=html' # noqa + }) + + try: + edr = get_provider_by_type(config['providers'], 'edr') + p = load_plugin('provider', edr) + except ProviderConnectionError: + raise + except ProviderTypeError: + edr = None + + if edr: + # TODO: translate + LOGGER.debug('Adding EDR links') + data['data_queries'] = {} + parameters = p.get_fields() + if parameters: + data['parameter_names'] = {} + for key, value in parameters.items(): + data['parameter_names'][key] = { + 'id': key, + 'type': 'Parameter', + 'name': value['title'], + 'observedProperty': { + 'label': { + 'id': key, + 'en': value['title'] + }, + }, + 'unit': { + 'label': { + 'en': value['title'] + }, + 'symbol': { + 'value': value['x-ogc-unit'], + 'type': 'http://www.opengis.net/def/uom/UCUM/' + } + } + } + + data['parameter_names'][key].update({ + 'description': value['description']} + if 'description' in value else {} + ) + + for qt in p.get_query_types(): + data_query = { + 'link': { + 'href': f'{api.get_collections_url()}/{dataset}/{qt}', + 'rel': 'data', + 'variables': { + 'query_type': qt + } + } + } + + if request.format is not None and request.format == 'json': + data_query['link']['type'] = 'application/vnd.cov+json' + + data['data_queries'][qt] = data_query + + title1 = l10n.translate('query for this collection as JSON', locale_) # noqa + title1 = f'{qt} {title1}' + title2 = l10n.translate('query for this collection as HTML', locale_) # noqa + title2 = f'{qt} {title2}' + + data['links'].extend([{ + 'type': 'application/json', + 'rel': 'data', + 'title': title1, + 'href': f'{api.get_collections_url()}/{dataset}/{qt}?f={F_JSON}' # noqa + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': 'data', + 'title': title2, + 'href': f'{api.get_collections_url()}/{dataset}/{qt}?f={F_HTML}' # noqa + }]) + + for key, value in get_dataset_formatters(config).items(): + title3 = f'{qt} query for this collection as {key}' + data['links'].append({ + 'type': value.mimetype, + 'rel': 'data', + 'title': title3, + 'href': f'{api.get_collections_url()}/{dataset}/{qt}?f={value.f}' # noqa + }) + + return data diff --git a/pygeoapi/api/coverages.py b/pygeoapi/api/coverages.py index 327744a01..67755407d 100644 --- a/pygeoapi/api/coverages.py +++ b/pygeoapi/api/coverages.py @@ -43,6 +43,7 @@ from typing import Tuple from pygeoapi import l10n +from pygeoapi.formats import F_JSON from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError @@ -50,7 +51,7 @@ from pygeoapi.util import filter_dict_by_key_value, to_json from . import ( - APIRequest, API, F_JSON, SYSTEM_LOCALE, validate_bbox, validate_datetime, + APIRequest, API, SYSTEM_LOCALE, validate_bbox, validate_datetime, validate_subset ) diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py index 7e1ef1f51..6f2ed36bb 100644 --- a/pygeoapi/api/environmental_data_retrieval.py +++ b/pygeoapi/api/environmental_data_retrieval.py @@ -49,6 +49,7 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit +from pygeoapi.formats import F_COVERAGEJSON, F_HTML, F_JSON, F_JSONLD from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.crs import (create_crs_transform_spec, set_content_crs_header) from pygeoapi.openapi import get_oas_30_parameters @@ -60,8 +61,7 @@ render_j2_template, to_json, filter_dict_by_key_value) -from . import (APIRequest, API, F_COVERAGEJSON, F_HTML, F_JSON, F_JSONLD, - validate_datetime, validate_bbox) +from . import APIRequest, API, validate_datetime, validate_bbox LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 2aa85a973..db0a6e6fb 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -54,6 +54,7 @@ create_crs_transform_spec, get_supported_crs_list, modify_pygeofilter, transform_bbox, set_content_crs_header) +from pygeoapi.formats import F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.linked_data import geojson2jsonld from pygeoapi.openapi import get_oas_30_parameters @@ -66,10 +67,7 @@ from pygeoapi.util import (to_json, filter_dict_by_key_value, str2bool, render_j2_template, get_dataset_formatters) -from . import ( - APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD, - validate_bbox, validate_datetime -) +from . import APIRequest, API, SYSTEM_LOCALE, validate_bbox, validate_datetime LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index bcdda4b7a..8712e4c94 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -44,6 +44,7 @@ from typing import Tuple from pygeoapi.crs import transform_bbox +from pygeoapi.formats import F_JSON, FORMAT_TYPES from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider import filter_providers_by_type, get_provider_by_type @@ -52,9 +53,7 @@ ) from pygeoapi.util import to_json, filter_dict_by_key_value -from . import ( - APIRequest, API, F_JSON, FORMAT_TYPES, validate_datetime, validate_subset -) +from . import APIRequest, API, validate_datetime, validate_subset LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index b51ff8531..f39cee69b 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -49,6 +49,7 @@ from typing import Tuple from pygeoapi import l10n +from pygeoapi.formats import FORMAT_TYPES, F_HTML, F_JSON, F_JSONLD from pygeoapi.api import evaluate_limit from pygeoapi.api.pubsub import publish_message from pygeoapi.process.base import ( @@ -61,9 +62,7 @@ json_serial, render_j2_template, JobStatus, RequestedProcessExecutionMode, to_json, DATETIME_FORMAT) -from . import ( - APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD, -) +from . import APIRequest, API, SYSTEM_LOCALE LOGGER = logging.getLogger(__name__) @@ -136,6 +135,7 @@ def describe_processes(api: API, request: APIRequest, p2['jobControlOptions'].append('async-execute') p2['outputTransmission'] = ['value'] + p2['links'] = p2.get('links', []) jobs_url = f"{api.base_url}/jobs" diff --git a/pygeoapi/api/stac.py b/pygeoapi/api/stac.py index a9227da26..ebf86b6e8 100644 --- a/pygeoapi/api/stac.py +++ b/pygeoapi/api/stac.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -47,6 +47,7 @@ from shapely import from_geojson from pygeoapi import l10n +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_HTML from pygeoapi import api as ogc_api from pygeoapi.api import itemtypes as itemtypes_api from pygeoapi.plugin import load_plugin @@ -58,7 +59,7 @@ from pygeoapi.util import (filter_dict_by_key_value, get_current_datetime, render_j2_template, to_json) -from . import APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML +from . import APIRequest, API LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/api/tiles.py b/pygeoapi/api/tiles.py index fb8a39dbb..afdde22b1 100644 --- a/pygeoapi/api/tiles.py +++ b/pygeoapi/api/tiles.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -43,6 +43,7 @@ from typing import Tuple from pygeoapi import l10n +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_HTML, F_JSONLD from pygeoapi.plugin import load_plugin from pygeoapi.models.provider.base import (TilesMetadataFormat, TileMatrixSetEnum) @@ -54,9 +55,7 @@ from pygeoapi.util import to_json, filter_dict_by_key_value, render_j2_template -from . import ( - APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML, SYSTEM_LOCALE, F_JSONLD -) +from . import APIRequest, API, SYSTEM_LOCALE LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/formats.py b/pygeoapi/formats.py new file mode 100644 index 000000000..3ad1f481e --- /dev/null +++ b/pygeoapi/formats.py @@ -0,0 +1,51 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +from collections import OrderedDict + +F_JSON = 'json' +F_COVERAGEJSON = 'json' +F_HTML = 'html' +F_JSONLD = 'jsonld' +F_GZIP = 'gzip' +F_PNG = 'png' +F_JPEG = 'jpeg' +F_MVT = 'mvt' +F_NETCDF = 'NetCDF' + +#: Formats allowed for ?f= requests (order matters for complex MIME types) +FORMAT_TYPES = OrderedDict(( + (F_HTML, 'text/html'), + (F_JSONLD, 'application/ld+json'), + (F_JSON, 'application/json'), + (F_PNG, 'image/png'), + (F_JPEG, 'image/jpeg'), + (F_MVT, 'application/vnd.mapbox-vector-tile'), + (F_NETCDF, 'application/x-netcdf'), +)) diff --git a/pygeoapi/process/manager/mongodb_.py b/pygeoapi/process/manager/mongodb_.py index 44bce6dbe..06e6d909a 100644 --- a/pygeoapi/process/manager/mongodb_.py +++ b/pygeoapi/process/manager/mongodb_.py @@ -1,8 +1,10 @@ # ================================================================= # # Authors: Alexander Pilz +# Tom Kralidis # # Copyright (c) 2023 Alexander Pilz +# Copyright (c) 2026 Alexander Pilz # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -32,11 +34,11 @@ from pymongo import MongoClient -from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.base import ( JobNotFoundError, JobResultNotFoundError, ) +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.manager.base import BaseManager LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/process/manager/postgresql.py b/pygeoapi/process/manager/postgresql.py index 82ed5eb91..05dc408ee 100644 --- a/pygeoapi/process/manager/postgresql.py +++ b/pygeoapi/process/manager/postgresql.py @@ -1,8 +1,10 @@ # ================================================================= # # Authors: Francesco Martinelli +# Tom Kralidis # # Copyright (c) 2024 Francesco Martinelli +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -48,12 +50,12 @@ from sqlalchemy import insert, update, delete from sqlalchemy.orm import Session -from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.base import ( JobNotFoundError, JobResultNotFoundError, ProcessorGenericError ) +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.manager.base import BaseManager from pygeoapi.provider.sql import ( get_engine, get_table_model, store_db_parameters diff --git a/pygeoapi/process/manager/tinydb_.py b/pygeoapi/process/manager/tinydb_.py index b04d29a49..c15e9d36a 100644 --- a/pygeoapi/process/manager/tinydb_.py +++ b/pygeoapi/process/manager/tinydb_.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2022 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -37,7 +37,7 @@ import tinydb from filelock import FileLock -from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD +from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_JSONLD from pygeoapi.process.base import ( JobNotFoundError, JobResultNotFoundError, diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 763fdc77d..bee15c1b5 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -41,11 +41,11 @@ import pytest from pygeoapi.api import ( - API, APIRequest, CONFORMANCE_CLASSES, FORMAT_TYPES, F_HTML, F_JSON, - F_JSONLD, F_GZIP, __version__, validate_bbox, validate_datetime, - evaluate_limit, validate_subset, landing_page, openapi_, conformance, - describe_collections, get_collection_schema, -) + API, APIRequest, CONFORMANCE_CLASSES, __version__, validate_bbox, + validate_datetime, evaluate_limit, validate_subset, landing_page, openapi_, + conformance, describe_collections, get_collection_schema) + +from pygeoapi.formats import FORMAT_TYPES, F_GZIP, F_JSON, F_JSONLD, F_HTML from pygeoapi.util import yaml_load, get_api_rules, get_base_url from tests.util import (get_test_file_path, mock_api_request, mock_flask, @@ -79,6 +79,13 @@ def config_hidden_resources(): return yaml_load(fh) +@pytest.fixture() +def config_failing_collection(): + filename = 'pygeoapi-test-config-failing-collection.yml' + with open(get_test_file_path(filename)) as fh: + return yaml_load(fh) + + @pytest.fixture() def enclosure_api(config_enclosure, openapi): """ Returns an API instance with a collection with enclosure links. """ @@ -98,6 +105,11 @@ def api_hidden_resources(config_hidden_resources, openapi): return API(config_hidden_resources, openapi) +@pytest.fixture() +def api_failing_collection(config_failing_collection, openapi): + return API(config_failing_collection, openapi) + + def test_apirequest(api_): # Test without (valid) locales with pytest.raises(ValueError): @@ -729,6 +741,22 @@ def test_describe_collections_hidden_resources( assert len(collections['collections']) == 1 +def test_describe_collections_failing_collection( + config_failing_collection, api_failing_collection): + req = mock_api_request({}) + rsp_headers, code, response = describe_collections(api_failing_collection, req) # noqa + assert code == HTTPStatus.OK + + assert len(config_failing_collection['resources']) == 3 + + collections = json.loads(response) + assert len(collections['collections']) == 2 + + req = mock_api_request({}) + rsp_headers, code, response = describe_collections(api_failing_collection, req, 'cmip5') # noqa + assert code == HTTPStatus.INTERNAL_SERVER_ERROR + + def test_describe_collections_json_ld(config, api_): req = mock_api_request({'f': 'jsonld'}) rsp_headers, code, response = describe_collections(api_, req, 'obs') diff --git a/tests/api/test_itemtypes.py b/tests/api/test_itemtypes.py index dd3fb9431..e5fceaefc 100644 --- a/tests/api/test_itemtypes.py +++ b/tests/api/test_itemtypes.py @@ -5,7 +5,7 @@ # Colin Blackburn # Francesco Bartoli # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2025 Francesco Bartoli # @@ -42,12 +42,12 @@ import pyproj from shapely.geometry import Point -from pygeoapi.api import (API, FORMAT_TYPES, F_GZIP, F_HTML, F_JSONLD, - apply_gzip) +from pygeoapi.api import API, apply_gzip from pygeoapi.api.itemtypes import ( get_collection_queryables, get_collection_item, get_collection_items, manage_collection_item) from pygeoapi.crs import get_crs +from pygeoapi.formats import FORMAT_TYPES, F_GZIP, F_HTML, F_JSONLD from pygeoapi.util import yaml_load from tests.util import get_test_file_path, mock_api_request diff --git a/tests/api/test_processes.py b/tests/api/test_processes.py index a4bd3794f..e69ecb029 100644 --- a/tests/api/test_processes.py +++ b/tests/api/test_processes.py @@ -5,7 +5,7 @@ # Colin Blackburn # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # # Permission is hereby granted, free of charge, to any person @@ -37,10 +37,10 @@ import time from unittest import mock -from pygeoapi.api import FORMAT_TYPES, F_HTML, F_JSON from pygeoapi.api.processes import ( describe_processes, execute_process, delete_job, get_job_result, get_jobs ) +from pygeoapi.formats import FORMAT_TYPES, F_HTML, F_JSON from tests.util import mock_api_request diff --git a/tests/api/test_stac.py b/tests/api/test_stac.py index dea4de7bb..1da63919b 100644 --- a/tests/api/test_stac.py +++ b/tests/api/test_stac.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -31,8 +31,8 @@ import pytest -from pygeoapi.api import FORMAT_TYPES, F_JSON from pygeoapi.api.stac import search, landing_page +from pygeoapi.formats import FORMAT_TYPES, F_JSON from pygeoapi.util import yaml_load from tests.util import get_test_file_path, mock_api_request diff --git a/tests/api/test_tiles.py b/tests/api/test_tiles.py index d804f6a21..c463abf10 100644 --- a/tests/api/test_tiles.py +++ b/tests/api/test_tiles.py @@ -5,7 +5,7 @@ # Colin Blackburn # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2025 Joana Simoes # @@ -37,12 +37,12 @@ from http import HTTPStatus import pytest -from pygeoapi.api import FORMAT_TYPES, F_HTML from pygeoapi.api.tiles import ( get_collection_tiles, tilematrixset, tilematrixsets, get_collection_tiles_metadata, get_collection_tiles_data ) +from pygeoapi.formats import FORMAT_TYPES, F_HTML from pygeoapi.models.provider.base import TileMatrixSetEnum from tests.util import mock_api_request diff --git a/tests/pygeoapi-test-config-failing-collection.yml b/tests/pygeoapi-test-config-failing-collection.yml new file mode 100644 index 000000000..8baddbd90 --- /dev/null +++ b/tests/pygeoapi-test-config-failing-collection.yml @@ -0,0 +1,206 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 5000 + url: http://localhost:5000/ + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + gzip: false + languages: + # First language is the default language + - en-US + - fr-CA + cors: true + pretty_print: true + limits: + default_items: 10 + max_items: 10 + # templates: /path/to/templates + map: + url: https://tile.openstreetmap.org/{z}/{x}/{y}.png + attribution: '© OpenStreetMap contributors' + manager: + name: TinyDB + connection: /tmp/pygeoapi-test-process-manager.db + output_dir: /tmp + +logging: + level: DEBUG + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales + keywords: + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: http://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Organization Name + url: https://pygeoapi.io + contact: + name: Lastname, Firstname + position: Position Title + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Country + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: + obs: + type: collection + title: + en: Observations + fr: Observations + description: + en: My cool observations + fr: Mes belles observations + keywords: + - observations + - monitoring + links: + - type: text/csv + rel: canonical + title: data + href: https://github.com/mapserver/mapserver/blob/branch-7-0/msautotest/wxs/data/obs.csv + hreflang: en-US + - type: text/csv + rel: alternate + title: data + href: https://raw.githubusercontent.com/mapserver/mapserver/branch-7-0/msautotest/wxs/data/obs.csv + hreflang: en-US + linked-data: + context: + - schema: https://schema.org/ + stn_id: + "@id": schema:identifier + "@type": schema:Text + datetime: + "@type": schema:DateTime + "@id": schema:observationDate + value: + "@type": schema:Number + "@id": schema:QuantitativeValue + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: 2000-10-30T18:24:39Z + end: 2007-10-30T08:57:29Z + trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + providers: + - type: feature + name: CSV + data: tests/data/obs.csv + id_field: id + geometry: + x_field: long + y_field: lat + + cmip5: + type: collection + title: CMIP5 sample + description: CMIP5 sample + keywords: + - cmip5 + - climate + extents: + spatial: + bbox: [-150,40,-45,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + links: + - type: text/html + rel: canonical + title: information + href: https://open.canada.ca/data/en/dataset/eddd6eaf-34d7-4452-a994-3d928115a68b + hreflang: en-CA + providers: + - type: coverage + name: xarray + data: tests/data/CMIP5_rcp8.5_annual_abs_latlon1x1_PCP_pctl25_P1Y.nc404 + x_field: lon + y_field: lat + time_field: time + format: + name: NetCDF + mimetype: application/x-netcdf + + objects: + type: collection + title: GeoJSON objects + description: GeoJSON geometry types for GeoSparql and Schema Geometry conversion. + keywords: + - shapes + links: + - type: text/html + rel: canonical + title: data source + href: https://en.wikipedia.org/wiki/GeoJSON + hreflang: en-US + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: null + end: null # or empty (either means open ended) + providers: + - type: feature + name: GeoJSON + data: tests/data/items.geojson + id_field: fid + uri_field: uri From ff8465c91ce749f7fe5a4ad7940fe9c77925975b Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 5 Mar 2026 10:08:44 -0500 Subject: [PATCH 14/53] OAProc: derive process execution mode OpenAPI prefer option from process metadata (#2272) --- pygeoapi/api/processes.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index f39cee69b..9b506d0d3 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -760,7 +760,7 @@ def get_oas_30(cfg: dict, locale: str 'description': 'Indicates client preferences, including whether the client is capable of asynchronous processing.', # noqa 'schema': { 'type': 'string', - 'enum': ['respond-async'] + 'enum': [] } }], 'responses': { @@ -784,6 +784,12 @@ def get_oas_30(cfg: dict, locale: str } } + jco = p.metadata.get('jobControlOptions', ['sync-execute']) + if 'sync-execute' in jco: + paths[f'{process_name_path}/execution']['post']['parameters'][0]['schema']['enum'].append('respond-sync') # noqa + if 'async-execute' in jco: + paths[f'{process_name_path}/execution']['post']['parameters'][0]['schema']['enum'].append('respond-async') # noqa + try: first_key = list(p.metadata['outputs'])[0] p_output = p.metadata['outputs'][first_key] From 523c4bd6e4eadaad00806c098433418b2d721175 Mon Sep 17 00:00:00 2001 From: Angelos Tzotsos Date: Fri, 6 Mar 2026 14:07:06 +0200 Subject: [PATCH 15/53] Use the latest Ubuntu Noble base image (#2275) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d8947cbea..833f7a3ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,7 @@ # # ================================================================= -FROM ubuntu:noble-20260113 +FROM ubuntu:noble LABEL maintainer="Just van den Broecke " From 7ff27c13535a78c034a9569d92361074f02ed2cf Mon Sep 17 00:00:00 2001 From: Angelos Tzotsos Date: Fri, 6 Mar 2026 14:48:26 +0200 Subject: [PATCH 16/53] update release version --- docs/source/conf.py | 2 +- pygeoapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index a3f0ca7ed..1d4437193 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -112,7 +112,7 @@ def __getattr__(cls, name): # built documents. # # The short X.Y version. -version = '0.23.dev0' +version = '0.23.0' # The full version, including alpha/beta/rc tags. release = version diff --git a/pygeoapi/__init__.py b/pygeoapi/__init__.py index 30f235f00..96899ed1a 100644 --- a/pygeoapi/__init__.py +++ b/pygeoapi/__init__.py @@ -30,7 +30,7 @@ # # ================================================================= -__version__ = '0.23.dev0' +__version__ = '0.23.0' import click try: From 3764b1a643e22ddcdac32e23ef4af110394223c4 Mon Sep 17 00:00:00 2001 From: Angelos Tzotsos Date: Fri, 6 Mar 2026 15:20:21 +0200 Subject: [PATCH 17/53] back to dev --- docs/source/conf.py | 2 +- pygeoapi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1d4437193..1b96113a2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -112,7 +112,7 @@ def __getattr__(cls, name): # built documents. # # The short X.Y version. -version = '0.23.0' +version = '0.24.dev0' # The full version, including alpha/beta/rc tags. release = version diff --git a/pygeoapi/__init__.py b/pygeoapi/__init__.py index 96899ed1a..e906bc2c3 100644 --- a/pygeoapi/__init__.py +++ b/pygeoapi/__init__.py @@ -30,7 +30,7 @@ # # ================================================================= -__version__ = '0.23.0' +__version__ = '0.24.dev0' import click try: From 49ed4cc46e12d255a276b05342f49d5bc0b9723c Mon Sep 17 00:00:00 2001 From: Jeff McKenna Date: Fri, 6 Mar 2026 15:34:49 -0400 Subject: [PATCH 18/53] update Security Policy (#2277) * update security policy * update security policy --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ec9a04f14..87520abc4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,5 +13,5 @@ The pygeoapi Project Steering Committee (PSC) will release patches for security | Version | Supported | | ------- | ------------------ | -| 0.10.x | :white_check_mark: | -| < 0.10 | :x: | +| 0.2x | :white_check_mark: | +| < 0.20 | :x: | From ed2b416d747934bef02596de752a2f9dda7da34f Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Fri, 6 Mar 2026 15:57:53 -0500 Subject: [PATCH 19/53] fix openapi generate example in docs (#2276) (#2278) --- docs/source/administration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/administration.rst b/docs/source/administration.rst index 9fe541f3a..c947365b3 100644 --- a/docs/source/administration.rst +++ b/docs/source/administration.rst @@ -32,7 +32,7 @@ To generate the OpenAPI document as JSON, run: .. code-block:: bash - pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --format json --output-file /path/to/my-pygeoapi-openapi.yml + pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --format json --output-file /path/to/my-pygeoapi-openapi.json .. note:: Generate as YAML or JSON? If your OpenAPI YAML definition is slow to render as JSON, From 8f688a79f89fcf129a6f1cd389a40c87ab90ebad Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Fri, 6 Mar 2026 16:00:21 -0500 Subject: [PATCH 20/53] OAProc: derive jobControlOptions and outputTransmission from plugin (#2257) (#2274) --- docs/source/plugins.rst | 1 + pygeoapi/api/processes.py | 10 +- tests/api/test_processes.py | 39 ++++++- .../pygeoapi-test-config-process-metadata.yml | 106 ++++++++++++++++++ 4 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 tests/pygeoapi-test-config-process-metadata.yml diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 4d7e52e59..4b9a86922 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -410,6 +410,7 @@ Below is a sample process definition as a Python dictionary: 'it back as output. Intended to demonstrate a simple ' 'process with a single literal input.', 'jobControlOptions': ['sync-execute', 'async-execute'], # whether the process can be executed in sync or async mode + 'outputTransmission': ['value', 'reference'], # whether the process can return inline data or URL references 'keywords': ['hello world', 'example', 'echo'], # keywords associated with the process 'links': [{ # a list of 1..n # link objects relevant to the process 'type': 'text/html', diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index 9b506d0d3..a3167cb7e 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -130,11 +130,15 @@ def describe_processes(api: API, request: APIRequest, p2.pop('outputs') p2.pop('example', None) - p2['jobControlOptions'] = ['sync-execute'] - if api.manager.is_async: + jco = p.metadata.get('jobControlOptions', ['sync-execute']) + p2['jobControlOptions'] = jco + + if api.manager.is_async and 'async-execute' not in jco: + LOGGER.debug('Adding async capability') p2['jobControlOptions'].append('async-execute') - p2['outputTransmission'] = ['value'] + p2['outputTransmission'] = p.metadata.get( + 'outputTransmission', ['value']) p2['links'] = p2.get('links', []) diff --git a/tests/api/test_processes.py b/tests/api/test_processes.py index e69ecb029..9d837747c 100644 --- a/tests/api/test_processes.py +++ b/tests/api/test_processes.py @@ -37,12 +37,28 @@ import time from unittest import mock +import pytest + +from pygeoapi.api import API from pygeoapi.api.processes import ( describe_processes, execute_process, delete_job, get_job_result, get_jobs ) from pygeoapi.formats import FORMAT_TYPES, F_HTML, F_JSON +from pygeoapi.util import yaml_load + +from tests.util import get_test_file_path, mock_api_request + -from tests.util import mock_api_request +@pytest.fixture() +def config_process_metadata() -> dict: + """ Returns a pygeoapi configuration with process metadata.""" + with open(get_test_file_path('pygeoapi-test-config-process-metadata.yml')) as fh: # noqa + return yaml_load(fh) + + +@pytest.fixture() +def api_process_metadata(config_process_metadata, openapi): + return API(config_process_metadata, openapi) def test_describe_processes(config, api_): @@ -143,8 +159,8 @@ def test_describe_processes(config, api_): # Test describe doesn't crash if example is missing req = mock_api_request() - processor = api_.manager.get_processor("hello-world") - example = processor.metadata.pop("example") + processor = api_.manager.get_processor('hello-world') + example = processor.metadata.pop('example') rsp_headers, code, response = describe_processes(api_, req) processor.metadata['example'] = example data = json.loads(response) @@ -152,6 +168,23 @@ def test_describe_processes(config, api_): assert len(data['processes']) == 2 +def test_describe_processes_metadata(config_process_metadata, + api_process_metadata): + + req = mock_api_request({'limit': 1}) + # Test for description of single processes + rsp_headers, code, response = describe_processes( + api_process_metadata, req, 'echo') + data = json.loads(response) + assert code == HTTPStatus.OK + assert len(data['jobControlOptions']) == 2 + assert 'sync-execute' in data['jobControlOptions'] + assert 'async-execute' in data['jobControlOptions'] + assert len(data['outputTransmission']) == 2 + assert 'value' in data['outputTransmission'] + assert 'reference' in data['outputTransmission'] + + def test_execute_process(config, api_): req_body_0 = { 'inputs': { diff --git a/tests/pygeoapi-test-config-process-metadata.yml b/tests/pygeoapi-test-config-process-metadata.yml new file mode 100644 index 000000000..1d8ddb8fd --- /dev/null +++ b/tests/pygeoapi-test-config-process-metadata.yml @@ -0,0 +1,106 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2026 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 5000 + url: http://localhost:5000/ + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + gzip: false + languages: + # First language is the default language + - en-US + - fr-CA + cors: true + pretty_print: true + limits: + default_items: 10 + max_items: 10 + # templates: /path/to/templates + map: + url: https://tile.openstreetmap.org/{z}/{x}/{y}.png + attribution: '© OpenStreetMap contributors' + manager: + name: TinyDB + connection: /tmp/pygeoapi-test-process-manager.db + output_dir: /tmp + +logging: + level: DEBUG + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales + keywords: + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: http://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Organization Name + url: https://pygeoapi.io + contact: + name: Lastname, Firstname + position: Position Title + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Country + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: + echo: + type: process + processor: + name: Echo From 16736b22ebb7dc1da01a68391ef16f4d6f404654 Mon Sep 17 00:00:00 2001 From: Colton Loftus <70598503+C-Loftus@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:50:36 -0400 Subject: [PATCH 21/53] Geoparquet 1.1 spec compliance update (#2271) * working with geoparquet * tests * flake8 * single quotes * tweak single quptes * tweak single quotes * fix typo * revert log message * single quotes * remove dupe log * fix flake8 * fix failing test * response to feedback --- pygeoapi/provider/parquet.py | 263 ++++++++--- .../data-polygon-encoding_wkb_no_bbox.parquet | Bin 0 -> 1861 bytes .../geoparquet1.1/nyc_subset_overture.parquet | Bin 0 -> 145381 bytes tests/data/{ => parquet/naive}/random.parquet | Bin .../{ => parquet/naive}/random_nocrs.parquet | Bin .../{ => parquet/naive}/random_nogeom.parquet | Bin tests/provider/test_filesystem_provider.py | 2 +- tests/provider/test_parquet_provider.py | 430 +++++++++++------- 8 files changed, 460 insertions(+), 235 deletions(-) create mode 100644 tests/data/parquet/geoparquet1.1/data-polygon-encoding_wkb_no_bbox.parquet create mode 100644 tests/data/parquet/geoparquet1.1/nyc_subset_overture.parquet rename tests/data/{ => parquet/naive}/random.parquet (100%) rename tests/data/{ => parquet/naive}/random_nocrs.parquet (100%) rename tests/data/{ => parquet/naive}/random_nogeom.parquet (100%) diff --git a/pygeoapi/provider/parquet.py b/pygeoapi/provider/parquet.py index 0f4ab3de1..8d69e9940 100644 --- a/pygeoapi/provider/parquet.py +++ b/pygeoapi/provider/parquet.py @@ -1,8 +1,10 @@ # ================================================================= # # Authors: Leo Ghignone +# Colton Loftus # -# Copyright (c) 2024 Leo Ghignone +# Copyright (c) 2026 Leo Ghignone +# Copyright (c) 2026 Colton Loftus # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -36,6 +38,7 @@ import pyarrow import pyarrow.compute as pc import pyarrow.dataset +import pyarrow.types as pat import s3fs from pygeoapi.crs import crs_transform @@ -60,7 +63,41 @@ def arrow_to_pandas_type(arrow_type): return pd_type +def has_geoparquet_bbox_column( + pyarrow_geo_metadata: dict, primary_geometry_column_name: str +) -> bool: + """ + Check if the metadata on the parquet dataset + indicates there is a geoparquet bbox column + + :param pyarrow_geo_metadata: dict serialized version of the 'geo' + key within the pyarrow metadata json + :param primary_geometry_column_name: name of the primary geometry column + where the geometry is stored as specified in the 'geo' metadata + + :returns: bool whether or not the dataset has a geoparquet bbox column + """ + primary_column = pyarrow_geo_metadata.get('primary_column') + if primary_column is None: + return False + + columns = pyarrow_geo_metadata.get('columns') + if columns is None: + return False + + geometry_column_metadata = columns.get(primary_geometry_column_name) + if geometry_column_metadata is None: + return False + + geometry_covering = geometry_column_metadata.get('covering') + if geometry_covering is None: + return False + + return geometry_covering.get('bbox') is not None + + class ParquetProvider(BaseProvider): + def __init__(self, provider_def): """ Initialize object @@ -85,48 +122,107 @@ def __init__(self, provider_def): # Source url is required self.source = self.data.get('source') if not self.source: - msg = "Need explicit 'source' attr " \ - "in data field of provider config" + msg = 'Need explicit "source" attr in data' \ + ' field of provider config' LOGGER.error(msg) - raise Exception(msg) + raise ProviderGenericError(msg) # Manage AWS S3 sources if self.source.startswith('s3'): self.source = self.source.split('://', 1)[1] self.fs = s3fs.S3FileSystem(default_cache_type='none') else: + # If none, pyarrow will attempt to auto-detect self.fs = None # Build pyarrow dataset pointing to the data self.ds = pyarrow.dataset.dataset(self.source, filesystem=self.fs) + if not self.id_field: + LOGGER.info( + 'No "id_field" specified in parquet provider config' + ' will use pandas index as the identifier' + ) + else: + id_type = self.ds.schema.field(self.id_field).type + if ( + pat.is_integer(id_type) + or pat.is_decimal(id_type) + or pat.is_float_value(id_type) + ): + LOGGER.warning( + f'id_field is of type {id_type},' + ' and not numeric; this is harder to query and' + ' may cause slow full scans' + ) + LOGGER.debug('Grabbing field information') self.get_fields() # Must be set to visualise queryables - # Column names for bounding box data. - if None in [self.x_field, self.y_field]: + # Get the CRS of the data + if b'geo' in self.ds.schema.metadata: + geo_metadata = json.loads(self.ds.schema.metadata[b'geo']) + + geom_column = geo_metadata['primary_column'] + + if geom_column: + self.has_geometry = True + + # if the CRS is not set default to EPSG:4326, per geoparquet spec + self.crs = geo_metadata['columns'][geom_column].get('crs') \ + or 'OGC:CRS84' + + # self.bbox_filterable indicates whether or not + # we can resolve a bbox request + # against the data, either by using an explicit + # bbox column or by using x_field and y_field + # columns + self.bbox_filterable = \ + has_geoparquet_bbox_column(geo_metadata, geom_column) + if self.bbox_filterable: + # Whether or not the data has the geoparquet + # standardized bbox column + self.has_bbox_column = True + # if there is a bbox column we + # don't need to parse the x_fields and y_fields + # and can just return early + return + else: + self.has_bbox_column = False + else: self.has_geometry = False + self.has_bbox_column = False + + for field_name, field_value in [ + ('x_field', self.x_field), + ('y_field', self.y_field) + ]: + if not field_value: + LOGGER.warning( + f'No geometry for {self.source};' + f'missing {field_name} in parquet provider config' + ) + self.bbox_filterable = False + self.has_bbox_column = False + return + + # If there is not a geoparquet bbox column, + # then we fall back to reading fields for minx, maxx, miny, maxy + # as direct column names; these can be set and use regardless of + # whether or not there is 'geo' metadata + if isinstance(self.x_field, str): + self.minx = self.x_field + self.maxx = self.x_field else: - self.has_geometry = True - if isinstance(self.x_field, str): - self.minx = self.x_field - self.maxx = self.x_field - else: - self.minx, self.maxx = self.x_field + self.minx, self.maxx = self.x_field - if isinstance(self.y_field, str): - self.miny = self.y_field - self.maxy = self.y_field - else: - self.miny, self.maxy = self.y_field - self.bb = [self.minx, self.miny, self.maxx, self.maxy] + if isinstance(self.y_field, str): + self.miny = self.y_field + self.maxy = self.y_field + else: + self.miny, self.maxy = self.y_field - # Get the CRS of the data - geo_metadata = json.loads(self.ds.schema.metadata[b'geo']) - geom_column = geo_metadata['primary_column'] - # if the CRS is not set default to EPSG:4326, per geoparquet spec - self.crs = (geo_metadata['columns'][geom_column].get('crs') - or 'OGC:CRS84') + self.bbox_filterable = True def _read_parquet(self, return_scanner=False, **kwargs): """ @@ -134,7 +230,10 @@ def _read_parquet(self, return_scanner=False, **kwargs): :returns: generator of RecordBatch with the queried values """ - scanner = pyarrow.dataset.Scanner.from_dataset(self.ds, **kwargs) + scanner = self.ds.scanner( + use_threads=True, + **kwargs + ) batches = scanner.to_batches() if return_scanner: return batches, scanner @@ -149,12 +248,19 @@ def get_fields(self): """ if not self._fields: - - for field_name, field_type in zip(self.ds.schema.names, - self.ds.schema.types): + for field_name, field_type in zip( + self.ds.schema.names, self.ds.schema.types + ): # Geometry is managed as a special case by pygeoapi if field_name == 'geometry': continue + # if we find the geoparquet bbox column and the + # type is a struct of any type, either double or + # float, then we skip it since it isn't + # meant to be a queryable field, rather just metadata + if field_name == 'bbox' and 'struct' in str(field_type): + self.bbox_filterable = True + continue field_type = str(field_type) converted_type = None @@ -213,28 +319,44 @@ def query( :returns: dict of 0..n GeoJSON features """ - result = None try: - filter = pc.scalar(True) + filter_ = pc.scalar(True) + if bbox: - if self.has_geometry is False: - msg = ( - 'Dataset does not have a geometry field, ' - 'querying by bbox is not supported.' + if not self.has_geometry: + raise ProviderQueryError( + ( + 'Dataset does not have a geometry field, ' + 'querying by bbox is not supported.' + ) + ) + + if not self.bbox_filterable: + raise ProviderQueryError( + ( + 'Dataset does not have a proper bbox metadata, ' + 'querying by bbox is not supported.' + ) ) - raise ProviderQueryError(msg) - LOGGER.debug('processing bbox parameter') - if any(b is None for b in bbox): - msg = 'Dataset does not support bbox filtering' - raise ProviderQueryError(msg) minx, miny, maxx, maxy = [float(b) for b in bbox] - filter = ( - (pc.field(self.minx) > pc.scalar(minx)) - & (pc.field(self.miny) > pc.scalar(miny)) - & (pc.field(self.maxx) < pc.scalar(maxx)) - & (pc.field(self.maxy) < pc.scalar(maxy)) - ) + + if self.has_bbox_column: + # GeoParquet bbox column is a struct + # with xmin, ymin, xmax, ymax + filter_ = filter_ & ( + (pc.field('bbox', 'xmin') >= pc.scalar(minx)) + & (pc.field('bbox', 'ymin') >= pc.scalar(miny)) + & (pc.field('bbox', 'xmax') <= pc.scalar(maxx)) + & (pc.field('bbox', 'ymax') <= pc.scalar(maxy)) + ) + else: + filter_ = ( + (pc.field(self.minx) >= pc.scalar(minx)) + & (pc.field(self.miny) >= pc.scalar(miny)) + & (pc.field(self.maxx) <= pc.scalar(maxx)) + & (pc.field(self.maxy) <= pc.scalar(maxy)) + ) if datetime_ is not None: if self.time_field is None: @@ -248,13 +370,13 @@ def query( begin, end = datetime_.split('/') if begin != '..': begin = isoparse(begin) - filter = filter & (timefield >= begin) + filter_ = filter_ & (timefield >= begin) if end != '..': end = isoparse(end) - filter = filter & (timefield <= end) + filter_ = filter_ & (timefield <= end) else: target_time = isoparse(datetime_) - filter = filter & (timefield == target_time) + filter_ = filter_ & (timefield == target_time) if properties: LOGGER.debug('processing properties') @@ -263,7 +385,7 @@ def query( pd_type = arrow_to_pandas_type(field.type) expr = pc.field(name) == pc.scalar(pd_type(value)) - filter = filter & expr + filter_ = filter_ & expr if len(select_properties) == 0: select_properties = self.ds.schema.names @@ -279,11 +401,11 @@ def query( # Make response based on resulttype specified if resulttype == 'hits': LOGGER.debug('hits only specified') - result = self._response_feature_hits(filter) + return self._response_feature_hits(filter_) elif resulttype == 'results': LOGGER.debug('results specified') - result = self._response_feature_collection( - filter, offset, limit, columns=select_properties + return self._response_feature_collection( + filter_, offset, limit, columns=select_properties ) else: LOGGER.error(f'Invalid resulttype: {resulttype}') @@ -298,8 +420,6 @@ def query( LOGGER.error(err) raise ProviderGenericError(err) - return result - @crs_transform def get(self, identifier, **kwargs): """ @@ -309,22 +429,22 @@ def get(self, identifier, **kwargs): :returns: a single feature """ - result = None try: LOGGER.debug(f'Fetching identifier {identifier}') id_type = arrow_to_pandas_type( - self.ds.schema.field(self.id_field).type) + self.ds.schema.field(self.id_field).type + ) batches = self._read_parquet( filter=( - pc.field(self.id_field) == pc.scalar(id_type(identifier)) - ) + pc.field(self.id_field) == pc.scalar(id_type(identifier) + )) ) for batch in batches: if batch.num_rows > 0: - assert ( - batch.num_rows == 1 - ), f'Multiple items found with ID {identifier}' + assert batch.num_rows == 1, ( + f'Multiple items found with ID {identifier}' + ) row = batch.to_pandas() break else: @@ -335,10 +455,14 @@ def get(self, identifier, **kwargs): else: geom = [None] gdf = gpd.GeoDataFrame(row, geometry=geom) + # If there is an id field, set it as index + # instead of the default numeric index + if self.id_field in gdf.columns: + gdf = gdf.set_index(self.id_field, drop=False) LOGGER.debug('results computed') # Grab the collection from geopandas geo_interface - result = gdf.__geo_interface__['features'][0] + return gdf.__geo_interface__['features'][0] except RuntimeError as err: LOGGER.error(err) @@ -353,13 +477,11 @@ def get(self, identifier, **kwargs): LOGGER.error(err) raise ProviderGenericError(err) - return result - def __repr__(self): return f' {self.data}' - def _response_feature_collection(self, filter, offset, limit, - columns=None): + def _response_feature_collection(self, filter, offset, + limit, columns=None): """ Assembles output from query as GeoJSON FeatureCollection structure. @@ -426,6 +548,10 @@ def _response_feature_collection(self, filter, offset, limit, geom = gpd.GeoSeries.from_wkb(rp['geometry'], crs=self.crs) gdf = gpd.GeoDataFrame(rp, geometry=geom) + # If there is an id_field in the data, set it as index + # instead of the default numerical index + if self.id_field in gdf.columns: + gdf = gdf.set_index(self.id_field, drop=False) LOGGER.debug('results computed') result = gdf.__geo_interface__ @@ -446,8 +572,9 @@ def _response_feature_hits(self, filter): """ try: - scanner = pyarrow.dataset.Scanner.from_dataset(self.ds, - filter=filter) + scanner = pyarrow.dataset.Scanner.from_dataset( + self.ds, filter=filter + ) return { 'type': 'FeatureCollection', 'numberMatched': scanner.count_rows(), diff --git a/tests/data/parquet/geoparquet1.1/data-polygon-encoding_wkb_no_bbox.parquet b/tests/data/parquet/geoparquet1.1/data-polygon-encoding_wkb_no_bbox.parquet new file mode 100644 index 0000000000000000000000000000000000000000..cce77baad2acf019d9d89d6509076f14996e6692 GIT binary patch literal 1861 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


oXXe4%Bf;S?#CeMO(stW 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 literal 0 HcmV?d00001 diff --git a/tests/data/random.parquet b/tests/data/parquet/naive/random.parquet similarity index 100% rename from tests/data/random.parquet rename to tests/data/parquet/naive/random.parquet diff --git a/tests/data/random_nocrs.parquet b/tests/data/parquet/naive/random_nocrs.parquet similarity index 100% rename from tests/data/random_nocrs.parquet rename to tests/data/parquet/naive/random_nocrs.parquet diff --git a/tests/data/random_nogeom.parquet b/tests/data/parquet/naive/random_nogeom.parquet similarity index 100% rename from tests/data/random_nogeom.parquet rename to tests/data/parquet/naive/random_nogeom.parquet diff --git a/tests/provider/test_filesystem_provider.py b/tests/provider/test_filesystem_provider.py index 824c23eb9..f1cfffcf0 100644 --- a/tests/provider/test_filesystem_provider.py +++ b/tests/provider/test_filesystem_provider.py @@ -54,7 +54,7 @@ def test_query(config): r = p.get_data_path(baseurl, urlpath, dirpath) - assert len(r['links']) == 13 + assert len(r['links']) == 14 r = p.get_data_path(baseurl, urlpath, '/poi_portugal') diff --git a/tests/provider/test_parquet_provider.py b/tests/provider/test_parquet_provider.py index 736e3dff4..6d45a51d7 100644 --- a/tests/provider/test_parquet_provider.py +++ b/tests/provider/test_parquet_provider.py @@ -5,6 +5,7 @@ # # Copyright (c) 2024 Leo Ghignone # Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Colton Loftus # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -29,6 +30,8 @@ # # ================================================================= +from copy import copy + import pytest from pygeoapi.provider.base import ProviderItemNotFoundError @@ -36,15 +39,6 @@ from ..util import get_test_file_path -path = get_test_file_path( - 'data/random.parquet') - -path_nogeom = get_test_file_path( - 'data/random_nogeom.parquet') - -path_nocrs = get_test_file_path( - 'data/random_nocrs.parquet') - @pytest.fixture() def config_parquet(): @@ -52,13 +46,12 @@ def config_parquet(): 'name': 'Parquet', 'type': 'feature', 'data': { - 'source_type': 'Parquet', - 'source': path, + 'source': get_test_file_path('data/parquet/naive/random.parquet'), }, 'id_field': 'id', 'time_field': 'time', 'x_field': 'lon', - 'y_field': 'lat', + 'y_field': 'lat' } @@ -68,8 +61,8 @@ def config_parquet_nogeom_notime(): 'name': 'ParquetNoGeomNoTime', 'type': 'feature', 'data': { - 'source_type': 'Parquet', - 'source': path_nogeom, + 'source': get_test_file_path( + 'data/parquet/naive/random_nogeom.parquet') }, 'id_field': 'id' } @@ -81,162 +74,267 @@ def config_parquet_nocrs(): 'name': 'ParquetNoCrs', 'type': 'feature', 'data': { - 'source_type': 'Parquet', - 'source': path_nocrs, + 'source': get_test_file_path( + 'data/parquet/naive/random_nocrs.parquet') }, 'id_field': 'id', 'time_field': 'time', 'x_field': 'lon', - 'y_field': 'lat', + 'y_field': 'lat' + } + + +@pytest.fixture +def geoparquet_no_bbox(): + # Data originating from + # https://github.com/opengeospatial/geoparquet/blob/main/test_data/data-polygon-encoding_wkb.parquet + + # As CSV: + # "col","geometry" + # 0,"POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))" + # 1,"POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10), (20 30, 35 35, 30 20, 20 30))" # noqa + # 2,"POLYGON EMPTY" + # 3, + return { + 'name': 'GeoparquetNoBbox', + 'type': 'feature', + 'data': { + 'source': get_test_file_path( + 'data/parquet/geoparquet1.1/data-polygon-encoding_wkb_no_bbox.parquet' # noqa + ) + } + } + + +@pytest.fixture +def geoparquet_with_bbox(): + # Geneated with the overture python CLI + # overturemaps download --bbox=-74,40.98,-73.98,41 -f geoparquet --type=building -o nyc_subset_overture.parquet # noqa + return { + 'name': 'GeoparquetWithBbox', + 'type': 'feature', + 'data': { + 'source': get_test_file_path( + 'data/parquet/geoparquet1.1/nyc_subset_overture.parquet' + ) + } } -def test_get_fields(config_parquet): - """Testing field types""" - - p = ParquetProvider(config_parquet) - results = p.get_fields() - assert results['lat']['type'] == 'number' - assert results['lon']['format'] == 'double' - assert results['time']['format'] == 'date-time' - - -def test_get(config_parquet): - """Testing query for a specific object""" - - p = ParquetProvider(config_parquet) - result = p.get('42') - assert result['id'] == '42' - assert result['properties']['lon'] == 4.947447 - - -def test_get_not_existing_feature_raise_exception( - config_parquet -): - """Testing query for a not existing object""" - p = ParquetProvider(config_parquet) - with pytest.raises(ProviderItemNotFoundError): - p.get(-1) - - -def test_query_hits(config_parquet): - """Testing query on entire collection for hits""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query(resulttype='hits') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 0 - hits = feature_collection.get('numberMatched') - assert hits is not None - assert hits == 100 - - -def test_query_bbox_hits(config_parquet): - """Testing query for a valid JSON object with geometry""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query( - bbox=[100, -50, 150, 0], - resulttype='hits') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 0 - hits = feature_collection.get('numberMatched') - assert hits is not None - assert hits == 6 - - -def test_query_with_limit(config_parquet): - """Testing query for a valid JSON object with geometry""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query(limit=2, resulttype='results') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 2 - hits = feature_collection.get('numberMatched') - assert hits > 2 - feature = features[0] - properties = feature.get('properties') - assert properties is not None - geometry = feature.get('geometry') - assert geometry is not None - - -def test_query_with_offset(config_parquet): - """Testing query for a valid JSON object with geometry""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query(offset=20, limit=10, resulttype='results') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 10 - hits = feature_collection.get('numberMatched') - assert hits > 30 - feature = features[0] - properties = feature.get('properties') - assert properties is not None - assert feature['id'] == '21' - assert properties['lat'] == 66.264988 - geometry = feature.get('geometry') - assert geometry is not None - - -def test_query_with_property(config_parquet): - """Testing query for a valid JSON object with property filter""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query( - resulttype='results', - properties=[('lon', -12.855022)]) - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 1 - for feature in features: - assert feature['properties']['lon'] == -12.855022 - - -def test_query_with_skip_geometry(config_parquet): - """Testing query for a valid JSON object with property filter""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query(skip_geometry=True) - for feature in feature_collection['features']: - assert feature.get('geometry') is None - - -def test_query_with_datetime(config_parquet): - """Testing query for a valid JSON object with time""" - - p = ParquetProvider(config_parquet) - feature_collection = p.query( - datetime_='2022-05-01T00:00:00Z/2022-05-31T23:59:59Z') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 7 - for feature in feature_collection['features']: - time = feature['properties'][config_parquet['time_field']] - assert time.year == 2022 - assert time.month == 5 - - -def test_query_nogeom(config_parquet_nogeom_notime): - """Testing query for a valid JSON object without geometry""" - - p = ParquetProvider(config_parquet_nogeom_notime) - feature_collection = p.query(resulttype='results') - assert feature_collection.get('type') == 'FeatureCollection' - assert len(feature_collection.get('features')) > 0 - for feature in feature_collection['features']: - assert feature.get('geometry') is None - - -def test_query_nocrs(config_parquet_nocrs): - """Testing a parquet provider without CRS""" - - p = ParquetProvider(config_parquet_nocrs) - results = p.get_fields() - assert results['lat']['type'] == 'number' - assert results['lon']['format'] == 'double' - assert results['time']['format'] == 'date-time' +class TestParquetProviderWithNaiveOrMissingGeometry: + """Tests for parquet that do not comply to geoparquet standard""" + + def test_get_fields(self, config_parquet): + """Testing field types""" + + p = ParquetProvider(config_parquet) + assert p.bbox_filterable + assert p.has_geometry + assert not p.has_bbox_column + results = p.get_fields() + assert results['lat']['type'] == 'number' + assert results['lon']['format'] == 'double' + assert results['time']['format'] == 'date-time' + + def test_get(self, config_parquet): + """Testing query for a specific object""" + + p = ParquetProvider(config_parquet) + result = p.get('42') + assert result['id'] == '42' + assert result['properties']['lon'] == 4.947447 + + def test_get_not_existing_feature_raise_exception( + self, config_parquet + ): + """Testing query for a not existing object""" + p = ParquetProvider(config_parquet) + with pytest.raises(ProviderItemNotFoundError): + p.get(-1) + + def test_query_hits(self, config_parquet): + """Testing query on entire collection for hits""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query(resulttype='hits') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 0 + hits = feature_collection.get('numberMatched') + assert hits is not None + assert hits == 100 + + def test_query_bbox_hits(self, config_parquet): + """Testing query for a valid JSON object with geometry""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query( + bbox=[100, -50, 150, 0], + resulttype='hits') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 0 + hits = feature_collection.get('numberMatched') + assert hits is not None + assert hits == 6 + + def test_query_with_limit(self, config_parquet): + """Testing query for a valid JSON object with geometry""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query(limit=2, resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 2 + hits = feature_collection.get('numberMatched') + assert hits > 2 + feature = features[0] + properties = feature.get('properties') + assert properties is not None + geometry = feature.get('geometry') + assert geometry is not None + + def test_query_with_offset(self, config_parquet): + """Testing query for a valid JSON object with geometry""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query(offset=20, limit=10, resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 10 + hits = feature_collection.get('numberMatched') + assert hits > 30 + feature = features[0] + properties = feature.get('properties') + assert properties is not None + assert feature['id'] == '21' + assert properties['lat'] == 66.264988 + geometry = feature.get('geometry') + assert geometry is not None + + def test_query_with_property(self, config_parquet): + """Testing query for a valid JSON object with property filter""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query( + resulttype='results', + properties=[('lon', -12.855022)]) + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 1 + for feature in features: + assert feature['properties']['lon'] == -12.855022 + + def test_query_with_skip_geometry(self, config_parquet): + """Testing query for a valid JSON object with property filter""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query(skip_geometry=True) + for feature in feature_collection['features']: + assert feature.get('geometry') is None + + def test_query_with_datetime(self, config_parquet): + """Testing query for a valid JSON object with time""" + + p = ParquetProvider(config_parquet) + feature_collection = p.query( + datetime_='2022-05-01T00:00:00Z/2022-05-31T23:59:59Z') + assert feature_collection.get('type') == 'FeatureCollection' + features = feature_collection.get('features') + assert len(features) == 7 + for feature in feature_collection['features']: + time = feature['properties'][config_parquet['time_field']] + assert time.year == 2022 + assert time.month == 5 + + def test_query_nogeom(self, config_parquet_nogeom_notime): + """Testing query for a valid JSON object without geometry""" + + p = ParquetProvider(config_parquet_nogeom_notime) + assert not p.has_geometry + assert not p.bbox_filterable + feature_collection = p.query(resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + assert len(feature_collection.get('features')) > 0 + for feature in feature_collection['features']: + assert feature.get('geometry') is None + + def test_query_nocrs(self, config_parquet_nocrs): + """Testing a parquet provider without CRS""" + + p = ParquetProvider(config_parquet_nocrs) + assert p.bbox_filterable + assert p.has_geometry + assert not p.has_bbox_column + results = p.get_fields() + assert results['lat']['type'] == 'number' + assert results['lon']['format'] == 'double' + assert results['time']['format'] == 'date-time' + + +class TestParquetProviderWithGeoparquetMetadata: + + def test_file_without_bbox_without_id_specified(self, geoparquet_no_bbox): + + p = ParquetProvider(geoparquet_no_bbox) + assert not p.bbox_filterable + assert not p.has_bbox_column + assert p.id_field is None + results = p.get_fields() + assert results['col']['type'] == 'integer' + + feature_collection = p.query(resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + assert feature_collection['features'][0]['geometry']['coordinates'] == ( # noqa + ( + ((30, 10), (40, 40), (20, 40), (10, 20), (30, 10)),) + ) + assert feature_collection['features'][0]['properties']['col'] == 0 + + def test_file_without_bbox_with_id_specified(self, geoparquet_no_bbox): + config = copy(geoparquet_no_bbox) + config['id_field'] = 'col' + + p = ParquetProvider( + config + ) + results = p.get_fields() + assert p.id_field == 'col' + assert results['col']['type'] == 'integer' + + feature_collection = p.query(resulttype='results') + assert feature_collection.get('type') == 'FeatureCollection' + assert feature_collection['features'][0]['geometry']['coordinates'] == ( # noqa + (((30, 10), (40, 40), (20, 40), (10, 20), (30, 10)),) + ) + assert feature_collection['features'][0]['properties']['col'] == 0 + assert feature_collection['features'][0]['id'] == '0' + + def test_get_by_id(self, geoparquet_no_bbox): + + config = copy(geoparquet_no_bbox) + config['id_field'] = 'col' + p = ParquetProvider( + config + ) + + feature = p.get('2') + assert feature.get('type') == 'Feature' + assert feature['geometry'] is None + + def test_file_with_bbox(self, geoparquet_with_bbox): + + p = ParquetProvider(geoparquet_with_bbox) + assert p.has_bbox_column + assert p.bbox_filterable + assert p.has_geometry + + hits = p.query(resulttype='hits')['numberMatched'] + assert hits == 679 + + huge_bbox = p.query(bbox=[-90, -90, 90, 90], resulttype='hits')[ + 'numberMatched' + ] + dataset_bounds = p.query(bbox=[-74.1, 40.97, -73.95, 41.1], + resulttype='hits')['numberMatched'] + assert huge_bbox == dataset_bounds From 939d37325aa944322dc5b61cfe3a512f6e83d4dc Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 9 Mar 2026 21:42:40 -0400 Subject: [PATCH 22/53] OAProc: safeguard link rel check (#2282) (#2283) * OAProc: safeguard link rel check (#2282) * fix ref --- pygeoapi/api/processes.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index a3167cb7e..8fdde5b61 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -724,11 +724,11 @@ def get_oas_30(cfg: dict, locale: str 'externalDocs': {} } for link in p.metadata.get('links', []): - if link['type'] == 'information': + if link.get('rel', '') == 'information': translated_link = l10n.translate(link, locale) tag['externalDocs']['description'] = translated_link[ - 'type'] - tag['externalDocs']['url'] = translated_link['url'] + 'rel'] + tag['externalDocs']['url'] = translated_link['href'] break if len(tag['externalDocs']) == 0: del tag['externalDocs'] From 47d46d355dd57571a4f1c80de649d28d2afd6cd7 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Tue, 10 Mar 2026 09:37:58 -0400 Subject: [PATCH 23/53] update EDR parameter defs (#2279) (#2284) --- pygeoapi/api/collection.py | 18 ++++++++++-------- pygeoapi/templates/collections/collection.html | 2 +- tests/api/test_environmental_data_retrieval.py | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/pygeoapi/api/collection.py b/pygeoapi/api/collection.py index 2f3c7fea1..524f2d64e 100644 --- a/pygeoapi/api/collection.py +++ b/pygeoapi/api/collection.py @@ -404,15 +404,15 @@ def gen_collection(api, request, dataset: str, if parameters: data['parameter_names'] = {} for key, value in parameters.items(): + p_label = value.get('title') + p_description = value.get('description') data['parameter_names'][key] = { 'id': key, 'type': 'Parameter', - 'name': value['title'], 'observedProperty': { 'label': { - 'id': key, - 'en': value['title'] - }, + 'en': p_label + } }, 'unit': { 'label': { @@ -425,10 +425,12 @@ def gen_collection(api, request, dataset: str, } } - data['parameter_names'][key].update({ - 'description': value['description']} - if 'description' in value else {} - ) + if p_description is not None: + data['parameter_names'][key]['observedProperty'].update({ + 'description': { + 'en': p_description + } + }) for qt in p.get_query_types(): data_query = { diff --git a/pygeoapi/templates/collections/collection.html b/pygeoapi/templates/collections/collection.html index 57146a506..4a3b0f7f5 100644 --- a/pygeoapi/templates/collections/collection.html +++ b/pygeoapi/templates/collections/collection.html @@ -98,7 +98,7 @@

Parameters

{% for parameter in data['parameter_names'].values() %} {{ parameter['id'] }} - {{ parameter['name'] }} + {{ parameter['observedProperty']['label'].values()|first }}{% if parameter['observedProperty']['description'] %}
{{ parameter['observedProperty']['description'].values()|first }}{% endif %} {{ parameter['unit']['symbol']['value'] }} {% endfor %} diff --git a/tests/api/test_environmental_data_retrieval.py b/tests/api/test_environmental_data_retrieval.py index 8d028a7c2..38ee4f852 100644 --- a/tests/api/test_environmental_data_retrieval.py +++ b/tests/api/test_environmental_data_retrieval.py @@ -41,6 +41,23 @@ from tests.util import mock_api_request +def test_describe_collection_edr(config, api_): + req = mock_api_request() + rsp_headers, code, response = describe_collections(api_, req, 'icoads-sst') + collection = json.loads(response) + parameter_names = list(collection['parameter_names'].keys()) + parameter_names.sort() + assert len(parameter_names) == 4 + assert parameter_names == ['AIRT', 'SST', 'UWND', 'VWND'] + + sst = collection['parameter_names']['SST'] + assert sst['id'] == 'SST' + assert sst['type'] == 'Parameter' + assert sst['observedProperty']['label']['en'] == 'SEA SURFACE TEMPERATURE' + assert sst['unit']['label']['en'] == 'SEA SURFACE TEMPERATURE' + assert sst['unit']['symbol']['value'] == 'Deg C' + + def test_get_collection_edr_query(config, api_): # edr resource req = mock_api_request() From 090ea9df77003228aca8680fa33d981d1418ff37 Mon Sep 17 00:00:00 2001 From: Colton Loftus <70598503+C-Loftus@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:07:53 -0400 Subject: [PATCH 24/53] Parquet batch configuration and documentation for spec updates (#2281) * parquet documentation and batch configuration * single quotes * fix typo --- docs/source/publishing/ogcapi-features.rst | 27 ++++++++++++++++-- pygeoapi/provider/parquet.py | 32 ++++++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/docs/source/publishing/ogcapi-features.rst b/docs/source/publishing/ogcapi-features.rst index df40d27bd..8a38e0b98 100644 --- a/docs/source/publishing/ogcapi-features.rst +++ b/docs/source/publishing/ogcapi-features.rst @@ -585,7 +585,7 @@ To publish a GeoParquet file (with a geometry column) the geopandas package is a - type: feature name: Parquet data: - source: ./tests/data/parquet/random.parquet + source: ./tests/data/parquet/naive/random.parquet id_field: id time_field: time x_field: @@ -595,11 +595,34 @@ To publish a GeoParquet file (with a geometry column) the geopandas package is a - minlat - maxlat -For GeoParquet data, the `x_field` and `y_field` must be specified in the provider definition, +For older versions of parquet data that don't comply to GeoParquet v1.1, the `x_field` and `y_field` must be specified in the provider definition, and they must be arrays of two column names that contain the x and y coordinates of the bounding box of each geometry. If the geometries in the data are all points, the `x_field` and `y_field` can be strings instead of arrays and refer to a single column each. +.. code-block:: yaml + + providers: + - type: feature + name: Parquet + id_field: id + data: + source: ./tests/data/parquet/geoparquet1.1/nyc_subset_overture.parquet + batch_size: 10000 + batch_readahead: 2 + + +For GeoParquet data which complies to spec version 1.1, all geometry metadata will be automatically +detected. + +Note that for any version of parquet, you may optionally specify ``batch_size`` and ``batch_readahead`` in the ``data`` section of the parquet provider config. +``batch_size`` controls how many rows are fetched per batch. Large batch sizes speed up data processing, but add more I/O time like increased latency when fetching data from an object store, and . If not defined it will +default to 20,000 rows. + +``batch_readahead`` controls how many batches are buffered in memory. If not specified it will default to 2. +Since OGC API Features payloads are often paginated and fairly small, it generally makes sense to specify a small number to avoid reading too many batches ahead of time, especially when fetching from an object store. + + .. _PostgreSQL: PostgreSQL diff --git a/pygeoapi/provider/parquet.py b/pygeoapi/provider/parquet.py index 8d69e9940..8413963e0 100644 --- a/pygeoapi/provider/parquet.py +++ b/pygeoapi/provider/parquet.py @@ -108,7 +108,8 @@ def __init__(self, provider_def): name: Parquet data: source: s3://example.com/parquet_directory/ - + batch_size: 10000 + batch_readahead: 2 id_field: gml_id @@ -121,6 +122,23 @@ def __init__(self, provider_def): # Source url is required self.source = self.data.get('source') + # When iterating over a dataset, the batch size + # controls how many records are read at a time; + # a larger batch size can reduce latency for large/complex + # requests at the cost of more memory usage + # and potentially overfetching; + # More information on batching can be found here: + # https://arrow.apache.org/docs/python/generated/pyarrow.dataset.Dataset.html#pyarrow.dataset.Dataset.scanner # noqa + # This value can be reduced to decrease network transfer + # if fetching data from an object store + self.batch_size = self.data.get('batch_size', 20_000) + + # batch_readahead is the number of batches to prefetch; + # This adds extra memory but can reduce latency for large + # or complicated queries; in an OGC API Features context, + # it generally makes sense to have some buffering but keep it + # low since most responses are small + self.batch_readahead = self.data.get('batch_readahead', 2) if not self.source: msg = 'Need explicit "source" attr in data' \ ' field of provider config' @@ -136,7 +154,8 @@ def __init__(self, provider_def): self.fs = None # Build pyarrow dataset pointing to the data - self.ds = pyarrow.dataset.dataset(self.source, filesystem=self.fs) + self.ds: pyarrow.dataset.Dataset = \ + pyarrow.dataset.dataset(self.source, filesystem=self.fs) if not self.id_field: LOGGER.info( @@ -231,6 +250,11 @@ def _read_parquet(self, return_scanner=False, **kwargs): :returns: generator of RecordBatch with the queried values """ scanner = self.ds.scanner( + batch_size=self.batch_size, + # default batch readahead is 16 which is generally + # far too high in a server context; we can safely set it + # to 2 which allows for queueing without excessive reads + batch_readahead=self.batch_readahead, use_threads=True, **kwargs ) @@ -573,7 +597,9 @@ def _response_feature_hits(self, filter): try: scanner = pyarrow.dataset.Scanner.from_dataset( - self.ds, filter=filter + self.ds, filter=filter, + batch_size=self.batch_size, + batch_readahead=self.batch_readahead ) return { 'type': 'FeatureCollection', From 27be6322b5fd34a5fc296f15dc20c318e833430f Mon Sep 17 00:00:00 2001 From: Mike Mahoney Date: Tue, 10 Mar 2026 13:38:27 -0400 Subject: [PATCH 25/53] Remove duplicated word (#2286) --- docs/source/publishing/ogcapi-edr.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/publishing/ogcapi-edr.rst b/docs/source/publishing/ogcapi-edr.rst index faf7fb37d..246a70b76 100644 --- a/docs/source/publishing/ogcapi-edr.rst +++ b/docs/source/publishing/ogcapi-edr.rst @@ -103,7 +103,7 @@ SensorThingsEDR ^^^^^^^^^^^^^^^ The SensorThings API EDR Provider for pygeaopi extends the feature provider to -produce CoverageJSON representations from SensorThings responses repsonses. This provider +produce CoverageJSON representations from SensorThings responses. This provider relies on using the ObservedProperty Entity to create the `parameter-name` set. .. code-block:: yaml From f3d31450ebec34dc281cd23e2dcdbe3850fb015a Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sun, 15 Mar 2026 21:54:59 -0400 Subject: [PATCH 26/53] add sanitization to to_json output (#2287) --- pygeoapi/util.py | 10 ++++++++-- tests/other/test_util.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pygeoapi/util.py b/pygeoapi/util.py index c91a4c3e0..fa99165be 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -261,8 +261,14 @@ def to_json(dict_: dict, pretty: bool = False) -> str: else: indent = None - return json.dumps(dict_, default=json_serial, indent=indent, - separators=(',', ':')) + LOGGER.debug('Dumping JSON') + json_dump = json.dumps(dict_, default=json_serial, indent=indent, + separators=(',', ':')) + + LOGGER.debug('Removing < and >') + json_dump = json_dump.replace('<', '<').replace('>', '>') + + return json_dump def format_datetime(value: str, format_: str = DATETIME_FORMAT) -> str: diff --git a/tests/other/test_util.py b/tests/other/test_util.py index 4e54840c6..c2602d241 100644 --- a/tests/other/test_util.py +++ b/tests/other/test_util.py @@ -74,6 +74,17 @@ def test_get_typed_value(): assert isinstance(value, bool) +@pytest.mark.parametrize('data,minified,pretty_printed', [ + [{'foo': 'bar'}, '{"foo":"bar"}', '{\n "foo":"bar"\n}'], + [{'foo': 'bar'}, + '{"foo<script>alert(\\"hi\\")</script>":"bar"}', + '{\n "foo<script>alert(\\"hi\\")</script>":"bar"\n}'] +]) +def test_to_json(data, minified, pretty_printed): + assert util.to_json(data) == minified + assert util.to_json(data, pretty=True) == pretty_printed + + def test_yaml_load(config): assert isinstance(config, dict) with pytest.raises(FileNotFoundError): From ab7366146d504f9882abaef056bac88c1e13d430 Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:33:28 -0400 Subject: [PATCH 27/53] Safely handle single feature SQL requests (#2288) * Update test_mysql_provider.py * Update sql.py * Fix import order * Update sql.py Fix assertion fix flake8 --- pygeoapi/provider/sql.py | 75 +++++++++++++++++++++- tests/provider/test_mysql_provider.py | 13 ++++ tests/provider/test_postgresql_provider.py | 13 ++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index 19cc35ca8..410f57c30 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -64,7 +64,8 @@ from sqlalchemy.exc import ( ConstraintColumnNotFoundError, InvalidRequestError, - OperationalError + OperationalError, + SQLAlchemyError ) from sqlalchemy.ext.automap import automap_base from sqlalchemy.orm import Session, load_only @@ -312,8 +313,12 @@ def get(self, identifier, crs_transform_spec=None, **kwargs): # Execute query within self-closing database Session context with Session(self._engine) as session: # Retrieve data from database as feature - item = session.get(self.table_model, identifier) - if item is None: + try: + item = session.get(self.table_model, identifier) + # Ensure that item is not None + assert item is not None + except (AssertionError, SQLAlchemyError) as e: + LOGGER.debug(e, exc_info=True) msg = f'No such item: {self.id_field}={identifier}.' raise ProviderItemNotFoundError(msg) crs_transform_out = get_transform_from_spec(crs_transform_spec) @@ -827,3 +832,67 @@ def _get_bbox_filter(self, bbox: list[float]): func.ST_GeomFromText(polygon_wkt), geom_column ) return bbox_filter + + def get(self, identifier, crs_transform_spec=None, **kwargs): + """ + Query the provider for a specific + feature id e.g: /collections/hotosm_bdi_waterways/items/13990765 + + :param identifier: feature id + :param crs_transform_spec: `CrsTransformSpec` instance, optional + + :returns: GeoJSON FeatureCollection + """ + LOGGER.debug(f'Get item by ID: {identifier}') + + # Execute query within self-closing database Session context + with Session(self._engine) as session: + # Retrieve data from database as feature + try: + item = session.get(self.table_model, identifier) + # Ensure that item is not None + assert item is not None + # Ensure returned row has exact match + feature_id = getattr(item, self.id_field) + assert str(feature_id) == identifier + except (AssertionError, SQLAlchemyError) as e: + LOGGER.debug(e, exc_info=True) + msg = f'No such item: {self.id_field}={identifier}.' + raise ProviderItemNotFoundError(msg) + crs_transform_out = get_transform_from_spec(crs_transform_spec) + feature = self._sqlalchemy_to_feature(item, crs_transform_out) + + # Drop non-defined properties + if self.properties: + props = feature['properties'] + dropping_keys = deepcopy(props).keys() + for item in dropping_keys: + if item not in self.properties: + props.pop(item) + + # Add fields for previous and next items + id_field = getattr(self.table_model, self.id_field) + prev_item = ( + session.query(self.table_model) + .order_by(id_field.desc()) + .filter(id_field < feature_id) + .first() + ) + next_item = ( + session.query(self.table_model) + .order_by(id_field.asc()) + .filter(id_field > feature_id) + .first() + ) + feature['prev'] = ( + getattr(prev_item, self.id_field) + if prev_item is not None + else feature_id + ) + feature['next'] = ( + getattr(next_item, self.id_field) + if next_item is not None + else feature_id + ) + + return feature diff --git a/tests/provider/test_mysql_provider.py b/tests/provider/test_mysql_provider.py index 1ac10e1c1..c92ec01fd 100644 --- a/tests/provider/test_mysql_provider.py +++ b/tests/provider/test_mysql_provider.py @@ -166,6 +166,19 @@ def test_query_skip_geometry(config): assert feature['geometry'] is None +def test_get_with_injection(config): + """Testing query for injection attack string""" + p = MySQLProvider(config) + feature = p.get('1') + assert feature.get('type') == 'Feature' + + with pytest.raises(ProviderItemNotFoundError): + p.get('1; DROP TABLE location;') + + with pytest.raises(ProviderItemNotFoundError): + p.get('1') + + def test_get_not_existing_item_raise_exception(config): """Testing query for a not existing object""" p = MySQLProvider(config) diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index eb0b8760c..b2e5ae0ae 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -354,6 +354,19 @@ def test_get_simple(config, id_, prev, next_): assert result['next'] == next_ +def test_get_with_injection(config): + """Testing query for injection attack string""" + p = PostgreSQLProvider(config) + feature = p.get('29701937') + assert feature.get('type') == 'Feature' + + with pytest.raises(ProviderItemNotFoundError): + p.get('29701937; DROP TABLE location;') + + with pytest.raises(ProviderItemNotFoundError): + p.get('29701937') + + def test_get_with_config_properties(config): """ Test that get is restricted by properties in the config. From 591a9ef6a4f41f7836cd6bdb26444d7f38fa3c87 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Fri, 20 Mar 2026 06:46:46 -0400 Subject: [PATCH 28/53] emit jobs endpoints only when manager supports async (#2291) (#2292) * emit jobs endpoints only when manager supports async (#2291) * update tests --- pygeoapi/api/__init__.py | 22 ++- pygeoapi/api/processes.py | 155 +++++++++++----------- pygeoapi/templates/landing_page.html | 2 + pygeoapi/templates/processes/process.html | 2 + tests/api/test_api.py | 3 +- tests/api/test_pubsub.py | 6 +- 6 files changed, 105 insertions(+), 85 deletions(-) diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index c98cac3c1..33e4b6e55 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -700,11 +700,6 @@ def landing_page(api: API, 'type': FORMAT_TYPES[F_JSON], 'title': l10n.translate('Processes', request.locale), 'href': f"{api.base_url}/processes" - }, { - 'rel': f'{OGC_RELTYPES_BASE}/job-list', - 'type': FORMAT_TYPES[F_JSON], - 'title': l10n.translate('Jobs', request.locale), - 'href': f"{api.base_url}/jobs" }, { 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes', 'type': FORMAT_TYPES[F_JSON], @@ -730,6 +725,20 @@ def landing_page(api: API, fcm['links'].append(pubsub_link) + if api.manager.is_async: + fcm['links'].append({ + 'rel': f'{OGC_RELTYPES_BASE}/job-list', + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate('Jobs', request.locale), + 'href': f"{api.base_url}/jobs" + }) + fcm['links'].append({ + 'rel': f'{OGC_RELTYPES_BASE}/job-list', + 'type': FORMAT_TYPES[F_HTML], + 'title': l10n.translate('Jobs', request.locale), + 'href': f"{api.base_url}/jobs?f=html" + }) + if api.asyncapi: fcm['links'].append({ 'rel': 'service-doc', @@ -760,6 +769,9 @@ def landing_page(api: API, 'tile'): fcm['tile'] = True + if api.manager.is_async: + fcm['jobs'] = True + if api.pubsub_client is not None and not api.pubsub_client.hidden: fcm['pubsub'] = { 'name': api.pubsub_client.name, diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index 8fdde5b61..74efa2efa 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -142,7 +142,6 @@ def describe_processes(api: API, request: APIRequest, p2['links'] = p2.get('links', []) - jobs_url = f"{api.base_url}/jobs" process_url = f"{api.base_url}/processes/{key}" # TODO translation support @@ -164,23 +163,22 @@ def describe_processes(api: API, request: APIRequest, } p2['links'].append(link) - link = { - 'type': FORMAT_TYPES[F_HTML], - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list', - 'href': f'{jobs_url}?f={F_HTML}', - 'title': l10n.translate('Jobs list as HTML', request.locale), # noqa - 'hreflang': api.default_locale - } - p2['links'].append(link) - - link = { - 'type': FORMAT_TYPES[F_JSON], - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list', - 'href': f'{jobs_url}?f={F_JSON}', - 'title': l10n.translate('Jobs list as JSON', request.locale), # noqa - 'hreflang': api.default_locale - } - p2['links'].append(link) + if api.manager.is_async: + jobs_url = f"{api.base_url}/jobs" + p2['links'].append({ + 'type': FORMAT_TYPES[F_HTML], + 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list', + 'href': f'{jobs_url}?f={F_HTML}', + 'title': l10n.translate('Jobs list as HTML', request.locale), # noqa + 'hreflang': api.default_locale + }) + p2['links'].append({ + 'type': FORMAT_TYPES[F_JSON], + 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list', + 'href': f'{jobs_url}?f={F_JSON}', + 'title': l10n.translate('Jobs list as JSON', request.locale), # noqa + 'hreflang': api.default_locale + }) link = { 'type': FORMAT_TYPES[F_JSON], @@ -825,68 +823,73 @@ def get_oas_30(cfg: dict, locale: str } } - paths['/jobs'] = { - 'get': { - 'summary': 'Retrieve jobs list', - 'description': 'Retrieve a list of jobs', - 'tags': ['jobs'], - 'operationId': 'getJobs', - 'responses': { - '200': {'$ref': '#/components/responses/200'}, - '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa - 'default': {'$ref': '#/components/responses/default'} + tag_objects = [{'name': 'processes'}] + + if process_manager.is_async: + paths['/jobs'] = { + 'get': { + 'summary': 'Retrieve jobs list', + 'description': 'Retrieve a list of jobs', + 'tags': ['jobs'], + 'operationId': 'getJobs', + 'responses': { + '200': {'$ref': '#/components/responses/200'}, + '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa + 'default': {'$ref': '#/components/responses/default'} + } } } - } - paths['/jobs/{jobId}'] = { - 'get': { - 'summary': 'Retrieve job details', - 'description': 'Retrieve job details', - 'tags': ['jobs'], - 'parameters': [ - name_in_path, - {'$ref': '#/components/parameters/f'} - ], - 'operationId': 'getJob', - 'responses': { - '200': {'$ref': '#/components/responses/200'}, - '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa - 'default': {'$ref': '#/components/responses/default'} - } - }, - 'delete': { - 'summary': 'Cancel / delete job', - 'description': 'Cancel / delete job', - 'tags': ['jobs'], - 'parameters': [ - name_in_path - ], - 'operationId': 'deleteJob', - 'responses': { - '204': {'$ref': '#/components/responses/204'}, - '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa - 'default': {'$ref': '#/components/responses/default'} - } - }, - } + paths['/jobs/{jobId}'] = { + 'get': { + 'summary': 'Retrieve job details', + 'description': 'Retrieve job details', + 'tags': ['jobs'], + 'parameters': [ + name_in_path, + {'$ref': '#/components/parameters/f'} + ], + 'operationId': 'getJob', + 'responses': { + '200': {'$ref': '#/components/responses/200'}, + '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa + 'default': {'$ref': '#/components/responses/default'} + } + }, + 'delete': { + 'summary': 'Cancel / delete job', + 'description': 'Cancel / delete job', + 'tags': ['jobs'], + 'parameters': [ + name_in_path + ], + 'operationId': 'deleteJob', + 'responses': { + '204': {'$ref': '#/components/responses/204'}, + '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa + 'default': {'$ref': '#/components/responses/default'} + } + }, + } - paths['/jobs/{jobId}/results'] = { - 'get': { - 'summary': 'Retrieve job results', - 'description': 'Retrieve job results', - 'tags': ['jobs'], - 'parameters': [ - name_in_path, - {'$ref': '#/components/parameters/f'} - ], - 'operationId': 'getJobResults', - 'responses': { - '200': {'$ref': '#/components/responses/200'}, - '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa - 'default': {'$ref': '#/components/responses/default'} + paths['/jobs/{jobId}/results'] = { + 'get': { + 'summary': 'Retrieve job results', + 'description': 'Retrieve job results', + 'tags': ['jobs'], + 'parameters': [ + name_in_path, + {'$ref': '#/components/parameters/f'} + ], + 'operationId': 'getJobResults', + 'responses': { + '200': {'$ref': '#/components/responses/200'}, + '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa + 'default': {'$ref': '#/components/responses/default'} + } } } - } - return [{'name': 'processes'}, {'name': 'jobs'}], {'paths': paths} + tag_objects.append({'name': 'jobs'}) + + return tag_objects, {'paths': paths} diff --git a/pygeoapi/templates/landing_page.html b/pygeoapi/templates/landing_page.html index 69e601787..11e8d7dfb 100644 --- a/pygeoapi/templates/landing_page.html +++ b/pygeoapi/templates/landing_page.html @@ -75,6 +75,7 @@

{% trans %}Processes{% endtrans %}

{% trans %}View the processes in this service{% endtrans %}

+ {% if data['jobs'] %}

{% trans %}Jobs{% endtrans %}

@@ -82,6 +83,7 @@

{% trans %}Jobs{% endtrans %}

{% endif %} + {% endif %} {% if data['tile'] %}

{% trans %}Tile Matrix Sets{% endtrans %}

diff --git a/pygeoapi/templates/processes/process.html b/pygeoapi/templates/processes/process.html index 66d560771..453ad2207 100644 --- a/pygeoapi/templates/processes/process.html +++ b/pygeoapi/templates/processes/process.html @@ -73,8 +73,10 @@

{% trans %}Execution modes{% endtrans %}

{% if 'sync-execute' in data.jobControlOptions %}
  • {% trans %}Synchronous{% endtrans %}
  • {% endif %} {% if 'async-execute' in data.jobControlOptions %}
  • {% trans %}Asynchronous{% endtrans %}
  • {% endif %} + {% if data['jobs'] %}

    {% trans %}Jobs{% endtrans %}

    {% trans %}Browse jobs{% endtrans %} + {% endif %}

    {% trans %}Links{% endtrans %}

      {% for link in data['links'] %} diff --git a/tests/api/test_api.py b/tests/api/test_api.py index bee15c1b5..70a5cf587 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -520,7 +520,7 @@ def test_root(config, api_): for link in root['links']) assert any(link['href'].endswith('f=html') and link['rel'] == 'alternate' for link in root['links']) - assert len(root['links']) == 12 + assert len(root['links']) == 13 assert 'title' in root assert root['title'] == 'pygeoapi default instance' assert 'description' in root @@ -622,6 +622,7 @@ def test_describe_collections(config, api_): collections = json.loads(response) assert len(collections) == 2 + print(json.dumps(collections['collections'])) assert len(collections['collections']) == 10 assert len(collections['links']) == 3 diff --git a/tests/api/test_pubsub.py b/tests/api/test_pubsub.py index 243c4661d..9226b97dd 100644 --- a/tests/api/test_pubsub.py +++ b/tests/api/test_pubsub.py @@ -54,7 +54,7 @@ def test_landing_page(config, openapi, asyncapi): content = json.loads(response) - assert len(content['links']) == 15 + assert len(content['links']) == 16 for link in content['links']: if link.get('rel') == 'hub': @@ -76,7 +76,7 @@ def test_landing_page(config, openapi, asyncapi): content = json.loads(response) - assert len(content['links']) == 12 + assert len(content['links']) == 13 for link in content['links']: if link.get('rel') == 'hub': @@ -96,7 +96,7 @@ def test_landing_page(config, openapi, asyncapi): content = json.loads(response) - assert len(content['links']) == 15 + assert len(content['links']) == 16 for link in content['links']: if link.get('rel') == 'hub': From a478147ab00457368dc1d6907dbbb11d26ede85b Mon Sep 17 00:00:00 2001 From: Francesco Bartoli Date: Tue, 24 Mar 2026 14:30:22 +0100 Subject: [PATCH 29/53] Pin trivy action version to v0.35.0 (#2293) Pin trivy to a safe version before the incident GHSA-69fq-xp46-6x23 --- .github/workflows/vulnerabilities.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/vulnerabilities.yml b/.github/workflows/vulnerabilities.yml index 45af41345..2d6e5171b 100644 --- a/.github/workflows/vulnerabilities.yml +++ b/.github/workflows/vulnerabilities.yml @@ -24,7 +24,7 @@ jobs: - name: Checkout pygeoapi uses: actions/checkout@master - name: Scan vulnerabilities with trivy - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@v0.35.0 with: scan-type: fs exit-code: 1 @@ -36,7 +36,7 @@ jobs: run: | docker buildx build -t ${{ github.repository }}:${{ github.sha }} --platform linux/amd64 --no-cache -f Dockerfile . - name: Scan locally built Docker image for vulnerabilities with trivy - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@v0.35.0 env: TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2 TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db:1 From 03203c84019242f8f2951e281355fc667c15e1dd Mon Sep 17 00:00:00 2001 From: Colton Loftus <70598503+C-Loftus@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:15:16 -0400 Subject: [PATCH 30/53] add path to collection id for map (#2294) --- pygeoapi/flask_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py index 1c5676af0..4800e1fab 100644 --- a/pygeoapi/flask_app.py +++ b/pygeoapi/flask_app.py @@ -399,8 +399,8 @@ def get_collection_tiles_data(collection_id: str | None = None, ) -@BLUEPRINT.route('/collections//map') -@BLUEPRINT.route('/collections//styles//map') +@BLUEPRINT.route('/collections//map') +@BLUEPRINT.route('/collections//styles//map') def collection_map(collection_id: str, style_id: str | None = None): """ OGC API - Maps map render endpoint From a80043adafea4f3e080f540d8faf57b9681dc2e4 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Wed, 25 Mar 2026 07:18:38 -0400 Subject: [PATCH 31/53] update TinyDB query builder (#2296) * fix TinyDB query handling to use query builder * flake8 --- pygeoapi/provider/tinydb_.py | 124 +++++++++++++++++++++-------------- 1 file changed, 76 insertions(+), 48 deletions(-) diff --git a/pygeoapi/provider/tinydb_.py b/pygeoapi/provider/tinydb_.py index 5453f70c8..c121af838 100644 --- a/pygeoapi/provider/tinydb_.py +++ b/pygeoapi/provider/tinydb_.py @@ -28,8 +28,11 @@ # ================================================================= import logging -import re # noqa +from functools import reduce +import operator import os +import re +from typing import Union import uuid from dateutil.parser import parse as parse_date @@ -157,9 +160,7 @@ def query(self, offset=0, limit=10, resulttype='results', """ Q = Query() - LOGGER.debug(f'Query initiated: {Q}') - - QUERY = [] + predicates = [] feature_collection = { 'type': 'FeatureCollection', @@ -173,58 +174,60 @@ def query(self, offset=0, limit=10, resulttype='results', if bbox: LOGGER.debug('processing bbox parameter') bbox_as_string = ','.join(str(s) for s in bbox) - QUERY.append(f"Q.geometry.test(bbox_intersects, '{bbox_as_string}')") # noqa + predicates.append(Q.geometry.test(bbox_intersects, bbox_as_string)) if datetime_ is not None: LOGGER.debug('processing datetime parameter') if self.time_field is None: LOGGER.error('time_field not enabled for collection') LOGGER.error('Using default time property') - time_field2 = 'time' + time_field2 = Q.time else: LOGGER.error(f'Using properties.{self.time_field}') - time_field2 = f"properties['{self.time_field}']" + time_field2 = getattr(Q.properties, self.time_field) if '/' in datetime_: # envelope LOGGER.debug('detected time range') time_begin, time_end = datetime_.split('/') if time_begin != '..': - QUERY.append(f"(Q.{time_field2}>='{time_begin}')") # noqa + predicates.append(time_field2 >= time_begin) if time_end != '..': - QUERY.append(f"(Q.{time_field2}<='{time_end}')") # noqa + predicates.append(time_field2 <= time_end) else: # time instant LOGGER.debug('detected time instant') - QUERY.append(f"(Q.{time_field2}=='{datetime_}')") # noqa + predicates.append(getattr(Q, time_field2) == datetime_) if properties: LOGGER.debug('processing properties') for prop in properties: - if isinstance(prop[1], str): - value = f"'{prop[1]}'" - else: - value = prop[1] - QUERY.append(f"(Q.properties['{prop[0]}']=={value})") - - QUERY = self._add_search_query(QUERY, q) - - QUERY_STRING = '&'.join(QUERY) - LOGGER.debug(f'QUERY_STRING: {QUERY_STRING}') - SEARCH_STRING = f'self.db.search({QUERY_STRING})' - LOGGER.debug(f'SEARCH_STRING: {SEARCH_STRING}') - - LOGGER.debug('querying database') - if len(QUERY) > 0: - LOGGER.debug(f'running eval on {SEARCH_STRING}') - try: - results = eval(SEARCH_STRING) - except SyntaxError as err: - msg = 'Invalid query' - LOGGER.error(f'{msg}: {err}') - raise ProviderInvalidQueryError(msg) + if prop[0] not in self.fields: + msg = 'Invalid query: invalid property name' + LOGGER.error(msg) + raise ProviderInvalidQueryError(msg) + + predicates.append(getattr(Q.properties, prop[0]) == prop[1]) + + PQ = reduce(operator.and_, predicates) if predicates else None + if q: + SQ = self._add_search_query(Q, q) else: - results = self.db.all() + SQ = None + + try: + if PQ and SQ: + results = self.db.search(PQ & SQ) + elif PQ and not SQ: + results = self.db.search(PQ) + elif not PQ and SQ is not None: + results = self.db.search(SQ) + else: + results = self.db.all() + except SyntaxError as err: + msg = 'Invalid query' + LOGGER.error(f'{msg}: {err}') + raise ProviderInvalidQueryError(msg) feature_collection['numberMatched'] = len(results) @@ -355,17 +358,29 @@ def _add_extra_fields(self, json_data: dict) -> dict: return json_data - def _add_search_query(self, query: list, search_term: str = None) -> str: + def _add_search_query(self, search_object, + search_term: str = None) -> Union[str, None]: """ - Helper function to add extra query predicates + Create a search query according to the OGC API - Records specification. + + https://docs.ogc.org/is/20-004r1/20-004r1.html (Listing 14) + + Examples (f is shorthand for Q.properties["_metadata-anytext"]): + +-------------+-----------------------------------+ + | search term | TinyDB search | + +-------------+-----------------------------------+ + | 'aa' | f.search('aa') | + | 'aa,bb' | f.search('aa')|f.search('bb') | + | 'aa,bb cc' | f.search('aa')|f.search('bb +cc') | + +-------------+-----------------------------------+ - :param query: `list` of query predicates - :param search_term: `str` of search term + :param Q: TinyDB search object + :param s: `str` of q parameter value - :returns: `list` of updated query predicates + :returns: `Query` object or `None` """ - return query + return search_object def __repr__(self): return f' {self.data}' @@ -402,7 +417,7 @@ def _add_extra_fields(self, json_data: dict) -> dict: return json_data - def _prepare_q_param_with_spaces(self, s: str) -> str: + def _prepare_q_param_with_spaces(self, Q: Query, s: str) -> str: """ Prepare a search statement for the search term `s`. The term `s` might have spaces. @@ -415,12 +430,18 @@ def _prepare_q_param_with_spaces(self, s: str) -> str: | 'aa bb' | f.search('aa +bb') | | ' aa bb ' | f.search('aa +bb') | +---------------+--------------------+ + + :param Q: TinyDB `Query` object + :param s: `str` of q parameter value + + :returns: `Query` object """ - return 'Q.properties["_metadata-anytext"].search("' \ - + ' +'.join(s.split()) \ - + '", flags=re.IGNORECASE)' - def _add_search_query(self, query: list, search_term: str = None) -> str: + return Q.properties["_metadata-anytext"].search( + ' +'.join(s.split()), flags=re.IGNORECASE) + + def _add_search_query(self, search_object, + search_term: str = None) -> Union[str, None]: """ Create a search query according to the OGC API - Records specification. @@ -434,15 +455,22 @@ def _add_search_query(self, query: list, search_term: str = None) -> str: | 'aa,bb' | f.search('aa')|f.search('bb') | | 'aa,bb cc' | f.search('aa')|f.search('bb +cc') | +-------------+-----------------------------------+ + + :param Q: TinyDB search object + :param s: `str` of q parameter value + + :returns: `Query` object or `None` """ + if search_term is not None and len(search_term) > 0: LOGGER.debug('catalogue q= query') terms = [s for s in search_term.split(',') if len(s) > 0] - query.append('|'.join( - [self._prepare_q_param_with_spaces(t) for t in terms] - )) + terms2 = [self._prepare_q_param_with_spaces(search_object, t) + for t in terms] - return query + return reduce(operator.or_, terms2) + else: + return None def __repr__(self): return f' {self.data}' From 30f1022a80be095561881091354a6d503f0df99e Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 26 Mar 2026 09:08:11 -0400 Subject: [PATCH 32/53] rename base_edr instance functions (#2298) (#2299) --- docs/source/plugins.rst | 10 +++++----- pygeoapi/api/environmental_data_retrieval.py | 6 +++--- pygeoapi/provider/base_edr.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 4b9a86922..0badbf4f6 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -306,11 +306,11 @@ The below template provides a minimal example (let's call the file ``mycooledrda self.covjson = {...} - def get_instances(self): + def instances(self): return ['foo', 'bar'] - def get_instance(self, instance): - return instance in get_instances() + def instance(self, instance): + return instance in instances() def position(self, **kwargs): return self.covjson @@ -320,8 +320,8 @@ The below template provides a minimal example (let's call the file ``mycooledrda For brevity, the ``position`` function returns ``self.covjson`` which is a -dictionary of a CoverageJSON representation. ``get_instances`` returns a list -of instances associated with the collection/plugin, and ``get_instance`` returns +dictionary of a CoverageJSON representation. ``instances`` returns a list +of instances associated with the collection/plugin, and ``instance`` returns a boolean of whether a given instance exists/is valid. EDR query types are subject to the query functions defined in the plugin. In the example above, the plugin implements ``position`` and ``trajectory`` queries, which will be advertised as diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py index 6f2ed36bb..98a77cc2a 100644 --- a/pygeoapi/api/environmental_data_retrieval.py +++ b/pygeoapi/api/environmental_data_retrieval.py @@ -113,14 +113,14 @@ def get_collection_edr_instances(api: API, request: APIRequest, if instance_id is not None: try: - if p.get_instance(instance_id): + if p.instance(instance_id): instances = [instance_id] except ProviderItemNotFoundError: msg = 'Instance not found' return api.get_exception( HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) else: - instances = p.get_instances() + instances = p.instances() for instance in instances: instance_dict = { @@ -281,7 +281,7 @@ def get_collection_edr_query(api: API, request: APIRequest, err.http_status_code, headers, request.format, err.ogc_exception_code, err.message) - if instance is not None and not p.get_instance(instance): + if instance is not None and not p.instance(instance): msg = 'Invalid instance identifier' return api.get_exception( HTTPStatus.BAD_REQUEST, headers, diff --git a/pygeoapi/provider/base_edr.py b/pygeoapi/provider/base_edr.py index 01b1602b6..08c30df9b 100644 --- a/pygeoapi/provider/base_edr.py +++ b/pygeoapi/provider/base_edr.py @@ -78,7 +78,7 @@ def __init_subclass__(cls, **kwargs): 'but requests will be routed to a feature provider' ) - def get_instances(self): + def instances(self): """ Get a list of instance identifiers @@ -87,7 +87,7 @@ def get_instances(self): return NotImplementedError() - def get_instance(self, instance): + def instance(self, instance): """ Validate instance identifier From 72abbb1217e788e64384f5e6c26789c0bb26e7fe Mon Sep 17 00:00:00 2001 From: Jo Date: Thu, 26 Mar 2026 16:33:45 +0000 Subject: [PATCH 33/53] Fix CRS support on WMS Facade (#2302) * - fixed support for crs 3857 on OGC API - Maps and WMS Facade provider * - updated maps documentation for the WMS facade provider --- docs/source/publishing/ogcapi-maps.rst | 9 +++++++++ pygeoapi/api/maps.py | 19 ++++++++++++++----- pygeoapi/provider/wms_facade.py | 1 - 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/source/publishing/ogcapi-maps.rst b/docs/source/publishing/ogcapi-maps.rst index 6924e3a39..a6b2aca1f 100644 --- a/docs/source/publishing/ogcapi-maps.rst +++ b/docs/source/publishing/ogcapi-maps.rst @@ -113,6 +113,15 @@ required. An optional style name can be defined via `options.style`. name: png mimetype: image/png +.. note:: + According to the `Standard `_, OGC API - Maps + supports a `crs` parameter, expressed as an uri. Currently, this provider supports WGS84 and Web Mercator; for a matter of convenience, they can be expressed in + a number of different ways, other than the uri format. + + - `EPSG:4326` + - `EPSG:3857` + - `4326` + - `3857` Data visualization examples --------------------------- diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index 8712e4c94..6af162a0b 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -61,9 +61,17 @@ 'http://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/core' ] - DEFAULT_CRS = 'http://www.opengis.net/def/crs/EPSG/0/4326' +CRS_CODES = { + '4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', + '3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', + 'http://www.opengis.net/def/crs/EPSG/0/4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', # noqa + 'http://www.opengis.net/def/crs/EPSG/0/3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', # noqa + 'EPSG:4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', + 'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', +} + def get_collection_map(api: API, request: APIRequest, dataset: str, style: str | None = None @@ -106,10 +114,10 @@ def get_collection_map(api: API, request: APIRequest, query_args['format_'] = request.params.get('f', 'png') query_args['style'] = style - query_args['crs'] = collection_def.get('crs', DEFAULT_CRS) - query_args['bbox_crs'] = request.params.get( - 'bbox-crs', DEFAULT_CRS - ) + query_args['crs'] = CRS_CODES[request.params.get( + 'crs', collection_def.get('crs', DEFAULT_CRS))] + query_args['bbox_crs'] = CRS_CODES[request.params.get( + 'bbox-crs', collection_def.get('crs', DEFAULT_CRS))] query_args['transparent'] = request.params.get('transparent', True) try: @@ -151,6 +159,7 @@ def get_collection_map(api: API, request: APIRequest, return headers, HTTPStatus.BAD_REQUEST, to_json( exception, api.pretty_print) + # the transformer function expects the crs to be in a uri format if query_args['bbox_crs'] != query_args['crs']: LOGGER.debug(f'Reprojecting bbox CRS: {query_args["crs"]}') bbox = transform_bbox(bbox, query_args['bbox_crs'], query_args['crs']) diff --git a/pygeoapi/provider/wms_facade.py b/pygeoapi/provider/wms_facade.py index e96f3c244..8c1507c49 100644 --- a/pygeoapi/provider/wms_facade.py +++ b/pygeoapi/provider/wms_facade.py @@ -42,7 +42,6 @@ } CRS_CODES = { - 4326: 'EPSG:4326', 'http://www.opengis.net/def/crs/EPSG/0/4326': 'EPSG:4326', 'http://www.opengis.net/def/crs/EPSG/0/3857': 'EPSG:3857' } From b9e81dd441301a9d1516780be757caf3c6c0cdff Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 26 Mar 2026 12:49:16 -0400 Subject: [PATCH 34/53] UI: prevent form element names from being passed on user click/search (#2300) --- pygeoapi/templates/collections/items/index.html | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pygeoapi/templates/collections/items/index.html b/pygeoapi/templates/collections/items/index.html index 3d325c445..88fa36b51 100644 --- a/pygeoapi/templates/collections/items/index.html +++ b/pygeoapi/templates/collections/items/index.html @@ -202,19 +202,19 @@

      {% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en var datetime = []; var q = document.getElementById('q').value; - var datetime_begin = document.getElementById('datetime_begin').value; - var datetime_end = document.getElementById('datetime_end').value; + var datetime_begin = document.getElementById('datetime_begin'); + var datetime_end = document.getElementById('datetime_end'); if (q) { query_string.push('q=' + encodeURIComponent(q)); } - if (datetime_begin !== "") { - datetime.push(datetime_begin + 'T00:00:00Z'); + if (datetime_begin.value !== "") { + datetime.push(datetime_begin.value + 'T00:00:00Z'); } else { datetime.push('..'); } - if (datetime_end !== "") { - datetime.push(datetime_end + 'T23:59:59Z'); + if (datetime_end.value !== "") { + datetime.push(datetime_end.value + 'T23:59:59Z'); } else { datetime.push('..'); } @@ -232,6 +232,8 @@

      {% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% en if (query_string.length > 0) { document.location.href = '{{ data['items_path'] }}' + '?' + query_string.join('&'); } + datetime_begin.disabled = true; + datetime_end.disabled = true; } {% endif %} var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 5); From 95580f2de3b1a7a6e2c2504b03451ea0fca420aa Mon Sep 17 00:00:00 2001 From: Jo Date: Fri, 27 Mar 2026 16:29:20 +0000 Subject: [PATCH 35/53] WMS Facade updates (#2303) * WMS Facade updates: - added unit test for wms facade - removed trailing comma - prevent errors with non existing keys * - fixed flak8 errors * - removed bbox validation * - removed invalid bbox test * - fixed flak8 --- pygeoapi/api/maps.py | 2 +- pygeoapi/provider/wms_facade.py | 4 +- tests/provider/test_wms_facade_provider.py | 64 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 tests/provider/test_wms_facade_provider.py diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index 6af162a0b..c22991799 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -69,7 +69,7 @@ 'http://www.opengis.net/def/crs/EPSG/0/4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', # noqa 'http://www.opengis.net/def/crs/EPSG/0/3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', # noqa 'EPSG:4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', - 'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', + 'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857' } diff --git a/pygeoapi/provider/wms_facade.py b/pygeoapi/provider/wms_facade.py index 8c1507c49..eb25e22b3 100644 --- a/pygeoapi/provider/wms_facade.py +++ b/pygeoapi/provider/wms_facade.py @@ -86,7 +86,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, version = self.options.get('version', '1.3.0') - if version == '1.3.0' and CRS_CODES[bbox_crs] == 'EPSG:4326': + if version == '1.3.0' and CRS_CODES.get('bbox_crs') == 'EPSG:4326': bbox = [bbox[1], bbox[0], bbox[3], bbox[2]] bbox2 = ','.join(map(str, bbox)) @@ -99,7 +99,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, 'service': 'WMS', 'request': 'GetMap', 'bbox': bbox2, - crs_param: CRS_CODES[crs], + crs_param: CRS_CODES.get(crs) or 'EPSG:4326', 'layers': self.options['layer'], 'styles': self.options.get('style', 'default'), 'width': width, diff --git a/tests/provider/test_wms_facade_provider.py b/tests/provider/test_wms_facade_provider.py new file mode 100644 index 000000000..e3ffd8bd9 --- /dev/null +++ b/tests/provider/test_wms_facade_provider.py @@ -0,0 +1,64 @@ +# ================================================================= +# +# Authors: Joana Simoes +# +# +# Copyright (c) 2026 Joana Simoes +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import pytest + +from pygeoapi.provider.wms_facade import WMSFacadeProvider + + +@pytest.fixture() +def config(): + return { + 'name': 'WMSFacade', + 'type': 'map', + 'data': 'https://demo.mapserver.org/cgi-bin/msautotest', + 'options': { + 'layer': 'world_latlong', + 'style': 'default' + }, + 'format': { + 'name': 'png', + 'mimetype': 'image/png' + } + } + + +def test_query(config): + p = WMSFacadeProvider(config) + + results = p.query() + assert len(results) > 0 + + # an invalid CRS should return the default bbox (4326) + results2 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/1111') + assert len(results2) == len(results) + + results3 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/3857') + assert len(results3) != len(results) From c7871488a6c87ed2fe99137828fcb282ea5f5e91 Mon Sep 17 00:00:00 2001 From: Jo Date: Sat, 28 Mar 2026 00:13:19 +0000 Subject: [PATCH 36/53] - fixed bug introduced in previous commit (#2306) * - fixed bug introduced in previous commit * - fixed flake8 --- pygeoapi/provider/wms_facade.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pygeoapi/provider/wms_facade.py b/pygeoapi/provider/wms_facade.py index eb25e22b3..467c12aba 100644 --- a/pygeoapi/provider/wms_facade.py +++ b/pygeoapi/provider/wms_facade.py @@ -46,6 +46,8 @@ 'http://www.opengis.net/def/crs/EPSG/0/3857': 'EPSG:3857' } +DEFAULT_CRS = 'http://www.opengis.net/def/crs/EPSG/0/4326' + class WMSFacadeProvider(BaseProvider): """WMS 1.3.0 provider""" @@ -64,8 +66,8 @@ def __init__(self, provider_def): LOGGER.debug(f'pyproj version: {pyproj.__version__}') def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, - height=300, crs=4326, datetime_=None, transparent=True, - bbox_crs=4326, format_='png', **kwargs): + height=300, crs=DEFAULT_CRS, datetime_=None, transparent=True, + bbox_crs=DEFAULT_CRS, format_='png', **kwargs): """ Generate map @@ -86,7 +88,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, version = self.options.get('version', '1.3.0') - if version == '1.3.0' and CRS_CODES.get('bbox_crs') == 'EPSG:4326': + if version == '1.3.0' and CRS_CODES.get(bbox_crs) == 'EPSG:4326': bbox = [bbox[1], bbox[0], bbox[3], bbox[2]] bbox2 = ','.join(map(str, bbox)) From f10f9c2cc98c2577693a979150e52487bf4b9ccb Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:27:40 -0400 Subject: [PATCH 37/53] Create test_csw_provider_live.py (#2309) * Create csw_live * Fix flake8 --- tests/provider/test_csw_provider.py | 195 +++++++++++++++++++---- tests/provider/test_csw_provider_live.py | 153 ++++++++++++++++++ 2 files changed, 317 insertions(+), 31 deletions(-) create mode 100644 tests/provider/test_csw_provider_live.py diff --git a/tests/provider/test_csw_provider.py b/tests/provider/test_csw_provider.py index 129a47f2a..5e0232408 100644 --- a/tests/provider/test_csw_provider.py +++ b/tests/provider/test_csw_provider.py @@ -29,11 +29,14 @@ # # ================================================================= +from unittest import mock import pytest from pygeoapi.provider.base import ProviderItemNotFoundError from pygeoapi.provider.csw_facade import CSWFacadeProvider +CSW_PROVIDER = 'pygeoapi.provider.csw_facade.CatalogueServiceWeb' + @pytest.fixture() def config(): @@ -46,14 +49,166 @@ def config(): } -def test_domains(config): +@pytest.fixture() +def mock_csw_record(): + """Mock owslib CSW record""" + record = mock.MagicMock() + record.identifier = 'urn:uuid:19887a8a-f6b0-4a63-ae56-7fba0e17801f' + record.title = 'Lorem ipsum' + record.abstract = 'Lorem ipsum dolor sit amet' + record.type = 'http://purl.org/dc/dcmitype/Image' + record.subjects = ['Tourism--Greece'] + record.date = '2006-03-26' + record.created = None + record.modified = None + record.rights = None + record.language = None + record.bbox = None # No geometry for first record + record.references = [] + record.uris = [] + return record + + +@pytest.fixture() +def mock_csw_record_polygon(): + """Mock owslib CSW record with polygon geometry""" + record = mock.MagicMock() + record.identifier = 'urn:uuid:1ef30a8b-876d-4828-9246-c37ab4510bbd' + record.title = 'Maecenas enim' + record.abstract = 'Maecenas enim' + record.type = 'http://purl.org/dc/dcmitype/Text' + record.subjects = [] + record.date = '2006-05-12' + record.created = None + record.modified = None + record.rights = None + record.language = None + record.bbox = mock.MagicMock() + record.bbox.minx = '13.754' + record.bbox.miny = '60.042' + record.bbox.maxx = '15.334' + record.bbox.maxy = '61.645' + record.references = [] + record.uris = [] + return record + + +@pytest.fixture() +def mock_csw_get_record(): + """Mock owslib CSW record for get operations""" + record = mock.MagicMock() + record.identifier = 'urn:uuid:a06af396-3105-442d-8b40-22b57a90d2f2' + record.title = 'Lorem ipsum dolor sit amet' + record.abstract = 'Lorem ipsum dolor sit amet' + record.type = 'http://purl.org/dc/dcmitype/Image' + record.subjects = [] + record.date = None + record.created = None + record.modified = None + record.rights = None + record.language = None + record.bbox = None + record.references = [] + record.uris = [] + return record + + +@pytest.fixture() +def mock_csw(mock_csw_record, mock_csw_record_polygon, mock_csw_get_record): + """Mock CSW service""" + with mock.patch(CSW_PROVIDER) as mock_csw_class: + csw_instance = mock.MagicMock() + mock_csw_class.return_value = csw_instance + + def mock_getrecords2(*args, **kwargs): + # Simulate different responses based on parameters + limit = kwargs.get('maxrecords', 10) + offset = kwargs.get('startposition', 0) + constraints = kwargs.get('constraints', []) + + # All available records + all_records = [ + ( + 'urn:uuid:19887a8a-f6b0-4a63-ae56-7fba0e17801f', + mock_csw_record + ), + ( + 'urn:uuid:1ef30a8b-876d-4828-9246-c37ab4510bbd', + mock_csw_record_polygon + ) + ] + + # Simulate filtering based on query constraints + filtered_records = all_records[:] + + # Simulate different total counts based on constraints + total_matches = 12 # Default total + if constraints: + # If there are constraints + # simulate fewer matches + constraint_str = str(constraints) + if 'lorem' in constraint_str.lower(): + total_matches = 5 + # Keep both records for lorem search + elif 'maecenas' in constraint_str.lower(): + total_matches = 1 + # Keep only the second record for maecenas search + filtered_records = [all_records[1]] + elif 'datetime' in constraint_str.lower(): + total_matches = 1 if '2006-05-12' in constraint_str else 3 + # Keep appropriate records based on date + if '2006-05-12' in constraint_str: + # Second record has matching date + filtered_records = [all_records[1]] + + # Apply offset and limit to filtered records + paginated_records = filtered_records[offset:offset+limit] + + # Convert to dictionary format expected by CSW + csw_instance.records = { + record_id: record for record_id, record in paginated_records + } + csw_instance.results = { + 'matches': total_matches, + 'returned': len(paginated_records) + } + + def mock_getrecordbyid(identifiers, **kwargs): + identifier = identifiers[0] + if identifier == 'urn:uuid:a06af396-3105-442d-8b40-22b57a90d2f2': + csw_instance.records = {identifier: mock_csw_get_record} + else: + csw_instance.records = {} + + def mock_getdomain(property_name, **kwargs): + # Mock domain values for testing + domain_values = { + 'type': [ + 'http://purl.org/dc/dcmitype/Image', + 'http://purl.org/dc/dcmitype/Text', + 'http://purl.org/dc/dcmitype/Dataset', + 'http://purl.org/dc/dcmitype/Service' + ] + } + csw_instance.results = { + 'values': domain_values.get(property_name, []) + } + + csw_instance.getrecords2.side_effect = mock_getrecords2 + csw_instance.getrecordbyid.side_effect = mock_getrecordbyid + csw_instance.getdomain.side_effect = mock_getdomain + + yield csw_instance + + +def test_domains(config, mock_csw): p = CSWFacadeProvider(config) domains, current = p.get_domains() assert current - expected_properties = ['description', 'keywords', 'title', 'type'] + expected_properties = ['date', 'description', 'keywords', 'title', 'type'] assert sorted(domains.keys()) == expected_properties @@ -66,7 +221,7 @@ def test_domains(config): assert list(domains.keys()) == ['type'] -def test_query(config): +def test_query(config, mock_csw): p = CSWFacadeProvider(config) fields = p.get_fields() @@ -76,9 +231,9 @@ def test_query(config): assert value['type'] == 'string' results = p.query() - assert len(results['features']) == 10 + assert len(results['features']) == 2 # Mock returns 2 records assert results['numberMatched'] == 12 - assert results['numberReturned'] == 10 + assert results['numberReturned'] == 2 assert results['features'][0]['id'] == 'urn:uuid:19887a8a-f6b0-4a63-ae56-7fba0e17801f' # noqa assert results['features'][0]['geometry'] is None assert results['features'][0]['properties']['title'] == 'Lorem ipsum' @@ -92,20 +247,11 @@ def test_query(config): assert len(results['features']) == 1 assert results['features'][0]['id'] == 'urn:uuid:19887a8a-f6b0-4a63-ae56-7fba0e17801f' # noqa - results = p.query(offset=2, limit=1) + results = p.query(offset=1, limit=1) assert len(results['features']) == 1 assert results['features'][0]['id'] == 'urn:uuid:1ef30a8b-876d-4828-9246-c37ab4510bbd' # noqa - assert len(results['features'][0]['properties']) == 2 - - results = p.query(q='lorem') - assert results['numberMatched'] == 5 - - results = p.query(q='lorem', sortby=[{'property': 'title', 'order': '-'}]) - assert results['numberMatched'] == 5 - results = p.query(resulttype='hits') - assert len(results['features']) == 0 assert results['numberMatched'] == 12 results = p.query(bbox=[-10, 40, 0, 60]) @@ -115,23 +261,10 @@ def test_query(config): assert len(results['features']) == 2 results = p.query(properties=[('title', 'Maecenas enim')]) - assert len(results['features']) == 1 - - properties = [ - ('title', 'Maecenas enim'), - ('type', 'http://purl.org/dc/dcmitype/Text') - ] - results = p.query(properties=properties) - assert len(results['features']) == 1 - - results = p.query(datetime_='2006-05-12') - assert len(results['features']) == 1 - - results = p.query(datetime_='2004/2007') - assert len(results['features']) == 3 + assert len(results['features']) == 2 -def test_get(config): +def test_get(config, mock_csw): p = CSWFacadeProvider(config) result = p.get('urn:uuid:a06af396-3105-442d-8b40-22b57a90d2f2') @@ -146,7 +279,7 @@ def test_get(config): assert 'service=CSW' in xml_link['href'] -def test_get_not_existing_item_raise_exception(config): +def test_get_not_existing_item_raise_exception(config, mock_csw): """Testing query for a not existing object""" p = CSWFacadeProvider(config) with pytest.raises(ProviderItemNotFoundError): diff --git a/tests/provider/test_csw_provider_live.py b/tests/provider/test_csw_provider_live.py new file mode 100644 index 000000000..129a47f2a --- /dev/null +++ b/tests/provider/test_csw_provider_live.py @@ -0,0 +1,153 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# Francesco Bartoli +# +# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2025 Francesco Bartoli +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import pytest + +from pygeoapi.provider.base import ProviderItemNotFoundError +from pygeoapi.provider.csw_facade import CSWFacadeProvider + + +@pytest.fixture() +def config(): + return { + 'name': 'CSWFacade', + 'type': 'record', + 'data': 'https://demo.pycsw.org/cite/csw', + 'id_field': 'identifier', + 'time_field': 'date' + } + + +def test_domains(config): + p = CSWFacadeProvider(config) + + domains, current = p.get_domains() + + assert current + + expected_properties = ['description', 'keywords', 'title', 'type'] + + assert sorted(domains.keys()) == expected_properties + + assert len(domains['type']) == 4 + + domains, current = p.get_domains(['type']) + + assert current + + assert list(domains.keys()) == ['type'] + + +def test_query(config): + p = CSWFacadeProvider(config) + + fields = p.get_fields() + assert len(fields) == 9 + + for key, value in fields.items(): + assert value['type'] == 'string' + + results = p.query() + assert len(results['features']) == 10 + assert results['numberMatched'] == 12 + assert results['numberReturned'] == 10 + assert results['features'][0]['id'] == 'urn:uuid:19887a8a-f6b0-4a63-ae56-7fba0e17801f' # noqa + assert results['features'][0]['geometry'] is None + assert results['features'][0]['properties']['title'] == 'Lorem ipsum' + assert results['features'][0]['properties']['keywords'][0] == 'Tourism--Greece' # noqa + + assert results['features'][1]['geometry']['type'] == 'Polygon' + assert results['features'][1]['geometry']['coordinates'][0][0][0] == 13.754 + assert results['features'][1]['geometry']['coordinates'][0][0][1] == 60.042 + + results = p.query(limit=1) + assert len(results['features']) == 1 + assert results['features'][0]['id'] == 'urn:uuid:19887a8a-f6b0-4a63-ae56-7fba0e17801f' # noqa + + results = p.query(offset=2, limit=1) + assert len(results['features']) == 1 + assert results['features'][0]['id'] == 'urn:uuid:1ef30a8b-876d-4828-9246-c37ab4510bbd' # noqa + + assert len(results['features'][0]['properties']) == 2 + + results = p.query(q='lorem') + assert results['numberMatched'] == 5 + + results = p.query(q='lorem', sortby=[{'property': 'title', 'order': '-'}]) + assert results['numberMatched'] == 5 + + results = p.query(resulttype='hits') + assert len(results['features']) == 0 + assert results['numberMatched'] == 12 + + results = p.query(bbox=[-10, 40, 0, 60]) + assert len(results['features']) == 2 + + results = p.query(bbox=[-10, 40, 0, 60, 0, 0]) + assert len(results['features']) == 2 + + results = p.query(properties=[('title', 'Maecenas enim')]) + assert len(results['features']) == 1 + + properties = [ + ('title', 'Maecenas enim'), + ('type', 'http://purl.org/dc/dcmitype/Text') + ] + results = p.query(properties=properties) + assert len(results['features']) == 1 + + results = p.query(datetime_='2006-05-12') + assert len(results['features']) == 1 + + results = p.query(datetime_='2004/2007') + assert len(results['features']) == 3 + + +def test_get(config): + p = CSWFacadeProvider(config) + + result = p.get('urn:uuid:a06af396-3105-442d-8b40-22b57a90d2f2') + assert result['id'] == 'urn:uuid:a06af396-3105-442d-8b40-22b57a90d2f2' + assert result['geometry'] is None + assert result['properties']['title'] == 'Lorem ipsum dolor sit amet' + assert result['properties']['type'] == 'http://purl.org/dc/dcmitype/Image' + + xml_link = result['links'][0] + assert xml_link['rel'] == 'alternate' + assert xml_link['type'] == 'application/xml' + assert 'service=CSW' in xml_link['href'] + + +def test_get_not_existing_item_raise_exception(config): + """Testing query for a not existing object""" + p = CSWFacadeProvider(config) + with pytest.raises(ProviderItemNotFoundError): + p.get('404') From 08031d4e47b096bcabd78a73af2f9c65ed54e053 Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:29:47 -0400 Subject: [PATCH 38/53] Fix to_json escape (#2310) * Fix to_json escape * Fix flake8 --- pygeoapi/util.py | 5 +++-- tests/other/test_util.py | 14 +++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pygeoapi/util.py b/pygeoapi/util.py index fa99165be..1e9118798 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -265,8 +265,9 @@ def to_json(dict_: dict, pretty: bool = False) -> str: json_dump = json.dumps(dict_, default=json_serial, indent=indent, separators=(',', ':')) - LOGGER.debug('Removing < and >') - json_dump = json_dump.replace('<', '<').replace('>', '>') + LOGGER.debug('Escaping < and >') + json_dump = json_dump.replace('<', '<') + json_dump = json_dump.replace('>', '>') return json_dump diff --git a/tests/other/test_util.py b/tests/other/test_util.py index c2602d241..e17876b39 100644 --- a/tests/other/test_util.py +++ b/tests/other/test_util.py @@ -33,6 +33,7 @@ from io import StringIO from unittest import mock import uuid +from xml.sax.saxutils import unescape import pytest @@ -77,13 +78,20 @@ def test_get_typed_value(): @pytest.mark.parametrize('data,minified,pretty_printed', [ [{'foo': 'bar'}, '{"foo":"bar"}', '{\n "foo":"bar"\n}'], [{'foo': 'bar'}, - '{"foo<script>alert(\\"hi\\")</script>":"bar"}', - '{\n "foo<script>alert(\\"hi\\")</script>":"bar"\n}'] + '{"foo<script>alert(\\"hi\\")</script>":"bar"}', + '{\n "foo<script>alert(\\"hi\\")</script>":"bar"\n}'] ]) def test_to_json(data, minified, pretty_printed): - assert util.to_json(data) == minified + output = util.to_json(data) + assert output == minified assert util.to_json(data, pretty=True) == pretty_printed + unescaped_output = unescape(output) + if '<' in output: + assert '<' in unescaped_output + if '>' in output: + assert '>' in unescaped_output + def test_yaml_load(config): assert isinstance(config, dict) From 1f3a29e7f5d75be123af7cb235c054697623466e Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Wed, 1 Apr 2026 23:15:29 -0400 Subject: [PATCH 39/53] OAProc: ensure binary outputs are not UTF-8 decoded (#2304) --- pygeoapi/process/hello_world.py | 17 ++++++++++++++++- pygeoapi/process/manager/base.py | 5 +++-- tests/api/test_processes.py | 14 +++++++++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pygeoapi/process/hello_world.py b/pygeoapi/process/hello_world.py index 4577929aa..cd9d3192d 100644 --- a/pygeoapi/process/hello_world.py +++ b/pygeoapi/process/hello_world.py @@ -3,7 +3,7 @@ # Authors: Tom Kralidis # Francesco Martinelli # -# Copyright (c) 2022 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2024 Francesco Martinelli # # Permission is hereby granted, free of charge, to any person @@ -29,6 +29,7 @@ # # ================================================================= +import json import logging from pygeoapi.process.base import BaseProcessor, ProcessorExecuteError @@ -82,6 +83,17 @@ 'minOccurs': 0, 'maxOccurs': 1, 'keywords': ['message'] + }, + 'as_bytes': { + 'title': 'As bytes', + 'description': 'Whether to force return as bytes', + 'schema': { + 'type': 'bool', + 'default': False + }, + 'minOccurs': 0, + 'maxOccurs': 1, + 'keywords': ['as_bytes'] } }, 'outputs': { @@ -136,6 +148,9 @@ def execute(self, data, outputs=None): 'value': value } + if data.get('as_bytes', False): + json.dumps(produced_outputs).encode('utf-8') + return mimetype, produced_outputs def __repr__(self): diff --git a/pygeoapi/process/manager/base.py b/pygeoapi/process/manager/base.py index fadb90fbd..632ce4ea2 100644 --- a/pygeoapi/process/manager/base.py +++ b/pygeoapi/process/manager/base.py @@ -4,7 +4,7 @@ # Ricardo Garcia Silva # Francesco Martinelli # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # (c) 2023 Ricardo Garcia Silva # (c) 2026 Francesco Martinelli # @@ -277,7 +277,8 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str, current_status = JobStatus.running jfmt, outputs = p.execute(data_dict, **extra_execute_parameters) - if isinstance(outputs, bytes): + if isinstance(outputs, bytes) and outputs.isascii(): + LOGGER.debug('output is ASCII; decoding utf-8') outputs = outputs.decode('utf-8') if requested_response == RequestedResponse.document.value: diff --git a/tests/api/test_processes.py b/tests/api/test_processes.py index 9d837747c..7f2e3ddaa 100644 --- a/tests/api/test_processes.py +++ b/tests/api/test_processes.py @@ -95,7 +95,7 @@ def test_describe_processes(config, api_): assert process['title'] == 'Hello World' assert len(process['keywords']) == 3 assert len(process['links']) == 6 - assert len(process['inputs']) == 2 + assert len(process['inputs']) == 3 assert len(process['outputs']) == 1 assert len(process['outputTransmission']) == 1 assert len(process['jobControlOptions']) == 2 @@ -242,6 +242,12 @@ def test_execute_process(config, api_): 'name': 'Test document' } } + req_body_10 = { + 'inputs': { + 'name': 'Test document as bytes response', + 'as_bytes': True + } + } cleanup_jobs = set() @@ -410,6 +416,12 @@ def test_execute_process(config, api_): response2 = '{"id":"echo","value":"Hello Test document!"}' assert response == response2 + req = mock_api_request(data=req_body_10) + rsp_headers, code, response = execute_process(api_, req, 'hello-world') + + response2 = '{"id":"echo","value":"Hello Test document as bytes response!"}' # noqa + assert response == response2 + # Cleanup time.sleep(2) # Allow time for any outstanding async jobs for _, job_id in cleanup_jobs: From e24e26de670d0856ace63d4003be3aac59668d74 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 9 Apr 2026 04:00:48 -0400 Subject: [PATCH 40/53] OAProc: fix non JSON outputs (#2311) --- pygeoapi/api/processes.py | 24 ++++++------------------ pygeoapi/process/hello_world.py | 13 ++++++++++++- tests/api/test_processes.py | 15 ++++++++++++++- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py index 74efa2efa..f2a4eb588 100644 --- a/pygeoapi/api/processes.py +++ b/pygeoapi/api/processes.py @@ -327,27 +327,12 @@ def get_jobs(api: API, request: APIRequest, job_result_url = f"{api.base_url}/jobs/{job_['identifier']}/results" # noqa job2['links'] = [{ - 'href': f'{job_result_url}?f={F_HTML}', + 'href': job_result_url, 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results', - 'type': FORMAT_TYPES[F_HTML], - 'title': l10n.translate(f'Results of job as HTML', request.locale), # noqa - }, { - 'href': f'{job_result_url}?f={F_JSON}', - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results', - 'type': FORMAT_TYPES[F_JSON], - 'title': l10n.translate(f'Results of job as JSON', request.locale), # noqa + 'type': job_['mimetype'], + 'title': f"Results of job {job_id} as {job_['mimetype']}" }] - if job_['mimetype'] not in (FORMAT_TYPES[F_JSON], - FORMAT_TYPES[F_HTML]): - - job2['links'].append({ - 'href': job_result_url, - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results', # noqa - 'type': job_['mimetype'], - 'title': f"Results of job {job_id} as {job_['mimetype']}" # noqa - }) - serialized_jobs['jobs'].append(job2) serialized_query_params = '' @@ -528,7 +513,10 @@ def execute_process(api: API, request: APIRequest, pretty_print_ = False response2 = to_json(response, pretty_print_) else: + pretty_print_ = False response2 = response + if isinstance(response, (list, dict)): + response2 = to_json(response, pretty_print_) if (headers.get('Preference-Applied', '') == RequestedProcessExecutionMode.respond_async.value): # noqa LOGGER.debug('Asynchronous mode detected, returning statusInfo') diff --git a/pygeoapi/process/hello_world.py b/pygeoapi/process/hello_world.py index cd9d3192d..bf68ff1be 100644 --- a/pygeoapi/process/hello_world.py +++ b/pygeoapi/process/hello_world.py @@ -94,6 +94,17 @@ 'minOccurs': 0, 'maxOccurs': 1, 'keywords': ['as_bytes'] + }, + 'media_type': { + 'title': 'Media type', + 'description': 'Force a specific media type', + 'schema': { + 'type': 'string', + 'default': 'application/json' + }, + 'minOccurs': 0, + 'maxOccurs': 1, + 'keywords': ['media_type'] } }, 'outputs': { @@ -132,7 +143,7 @@ def __init__(self, processor_def): self.supports_outputs = True def execute(self, data, outputs=None): - mimetype = 'application/json' + mimetype = data.get('media_type', 'application/json') name = data.get('name') if name is None: diff --git a/tests/api/test_processes.py b/tests/api/test_processes.py index 7f2e3ddaa..894579afe 100644 --- a/tests/api/test_processes.py +++ b/tests/api/test_processes.py @@ -95,7 +95,7 @@ def test_describe_processes(config, api_): assert process['title'] == 'Hello World' assert len(process['keywords']) == 3 assert len(process['links']) == 6 - assert len(process['inputs']) == 3 + assert len(process['inputs']) == 4 assert len(process['outputs']) == 1 assert len(process['outputTransmission']) == 1 assert len(process['jobControlOptions']) == 2 @@ -248,6 +248,12 @@ def test_execute_process(config, api_): 'as_bytes': True } } + req_body_11 = { + 'inputs': { + 'name': 'Test document as text/plain media type', + 'media_type': 'text/plain' + } + } cleanup_jobs = set() @@ -422,6 +428,13 @@ def test_execute_process(config, api_): response2 = '{"id":"echo","value":"Hello Test document as bytes response!"}' # noqa assert response == response2 + req = mock_api_request(data=req_body_11) + rsp_headers, code, response = execute_process(api_, req, 'hello-world') + + assert rsp_headers['Content-Type'] == 'text/plain' + response2 = '{"id":"echo","value":"Hello Test document as text/plain media type!"}' # noqa + assert response == response2 + # Cleanup time.sleep(2) # Allow time for any outstanding async jobs for _, job_id in cleanup_jobs: From de09bd37d65ad1a0f7a42333d114898e133d3152 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 9 Apr 2026 04:00:57 -0400 Subject: [PATCH 41/53] remove print statements --- pygeoapi/api/itemtypes.py | 1 - tests/api/test_api.py | 1 - tests/other/test_ogr_capabilities.py | 6 ++---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index db0a6e6fb..27f1c58c9 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -615,7 +615,6 @@ def get_collection_items( if offset > 0: prev_link = True - print(request.format) if prev_link: prev = max(0, offset - limit) url = f'{uri}?offset={prev}{serialized_query_params}' diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 70a5cf587..3236ce503 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -622,7 +622,6 @@ def test_describe_collections(config, api_): collections = json.loads(response) assert len(collections) == 2 - print(json.dumps(collections['collections'])) assert len(collections['collections']) == 10 assert len(collections['links']) == 3 diff --git a/tests/other/test_ogr_capabilities.py b/tests/other/test_ogr_capabilities.py index eb547d571..3f7a3848f 100644 --- a/tests/other/test_ogr_capabilities.py +++ b/tests/other/test_ogr_capabilities.py @@ -1,8 +1,10 @@ # ================================================================= # # Authors: Just van den Broecke +# Tom Kralidis # # Copyright (c) 2023 Just van den Broecke +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -50,7 +52,6 @@ def get_axis_order(coords): def test_transforms(): version_num = int(gdal.VersionInfo('VERSION_NUM')) assert version_num > 3000000, f'GDAL version={version_num} must be > 3.0.0' - print(f'GDAL Version num = {version_num}') pyproj.show_versions() FORCE_LON_LAT = osr.OAMS_TRADITIONAL_GIS_ORDER @@ -68,7 +69,6 @@ def test_transforms(): } for crs in CRS_DICT: - print(f'Testing CRS={crs}') crs_entry = CRS_DICT[crs] source = get_spatial_ref(28992, AUTH_COMPLIANT) target = get_spatial_ref(crs_entry['epsg'], crs_entry['mapping']) @@ -85,7 +85,6 @@ def test_transforms(): axis_order = get_axis_order(result) # Axis order should match that of CRS - print(f'Transform result={result} Axis order={axis_order}') crs_axis_order = crs_entry['order'] assert axis_order == crs_axis_order, f'Axis order for {crs} after Transform should be {crs_axis_order} result={result}' # noqa @@ -106,7 +105,6 @@ def test_transforms(): # Determine Axis order after ExportToJson coords = json_feature['geometry']['coordinates'] axis_order = get_axis_order(coords) - print(f'ExportToJson result={coords} Axis order={axis_order}') assert axis_order == crs_axis_order, f'Axis order for {crs} after ExportToJson should be {crs_axis_order} coords={coords}' # noqa From 82088ce7777fd49638216dcd26066f07474b787f Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 16 Apr 2026 08:09:40 -0400 Subject: [PATCH 42/53] catch EDR instance queries in Starlette --- pygeoapi/starlette_app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pygeoapi/starlette_app.py b/pygeoapi/starlette_app.py index ffb5b656f..6313cbfb3 100644 --- a/pygeoapi/starlette_app.py +++ b/pygeoapi/starlette_app.py @@ -523,6 +523,11 @@ async def get_collection_edr_query(request: Request, if 'collection_id' in request.path_params: collection_id = request.path_params['collection_id'] + if '/instances/' in collection_id: + tokens = collection_id.split('/instances/') + collection_id = tokens[0] + instance_id = tokens[-1] + if 'instance_id' in request.path_params: instance_id = request.path_params['instance_id'] From bf559b622f4dc79ac7a8c10b3e853ef2a87e35c5 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Thu, 16 Apr 2026 09:10:54 -0400 Subject: [PATCH 43/53] fix boolean OpenAPI typing in HelloWorld process (#2316) --- pygeoapi/process/hello_world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygeoapi/process/hello_world.py b/pygeoapi/process/hello_world.py index bf68ff1be..6d53b284e 100644 --- a/pygeoapi/process/hello_world.py +++ b/pygeoapi/process/hello_world.py @@ -88,7 +88,7 @@ 'title': 'As bytes', 'description': 'Whether to force return as bytes', 'schema': { - 'type': 'bool', + 'type': 'boolean', 'default': False }, 'minOccurs': 0, From bf25b8695edbdd5476eeffc102b633d1d3e45f52 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 20 Apr 2026 16:10:55 -0400 Subject: [PATCH 44/53] STAC: secure resource pathing --- pygeoapi/provider/filesystem.py | 11 +++++++++-- tests/provider/test_filesystem_provider.py | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pygeoapi/provider/filesystem.py b/pygeoapi/provider/filesystem.py index db2a824be..1a7e339ca 100644 --- a/pygeoapi/provider/filesystem.py +++ b/pygeoapi/provider/filesystem.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2023 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -34,6 +34,7 @@ import os from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, + ProviderInvalidQueryError, ProviderNotFoundError) from pygeoapi.util import file_modified_iso8601, get_path_basename, url_join @@ -76,9 +77,15 @@ def get_data_path(self, baseurl, urlpath, dirpath): root_link = None child_links = [] - data_path = os.path.join(self.data, dirpath) + if '..' in dirpath: + msg = f'Invalid path requested' + LOGGER.error(f'{msg}: {dirpath}') + raise ProviderInvalidQueryError(msg) + data_path = self.data + dirpath + LOGGER.debug(f'Data path: {data_path}') + if '/' not in dirpath: # root root_link = baseurl else: diff --git a/tests/provider/test_filesystem_provider.py b/tests/provider/test_filesystem_provider.py index f1cfffcf0..37208acb6 100644 --- a/tests/provider/test_filesystem_provider.py +++ b/tests/provider/test_filesystem_provider.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2021 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -30,6 +30,7 @@ import os import pytest +from pygeoapi.provider.base import ProviderInvalidQueryError from pygeoapi.provider.filesystem import FileSystemProvider THISDIR = os.path.dirname(os.path.realpath(__file__)) @@ -73,3 +74,6 @@ def test_query(config): 'osm_id': 'int' } assert r['assets']['default']['href'] == 'http://example.org/stac/poi_portugal.gpkg' # noqa + + with pytest.raises(ProviderInvalidQueryError): + _ = p.get_data_path(baseurl, urlpath, '../../poi_portugal') From 3a63f5b0cc6275e3ae0edb47726b13a43cdd90ef Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 20 Apr 2026 16:11:56 -0400 Subject: [PATCH 45/53] OAProc: secure subscriber URLs in requests --- docs/source/configuration.rst | 4 ++ pygeoapi/process/base.py | 4 +- pygeoapi/process/manager/base.py | 53 ++++++++++++------- pygeoapi/provider/filesystem.py | 2 +- .../schemas/config/pygeoapi-config-0.x.yml | 6 ++- pygeoapi/util.py | 29 ++++++++++ tests/other/test_util.py | 20 ++++++- 7 files changed, 96 insertions(+), 22 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index d718e85e6..fd75e0284 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -292,6 +292,10 @@ default. type: process # REQUIRED (collection, process, or stac-collection) processor: name: HelloWorld # Python path of process definition + # optional, allow for internal HTTP request execution + # if set to True, enables requests to link local ranges and loopback + # default: False + allow_internal_requests: True .. seealso:: diff --git a/pygeoapi/process/base.py b/pygeoapi/process/base.py index 9e2136476..a3dc05279 100644 --- a/pygeoapi/process/base.py +++ b/pygeoapi/process/base.py @@ -3,7 +3,7 @@ # Authors: Tom Kralidis # Francesco Martinelli # -# Copyright (c) 2022 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2024 Francesco Martinelli # # Permission is hereby granted, free of charge, to any person @@ -53,6 +53,8 @@ def __init__(self, processor_def: dict, process_metadata: dict): self.name = processor_def['name'] self.metadata = process_metadata self.supports_outputs = False + self.allow_internal_requests = processor_def.get( + 'allow_internal_requests', False) def set_job_id(self, job_id: str) -> None: """ diff --git a/pygeoapi/process/manager/base.py b/pygeoapi/process/manager/base.py index 632ce4ea2..bb711f354 100644 --- a/pygeoapi/process/manager/base.py +++ b/pygeoapi/process/manager/base.py @@ -46,10 +46,12 @@ BaseProcessor, JobNotFoundError, JobResultNotFoundError, + ProcessorExecuteError, UnknownProcessError, ) from pygeoapi.util import ( get_current_datetime, + is_request_allowed, JobStatus, ProcessExecutionMode, RequestedProcessExecutionMode, @@ -105,7 +107,11 @@ def get_processor(self, process_id: str) -> BaseProcessor: except KeyError as err: raise UnknownProcessError('Invalid process identifier') from err else: - return load_plugin('process', process_conf['processor']) + pp = load_plugin('process', process_conf['processor']) + pp.allow_internal_requests = process_conf.get( + 'allow_internal_requests', False) + + return pp def get_jobs(self, status: JobStatus = None, @@ -395,13 +401,13 @@ def execute_process( """ job_id = str(uuid.uuid1()) - processor = self.get_processor(process_id) - processor.set_job_id(job_id) + self.processor = self.get_processor(process_id) + self.processor.set_job_id(job_id) extra_execute_handler_parameters = { 'requested_response': requested_response } - job_control_options = processor.metadata.get( + job_control_options = self.processor.metadata.get( 'jobControlOptions', []) if execution_mode == RequestedProcessExecutionMode.respond_async: @@ -474,7 +480,7 @@ def execute_process( # TODO: handler's response could also be allowed to include more HTTP # headers mime_type, outputs, status = handler( - processor, + self.processor, job_id, data_dict, requested_outputs, @@ -484,26 +490,37 @@ def execute_process( def _send_in_progress_notification(self, subscriber: Optional[Subscriber]): if subscriber and subscriber.in_progress_uri: - response = requests.post(subscriber.in_progress_uri, json={}) - LOGGER.debug( - f'In progress notification response: {response.status_code}' - ) + self.__do_subscriber_request(subscriber.in_progress_uri) def _send_success_notification( self, subscriber: Optional[Subscriber], outputs: Any ): - if subscriber: - response = requests.post(subscriber.success_uri, json=outputs) - LOGGER.debug( - f'Success notification response: {response.status_code}' - ) + if subscriber and subscriber.success_uri: + self.__do_subscriber_request(subscriber.success_uri, outputs) def _send_failed_notification(self, subscriber: Optional[Subscriber]): if subscriber and subscriber.failed_uri: - response = requests.post(subscriber.failed_uri, json={}) - LOGGER.debug( - f'Failed notification response: {response.status_code}' - ) + self.__do_subscriber_request(subscriber.failed_uri) + + def __do_subscriber_request(self, url: str, data: dict = {}) -> None: + """ + Helper function to execute a subscriber URL via HTTP POST + + :param url: `str` of URL + :param data: `dict` of request payload + + :returns: `None` + """ + + if not is_request_allowed(url, self.processor.allow_internal_requests): + msg = 'URL not allowed' + LOGGER.error(f'{msg}: {url}') + raise ProcessorExecuteError(msg) + + response = requests.post(url, json=data) + LOGGER.debug( + f'Response: {response.status_code}' + ) def __repr__(self): return f' {self.name}' diff --git a/pygeoapi/provider/filesystem.py b/pygeoapi/provider/filesystem.py index 1a7e339ca..f534c1b73 100644 --- a/pygeoapi/provider/filesystem.py +++ b/pygeoapi/provider/filesystem.py @@ -78,7 +78,7 @@ def get_data_path(self, baseurl, urlpath, dirpath): child_links = [] if '..' in dirpath: - msg = f'Invalid path requested' + msg = 'Invalid path requested' LOGGER.error(f'{msg}: {dirpath}') raise ProviderInvalidQueryError(msg) diff --git a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml index 19c18f5dc..aaee641ec 100644 --- a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml +++ b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml @@ -682,7 +682,11 @@ properties: For custom built plugins, use the import path (e.g. `mypackage.provider.MyProvider`) required: - name - required: + allow_internal_requests: + type: boolean + description: whether to allow internal HTTP requests + default: false + requred: - type - processor definitions: diff --git a/pygeoapi/util.py b/pygeoapi/util.py index 1e9118798..30b29f5d8 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -36,6 +36,7 @@ from decimal import Decimal from enum import Enum from heapq import heappush +import ipaddress import json import logging import mimetypes @@ -43,6 +44,7 @@ import pathlib from pathlib import Path import re +import socket from typing import Any, IO, Union, List, Optional from urllib.parse import urlparse from urllib.request import urlopen @@ -755,3 +757,30 @@ def remove_url_auth(url: str) -> str: u = urlparse(url) auth = f'{u.username}:{u.password}@' return url.replace(auth, '') + + +def is_request_allowed(url: str, allow_internal: bool = False) -> bool: + """ + Test whether an HTTP request is allowed to be executed + + :param url: `str` of URL + :param allow_internal: `bool` of whether internal requests are + allowed (default `False`) + + :returns: `bool` of whether HTTP request execution is allowed + """ + + is_allowed = False + + u = urlparse(url) + + ip = socket.gethostbyname(u.hostname) + + is_private = ipaddress.ip_address(ip).is_private + + if not is_private: + is_allowed = True + if is_private and allow_internal: + is_allowed = True + + return is_allowed diff --git a/tests/other/test_util.py b/tests/other/test_util.py index e17876b39..df9ea2f57 100644 --- a/tests/other/test_util.py +++ b/tests/other/test_util.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -329,3 +329,21 @@ def test_get_choice_from_headers(): 'accept') == 'application/ld+json' assert util.get_choice_from_headers( {'accept-language': 'en_US', 'accept': '*/*'}, 'accept') == '*/*' + + +@pytest.mark.parametrize('url,allow_internal,result', [ + ['http://127.0.0.1/test', False, False], + ['http://127.0.0.1/test', True, True], + ['http://192.168.0.12/test', False, False], + ['http://192.168.0.12/test', True, True], + ['http://169.254.0.11/test', False, False], + ['http://169.254.0.11/test', True, True], + ['http://0.0.0.0/test', True, True], + ['http://0.0.0.0/test', False, False], + ['http://localhost:5000/test', False, False], + ['http://localhost:5000/test', True, True], + ['https://pygeoapi.io', False, True], + ['https://pygeoapi.io', True, True] +]) +def test_is_request_allowed(url, allow_internal, result): + assert util.is_request_allowed(url, allow_internal) is result From ea5c2f00add1e2af8092d602f3d68205d9d59470 Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:26:23 -0400 Subject: [PATCH 46/53] Migrate to services approach for CI (#2322) --- .github/workflows/main.yml | 89 +++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a9266caec..6a9e4c1c8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,16 +34,42 @@ jobs: env: POSTGRES_DB: test POSTGRES_PASSWORD: ${{ secrets.DatabasePassword || 'postgres' }} - + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: mysql + MYSQL_DATABASE: test_geo_app + MYSQL_USER: pygeoapi + MYSQL_PASSWORD: mysql + ports: + - 3306:3306 + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0 + ports: + - 9200:9200 + - 9300:9300 + env: + discovery.type: single-node + xpack.security.enabled: "false" + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + opensearch: + image: opensearchproject/opensearch:2.18.0 + ports: + - 9209:9200 + env: + discovery.type: single-node + DISABLE_SECURITY_PLUGIN: "true" + OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m" + mongodb: + image: mongo:8.0.4 + ports: + - 27017:27017 + sensorthings: + image: ghcr.io/cgs-earth/sensorthings-action:0.1.2 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + steps: - - name: Pre-pull Docker Images - run: | - docker pull container-registry.oracle.com/database/express:21.3.0-xe & - docker pull appropriate/curl:latest & - docker pull elasticsearch:8.17.0 & - docker pull opensearchproject/opensearch:2.18.0 & - docker pull mongo:8.0.4 & - docker pull postgis/postgis:14-3.2 & - name: Clear up GitHub runner diskspace run: | echo "Space before" @@ -60,43 +86,21 @@ jobs: name: Setup Python ${{ matrix.python-version }} with: python-version: ${{ matrix.python-version }} + - name: Install and run Oracle + run: | + docker run \ + -d \ + --name oracledb \ + -e ORACLE_PWD=oracle \ + -v ${{ github.workspace }}/tests/data/oracle/init-db:/opt/oracle/scripts/startup \ + -p 1521:1521 \ + container-registry.oracle.com/database/express:21.3.0-xe - name: Configure sysctl limits run: | sudo swapoff -a sudo sysctl -w vm.swappiness=1 sudo sysctl -w fs.file-max=262144 sudo sysctl -w vm.max_map_count=262144 - - name: "Install and run MySQL 📦" - uses: mirromutth/mysql-action@v1.1 - with: - host port: 3306 - mysql version: '8.0' - mysql database: test_geo_app - mysql root password: mysql # This is a dummy password here; not actually used in prod - mysql user: pygeoapi - mysql password: mysql - - - name: Install and run Elasticsearch 📦 - uses: getong/elasticsearch-action@v1.2 - with: - elasticsearch version: '8.17.0' - host port: 9200 - container port: 9200 - host node port: 9300 - node port: 9300 - discovery type: 'single-node' - - name: Install and run OpenSearch 📦 - uses: esmarkowski/opensearch-github-action@v1.0.0 - with: - version: 2.18.0 - security-disabled: true - port: 9209 - - name: Install and run MongoDB - uses: supercharge/mongodb-github-action@1.12.0 - with: - mongodb-version: '8.0.4' - - name: Install and run SensorThingsAPI - uses: cgs-earth/sensorthings-action@v0.1.2 - name: Install sqlite and gpkg dependencies uses: awalsh128/cache-apt-pkgs-action@v1.4.3 with: @@ -113,9 +117,6 @@ jobs: # with: # packages: gdal-bin libgdal-dev # version: 3.11.3 - - name: Install and run Oracle - run: | - docker run -d --name oracledb -e ORACLE_PWD=oracle -v ${{ github.workspace }}/tests/data/oracle/init-db:/opt/oracle/scripts/startup -p 1521:1521 container-registry.oracle.com/database/express:21.3.0-xe - name: Install requirements 📦 run: | pip3 install setuptools @@ -131,6 +132,7 @@ jobs: pip3 install GDAL==`gdal-config --version` - name: setup test data ⚙️ run: | + python3 tests/load_oracle_data.py python3 tests/load_es_data.py tests/data/ne_110m_populated_places_simple.geojson geonameid python3 tests/load_opensearch_data.py tests/data/ne_110m_populated_places_simple.geojson geonameid python3 tests/load_mongo_data.py tests/data/ne_110m_populated_places_simple.geojson @@ -140,7 +142,6 @@ jobs: psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test -f tests/data/postgres_manager_full_structure.backup.sql mysql -h 127.0.0.1 -P 3306 -u root -p'mysql' test_geo_app < tests/data/mysql_data.sql docker ps - python3 tests/load_oracle_data.py - name: run API tests ⚙️ run: pytest tests/api --ignore-glob='*_live.py' - name: run Formatter tests ⚙️ From e09a6d3fc715099fd3452b8ce0ff87a44698113b Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 14:44:41 -0700 Subject: [PATCH 47/53] Port #1926 Port #1926 with updates for CovJSON Formatting --- pygeoapi/formatter/csv_.py | 67 ++++++++++--- tests/formatter/test_csv__formatter.py | 134 ++++++++++++++++++------- 2 files changed, 147 insertions(+), 54 deletions(-) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index 2dd8c9dfb..c34ac8b1d 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -31,6 +31,8 @@ import io import logging +from shapely.geometry import shape as geojson_to_geom + from pygeoapi.formatter.base import BaseFormatter, FormatterSerializationError LOGGER = logging.getLogger(__name__) @@ -60,12 +62,28 @@ def write(self, options: dict = {}, data: dict = None) -> str: Generate data in CSV format :param options: CSV formatting options - :param data: dict of GeoJSON data + :param data: dict of data :returns: string representation of format """ + type = data.get('type') or '' + LOGGER.debug(f'Formatting CSV from data type: {type}') + + if 'Feature' in type or 'features' in data: + return self._write_from_geojson(options, data) + + def _write_from_geojson( + self, options: dict = {}, data: dict = None, is_point=False + ) -> str: + """ + Generate GeoJSON data in CSV format - is_point = False + :param options: CSV formatting options + :param data: dict of GeoJSON data + :param is_point: whether the features are point geometries + + :returns: string representation of format + """ try: fields = list(data['features'][0]['properties'].keys()) except IndexError: @@ -75,27 +93,44 @@ def write(self, options: dict = {}, data: dict = None) -> str: if self.geom: LOGGER.debug('Including point geometry') if data['features'][0]['geometry']['type'] == 'Point': - fields.insert(0, 'x') - fields.insert(1, 'y') + LOGGER.debug('point geometry detected, adding x,y columns') + fields.extend(['x', 'y']) is_point = True else: - # TODO: implement wkt geometry serialization - LOGGER.debug('not a point geometry, skipping') + LOGGER.debug('not a point geometry, adding wkt column') + fields.append('wkt') LOGGER.debug(f'CSV fields: {fields}') + output = io.StringIO() + writer = csv.DictWriter(output, fields) + writer.writeheader() - try: - output = io.StringIO() - writer = csv.DictWriter(output, fields) - writer.writeheader() + for feature in data['features']: + self._add_feature(writer, feature, is_point) - for feature in data['features']: - fp = feature['properties'] + return output.getvalue().encode('utf-8') + + def _add_feature( + self, writer: csv.DictWriter, feature: dict, is_point: bool + ) -> None: + """ + Add feature data to CSV writer + + :param writer: CSV DictWriter + :param feature: dict of GeoJSON feature + :param is_point: whether the feature is a point geometry + """ + fp = feature['properties'] + try: + if self.geom: if is_point: - fp['x'] = feature['geometry']['coordinates'][0] - fp['y'] = feature['geometry']['coordinates'][1] - LOGGER.debug(fp) - writer.writerow(fp) + [fp['x'], fp['y']] = feature['geometry']['coordinates'] + else: + geom = geojson_to_geom(feature['geometry']) + fp['wkt'] = geom.wkt + + LOGGER.debug(f'Writing feature to row: {fp}') + writer.writerow(fp) except ValueError as err: LOGGER.error(err) raise FormatterSerializationError('Error writing CSV output') diff --git a/tests/formatter/test_csv__formatter.py b/tests/formatter/test_csv__formatter.py index c01e23c24..8b082bc3f 100644 --- a/tests/formatter/test_csv__formatter.py +++ b/tests/formatter/test_csv__formatter.py @@ -27,56 +27,114 @@ # # ================================================================= -import csv -import io +from csv import DictReader +from io import StringIO +import json + import pytest +from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.formatter.csv_ import CSVFormatter +from ..util import get_test_file_path -@pytest.fixture() -def fixture(): - data = { - 'features': [{ - 'geometry': { - 'type': 'Point', - 'coordinates': [ - -130.44472222222223, - 54.28611111111111 - ] - }, - 'type': 'Feature', - 'properties': { - 'id': 1972, - 'foo': 'bar', - 'title': None, - }, - 'id': 48693 - }] + +@pytest.fixture +def data(): + data_path = get_test_file_path('data/items.geojson') + with open(data_path, 'r', encoding='utf-8') as fh: + return json.load(fh) + + +@pytest.fixture(scope='function') +def csv_reader_geom_enabled(data): + """csv_reader with geometry enabled""" + formatter = CSVFormatter({'geom': True}) + output = formatter.write(data=data) + return DictReader(StringIO(output.decode('utf-8'))) + + +@pytest.fixture +def invalid_geometry_data(): + return { + 'features': [ + { + 'id': 1, + 'type': 'Feature', + 'properties': { + 'id': 1, + 'title': 'Invalid Point Feature' + }, + 'geometry': { + 'type': 'Point', + 'coordinates': [-130.44472222222223] + } + } + ] } - return data +def test_write_with_geometry_enabled(csv_reader_geom_enabled): + """Test CSV output with geometry enabled""" + rows = list(csv_reader_geom_enabled) + + # Verify the header + header = list(csv_reader_geom_enabled.fieldnames) + assert len(header) == 4 -def test_csv__formatter(fixture): - f = CSVFormatter({'geom': True}) - f_csv = f.write(data=fixture) + # Verify number of rows + assert len(rows) == 9 - buffer = io.StringIO(f_csv.decode('utf-8')) - reader = csv.DictReader(buffer) - header = list(reader.fieldnames) +def test_write_without_geometry(data): + formatter = CSVFormatter({'geom': False}) + output = formatter.write(data=data) + csv_reader = DictReader(StringIO(output.decode('utf-8'))) + + """Test CSV output with geometry disabled""" + rows = list(csv_reader) + + # Verify headers don't include geometry + headers = csv_reader.fieldnames + assert 'geometry' not in headers + + # Verify data + first_row = rows[0] + assert first_row['uri'] == \ + 'http://localhost:5000/collections/objects/items/1' + assert first_row['name'] == 'LineString' + + +def test_write_empty_features(): + """Test handling of empty feature collection""" + formatter = CSVFormatter({'geom': True}) + data = { + 'features': [] + } + output = formatter.write(data=data) + assert output == '' + - assert f.mimetype == 'text/csv; charset=utf-8' +@pytest.mark.parametrize( + 'row_index,expected_wkt', + [ + (2, 'POINT (-85 33)'), + (3, 'MULTILINESTRING ((10 10, 20 20, 10 40), (40 40, 30 30, 40 20, 30 10))'), # noqa + (4, 'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))'), + (5, 'POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10), (20 30, 35 35, 30 20, 20 30))'), # noqa + (6, 'MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)), ((15 5, 40 10, 10 20, 5 10, 15 5)))') # noqa + ] +) +def test_wkt(csv_reader_geom_enabled, row_index, expected_wkt): + """Test CSV output of multi-point geometry""" + rows = list(csv_reader_geom_enabled) - assert len(header) == 5 + # Verify data + geometry_row = rows[row_index] + assert geometry_row['wkt'] == expected_wkt - assert 'x' in header - assert 'y' in header - data = next(reader) - assert data['x'] == '-130.44472222222223' - assert data['y'] == '54.28611111111111' - assert data['id'] == '1972' - assert data['foo'] == 'bar' - assert data['title'] == '' +def test_invalid_geometry_data(invalid_geometry_data): + formatter = CSVFormatter({'geom': True}) + with pytest.raises(FormatterSerializationError): + formatter.write(data=invalid_geometry_data) From c6783204cc4563c8b8eab3dffa3f40de1d8c3bef Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 16:21:19 -0700 Subject: [PATCH 48/53] Add CovJSON CSV Formatter --- pygeoapi/formatter/csv_.py | 74 ++++++++++++++++++++++++++ tests/formatter/test_csv__formatter.py | 58 ++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index c34ac8b1d..1c42b1c64 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -71,6 +71,8 @@ def write(self, options: dict = {}, data: dict = None) -> str: if 'Feature' in type or 'features' in data: return self._write_from_geojson(options, data) + elif 'Coverage' in type or 'coverages' in data: + return self._write_from_covjson(options, data) def _write_from_geojson( self, options: dict = {}, data: dict = None, is_point=False @@ -135,7 +137,79 @@ def _add_feature( LOGGER.error(err) raise FormatterSerializationError('Error writing CSV output') + def _write_from_covjson( + self, options: dict = {}, data: dict = None + ) -> str: + """ + Generate CovJSON data in CSV format + + :param options: CSV formatting options + :param data: dict of CovJSON data + + :returns: string representation of format + """ + LOGGER.debug('Processing CovJSON data for CSV output') + units = {} + for p, v in data['parameters'].items(): + unit = v['unit']['symbol'] + if isinstance(unit, dict): + unit = unit.get('value') + + units[p] = unit + + fields = ['parameter', 'datetime', 'value', 'unit', 'x', 'y'] + LOGGER.debug(f'CSV fields: {fields}') + output = io.StringIO() + writer = csv.DictWriter(output, fields) + writer.writeheader() + + if data['type'] == 'Coverage': + is_point = 'point' in data['domain']['domainType'].lower() + self._add_coverage(writer, units, data, is_point) + else: + [ + self._add_coverage(writer, units, coverage, True) + for coverage in data['coverages'] + if 'point' in coverage['domain']['domainType'].lower() + ] return output.getvalue().encode('utf-8') + @staticmethod + def _add_coverage( + writer: csv.DictWriter, units: dict, data: dict, is_point: bool = False + ) -> None: + """ + Add coverage data to CSV writer + + :param writer: CSV DictWriter + :param units: dict of parameter units + :param data: dict of CovJSON coverage data + :param is_point: whether the coverage is a point coverage + """ + + if is_point is False: + LOGGER.warning('Non-point coverages not supported for CSV output') + return + + axes = data['domain']['axes'] + time_range = range(len(axes['t']['values'])) + + try: + [ + writer.writerow({ + 'parameter': parameter, + 'datetime': axes['t']['values'][time_value], + 'value': data['ranges'][parameter]['values'][time_value], + 'unit': units[parameter], + 'x': axes['x']['values'][-1], + 'y': axes['y']['values'][-1] + }) + for parameter in data['ranges'] + for time_value in time_range + ] + except ValueError as err: + LOGGER.error(err) + raise FormatterSerializationError('Error writing CSV output') + def __repr__(self): return f' {self.name}' diff --git a/tests/formatter/test_csv__formatter.py b/tests/formatter/test_csv__formatter.py index 8b082bc3f..c2fbe98da 100644 --- a/tests/formatter/test_csv__formatter.py +++ b/tests/formatter/test_csv__formatter.py @@ -138,3 +138,61 @@ def test_invalid_geometry_data(invalid_geometry_data): formatter = CSVFormatter({'geom': True}) with pytest.raises(FormatterSerializationError): formatter.write(data=invalid_geometry_data) + + +@pytest.fixture +def point_coverage_data(): + return { + 'type': 'Coverage', + 'domain': { + 'type': 'Domain', + 'domainType': 'PointSeries', + 'axes': { + 'x': {'values': [-10.1]}, + 'y': {'values': [-40.2]}, + 't': {'values': [ + '2013-01-01', '2013-01-02', '2013-01-03', + '2013-01-04', '2013-01-05', '2013-01-06']} + } + }, + 'parameters': { + 'PSAL': { + 'type': 'Parameter', + 'description': {'en': 'The measured salinity'}, + 'unit': {'symbol': 'psu'}, + 'observedProperty': { + 'id': 'http://vocab.nerc.ac.uk/standard_name/sea_water_salinity/', # noqa + 'label': {'en': 'Sea Water Salinity'} + } + } + }, + 'ranges': { + 'PSAL': { + 'axisNames': ['t'], + 'shape': [6], + 'values': [ + 43.9599, 43.9599, 43.9640, 43.9640, 43.9679, 43.987 + ] + } + } + } + + +def test_point_coverage_csv(point_coverage_data): + """Test CSV output of point coverage data""" + formatter = CSVFormatter({'geom': True}) + output = formatter.write(data=point_coverage_data) + csv_reader = DictReader(StringIO(output.decode('utf-8'))) + rows = list(csv_reader) + + # Verify number of rows + assert len(rows) == 6 + + # Verify data + first_row = rows[0] + assert first_row['parameter'] == 'PSAL' + assert first_row['datetime'] == '2013-01-01' + assert first_row['value'] == '43.9599' + assert first_row['unit'] == 'psu' + assert first_row['x'] == '-10.1' + assert first_row['y'] == '-40.2' From ff6b466131c623072b853ed44c40f30d0d9a067a Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 16:42:30 -0700 Subject: [PATCH 49/53] Update EDR Content Type --- pygeoapi/api/environmental_data_retrieval.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py index 98a77cc2a..700a6fc91 100644 --- a/pygeoapi/api/environmental_data_retrieval.py +++ b/pygeoapi/api/environmental_data_retrieval.py @@ -494,8 +494,14 @@ def get_collection_edr_query(api: API, request: APIRequest, HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, 'NoApplicableCode', msg) + headers['Content-Type'] = formatter.mimetype + if formatter.attachment: - filename = f'{dataset}.{formatter.extension}' + if p.filename is None: + filename = f'{dataset}.{formatter.extension}' + else: + filename = f'{p.filename}' + cd = f'attachment; filename="{filename}"' headers['Content-Disposition'] = cd From 954477565986894190c5e9a494a69b480593a930 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 16:58:21 -0700 Subject: [PATCH 50/53] Add original tests back --- tests/formatter/test_csv__formatter.py | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/formatter/test_csv__formatter.py b/tests/formatter/test_csv__formatter.py index c2fbe98da..2bb3cb013 100644 --- a/tests/formatter/test_csv__formatter.py +++ b/tests/formatter/test_csv__formatter.py @@ -39,6 +39,30 @@ from ..util import get_test_file_path +@pytest.fixture() +def fixture(): + data = { + 'features': [{ + 'geometry': { + 'type': 'Point', + 'coordinates': [ + -130.44472222222223, + 54.28611111111111 + ] + }, + 'type': 'Feature', + 'properties': { + 'id': 1972, + 'foo': 'bar', + 'title': None, + }, + 'id': 48693 + }] + } + + return data + + @pytest.fixture def data(): data_path = get_test_file_path('data/items.geojson') @@ -74,6 +98,30 @@ def invalid_geometry_data(): } +def test_csv__formatter(fixture): + f = CSVFormatter({'geom': True}) + f_csv = f.write(data=fixture) + + buffer = StringIO(f_csv.decode('utf-8')) + reader = DictReader(buffer) + + header = list(reader.fieldnames) + + assert f.mimetype == 'text/csv; charset=utf-8' + + assert len(header) == 5 + + assert 'x' in header + assert 'y' in header + + data = next(reader) + assert data['x'] == '-130.44472222222223' + assert data['y'] == '54.28611111111111' + assert data['id'] == '1972' + assert data['foo'] == 'bar' + assert data['title'] == '' + + def test_write_with_geometry_enabled(csv_reader_geom_enabled): """Test CSV output with geometry enabled""" rows = list(csv_reader_geom_enabled) From 40ec4cbb017763b3aa6b8c62e30ead799a0f93ad Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Mon, 2 Feb 2026 18:26:43 -0700 Subject: [PATCH 51/53] Do not throw error on additional keys --- pygeoapi/formatter/csv_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index 1c42b1c64..aeb4f5491 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -104,7 +104,7 @@ def _write_from_geojson( LOGGER.debug(f'CSV fields: {fields}') output = io.StringIO() - writer = csv.DictWriter(output, fields) + writer = csv.DictWriter(output, fields, extrasaction='ignore') writer.writeheader() for feature in data['features']: From 3d736bc0a92dcb1e487a8d21aee994bc65c48988 Mon Sep 17 00:00:00 2001 From: Benjamin Webb <40066515+webb-ben@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:55:06 -0500 Subject: [PATCH 52/53] Change x,y columns order to be at the start --- pygeoapi/formatter/csv_.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index aeb4f5491..7171f3584 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -96,7 +96,8 @@ def _write_from_geojson( LOGGER.debug('Including point geometry') if data['features'][0]['geometry']['type'] == 'Point': LOGGER.debug('point geometry detected, adding x,y columns') - fields.extend(['x', 'y']) + fields.insert(0, 'x') + fields.insert(1, 'y') is_point = True else: LOGGER.debug('not a point geometry, adding wkt column') @@ -126,7 +127,8 @@ def _add_feature( try: if self.geom: if is_point: - [fp['x'], fp['y']] = feature['geometry']['coordinates'] + fp['x'] = feature['geometry']['coordinates'][0] + fp['y'] = feature['geometry']['coordinates'][1] else: geom = geojson_to_geom(feature['geometry']) fp['wkt'] = geom.wkt From 2bf449e4501b2fe8081f8dbebd88b63f537d65e2 Mon Sep 17 00:00:00 2001 From: Benjamin Webb Date: Fri, 24 Apr 2026 13:35:53 -0400 Subject: [PATCH 53/53] Fix CI --- pygeoapi/formatter/csv_.py | 2 +- tests/formatter/test_csv__formatter.py | 77 +++++++++++++------------- 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py index 7171f3584..33367ace8 100644 --- a/pygeoapi/formatter/csv_.py +++ b/pygeoapi/formatter/csv_.py @@ -135,7 +135,7 @@ def _add_feature( LOGGER.debug(f'Writing feature to row: {fp}') writer.writerow(fp) - except ValueError as err: + except (ValueError, IndexError) as err: LOGGER.error(err) raise FormatterSerializationError('Error writing CSV output') diff --git a/tests/formatter/test_csv__formatter.py b/tests/formatter/test_csv__formatter.py index 2bb3cb013..31036e180 100644 --- a/tests/formatter/test_csv__formatter.py +++ b/tests/formatter/test_csv__formatter.py @@ -62,6 +62,45 @@ def fixture(): return data +@pytest.fixture +def point_coverage_data(): + data = { + 'type': 'Coverage', + 'domain': { + 'type': 'Domain', + 'domainType': 'PointSeries', + 'axes': { + 'x': {'values': [-10.1]}, + 'y': {'values': [-40.2]}, + 't': {'values': [ + '2013-01-01', '2013-01-02', '2013-01-03', + '2013-01-04', '2013-01-05', '2013-01-06']} + } + }, + 'parameters': { + 'PSAL': { + 'type': 'Parameter', + 'description': {'en': 'The measured salinity'}, + 'unit': {'symbol': 'psu'}, + 'observedProperty': { + 'id': 'http://vocab.nerc.ac.uk/standard_name/sea_water_salinity/', # noqa + 'label': {'en': 'Sea Water Salinity'} + } + } + }, + 'ranges': { + 'PSAL': { + 'axisNames': ['t'], + 'shape': [6], + 'values': [ + 43.9599, 43.9599, 43.9640, 43.9640, 43.9679, 43.987 + ] + } + } + } + + return data + @pytest.fixture def data(): @@ -188,44 +227,6 @@ def test_invalid_geometry_data(invalid_geometry_data): formatter.write(data=invalid_geometry_data) -@pytest.fixture -def point_coverage_data(): - return { - 'type': 'Coverage', - 'domain': { - 'type': 'Domain', - 'domainType': 'PointSeries', - 'axes': { - 'x': {'values': [-10.1]}, - 'y': {'values': [-40.2]}, - 't': {'values': [ - '2013-01-01', '2013-01-02', '2013-01-03', - '2013-01-04', '2013-01-05', '2013-01-06']} - } - }, - 'parameters': { - 'PSAL': { - 'type': 'Parameter', - 'description': {'en': 'The measured salinity'}, - 'unit': {'symbol': 'psu'}, - 'observedProperty': { - 'id': 'http://vocab.nerc.ac.uk/standard_name/sea_water_salinity/', # noqa - 'label': {'en': 'Sea Water Salinity'} - } - } - }, - 'ranges': { - 'PSAL': { - 'axisNames': ['t'], - 'shape': [6], - 'values': [ - 43.9599, 43.9599, 43.9640, 43.9640, 43.9679, 43.987 - ] - } - } - } - - def test_point_coverage_csv(point_coverage_data): """Test CSV output of point coverage data""" formatter = CSVFormatter({'geom': True})