diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 66ec83e..0990258 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,10 +24,16 @@ jobs: name: Setup Python with: python-version: 3.12 + - name: Use ubuntuGIS unstable ppa + run: | + sudo add-apt-repository ppa:ubuntugis/ubuntugis-unstable + sudo apt update + sudo apt-get install gdal-bin libgdal-dev -y - name: Install requirements 📦 run: | pip3 install setuptools pip3 install https://github.com/geopython/pygeoapi/archive/refs/heads/master.zip + pip3 install GDAL==`gdal-config --version` pip3 install -r requirements.txt pip3 install -r requirements-dev.txt python3 setup.py install @@ -41,6 +47,7 @@ jobs: run: | # pytest tests/test_ckan_provider.py pytest tests/test_geopandas_provider.py + pytest tests/test_jsonfg_formatter.py pytest tests/test_postgresql_provider.py pytest tests/test_mvt_postgresql_provider.py pytest tests/test_sitemap_process.py diff --git a/Dockerfile b/Dockerfile index 4943172..4590b7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,4 +7,6 @@ ADD . /pygeoapi_plugins RUN /venv/bin/python3 -m pip install -r /pygeoapi_plugins/requirements.txt \ && /venv/bin/python3 -m pip install -e /pygeoapi_plugins +RUN /venv/bin/python3 -m pip install GDAL>=3.12.0 + ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/pygeoapi_plugins/formatter/jsonfg.py b/pygeoapi_plugins/formatter/jsonfg.py new file mode 100644 index 0000000..d18db06 --- /dev/null +++ b/pygeoapi_plugins/formatter/jsonfg.py @@ -0,0 +1,131 @@ +# ================================================================= +# +# Authors: Francesco Bartoli +# Benjamin Webb +# +# Copyright (c) 2025 Francesco Bartoli +# Copyright (c) 2026 Lincoln Institute of Land Policy +# +# 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. +# +# ================================================================= +"""JSON-FG capabilities +Returns content as JSON-FG representations +""" + +import json +import logging +import uuid + +from osgeo import gdal + +from pygeoapi.formatter.base import BaseFormatter, FormatterSerializationError + + +LOGGER = logging.getLogger(__name__) + + +class JSONFGFormatter(BaseFormatter): + """JSON-FG formatter""" + + def __init__(self, formatter_def: dict): + """ + Initialize object + + :param formatter_def: formatter definition + + :returns: `pygeoapi_plugins.formatter.jsonfg.JSONFGFormatter` + """ + + geom = False + if 'geom' in formatter_def: + geom = formatter_def['geom'] + + super().__init__({'name': 'JSONFG', 'geom': geom}) + self.mimetype = 'application/geo+json' + self.f = 'jsonfg' + self.extension = 'json' + + def write(self, options: dict = {}, data: dict = None) -> dict: + """ + Generate data in JSON-FG format + + :param options: JSON-FG formatting options + :param data: dict of GeoJSON data + + :returns: string representation of format + """ + + try: + fields = list( + data['features'][0]['properties'].keys() + if data.get('features') + else data['properties'].keys() + ) + except IndexError: + LOGGER.error('no features') + return dict() + + LOGGER.debug(f'JSONFG fields: {fields}') + + try: + links = data.get('links') + output = geojson2jsonfg(data=data) + output['links'] = links + return output + except ValueError as err: + LOGGER.error(err) + raise FormatterSerializationError('Error writing JSONFG output') + + def __repr__(self): + return f' {self.name}' + + +def geojson2jsonfg(data: dict) -> dict: + """ + Return JSON-FG from a GeoJSON content. + + :param data: dict of data + + :returns: dict of converted GeoJSON (JSON-FG) + """ + gdal.UseExceptions() + LOGGER.debug('Dump GeoJSON content into a data source') + try: + with gdal.OpenEx(json.dumps(data)) as srcDS: + tmpfile = f'/vsimem/{uuid.uuid1()}.json' + LOGGER.debug('Translate GeoJSON into a JSONFG memory file') + gdal.VectorTranslate(tmpfile, srcDS, format='JSONFG') + LOGGER.debug('Read JSONFG content from a memory file') + data = gdal.VSIFOpenL(tmpfile, 'rb') + if not data: + raise ValueError('Failed to read JSONFG content') + gdal.VSIFSeekL(data, 0, 2) + length = gdal.VSIFTellL(data) + gdal.VSIFSeekL(data, 0, 0) + jsonfg = json.loads(gdal.VSIFReadL(1, length, data).decode()) + return jsonfg + except Exception as e: + LOGGER.error(f'Failed to convert GeoJSON to JSON-FG: {e}') + raise + finally: + gdal.VSIFCloseL(data) diff --git a/pygeoapi_plugins/provider/geopandas_.py b/pygeoapi_plugins/provider/geopandas_.py index a673053..0ba5ee1 100644 --- a/pygeoapi_plugins/provider/geopandas_.py +++ b/pygeoapi_plugins/provider/geopandas_.py @@ -501,7 +501,8 @@ def create(self, item): if len(item) != len(self.gdf.columns): raise ProviderQueryError('Item to update does not match dataframe shape') - self.gdf = self.gdf._append(item, ignore_index=True) + new_row = geopandas.GeoDataFrame([item], crs=self.gdf.crs) + self.gdf = pandas.concat([self.gdf, new_row], ignore_index=True) return self.gdf[self.id_field].iloc[-1] diff --git a/requirements.txt b/requirements.txt index 996bc01..39c8d55 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ pygeoapi +GDAL geoalchemy geopandas numpy diff --git a/tests/test_geopandas_provider.py b/tests/test_geopandas_provider.py index fb12193..858fe16 100644 --- a/tests/test_geopandas_provider.py +++ b/tests/test_geopandas_provider.py @@ -30,6 +30,7 @@ import datetime import geopandas as gpd +import pandas as pd import pytest import shapely @@ -284,7 +285,8 @@ def test_gpkg_sort_query(gpkg_config): 'geometry': shapely.box(0, 0, 0, 0), } - p.gdf = p.gdf._append(dummy_row, ignore_index=True) + new_row = gpd.GeoDataFrame([dummy_row], crs=p.gdf.crs) + p.gdf = pd.concat([p.gdf, new_row], ignore_index=True) assert (len(p.gdf)) == 23 results = p.query( diff --git a/tests/test_jsonfg_formatter.py b/tests/test_jsonfg_formatter.py new file mode 100644 index 0000000..2f0f729 --- /dev/null +++ b/tests/test_jsonfg_formatter.py @@ -0,0 +1,82 @@ +# ================================================================= +# +# Authors: Francesco Bartoli +# Benjamin Webb +# +# Copyright (c) 2025 Francesco Bartoli +# Copyright (c) 2026 Lincoln Institute of Land Policy +# +# 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_plugins.formatter.jsonfg import JSONFGFormatter + + +@pytest.fixture() +def fixture(): + data = { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'id': '123-456', + 'geometry': {'type': 'Point', 'coordinates': [125.6, 10.1]}, + 'properties': {'name': 'Dinagat Islands', 'foo': 'bar'}, + } + ], + 'links': [ + { + 'rel': 'self', + 'type': 'application/geo+json', + 'title': 'GeoJSON', + 'href': 'http://example.com', + } + ], + } + + return data + + +def test_jsonfg__formatter(fixture): + f = JSONFGFormatter({'geom': True}) + f_jsonfg = f.write(data=fixture) + + assert f.mimetype == 'application/geo+json' + + assert f_jsonfg['type'] == 'FeatureCollection' + assert f_jsonfg['features'][0]['type'] == 'Feature' + assert f_jsonfg['features'][0]['geometry']['type'] == 'Point' + assert f_jsonfg['features'][0]['geometry']['coordinates'] == [125.6, 10.1] + assert f_jsonfg['features'][0]['properties']['id'] == '123-456' + assert f_jsonfg['features'][0]['properties']['name'] == 'Dinagat Islands' + assert f_jsonfg['features'][0]['properties']['foo'] == 'bar' + + assert f_jsonfg['featureType'] == 'OGRGeoJSON' + assert f_jsonfg['conformsTo'] + assert f_jsonfg['coordRefSys'] == '[EPSG:4326]' + assert f_jsonfg['features'][0]['place'] is None + assert f_jsonfg['features'][0]['time'] is None + + assert len(f_jsonfg['links']) == 1