66 Commits

Author SHA1 Message Date
Youssef Harby 0235bba4e5 Add Arabic Translation and RTL Support (#1854)
flake8 / flake8_py3 (push) Has been cancelled
Build / main (3.10) (push) Has been cancelled
Build / admin (3.10) (push) Has been cancelled
Check vulnerabilities / vulnerabilities (push) Has been cancelled
* Update HTML template to support dynamic text direction and improve layout styling

* add translation for arabic

* Improve header layout in base HTML template for better alignment and spacing with RTL and LTR

* Add language attribute to HTML tag

* Update Arabic translations to include "STAC" in SpatioTemporal asset terminology

* Add "text_direction" to be "ltr" translation for bs, de, en, es, fr, sr languages
2024-11-21 11:47:12 -05:00
Tom Kralidis e1fec87d6f add support for OpenSearch provider (#1844) 2024-11-20 18:49:04 -05:00
Benjamin Webb 065ef3a495 Support empty default environment variable values (#1822)
* Add default env variable

Add env variable test

Co-Authored-By: Sarah Gammon <91751417+sarahg-579462@users.noreply.github.com>

* Update test for empty env variable

* Remove pygeoapi env variable from config

---------

Co-authored-by: Sarah Gammon <91751417+sarahg-579462@users.noreply.github.com>
2024-11-19 20:53:28 -05:00
Tom Kralidis 3188db91da update pygeoapi-auth info to security page (#1852) 2024-11-19 17:39:16 -05:00
FritzHoing acc3b9ae93 Unused requested_outputs in execute_process (#1834)
* fix: adds requested_outputs to execute function

* Update dummy.py

---------

Co-authored-by: Tom Kralidis <tomkralidis@gmail.com>
2024-11-18 12:35:14 -05:00
Benjamin Webb 183caacff4 Support EDR single item locations for Starlette (#1827)
* Support EDR single item locations for starlette

* Model behavior after flask app handler
2024-11-18 12:12:22 -05:00
Martin Pontius a3b42ba0ed Fix bug when trying to get job result in binary format (#1798) 2024-11-18 11:54:12 -05:00
Jo 2e4ff714f6 Improved documentation about admin api (#1846)
* - added note about admin api routes

* - fixed openapi path

* - open api -> OpenAPI, admin api -> admin API

---------

Co-authored-by: doublebyte1 <info@doublebyte.net>
2024-11-17 03:19:36 -05:00
Tom Kralidis 9e87184fbf fix trivy error on vulnerability testing (#1843) 2024-11-08 09:36:10 +01:00
Ricardo Garcia Silva 09423fb4be Lifted pin on sqlalchemy in order to be able to use v2+ (#1832) 2024-11-07 18:09:09 -05:00
Tom Kralidis e4beaf758e add service contact to OpenAPI (#1835) (#1839) 2024-11-04 05:37:51 -05:00
Tom Kralidis b6c38b66ee fix HEAD requests for items (#1836) (#1838) 2024-11-04 05:37:13 -05:00
Tom Kralidis d2f38dea07 Update FUNDING.yml 2024-11-03 11:21:32 -05:00
Leo Ghignone 3bdeefe4e7 Properly support int variables of any width (#1829) 2024-10-15 15:49:15 -04:00
Alex 179c90ff31 Fixes a memoryfile issue with rasterio. When using f=json, it doesn't need to use the MemoryFile. (#1824) 2024-10-03 09:21:05 -04:00
Colin Henderson e736fa3b2f Custom esri token service (#1813)
* Added ability for self-hosted token service to be specified.

* Update documentation to show the available parameters

* Update pygeoapi/provider/esri.py

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>

* Update pygeoapi/provider/esri.py

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>

* Update pygeoapi/provider/esri.py

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>

* Update pygeoapi/provider/esri.py

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>

* Update pygeoapi/provider/esri.py

* Update ogcapi-features.rst

---------

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>
Co-authored-by: Tom Kralidis <tomkralidis@gmail.com>
2024-10-01 10:53:39 -04:00
Leo Ghignone d240a8210e Improvements for xarray provider (#1800)
* Manage non-cf-compliant time dimension

* Manage datasets without a time dimension

* Allow reversed slices also for axes

* Convert also metadata to float64 for json output

* Use named temporary file to enable netcdf4 engine

* Make float64 conversion faster

* Add netcdf output to xarray provider

* Flake8 fixes

* Fix bug when no time axis in data

* Use new xarray interface

* Add test for zarr dataset without time dimension

* Avoid errors if missing long_name

* Manage zarr and netcdf output in the same way

* Revert "Manage zarr and netcdf output in the same way"

This reverts commit 0b09281b608da95221951d05004f213379da168d.

* Revert "Add netcdf output to xarray provider"

This reverts commit 9f72bf7614775b418f53f4808fcaeab567c7024a.
2024-09-30 05:40:31 -04:00
Tom Kralidis 474cb60d82 fix item queryables provider handling (#1820)
* fix queryables provider handling

* fix test
2024-09-29 12:07:24 -04:00
Angelos Tzotsos b3a70719a2 back to dev 2024-09-27 20:39:45 +03:00
Angelos Tzotsos 83ef1ac174 update release version 2024-09-27 20:15:05 +03:00
Benjamin Webb 6b91024aa5 Zoom to first layer on EDR (#1819) 2024-09-26 19:23:18 -04:00
Tom Kralidis 52bec0fa89 docs: update compliance for OGC API - Processes (#1817)
* Update compliance for OGC API - Processes

* Update introduction.rst
2024-09-21 20:19:43 +02:00
Angelos Tzotsos 76fd130493 Update Ubuntu Jammy docker base image to 20240911.1 (#1815) 2024-09-20 23:06:18 -04:00
Sarah Jordan 6682b44928 CRS handling in xarray provider properties (#1641)
* update crs handling

* fix epsg code

* config parsing, lean on pyproj

* consolidate code and leverage prior crs work

* update crs handling

* fix epsg code

* config parsing, lean on pyproj

* consolidate code and leverage prior crs work

* fix function call

* bug and flake8 fixes

* documentation updates

* flake8

* Update ogcapi-coverages.rst

* update crs handling

* fix epsg code

* config parsing, lean on pyproj

* consolidate code and leverage prior crs work

* update crs handling

* fix epsg code

* config parsing, lean on pyproj

* consolidate code and leverage prior crs work

* fix function call

* bug and flake8 fixes

* documentation updates

* flake8

* Update ogcapi-coverages.rst

* flake8 fix

* rebase issues

* update import formatting

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>

* update conditional logic

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>

* update error handling

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>

* parse storage crs in init

---------

Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>
2024-09-12 12:22:25 -04:00
Bernhard Mallinger deb043f928 Jobs pagination (#1779)
* Add pagination for job list

Adds limit and offset parameter to `get_jobs`.

Process manager `get_jobs` now also returns the number of matched jobs
additionally to the jobs themselves so we can calculate whether we need
a next link.

Note that this is a breaking change.

* Add pagination support to jobs UI

This works exactly the same way as for itemtypes

* Add note regarding job sorting

* Formatting fixes
2024-09-12 07:37:11 -04:00
Benjamin Webb 0677c2e646 SensorThings API provider cleanup (#1807)
* Add support for ObservedProperties OAF and custom expand of entities

* Update sensorthings.py

* Respond to feedback

* Use `pygeoapi.get_config` for SensorThings Intralinking
2024-09-11 15:29:40 -04:00
Simon Seyock 28618034b8 feat: add version parameter to WMSFacade provider (#1806) 2024-09-11 12:24:26 -04:00
Tom Kralidis 6ad14a6d54 drop unicodecsv package (#1805)
* remove unicodecsv (#1804)

* add test
2024-09-09 12:02:33 -04:00
Benjamin Webb 1429a81887 Always use MarkerCluster to display items (#1799)
For a FeatureCollection of mixed geometry types Marker Cluster is able to put all features on the map and make clusters for all Point features
2024-08-22 20:59:10 -04:00
James Varndell 15be1dcd4f OGC API - Coverages: Propagate selected fields into covjson conversion (#1788)
* Propagate selected fields into covjson conversion

* Update xarray_.py

---------

Co-authored-by: Tom Kralidis <tomkralidis@gmail.com>
2024-08-21 22:15:29 -04:00
Benjamin Webb 33b4ff73a4 Display numberMatched in HTML view (#1797)
* Display numberMatched in HTML view

* Fix display on no numberMatched

* Amend based on feedback
2024-08-21 20:23:11 -04:00
Benjamin Webb 7a3d8a824e Check if query is implemented before validating params (#1796)
* Check if query is implemented before validating params

* fix flake8
2024-08-21 06:47:09 -04:00
Benjamin Webb 067b1587b9 Skip resources with no providers in STA intralink (#1793) 2024-08-20 06:42:11 -04:00
PascalLike 08876b5843 Fix indentation in yaml example (#1794) 2024-08-20 06:41:12 -04:00
Moritz Langer 7e734348da Extra params fixes #1667 (#1673)
* Added changes for extra_params to itemtypes, oracle provider and the according tests.

* Add extra params to properties

Oracle provider still needs to be adapted to this change

* Adapt oracle provider for new extra params behavior

* Fix logging calls for additional properties

* Remove trailing comma

* Fix grammar in test message

* Use f-string instead of plus for string manipulation

---------

Co-authored-by: Bernhard Mallinger <bernhard.mallinger@eox.at>
2024-08-19 08:17:13 -04:00
Tom Kralidis 44c589c1a4 fix CI (#1791)
* fix CI

* fix

* remove elasticsearch upgrade in CI
2024-08-19 06:55:53 -04:00
Leo Ghignone 54b9be4463 Pyarrow parquet provider (#1722)
* Pyarrow parquet provider

* Defer crs management to pygeoapi

* Add parquet provider docs

* Fix flake8 errors

* Remove extra .parquet

* Address reviews
2024-08-18 22:39:53 -04:00
Tom Kralidis bc1e8a6566 do not echo query parameter values on exceptions (#1789) (#1790) 2024-08-16 22:27:40 -04:00
Benjamin Webb 7d1028cf11 Show map on all CovJSON data (#1786)
* Show map on all CovJSON data

* Show map on all CovJSON data

* Update query.html
2024-08-15 16:45:33 -04:00
Benjamin Webb 501bc6e839 Center pygeoapi footer (#1785)
* Center pygeoapi footer and stick to bottom of window

* Revert sticky bottom for the footer
2024-08-15 12:18:25 -04:00
Benjamin Webb 4e77d75ea3 Remove extra parameters from OAS-EDR for locations (#1776)
* Remove extra parameters from OAS-EDR for locations

* Revert "Remove extra parameters from OAS-EDR for locations"

This reverts commit cd84a3ce5ebdaea0e8b90f20f2ec63bb027d10c7.
2024-08-15 12:15:31 -04:00
Benjamin Webb 60bd40385e Add C3 plotting to EDR HTML view (#1784)
* Add C3 plotting to EDR HTML view

* Remove unused function
2024-08-15 12:06:36 -04:00
Tom Kralidis 2a131c5131 update docstrings for base provider fields functionality (#1783)
* update docstrings for base provider fields functionality

* fix flake8
2024-08-13 11:12:44 -04:00
Benjamin Webb 71ce03e548 Add validation check to EDR query registration (#1774)
* Add validation check to EDR query registration

* Fix flake8

* fix edr query types
2024-08-13 10:55:17 -04:00
Tom Kralidis c1b90dc3ac update basemap URL across all configurations (#1777) (#1778)
* update basemap URL across all configurations (#1777)

* update basemap URL across all configurations (#1777)
2024-08-09 09:56:02 -04:00
Tom Kralidis 9ad8706223 fix item id breadcrumb (#1772) 2024-08-06 06:03:27 -04:00
Tom Kralidis d4063f360e fix EDR HTML breadcrumbs (#1764)
* fix EDR HTML breadcrumbs

* add translations

* do not include CoverageJSON to format types

* set JSON-LD link for HTML templating

* add Locations and Instances to translations
2024-08-06 06:03:00 -04:00
Tom Kralidis 4b28de6d42 fix breadcrumbs again (follow on of #1769) (#1770) 2024-08-05 13:13:44 +01:00
Tom Kralidis 491ceaff48 fix collection breadcrumbs on queryables and schemas HTML Jinja2 templates (#1769) 2024-08-02 09:07:47 -04:00
Moritz Langer d1dfa179b3 Add Wallet for Session pool connections in oracle.py (#1768)
* Added Wallet to Connection Pool

* Flake8 changes

* Flake8 changes

* Feedback from Pull Request

* Flake8
2024-07-31 11:04:02 -04:00
Tom Kralidis a806f89a31 add installation note about Python version support (#1644) (#1760) 2024-07-27 07:50:17 -04:00
Tom Kralidis 0a7bb7f5f4 fix various deprecation warnings (#1761) 2024-07-25 14:13:20 -04:00
Benjamin Webb b712cb2695 Fix typo in docs (#1762)
* Fix typo in docs

* Fix doc x/y fields
2024-07-25 14:06:51 -04:00
francescoingv b8dcf6a885 Fixed typo (#1763) 2024-07-25 12:20:08 -04:00
Tom Kralidis 86390a6f12 OAProc: fix response: document encoding for results (#1579) (#1759) 2024-07-24 21:27:04 -04:00
Tom Kralidis b2a8e0678d safeguard OpenAPI detection on startup (#1650) (#1758) 2024-07-24 18:26:00 -04:00
Benjamin Webb 3adfdb2341 Describe required collection level metadata about EDR Queries (#1744)
* Add data_queries to describe EDR Queries

* Fix flake8

* Use covjson media type

* Update pygeoapi-config-0.x.yml

Update schema definition to match https://schemas.opengis.net/ogcapi/edr/1.1/openapi/schemas/collections/extent.yaml

* Add data_queries to describe EDR Queries

* Fix flake8

* Use covjson media type

* Update pygeoapi-config-0.x.yml

Update schema definition to match https://schemas.opengis.net/ogcapi/edr/1.1/openapi/schemas/collections/extent.yaml

* Enable query type registration

* Update base_edr.py

* Add GeoJSON as a valid response type

* Preserve query_types as list

* Revert changes to min required by EDR spec
2024-07-24 18:25:23 -04:00
Tom Kralidis 6c538ca330 raise error for collections without queryables (#1757) 2024-07-24 16:11:44 -04:00
Tom Kralidis af8483a25b OAProc: handle binary data when response: document (#1285) (#1756) 2024-07-24 14:58:27 -04:00
Tom Kralidis 0281732c5c add CORS expose headers setting (#1689) (#1755) 2024-07-24 11:08:15 -04:00
Tom Kralidis 7bb7b38016 fix pagination for features/records (#1658) (#1754)
* fix pagination for features/records (#1658)

* remove given tests already existing
2024-07-24 08:56:50 -04:00
francescoingv d600f55214 Update test_postgresql_manager.py (#1751)
* Update test_postgresql_manager.py

Test issue #1750 is fixed

* Update test_postgresql_manager.py

* Update test_postgresql_manager.py

* Update postgresql.py

* Update requirements-manager.txt

* Update postgresql.py

* Update test_postgresql_manager.py

* Update test_postgresql_manager.py

* Update test_postgresql_manager.py

* Update postgresql.py

Initialization problem with search path in case of exception.

* Update requirements-manager.txt
2024-07-24 06:12:43 -04:00
Benjamin Webb bbb5035508 Render covjson and geojson in EDR HTML view (#1749) 2024-07-22 18:06:23 -04:00
Benjamin Webb 31480af845 Use consistent get_field ref in providers (#1727)
* Use consistent get_field ref in providers

* Fix flake8

* Update remaining providers

* Fix recursive call

* Fix recursive call

* Fix tinydb_.py

* Refresh TinyDB catalog fields

* s/self.fields_/self._fields/g

* Update BaseProvider.fields based on feedback

* Fix flake8
2024-07-22 17:58:06 -04:00
Vincent Privat e2676bdc56 Document PostgreSQL process manager (#1746)
* Document PostgreSQL process manager

* Update ogcapi-processes.rst

---------

Co-authored-by: Tom Kralidis <tomkralidis@gmail.com>
2024-07-22 16:59:11 -04:00
Benjamin Webb f55aa875c2 Remove spatial parameter from OAS for single location (#1747) 2024-07-22 16:52:42 -04:00
100 changed files with 4711 additions and 710 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# These are supported funding model platforms
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
custom: ['https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=WT27AS28UFSNW&source=url'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
custom: ['https://github.com/geopython/pygeoapi/wiki/Sponsorship'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+8 -2
View File
@@ -62,6 +62,12 @@ jobs:
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.12.0
security-disabled: true
port: 9209
- name: Install and run MongoDB
uses: supercharge/mongodb-github-action@1.5.0
with:
@@ -94,13 +100,12 @@ jobs:
pip3 install -r requirements-manager.txt
pip3 install -r requirements-django.txt
python3 setup.py install
pip3 install --upgrade numpy elasticsearch
pip3 install --upgrade numpy "sqlalchemy<2"
pip3 install --global-option=build_ext --global-option="-I/usr/include/gdal" GDAL==`gdal-config --version`
#pip3 install --upgrade rasterio==1.1.8
- name: setup test data ⚙️
run: |
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
gunzip < tests/data/hotosm_bdi_waterways.sql.gz | psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test
psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test -f tests/data/dummy_data.sql
@@ -119,6 +124,7 @@ jobs:
pytest tests/test_csv__provider.py
pytest tests/test_django.py
pytest tests/test_elasticsearch__provider.py
pytest tests/test_opensearch__provider.py
pytest tests/test_esri_provider.py
pytest tests/test_filesystem_provider.py
pytest tests/test_geojson_provider.py
+4 -1
View File
@@ -22,7 +22,7 @@ jobs:
working-directory: .
steps:
- name: Checkout pygeoapi
uses: actions/checkout@v4
uses: actions/checkout@master
- name: Scan vulnerabilities with trivy
uses: aquasecurity/trivy-action@master
with:
@@ -37,6 +37,9 @@ jobs:
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
env:
TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2
TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db:1
with:
scan-type: image
exit-code: 1
+1 -2
View File
@@ -34,7 +34,7 @@
#
# =================================================================
FROM ubuntu:jammy-20240627.1
FROM ubuntu:jammy-20240911.1
LABEL maintainer="Just van den Broecke <justb4@gmail.com>"
@@ -98,7 +98,6 @@ ENV TZ=${TZ} \
python3-greenlet \
python3-pip \
python3-tz \
python3-unicodecsv \
python3-yaml \
${ADD_DEB_PACKAGES}"
+2 -2
View File
@@ -48,8 +48,8 @@ server:
limit: 10
# templates: /path/to/templates
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
ogc_schemas_location: /schemas.opengis.net
logging:
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

+5 -2
View File
@@ -16,11 +16,14 @@
<a title="OGC Reference Implementation" href="https://www.ogc.org/resource/products/details/?pid=1663">
<img alt="OGC Reference Implementation" src="https://portal.ogc.org/public_ogc/compliance/badge.php?s=ogcapi-tiles-1%201.0.1&r=1&n=1)" width="164" height="44"/>
</a>
<a title="OGC Reference Implementation" href="https://www.ogc.org/resource/products/details/?pid=1826">
<img alt="OGC Reference Implementation" src="https://portal.ogc.org/public_ogc/compliance/badge.php?s=ogcapi-processes-1%201.0.1&n=1)" width="164" height="44"/>
</a>
<a title="OSGeo Project" href="https://www.osgeo.org/projects/pygeoapi">
<img style="background: white;" alt="OSGeo Project" src="https://raw.githubusercontent.com/OSGeo/osgeo/master/incubation/project/OSGeo_project.png" width="164" height="64"/>
</a>
<a title="FOSS4G Conference" href="https://2023.foss4g.org">
<img style="background: white;" alt="FOSS4G Conference" width="145" height="45" src="https://2023.foss4g.org/img/logo.png"/>
<a title="FOSS4G Conference" href="https://2024.foss4g.org">
<img style="background: white;" alt="FOSS4G Conference" width="145" height="45" src="https://2024.foss4g.org/_next/static/media/foss4g-belem-logo-vertical.30d8343b.svg"/>
</a>
</p>
+9
View File
@@ -14,6 +14,15 @@ The API is enabled with the following server configuration:
server:
admin: true # boolean on whether to enable Admin API.
.. note::
If you generate the OpenAPI definition after enabling the admin API, the admin routes will be exposed on ``/openapi``
.. image:: /_static/openapi_admin.png
:alt: admin routes
:align: center
Access control
--------------
+1 -1
View File
@@ -112,7 +112,7 @@ today_fmt = '%Y-%m-%d'
# built documents.
#
# The short X.Y version.
version = '0.18.dev0'
version = '0.19.dev0'
# The full version, including alpha/beta/rc tags.
release = version
+3 -3
View File
@@ -57,8 +57,8 @@ For more information related to API design rules (the ``api_rules`` property in
static: /path/to/static/folder # path to static folder containing css, js, images and other static files referenced by the template
map: # leaflet map setup for HTML pages
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
ogc_schemas_location: /opt/schemas.opengis.net # local copy of https://schemas.opengis.net
manager: # optional OGC API - Processes asynchronous job management
@@ -241,7 +241,7 @@ default.
option_name: option_value
hello-world: # name of process
type: collection # REQUIRED (collection, process, or stac-collection)
type: process # REQUIRED (collection, process, or stac-collection)
processor:
name: HelloWorld # Python path of process definition
@@ -72,9 +72,12 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data.
data: tests/data/coads_sst.nc
# optionally specify x/y/time fields, else provider will attempt
# to derive automagically
x_field: lat
x_field: lon
y_field: lat
time_field: time
# optionally specify the coordinate reference system of your dataset
# else pygeoapi assumes it is WGS84 (EPSG:4326).
storage_crs: 4326
format:
name: netcdf
mimetype: application/x-netcdf
@@ -96,6 +99,11 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data.
be sure to provide the full S3 URL. Any parameters required to open the dataset
using fsspec can be added to the config file under `options` and `s3`.
.. note::
When providing a `storage_crs` value in the xarray configuration, specify the
coordinate reference system using any valid input for
`pyproj.CRS.from_user_input`_.
Data access examples
--------------------
@@ -146,3 +154,4 @@ Data access examples
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
.. _`GDAL raster driver short name`: https://gdal.org/drivers/raster/index.html
.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input
+10 -1
View File
@@ -44,9 +44,12 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data
data: tests/data/coads_sst.nc
# optionally specify x/y/time fields, else provider will attempt
# to derive automagically
x_field: lat
x_field: lon
y_field: lat
time_field: time
# optionally specify the coordinate reference system of your dataset
# else pygeoapi assumes it is WGS84 (EPSG:4326).
storage_crs: 4326
format:
name: netcdf
mimetype: application/x-netcdf
@@ -81,6 +84,11 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data
S3 URL. Any parameters required to open the dataset using fsspec can be added
to the config file under `options` and `s3`, as shown above.
.. note::
When providing a `storage_crs` value in the EDR configuration, specify the
coordinate reference system using any valid input for
`pyproj.CRS.from_user_input`_.
Data access examples
--------------------
@@ -105,6 +113,7 @@ Data access examples
.. _`xarray`: https://docs.xarray.dev/en/stable/
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input
.. _`OGC Environmental Data Retrieval (EDR) (API)`: https://github.com/opengeospatial/ogcapi-coverages
@@ -26,7 +26,9 @@ parameters.
`GeoJSON`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌,✅
`MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌,✅
`OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
`OpenSearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅
`Oracle`_,✅/✅,results/hits,✅,❌,✅,✅,❌,❌,✅
`Parquet`_,✅/✅,results/hits,✅,✅,❌,✅,❌,❌,✅
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅
`SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅
@@ -144,7 +146,11 @@ To publish an ESRI `Feature Service`_ or `Map Service`_ specify the URL for the
* ``id_field`` will often be ``OBJECTID``, ``objectid``, or ``FID``.
* If the map or feature service is not shared publicly, the ``username`` and ``password`` fields can be set in the
configuration to authenticate into the service.
configuration to authenticate to the service.
* If the map or feature service is self-hosted and not shared publicly, the ``token_service`` and optional ``referer`` fields
can be set in the configuration to authenticate to the service.
To publish from an ArcGIS online hosted service:
.. code-block:: yaml
@@ -157,6 +163,24 @@ To publish an ESRI `Feature Service`_ or `Map Service`_ specify the URL for the
crs: 4326 # Optional crs (default is EPSG:4326)
username: username # Optional ArcGIS username
password: password # Optional ArcGIS password
token_service: https://your.server.com/arcgis/sharing/rest/generateToken # optional URL to your generateToken service
referer: https://your.server.com # optional referer, defaults to https://www.arcgis.com if not set
To publish from a self-hosted service that is not publicly accessible, the ``token_service`` field is required:
.. code-block:: yaml
providers:
- type: feature
name: ESRI
data: https://your.server.com/arcgis/rest/services/your-layer/MapServer/0
id_field: objectid
time_field: date_in_your_device_time_zone # Optional time field
crs: 4326 # Optional crs (default is EPSG:4326)
username: username # Optional ArcGIS username
password: password # Optional ArcGIS password
token_service: https://your.server.com/arcgis/sharing/rest/generateToken # Optional url to your generateToken service
referer: https://your.server.com # Optional referer, defaults to https://www.arcgis.com if not set
GeoJSON
^^^^^^^
@@ -299,6 +323,44 @@ The OGR provider requires a recent (3+) version of GDAL to be installed.
The `crs` query parameter is used as follows:
e.g. ``http://localhost:5000/collections/foo/items?crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992``.
.. _OpenSearch:
OpenSearch
^^^^^^^^^^
.. note::
Requires Python package opensearch-py
To publish an OpenSearch index, the following are required in your index:
* indexes must be documents of valid GeoJSON Features
* index mappings must define the GeoJSON ``geometry`` as a ``geo_shape``
.. code-block:: yaml
providers:
- type: feature
name: OpenSearch
editable: true|false # optional, default is false
data: http://localhost:9200/ne_110m_populated_places_simple
id_field: geonameid
time_field: datetimefield
.. note::
For OpenSearch indexes that are password protect, a RFC1738 URL can be used as follows:
``data: http://username:password@localhost:9200/ne_110m_populated_places_simple``
To further conceal authentication credentials, environment variables can be used:
``data: http://${MY_USERNAME}:${MY_PASSWORD}@localhost:9200/ne_110m_populated_places_simple``
The OpenSearch provider also has the support for the CQL queries as indicated in the table above.
.. seealso::
:ref:`cql` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries.
.. _Oracle:
Oracle
@@ -420,7 +482,7 @@ Configured using environment variables.
export ORACLE_POOL_MAX=10
The ``ORACLE_POOL_MIN`` and ``ORACLE_POOL_MAX`` environment variables are used to trigger session pool creation in the Oracle Provider and the ``DatabaseConnection`` class. See https://python-oracledb.readthedocs.io/en/latest/api_manual/module.html#oracledb.create_pool for documentation of the ``create_pool`` function.
The ``ORACLE_POOL_MIN`` and ``ORACLE_POOL_MAX`` environment variables are used to trigger session pool creation in the Oracle Provider and the ``DatabaseConnection`` class. Supports auth via user + password or wallet. For an example of the configuration see above at Oracle - Connection. See https://python-oracledb.readthedocs.io/en/latest/api_manual/module.html#oracledb.create_pool for documentation of the ``create_pool`` function.
If none or only one of the environment variables is set, session pooling will not be activated and standalone connections are established at every request.
@@ -432,6 +494,40 @@ useful e.g. for authorization at row level or manipulation of the explain plan w
An example an more information about that feature you can find in the test class in tests/test_oracle_provider.py.
.. _Parquet:
Parquet
^^^^^^^
.. note::
Requires Python package pyarrow
To publish a GeoParquet file (with a geometry column) the geopandas package is also required.
.. note::
Reading data directly from a public s3 bucket is also supported.
.. code-block:: yaml
providers:
- type: feature
name: Parquet
data:
source: ./tests/data/parquet/random.parquet
id_field: id
time_field: time
x_field:
- minlon
- maxlon
y_field:
- minlat
- maxlat
For GeoParquet data, 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.
.. _PostgreSQL:
PostgreSQL
+5 -4
View File
@@ -51,7 +51,7 @@ Currently supported style files (`options.style`):
.. code-block:: yaml
providers:
- type: map
- type: map
name: MapScript
data: /path/to/data.shp
options:
@@ -59,7 +59,7 @@ Currently supported style files (`options.style`):
layer: foo_name
style: ./foo.sld
format:
name: png
name: png
mimetype: image/png
WMSFacade
@@ -71,14 +71,15 @@ required. An optional style name can be defined via `options.style`.
.. code-block:: yaml
providers:
- type: map
- type: map
name: WMSFacade
data: https://demo.mapserver.org/cgi-bin/msautotest
options:
layer: world_latlong
style: default
version: 1.3.0
format:
name: png
name: png
mimetype: image/png
@@ -14,15 +14,47 @@ The pygeoapi offers two processes: a default ``hello-world`` process which allow
Configuration
-------------
The below configuration is an example of a process defined within the pygeoapi internal plugin registry:
.. code-block:: yaml
processes:
# enabled by default
# enabled by default
hello-world:
processor:
name: HelloWorld
The below configuration is an example of a process defined as part of a custom Python process:
.. code-block:: yaml
processes:
# enabled by default
hello-world:
processor:
# refer to a process in the standard PYTHONPATH
# e.g. my_package/my_module/my_file.py (class MyProcess)
# the MyProcess class must subclass from pygeoapi.process.base.BaseProcessor
name: my_package.my_module.my_file.MyProcess
See :ref:`example-custom-pygeoapi-processing-plugin` for processing plugin examples.
Processing and response handling
--------------------------------
pygeoapi processing plugins must return a tuple of media type and native outputs. Multipart
responses are not supported at this time, and it is up to the process plugin implementor to return a single
payload defining multiple artifacts (or references to them).
By default (or via the OGC API - Processes ``response: raw`` execution parameter), pygeoapi provides
processing responses in their native encoding and media type, as defined by a given
plugin (which needs to set the response content type and payload accordingly).
pygeoapi also supports a JSON-based response type (via the OGC API - Processes ``response: document``
execution parameter). When this mode is requested, the response will always be a JSON encoding, embedding
the resulting payload (part of which may be Base64 encoded for binary data, for example).
Asynchronous support
--------------------
@@ -33,15 +65,27 @@ an asynchronous design pattern. This means that when a job is submitted in asyn
mode, the server responds immediately with a reference to the job, which allows the client
to periodically poll the server for the processing status of a given job.
pygeoapi provides asynchronous support by providing a 'manager' concept which, well,
In keeping with the OGC API - Processes specification, asynchronous process execution
can be requested by including the ``Prefer: respond-async`` HTTP header in the request.
Job management is required for asynchronous functionality.
Job management
--------------
pygeoapi provides job management by providing a 'manager' concept which, well,
manages job execution. The manager concept is implemented as part of the pygeoapi
:ref:`plugins` architecture. pygeoapi provides a default manager implementation
based on `TinyDB`_ for simplicity. Custom manager plugins can be developed for more
advanced job management capabilities (e.g. Kubernetes, databases, etc.).
In keeping with the OGC API - Processes specification, asynchronous process execution
can be requested by including the ``Prefer: respond-async`` HTTP header in the request
Job managers
------------
TinyDB
^^^^^^
TinyDB is the default job manager for pygeoapi when enabled.
.. code-block:: yaml
@@ -52,11 +96,12 @@ can be requested by including the ``Prefer: respond-async`` HTTP header in the r
output_dir: /tmp/
MongoDB
--------------------
As an alternative to the default a manager employing `MongoDB`_ can be used.
The connection to an installed `MongoDB`_ instance must be provided in the configuration.
`MongoDB`_ uses the localhost and port 27017 by default. Jobs are stored in a collection named
job_manager_pygeoapi.
^^^^^^^
As an alternative to the default, a manager employing `MongoDB`_ can be used.
The connection to a `MongoDB`_ instance must be provided in the configuration.
`MongoDB`_ uses ``localhost`` and port ``27017`` by default. Jobs are stored in a collection named
``job_manager_pygeoapi``.
.. code-block:: yaml
@@ -66,11 +111,34 @@ job_manager_pygeoapi.
connection: mongodb://host:port
output_dir: /tmp/
PostgreSQL
^^^^^^^^^^
As another alternative to the default, a manager employing `PostgreSQL`_ can be used.
The connection to a `PostgreSQL`_ database must be provided in the configuration.
`PostgreSQL`_ uses ``localhost`` and port ``5432`` by default. Jobs are stored in a table named ``jobs``.
.. code-block:: yaml
server:
manager:
name: PostgreSQL
connection:
host: localhost
port: 5432
database: test
user: postgres
password: ${POSTGRESQL_PASSWORD:-postgres}
# Alternative accepted connection definition:
# connection: postgresql://postgres:postgres@localhost:5432/test
# connection: postgresql://postgres:${POSTGRESQL_PASSWORD:-postgres}@localhost:5432/test
output_dir: /tmp
Putting it all together
-----------------------
To summarize how pygeoapi processes and managers work together::
To summarize how pygeoapi processes and managers work together:
* process plugins implement the core processing / workflow functionality
* manager plugins control and manage how processes are executed
+4 -4
View File
@@ -106,8 +106,8 @@ Following block shows how to configure pygeoapi to read Mapbox vector tiles from
zoom:
min: 0
max: 15
schemes:
- WebMercatorQuad # this option is needed in the MVT-proxy provider
schemes:
- WebMercatorQuad # this option is needed in the MVT-proxy provider
format:
name: pbf
mimetype: application/vnd.mapbox-vector-tile
@@ -124,8 +124,8 @@ Following code block shows how to configure pygeoapi to read Mapbox vector tiles
zoom:
min: 0
max: 15
schemes:
- WebMercatorQuad
schemes:
- WebMercatorQuad
format:
name: pbf
mimetype: application/vnd.mapbox-vector-tile
+9 -1
View File
@@ -11,6 +11,13 @@ Requirements and dependencies
pygeoapi runs on Python 3.
.. note::
The exact Python version requirements are aligned with the version of Python on the pygeoapi supported Ubuntu
operating system version. For example, as of 2024-07, the supported version of Python is bound to Ubuntu 22.04
(Jammy) which supports Python 3.10. Ensure you have a Python version that is compatible with the current Ubuntu
version that is specified in pygeoapi's `Dockerfile`_.
Core dependencies are included as part of a given pygeoapi installation procedure. More specific requirements
details are described below depending on the platform.
@@ -32,7 +39,7 @@ For developers and the truly impatient
vi example-config.yml # edit as required
export PYGEOAPI_CONFIG=example-config.yml
export PYGEOAPI_OPENAPI=example-openapi.yml
pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
pygeoapi openapi generate $PYGEOAPI_CONFIG --output-file $PYGEOAPI_OPENAPI
pygeoapi serve
curl http://localhost:5000
@@ -142,3 +149,4 @@ onto your system.
.. _`Docker image`: https://github.com/geopython/pygeoapi/pkgs/container/pygeoapi
.. _`Dockerfile`: https://github.com/geopython/pygeoapi/blob/master/Dockerfile
+2 -2
View File
@@ -14,12 +14,12 @@ Features
* OGC API - Features
* OGC API - Environmental Data Retrieval
* OGC API - Tiles
* OGC API - Processes
* additionally implements
* OGC API - Coverages
* OGC API - Maps
* OGC API - Processes
* OGC API - Records
* SpatioTemporal Asset Library
@@ -52,7 +52,7 @@ Standards are at the core of pygeoapi. Below is the project's standards support
`OGC API - Coverages`_,Implementing
`OGC API - Maps`_,Implementing
`OGC API - Tiles`_,Reference Implementation
`OGC API - Processes`_,Implementing
`OGC API - Processes`_,Compliant
`OGC API - Records`_,Implementing
`OGC API - Environmental Data Retrieval`_,Reference Implementation
`SpatioTemporal Asset Catalog`_,Implementing
+5 -2
View File
@@ -240,15 +240,16 @@ The below template provides a minimal example (let's call the file ``mycoolraste
super().__init__(provider_def)
self.num_bands = 4
self.axes = ['Lat', 'Long']
self.fields = self.get_fields()
self.get_fields()
def get_fields(self):
# generate a JSON Schema of coverage band metadata
return {
self._fields = {
'b1': {
'type': 'number'
}
}
return self._fields
def query(self, bands=[], subsets={}, format_='json', **kwargs):
# process bands and subsets parameters
@@ -272,6 +273,8 @@ implementation.
Each base class documents the functions, arguments and return types required for implementation.
.. _example-custom-pygeoapi-processing-plugin:
Example: custom pygeoapi processing plugin
------------------------------------------
+2 -1
View File
@@ -14,4 +14,5 @@ as required.
The following projects provide security frameworks atop pygeoapi:
* `fastgeoapi <https://github.com/geobeyond/fastgeoapi>`_
* `pygeoapi-auth <https://github.com/cartologic/pygeoapi-auth>`_
* `pygeoapi-auth-deployment <https://github.com/cartologic/pygeoapi-auth-deployment>`_
* `pygeoapi-auth <https://github.com/geopython/pygeoapi-auth>`_ (Python package for use along with pygeoapi-auth-deployment)
+748
View File
@@ -0,0 +1,748 @@
# Arabic translations for PROJECT.
# Copyright (C) 2024 OSGeo
# This file is distributed under the same license as the pygeoapi project.
# FIRST AUTHOR Youssef Harby, 2024.
#
msgid ""
msgstr ""
"Project-Id-Version: 0.0.19\n"
"Report-Msgid-Bugs-To: pygeoapi@lists.osgeo.org\n"
"POT-Creation-Date: 2024-11-19 23:22+0200\n"
"PO-Revision-Date: 2024-11-19 23:22+0200\n"
"Last-Translator: Youssef Harby <me@youssefharby.com>\n"
"Language: ar\n"
"Language-Team: ar <LL@li.org>\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : "
"n%100>=3 && n%100<=10 ? 3 : n%100>=0 && n%100<=2 ? 4 : 5);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.13.0\n"
#: build/lib/pygeoapi/templates/_base.html:62
#: build/lib/pygeoapi/templates/landing_page.html:2
#: pygeoapi/templates/_base.html:67 pygeoapi/templates/landing_page.html:2
msgid "Home"
msgstr "الصفحة الرئيسية"
#: build/lib/pygeoapi/templates/_base.html:70
#: build/lib/pygeoapi/templates/_base.html:78 pygeoapi/templates/_base.html:75
#: pygeoapi/templates/_base.html:83
msgid "json"
msgstr "json"
#: build/lib/pygeoapi/templates/_base.html:73
#: build/lib/pygeoapi/templates/_base.html:81 pygeoapi/templates/_base.html:78
#: pygeoapi/templates/_base.html:86
msgid "jsonld"
msgstr "jsonld"
#: build/lib/pygeoapi/templates/_base.html:100
#: pygeoapi/templates/_base.html:107
msgid "Powered by "
msgstr "مدعوم بواسطة "
#: build/lib/pygeoapi/templates/conformance.html:2
#: build/lib/pygeoapi/templates/conformance.html:4
#: build/lib/pygeoapi/templates/conformance.html:8
#: build/lib/pygeoapi/templates/landing_page.html:86
#: pygeoapi/templates/conformance.html:2 pygeoapi/templates/conformance.html:4
#: pygeoapi/templates/conformance.html:8
#: pygeoapi/templates/landing_page.html:95
msgid "Conformance"
msgstr "التوافق"
#: build/lib/pygeoapi/templates/exception.html:2
#: build/lib/pygeoapi/templates/exception.html:5
#: pygeoapi/templates/exception.html:2 pygeoapi/templates/exception.html:5
msgid "Exception"
msgstr "استثناء"
#: build/lib/pygeoapi/templates/landing_page.html:25
#: pygeoapi/templates/landing_page.html:25
msgid "Terms of service"
msgstr "شروط الخدمة"
#: build/lib/pygeoapi/templates/collections/collection.html:38
#: build/lib/pygeoapi/templates/landing_page.html:35
#: pygeoapi/templates/collections/collection.html:38
#: pygeoapi/templates/landing_page.html:35
msgid "License"
msgstr "الرخصة"
#: build/lib/pygeoapi/templates/collections/collection.html:6
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:4
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:4
#: build/lib/pygeoapi/templates/collections/edr/query.html:4
#: build/lib/pygeoapi/templates/collections/index.html:2
#: build/lib/pygeoapi/templates/collections/index.html:4
#: build/lib/pygeoapi/templates/collections/items/index.html:4
#: build/lib/pygeoapi/templates/collections/items/item.html:27
#: build/lib/pygeoapi/templates/collections/queryables.html:4
#: build/lib/pygeoapi/templates/collections/tiles/index.html:4
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:4
#: build/lib/pygeoapi/templates/landing_page.html:48
#: pygeoapi/templates/collections/collection.html:6
#: pygeoapi/templates/collections/edr/query.html:4
#: pygeoapi/templates/collections/index.html:2
#: pygeoapi/templates/collections/index.html:4
#: pygeoapi/templates/collections/items/index.html:4
#: pygeoapi/templates/collections/items/item.html:27
#: pygeoapi/templates/collections/queryables.html:4
#: pygeoapi/templates/collections/schema.html:4
#: pygeoapi/templates/collections/tiles/index.html:4
#: pygeoapi/templates/collections/tiles/metadata.html:4
#: pygeoapi/templates/landing_page.html:57
#: pygeoapi/templates/stac/collection_base.html:19
msgid "Collections"
msgstr "المجموعات"
#: build/lib/pygeoapi/templates/landing_page.html:50
#: pygeoapi/templates/landing_page.html:59
msgid "View the collections in this service"
msgstr "عرض المجموعات في هذه الخدمة"
#: build/lib/pygeoapi/templates/landing_page.html:56
#: pygeoapi/templates/landing_page.html:65
msgid "SpatioTemporal Assets"
msgstr "الأصول الزمانية والمكانية (STAC)"
#: build/lib/pygeoapi/templates/landing_page.html:58
#: pygeoapi/templates/landing_page.html:67
msgid "View the SpatioTemporal Assets in this service"
msgstr "عرض الأصول الزمانية والمكانية في هذه الخدمة"
#: build/lib/pygeoapi/templates/landing_page.html:64
#: build/lib/pygeoapi/templates/processes/index.html:2
#: build/lib/pygeoapi/templates/processes/index.html:4
#: build/lib/pygeoapi/templates/processes/process.html:4
#: pygeoapi/templates/landing_page.html:73
#: pygeoapi/templates/processes/index.html:2
#: pygeoapi/templates/processes/index.html:4
#: pygeoapi/templates/processes/process.html:4
msgid "Processes"
msgstr "العمليات"
#: build/lib/pygeoapi/templates/landing_page.html:66
#: pygeoapi/templates/landing_page.html:75
msgid "View the processes in this service"
msgstr "عرض العمليات في هذه الخدمة"
#: build/lib/pygeoapi/templates/jobs/index.html:2
#: build/lib/pygeoapi/templates/jobs/index.html:4
#: build/lib/pygeoapi/templates/jobs/index.html:11
#: build/lib/pygeoapi/templates/jobs/job.html:4
#: build/lib/pygeoapi/templates/jobs/results/index.html:4
#: build/lib/pygeoapi/templates/landing_page.html:70
#: build/lib/pygeoapi/templates/processes/process.html:76
#: pygeoapi/templates/jobs/index.html:2 pygeoapi/templates/jobs/index.html:4
#: pygeoapi/templates/jobs/index.html:11 pygeoapi/templates/jobs/job.html:4
#: pygeoapi/templates/jobs/results/index.html:4
#: pygeoapi/templates/landing_page.html:79
#: pygeoapi/templates/processes/process.html:76
msgid "Jobs"
msgstr "المهام"
#: build/lib/pygeoapi/templates/landing_page.html:72
#: build/lib/pygeoapi/templates/processes/process.html:77
#: pygeoapi/templates/landing_page.html:81
#: pygeoapi/templates/processes/process.html:77
msgid "Browse jobs"
msgstr "تصفح المهام"
#: build/lib/pygeoapi/templates/landing_page.html:77
#: pygeoapi/templates/landing_page.html:86
msgid "API Definition"
msgstr "تعريف API"
#: build/lib/pygeoapi/templates/landing_page.html:79
#: pygeoapi/templates/landing_page.html:88
msgid "Documentation"
msgstr "التوثيق"
#: build/lib/pygeoapi/templates/landing_page.html:79
#: pygeoapi/templates/landing_page.html:88
msgid "Swagger UI"
msgstr "واجهة Swagger"
#: build/lib/pygeoapi/templates/landing_page.html:79
#: pygeoapi/templates/landing_page.html:88
msgid "ReDoc"
msgstr "ReDoc"
#: build/lib/pygeoapi/templates/landing_page.html:82
#: pygeoapi/templates/landing_page.html:91
msgid "OpenAPI Document"
msgstr "وثيقة OpenAPI"
#: build/lib/pygeoapi/templates/landing_page.html:88
#: pygeoapi/templates/landing_page.html:97
msgid "View the conformance classes of this service"
msgstr "عرض فئات التوافق لهذه الخدمة"
#: build/lib/pygeoapi/templates/landing_page.html:95
#: pygeoapi/templates/landing_page.html:110
msgid "Provider"
msgstr "المزود"
#: build/lib/pygeoapi/templates/landing_page.html:104
#: pygeoapi/templates/landing_page.html:119
msgid "Contact point"
msgstr "نقطة الاتصال"
#: build/lib/pygeoapi/templates/landing_page.html:107
#: pygeoapi/templates/landing_page.html:122
msgid "Address"
msgstr "العنوان"
#: build/lib/pygeoapi/templates/landing_page.html:116
#: pygeoapi/templates/landing_page.html:131
msgid "Email"
msgstr "البريد الإلكتروني"
#: build/lib/pygeoapi/templates/landing_page.html:119
#: pygeoapi/templates/landing_page.html:134
msgid "Telephone"
msgstr "الهاتف"
#: build/lib/pygeoapi/templates/landing_page.html:123
#: pygeoapi/templates/landing_page.html:138
msgid "Fax"
msgstr "الفاكس"
#: build/lib/pygeoapi/templates/landing_page.html:127
#: pygeoapi/templates/landing_page.html:142
msgid "Contact URL"
msgstr "رابط الاتصال"
#: build/lib/pygeoapi/templates/landing_page.html:131
#: pygeoapi/templates/landing_page.html:146
msgid "Hours"
msgstr "ساعات العمل"
#: build/lib/pygeoapi/templates/landing_page.html:135
#: pygeoapi/templates/landing_page.html:150
msgid "Contact instructions"
msgstr "تعليمات الاتصال"
#: build/lib/pygeoapi/templates/collections/collection.html:51
#: pygeoapi/templates/collections/collection.html:51
msgid "Browse"
msgstr "تصفح"
#: build/lib/pygeoapi/templates/collections/collection.html:55
#: pygeoapi/templates/collections/collection.html:55
msgid "Browse Items"
msgstr "تصفح العناصر"
#: build/lib/pygeoapi/templates/collections/collection.html:56
#: pygeoapi/templates/collections/collection.html:56
msgid "Browse through the items of"
msgstr "تصفح عناصر"
#: build/lib/pygeoapi/templates/collections/collection.html:59
#: build/lib/pygeoapi/templates/collections/queryables.html:6
#: build/lib/pygeoapi/templates/collections/queryables.html:17
#: pygeoapi/templates/collections/collection.html:59
#: pygeoapi/templates/collections/queryables.html:6
#: pygeoapi/templates/collections/queryables.html:17
msgid "Queryables"
msgstr "قابليات الاستعلام"
#: build/lib/pygeoapi/templates/collections/collection.html:63
#: pygeoapi/templates/collections/collection.html:63
msgid "Display Queryables"
msgstr "عرض قابليات الاستعلام"
#: build/lib/pygeoapi/templates/collections/collection.html:64
#: pygeoapi/templates/collections/collection.html:64
msgid "Display Queryables of"
msgstr "عرض قابليات الاستعلام لـ"
#: build/lib/pygeoapi/templates/collections/collection.html:69
#: build/lib/pygeoapi/templates/collections/tiles/index.html:6
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:6
#: pygeoapi/templates/collections/collection.html:77
#: pygeoapi/templates/collections/tiles/index.html:6
#: pygeoapi/templates/collections/tiles/metadata.html:6
msgid "Tiles"
msgstr "البلاطات"
#: build/lib/pygeoapi/templates/collections/collection.html:73
#: pygeoapi/templates/collections/collection.html:81
msgid "Display Tiles"
msgstr "عرض البلاطات"
#: build/lib/pygeoapi/templates/collections/collection.html:73
#: pygeoapi/templates/collections/collection.html:81
msgid "Display Tiles of"
msgstr "عرض البلاطات لـ"
#: build/lib/pygeoapi/templates/collections/collection.html:80
#: build/lib/pygeoapi/templates/jobs/job.html:50
#: build/lib/pygeoapi/templates/processes/process.html:78
#: pygeoapi/templates/collections/collection.html:107
#: pygeoapi/templates/collections/items/item.html:101
#: pygeoapi/templates/jobs/job.html:50
#: pygeoapi/templates/processes/process.html:78
msgid "Links"
msgstr "الروابط"
#: build/lib/pygeoapi/templates/collections/collection.html:90
#: pygeoapi/templates/collections/collection.html:117
msgid "Reference Systems"
msgstr "أنظمة الإسناد المرجعي"
#: build/lib/pygeoapi/templates/collections/collection.html:98
#: pygeoapi/templates/collections/collection.html:125
msgid "Storage CRS"
msgstr "نظام الإحداثيات المرجعي للتخزين"
#: build/lib/pygeoapi/templates/collections/index.html:12
#: build/lib/pygeoapi/templates/processes/index.html:14
#: build/lib/pygeoapi/templates/stac/catalog.html:17
#: build/lib/pygeoapi/templates/stac/collection.html:17
#: pygeoapi/templates/collections/index.html:12
#: pygeoapi/templates/collections/index.html:39
#: pygeoapi/templates/processes/index.html:14
#: pygeoapi/templates/stac/catalog.html:17
#: pygeoapi/templates/stac/collection.html:17
msgid "Name"
msgstr "الاسم"
#: build/lib/pygeoapi/templates/collections/index.html:13
#: build/lib/pygeoapi/templates/stac/catalog.html:18
#: pygeoapi/templates/stac/catalog.html:18
msgid "Type"
msgstr "النوع"
#: build/lib/pygeoapi/templates/collections/index.html:14
#: build/lib/pygeoapi/templates/processes/index.html:15
#: build/lib/pygeoapi/templates/processes/process.html:26
#: build/lib/pygeoapi/templates/processes/process.html:56
#: build/lib/pygeoapi/templates/stac/collection.html:18
#: pygeoapi/templates/collections/index.html:13
#: pygeoapi/templates/collections/index.html:40
#: pygeoapi/templates/processes/index.html:15
#: pygeoapi/templates/processes/process.html:26
#: pygeoapi/templates/processes/process.html:56
#: pygeoapi/templates/stac/collection.html:18
#: pygeoapi/templates/tilematrixsets/index.html:16
msgid "Description"
msgstr "الوصف"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:11
msgid "Coverage domain set"
msgstr "مجموعة مجال التغطية"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:12
msgid "Axis labels"
msgstr "تسميات المحاور"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:18
msgid "Extent"
msgstr "النطاق"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:24
msgid "Coordinate reference system"
msgstr "نظام الإحداثيات المرجعي"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:26
#: build/lib/pygeoapi/templates/stac/catalog.html:20
#: build/lib/pygeoapi/templates/stac/item.html:34
#: pygeoapi/templates/stac/catalog.html:20
#: pygeoapi/templates/stac/collection_base.html:34
#: pygeoapi/templates/stac/item.html:34
msgid "Size"
msgstr "الحجم"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:28
msgid "width"
msgstr "العرض"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:29
msgid "height"
msgstr "الارتفاع"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:31
msgid "Resolution"
msgstr "الدقة"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:33
msgid "x"
msgstr "x"
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:34
msgid "y"
msgstr "y"
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:11
msgid "Coverage range type"
msgstr "نوع نطاق التغطية"
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:12
msgid "Fields"
msgstr "الحقول"
#: build/lib/pygeoapi/templates/collections/edr/query.html:11
#: build/lib/pygeoapi/templates/collections/items/index.html:11
#: build/lib/pygeoapi/templates/collections/items/item.html:33
#: pygeoapi/templates/collections/items/index.html:11
#: pygeoapi/templates/collections/items/item.html:33
msgid "Items"
msgstr "العناصر"
#: build/lib/pygeoapi/templates/collections/items/index.html:25
#: pygeoapi/templates/collections/items/index.html:38
#: pygeoapi/templates/collections/items/index.html:142
msgid "Items in this collection"
msgstr "العناصر في هذه المجموعة"
#: build/lib/pygeoapi/templates/collections/items/index.html:38
#: pygeoapi/templates/collections/items/index.html:51
msgid "Warning: Higher limits not recommended!"
msgstr "تحذير: لا يُنصح بالحدود الأعلى!"
#: build/lib/pygeoapi/templates/collections/items/index.html:43
#: pygeoapi/templates/collections/items/index.html:44
#: pygeoapi/templates/jobs/index.html:53
msgid "Limit"
msgstr "الحد"
#: build/lib/pygeoapi/templates/collections/items/index.html:45
#: pygeoapi/templates/collections/items/index.html:46
#: pygeoapi/templates/jobs/index.html:55
msgid "default"
msgstr "افتراضي"
#: build/lib/pygeoapi/templates/collections/items/index.html:66
#: build/lib/pygeoapi/templates/collections/items/item.html:62
#: pygeoapi/templates/collections/items/index.html:68
#: pygeoapi/templates/collections/items/item.html:62
#: pygeoapi/templates/jobs/index.html:76
msgid "Prev"
msgstr "السابق"
#: build/lib/pygeoapi/templates/collections/items/index.html:68
#: build/lib/pygeoapi/templates/collections/items/item.html:64
#: pygeoapi/templates/collections/items/index.html:70
#: pygeoapi/templates/collections/items/item.html:64
#: pygeoapi/templates/jobs/index.html:78
msgid "Next"
msgstr "التالي"
#: build/lib/pygeoapi/templates/collections/items/index.html:139
#: pygeoapi/templates/collections/edr/query.html:37
#: pygeoapi/templates/collections/items/index.html:147
msgid "No items"
msgstr "لا توجد عناصر"
#: build/lib/pygeoapi/templates/collections/items/item.html:77
#: build/lib/pygeoapi/templates/stac/item.html:58
#: pygeoapi/templates/collections/items/item.html:77
#: pygeoapi/templates/stac/collection_base.html:58
#: pygeoapi/templates/stac/item.html:58
msgid "Property"
msgstr "الخاصية"
#: build/lib/pygeoapi/templates/collections/items/item.html:78
#: build/lib/pygeoapi/templates/stac/item.html:59
#: pygeoapi/templates/collections/items/item.html:78
#: pygeoapi/templates/stac/collection_base.html:59
#: pygeoapi/templates/stac/item.html:59
msgid "Value"
msgstr "القيمة"
#: build/lib/pygeoapi/templates/collections/tiles/index.html:31
#: pygeoapi/templates/collections/tiles/index.html:31
msgid "Tile Matrix Set"
msgstr "مجموعة مصفوفة البلاطات"
#: build/lib/pygeoapi/templates/collections/tiles/index.html:43
#: pygeoapi/templates/collections/tiles/index.html:42
msgid "Metadata"
msgstr "البيانات الوصفية"
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:18
msgid "Tiles metadata"
msgstr "بيانات وصفية للبلاطات"
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:18
msgid "format"
msgstr "التنسيق"
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:19
msgid "Tileset"
msgstr "مجموعة البلاطات"
#: build/lib/pygeoapi/templates/jobs/index.html:14
#: pygeoapi/templates/jobs/index.html:14
msgid "Job ID"
msgstr "معرف المهمة"
#: build/lib/pygeoapi/templates/jobs/index.html:15
#: pygeoapi/templates/jobs/index.html:15
msgid "Process ID"
msgstr "معرف العملية"
#: build/lib/pygeoapi/templates/jobs/index.html:16
#: pygeoapi/templates/jobs/index.html:16
msgid "Start"
msgstr "بداية"
#: build/lib/pygeoapi/templates/jobs/index.html:17
#: build/lib/pygeoapi/templates/jobs/job.html:37
#: pygeoapi/templates/jobs/index.html:17 pygeoapi/templates/jobs/job.html:37
msgid "Duration"
msgstr "المدة"
#: build/lib/pygeoapi/templates/jobs/index.html:18
#: build/lib/pygeoapi/templates/jobs/job.html:17
#: build/lib/pygeoapi/templates/jobs/job.html:35
#: pygeoapi/templates/jobs/index.html:18 pygeoapi/templates/jobs/job.html:17
#: pygeoapi/templates/jobs/job.html:35
msgid "Progress"
msgstr "التقدم"
#: build/lib/pygeoapi/templates/jobs/index.html:19
#: build/lib/pygeoapi/templates/jobs/job.html:16
#: pygeoapi/templates/jobs/index.html:19 pygeoapi/templates/jobs/job.html:16
msgid "Status"
msgstr "الحالة"
#: build/lib/pygeoapi/templates/jobs/index.html:20
#: build/lib/pygeoapi/templates/jobs/job.html:21
#: pygeoapi/templates/jobs/index.html:20 pygeoapi/templates/jobs/job.html:21
msgid "Message"
msgstr "الرسالة"
#: build/lib/pygeoapi/templates/jobs/job.html:2
#: build/lib/pygeoapi/templates/jobs/job.html:10
#: pygeoapi/templates/jobs/job.html:2 pygeoapi/templates/jobs/job.html:10
msgid "Job status"
msgstr "حالة المهمة"
#: build/lib/pygeoapi/templates/jobs/job.html:26
#: pygeoapi/templates/jobs/job.html:26
msgid "Parameters"
msgstr "المعلمات"
#: build/lib/pygeoapi/templates/jobs/job.html:45
#: pygeoapi/templates/jobs/job.html:45
msgid "Started processing"
msgstr "بدأت المعالجة"
#: build/lib/pygeoapi/templates/jobs/job.html:47
#: pygeoapi/templates/jobs/job.html:47
msgid "Finished processing"
msgstr "انتهت المعالجة"
#: build/lib/pygeoapi/templates/jobs/results/index.html:2
#: pygeoapi/templates/jobs/results/index.html:2
msgid "Job result"
msgstr "نتيجة المهمة"
#: build/lib/pygeoapi/templates/jobs/results/index.html:6
#: pygeoapi/templates/jobs/results/index.html:6
msgid "Results"
msgstr "النتائج"
#: build/lib/pygeoapi/templates/jobs/results/index.html:10
#: pygeoapi/templates/jobs/results/index.html:10
msgid "Results of job"
msgstr "نتائج المهمة"
#: build/lib/pygeoapi/templates/processes/index.html:8
#: pygeoapi/templates/processes/index.html:8
msgid "Processes in this service"
msgstr "العمليات في هذه الخدمة"
#: build/lib/pygeoapi/templates/processes/process.html:20
#: pygeoapi/templates/processes/process.html:20
msgid "Inputs"
msgstr "المدخلات"
#: build/lib/pygeoapi/templates/processes/process.html:23
#: build/lib/pygeoapi/templates/processes/process.html:54
#: pygeoapi/templates/processes/process.html:23
#: pygeoapi/templates/processes/process.html:54
msgid "Id"
msgstr "معرف"
#: build/lib/pygeoapi/templates/processes/process.html:24
#: build/lib/pygeoapi/templates/processes/process.html:55
#: pygeoapi/templates/processes/process.html:24
#: pygeoapi/templates/processes/process.html:55
#: pygeoapi/templates/tilematrixsets/index.html:15
msgid "Title"
msgstr "العنوان"
#: build/lib/pygeoapi/templates/processes/process.html:25
#: pygeoapi/templates/processes/process.html:25
msgid "Data Type"
msgstr "نوع البيانات"
#: build/lib/pygeoapi/templates/processes/process.html:51
#: pygeoapi/templates/processes/process.html:51
msgid "Outputs"
msgstr "المخرجات"
#: build/lib/pygeoapi/templates/processes/process.html:71
#: pygeoapi/templates/processes/process.html:71
msgid "Execution modes"
msgstr "أنماط التنفيذ"
#: build/lib/pygeoapi/templates/processes/process.html:73
#: pygeoapi/templates/processes/process.html:73
msgid "Synchronous"
msgstr "متزامن"
#: build/lib/pygeoapi/templates/processes/process.html:74
#: pygeoapi/templates/processes/process.html:74
msgid "Asynchronous"
msgstr "غير متزامن"
#: build/lib/pygeoapi/templates/stac/catalog.html:4
#: build/lib/pygeoapi/templates/stac/collection.html:2
#: build/lib/pygeoapi/templates/stac/collection.html:4
#: build/lib/pygeoapi/templates/stac/item.html:4
#: pygeoapi/templates/stac/catalog.html:4
#: pygeoapi/templates/stac/collection.html:2
#: pygeoapi/templates/stac/collection.html:4
#: pygeoapi/templates/stac/collection_base.html:4
#: pygeoapi/templates/stac/item.html:4
msgid "SpatioTemporal Asset Catalog"
msgstr "كتالوج الأصول الزمانية والمكانية (STAC)"
#: build/lib/pygeoapi/templates/stac/catalog.html:19
#: pygeoapi/templates/stac/catalog.html:19
msgid "Last modified"
msgstr "آخر تعديل"
#: build/lib/pygeoapi/templates/stac/collection.html:9
#: pygeoapi/templates/stac/collection.html:9
msgid "STAC Version"
msgstr "إصدار STAC"
#: build/lib/pygeoapi/templates/stac/item.html:19
#: pygeoapi/templates/stac/item.html:19
msgid "Item"
msgstr "العنصر"
#: build/lib/pygeoapi/templates/stac/item.html:28
#: pygeoapi/templates/stac/collection_base.html:28
#: pygeoapi/templates/stac/item.html:28
msgid "Assets"
msgstr "الأصول"
#: build/lib/pygeoapi/templates/stac/item.html:32
#: pygeoapi/templates/landing_page.html:45
#: pygeoapi/templates/stac/collection_base.html:32
#: pygeoapi/templates/stac/item.html:32
msgid "URL"
msgstr "الرابط"
#: build/lib/pygeoapi/templates/stac/item.html:33
#: pygeoapi/templates/stac/collection_base.html:33
#: pygeoapi/templates/stac/item.html:33
msgid "Last Modified"
msgstr "آخر تعديل"
#: build/lib/pygeoapi/templates/stac/item.html:64
#: pygeoapi/templates/stac/collection_base.html:64
#: pygeoapi/templates/stac/item.html:64
msgid "id"
msgstr "معرف"
#: pygeoapi/templates/_base.html:2
msgid "text_direction"
msgstr "rtl"
#: pygeoapi/templates/_base.html:47
msgid "Contact"
msgstr "الاتصال"
#: pygeoapi/templates/_base.html:51
msgid "Admin"
msgstr "الإدارة"
#: pygeoapi/templates/landing_page.html:101
msgid "Tile Matrix Sets"
msgstr "مجموعات مصفوفة البلاطات"
#: pygeoapi/templates/landing_page.html:103
msgid "View the Tile Matrix Sets available on this service"
msgstr "عرض مجموعات مصفوفة البلاطات المتاحة في هذه الخدمة"
#: pygeoapi/templates/collections/collection.html:67
#: pygeoapi/templates/collections/schema.html:6
#: pygeoapi/templates/collections/schema.html:17
msgid "Schema"
msgstr "المخطط"
#: pygeoapi/templates/collections/collection.html:71
msgid "Display Schema"
msgstr "عرض المخطط"
#: pygeoapi/templates/collections/collection.html:72
msgid "Display Schema of"
msgstr "عرض مخطط لـ"
#: pygeoapi/templates/collections/collection.html:128
msgid "CRS"
msgstr "CRS"
#: pygeoapi/templates/collections/collection.html:131
msgid "Epoch"
msgstr "العصر"
#: pygeoapi/templates/collections/index.html:8
msgid "Data collections in this service"
msgstr "مجموعات البيانات في هذه الخدمة"
#: pygeoapi/templates/collections/index.html:35
msgid "Record collections in this service"
msgstr "مجموعات السجلات في هذه الخدمة"
#: pygeoapi/templates/collections/edr/query.html:11
#, python-format
msgid "%(query_type)s"
msgstr "%(query_type)s"
#: pygeoapi/templates/collections/tiles/metadata.html:15
msgid "TileJSON"
msgstr "TileJSON"
#: pygeoapi/templates/collections/tiles/metadata.html:17
msgid "JSON"
msgstr "JSON"
#: pygeoapi/templates/stac/collection_base.html:68
msgid "description"
msgstr "الوصف"
#: pygeoapi/templates/stac/collection_base.html:72
msgid "extent"
msgstr "النطاق"
#: pygeoapi/templates/stac/collection_base.html:77
msgid "cube:dimensions"
msgstr "cube:dimensions"
#: pygeoapi/templates/stac/collection_base.html:83
msgid "cube:variables"
msgstr "cube:variables"
#: pygeoapi/templates/tilematrixsets/index.html:2
#: pygeoapi/templates/tilematrixsets/index.html:4
#: pygeoapi/templates/tilematrixsets/tilematrixset.html:4
msgid "TileMatrixSets"
msgstr "مجموعات مصفوفة البلاطات"
#: pygeoapi/templates/tilematrixsets/index.html:9
msgid "Tile matrix sets available in this service"
msgstr "مجموعات مصفوفة البلاطات المتاحة في هذه الخدمة"
#: pygeoapi/templates/tilematrixsets/tilematrixset.html:2
msgid "TileMatrixSet"
msgstr "مجموعة مصفوفة البلاطات"
+28
View File
@@ -19,6 +19,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.14.0\n"
#: pygeoapi/templates/_base.html:2
msgid "text_direction"
msgstr "ltr"
#: pygeoapi/templates/_base.html:51
msgid "Admin"
msgstr "Admin"
@@ -656,3 +660,27 @@ msgstr ""
msgid "not specified"
msgstr ""
msgid "Position"
msgstr ""
msgid "Cube"
msgstr ""
msgid "Area"
msgstr ""
msgid "Corridor"
msgstr ""
msgid "Trajectory"
msgstr ""
msgid "Radius"
msgstr ""
msgid "Locations"
msgstr ""
msgid "Instances"
msgstr ""
+28
View File
@@ -18,6 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.1\n"
#: pygeoapi/templates/_base.html:2
msgid "text_direction"
msgstr "ltr"
#: build/lib/pygeoapi/templates/_base.html:40
#: build/lib/pygeoapi/templates/landing_page.html:2
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
@@ -706,3 +710,27 @@ msgstr ""
msgid "not specified"
msgstr ""
msgid "Position"
msgstr ""
msgid "Cube"
msgstr ""
msgid "Area"
msgstr ""
msgid "Corridor"
msgstr ""
msgid "Trajectory"
msgstr ""
msgid "Radius"
msgstr ""
msgid "Locations"
msgstr ""
msgid "Instances"
msgstr ""
+28
View File
@@ -18,6 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.1\n"
#: pygeoapi/templates/_base.html:2
msgid "text_direction"
msgstr "ltr"
#: build/lib/pygeoapi/templates/_base.html:40
#: build/lib/pygeoapi/templates/landing_page.html:2
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
@@ -708,3 +712,27 @@ msgstr ""
msgid "not specified"
msgstr ""
msgid "Position"
msgstr ""
msgid "Cube"
msgstr ""
msgid "Area"
msgstr ""
msgid "Corridor"
msgstr ""
msgid "Trajectory"
msgstr ""
msgid "Radius"
msgstr ""
msgid "Locations"
msgstr ""
msgid "Instances"
msgstr ""
+28
View File
@@ -18,6 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
#: pygeoapi/templates/_base.html:2
msgid "text_direction"
msgstr "ltr"
#: pygeoapi/templates/_base.html:51
msgid "Admin"
msgstr "Admin"
@@ -521,3 +525,27 @@ msgstr ""
msgid "not specified"
msgstr ""
msgid "Position"
msgstr ""
msgid "Cube"
msgstr ""
msgid "Area"
msgstr ""
msgid "Corridor"
msgstr ""
msgid "Trajectory"
msgstr ""
msgid "Radius"
msgstr ""
msgid "Locations"
msgstr ""
msgid "Instances"
msgstr ""
+28
View File
@@ -18,6 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.1\n"
#: pygeoapi/templates/_base.html:2
msgid "text_direction"
msgstr "ltr"
#: build/lib/pygeoapi/templates/_base.html:40
#: build/lib/pygeoapi/templates/landing_page.html:2
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
@@ -715,3 +719,27 @@ msgstr ""
msgid "not specified"
msgstr ""
msgid "Position"
msgstr ""
msgid "Cube"
msgstr ""
msgid "Area"
msgstr ""
msgid "Corridor"
msgstr ""
msgid "Trajectory"
msgstr ""
msgid "Radius"
msgstr ""
msgid "Locations"
msgstr ""
msgid "Instances"
msgstr ""
+28
View File
@@ -19,6 +19,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.14.0\n"
#: pygeoapi/templates/_base.html:2
msgid "text_direction"
msgstr "ltr"
#: pygeoapi/templates/_base.html:51
msgid "Admin"
msgstr "Admin"
@@ -656,3 +660,27 @@ msgstr ""
msgid "not specified"
msgstr ""
msgid "Position"
msgstr ""
msgid "Cube"
msgstr ""
msgid "Area"
msgstr ""
msgid "Corridor"
msgstr ""
msgid "Trajectory"
msgstr ""
msgid "Radius"
msgstr ""
msgid "Locations"
msgstr ""
msgid "Instances"
msgstr ""
+1 -1
View File
@@ -30,7 +30,7 @@
#
# =================================================================
__version__ = '0.18.dev0'
__version__ = '0.19.dev0'
import click
try:
+13 -1
View File
@@ -80,6 +80,7 @@ HEADERS = {
CHARSET = ['utf-8']
F_JSON = 'json'
F_COVERAGEJSON = 'json'
F_HTML = 'html'
F_JSONLD = 'jsonld'
F_GZIP = 'gzip'
@@ -1209,6 +1210,7 @@ class API:
if edr:
# TODO: translate
LOGGER.debug('Adding EDR links')
collection['data_queries'] = {}
parameters = p.get_fields()
if parameters:
collection['parameter_names'] = {}
@@ -1229,6 +1231,14 @@ class API:
}
for qt in p.get_query_types():
data_query = {
'link': {
'href': f'{self.get_collections_url()}/{k}/{qt}',
'rel': 'data'
}
}
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
@@ -1373,6 +1383,7 @@ class API:
self.config['resources'][dataset]['title'], request.locale)
schema['collections_path'] = self.get_collections_url()
schema['dataset_path'] = f'{self.get_collections_url()}/{dataset}'
content = render_j2_template(self.tpl_config,
'collections/schema.html',
@@ -1430,7 +1441,8 @@ class API:
# Content-Language is in the system locale (ignore language settings)
headers = request.get_response_headers(SYSTEM_LOCALE,
**self.api_headers)
msg = f'Invalid format: {request.format}'
msg = 'Invalid format requested'
LOGGER.error(f'{msg}: {request.format}')
return self.get_exception(
HTTPStatus.BAD_REQUEST, headers,
request.format, 'InvalidParameterValue', msg)
+57 -26
View File
@@ -41,10 +41,12 @@
from http import HTTPStatus
import logging
from typing import Tuple
import urllib
from shapely.errors import WKTReadingError
from shapely.errors import ShapelyError
from shapely.wkt import loads as shapely_loads
from pygeoapi import l10n
from pygeoapi.plugin import load_plugin, PLUGINS
from pygeoapi.provider.base import ProviderGenericError
from pygeoapi.util import (
@@ -52,7 +54,8 @@ from pygeoapi.util import (
to_json, filter_dict_by_key_value
)
from . import APIRequest, API, F_HTML, validate_datetime, validate_bbox
from . import (APIRequest, API, F_COVERAGEJSON, F_HTML, F_JSONLD,
validate_datetime, validate_bbox)
LOGGER = logging.getLogger(__name__)
@@ -88,6 +91,27 @@ def get_collection_edr_query(api: API, request: APIRequest,
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
LOGGER.debug('Loading provider')
try:
p = load_plugin('provider', get_provider_by_type(
collections[dataset]['providers'], 'edr'))
except ProviderGenericError as err:
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
if instance is not None and not p.get_instance(instance):
msg = 'Invalid instance identifier'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers,
request.format, 'InvalidParameterValue', msg)
if query_type not in p.get_query_types():
msg = 'Unsupported query type'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
LOGGER.debug('Processing query parameters')
LOGGER.debug('Processing datetime parameter')
@@ -124,7 +148,7 @@ def get_collection_edr_query(api: API, request: APIRequest,
if wkt:
try:
wkt = shapely_loads(wkt)
except WKTReadingError:
except ShapelyError:
msg = 'invalid coords parameter'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
@@ -144,27 +168,6 @@ def get_collection_edr_query(api: API, request: APIRequest,
LOGGER.debug('Processing z parameter')
z = request.params.get('z')
LOGGER.debug('Loading provider')
try:
p = load_plugin('provider', get_provider_by_type(
collections[dataset]['providers'], 'edr'))
except ProviderGenericError as err:
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
if instance is not None and not p.get_instance(instance):
msg = 'Invalid instance identifier'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers,
request.format, 'InvalidParameterValue', msg)
if query_type not in p.get_query_types():
msg = 'Unsupported query type'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
if parameternames and not any((fld in parameternames)
for fld in p.get_fields().keys()):
msg = 'Invalid parameter-name'
@@ -195,6 +198,36 @@ def get_collection_edr_query(api: API, request: APIRequest,
err.ogc_exception_code, err.message)
if request.format == F_HTML: # render
uri = f'{api.get_collections_url()}/{dataset}/{query_type}'
serialized_query_params = ''
for k, v in request.params.items():
if k != 'f':
serialized_query_params += '&'
serialized_query_params += urllib.parse.quote(k, safe='')
serialized_query_params += '='
serialized_query_params += urllib.parse.quote(str(v), safe=',')
data['query_type'] = query_type.capitalize()
data['query_path'] = uri
data['dataset_path'] = '/'.join(uri.split('/')[:-1])
data['collections_path'] = api.get_collections_url()
data['links'] = [{
'rel': 'collection',
'title': collections[dataset]['title'],
'href': data['dataset_path']
}, {
'type': 'application/prs.coverage+json',
'rel': request.get_linkrel(F_COVERAGEJSON),
'title': l10n.translate('This document as CoverageJSON', request.locale), # noqa
'href': f'{uri}?f={F_COVERAGEJSON}{serialized_query_params}'
}, {
'type': 'application/ld+json',
'rel': 'alternate',
'title': l10n.translate('This document as JSON-LD', request.locale), # noqa
'href': f'{uri}?f={F_JSONLD}{serialized_query_params}'
}]
content = render_j2_template(api.tpl_config,
'collections/edr/query.html', data,
api.default_locale)
@@ -305,11 +338,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
'tags': [k],
'operationId': f'queryLOCATIONSBYID{k.capitalize()}',
'parameters': [
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{spatial_parameter}.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/locationId.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/parameter-name.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/z.yaml"}, # noqa
{'$ref': '#/components/parameters/f'}
],
'responses': {
+44 -31
View File
@@ -121,23 +121,22 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
LOGGER.debug('Creating collection queryables')
try:
LOGGER.debug('Loading feature provider')
p = load_plugin('provider', get_provider_by_type(
api.config['resources'][dataset]['providers'], 'feature'))
except ProviderTypeError:
p = None
for pt in ['feature', 'coverage', 'record']:
try:
LOGGER.debug('Loading coverage provider')
LOGGER.debug(f'Loading {pt} provider')
p = load_plugin('provider', get_provider_by_type(
api.config['resources'][dataset]['providers'], 'coverage')) # noqa
api.config['resources'][dataset]['providers'], pt))
break
except ProviderTypeError:
LOGGER.debug('Loading record provider')
p = load_plugin('provider', get_provider_by_type(
api.config['resources'][dataset]['providers'], 'record'))
except ProviderGenericError as err:
LOGGER.debug(f'Providing type {pt} not found')
if p is None:
msg = 'queryables not available for this collection'
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
HTTPStatus.BAD_REQUEST, headers, request.format,
'NoApplicableError', msg)
queryables = {
'type': 'object',
@@ -182,6 +181,7 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
api.config['resources'][dataset]['title'], request.locale)
queryables['collections_path'] = api.get_collections_url()
queryables['dataset_path'] = f'{api.get_collections_url()}/{dataset}'
content = render_j2_template(api.tpl_config,
'collections/queryables.html',
@@ -380,8 +380,12 @@ def get_collection_items(
LOGGER.debug('processing property parameters')
for k, v in request.params.items():
if k not in reserved_fieldnames and k in list(p.fields.keys()):
LOGGER.debug(f'Adding property filter {k}={v}')
if k not in reserved_fieldnames:
if k in list(p.fields.keys()):
LOGGER.debug(f'Adding property filter {k}={v}')
else:
LOGGER.debug(f'Adding additional property filter {k}={v}')
properties.append((k, v))
LOGGER.debug('processing sort parameter')
@@ -444,7 +448,8 @@ def get_collection_items(
geometry_column_name=provider_def.get('geom_field'),
)
except Exception:
msg = f'Bad CQL string : {cql_text}'
msg = 'Bad CQL text'
LOGGER.error(f'{msg}: {cql_text}')
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
@@ -531,17 +536,23 @@ def get_collection_items(
'href': f'{uri}?offset={prev}{serialized_query_params}'
})
if 'numberMatched' in content:
if content['numberMatched'] > (limit + offset):
next_ = offset + limit
next_href = f'{uri}?offset={next_}{serialized_query_params}'
content['links'].append(
{
'type': 'application/geo+json',
'rel': 'next',
'title': l10n.translate('Items (next)', request.locale),
'href': next_href
})
next_link = False
if content.get('numberMatched', -1) > (limit + offset):
next_link = True
elif len(content['features']) == limit:
next_link = True
if next_link:
next_ = offset + limit
next_href = f'{uri}?offset={next_}{serialized_query_params}'
content['links'].append(
{
'type': 'application/geo+json',
'rel': 'next',
'title': l10n.translate('Items (next)', request.locale),
'href': next_href
})
content['links'].append(
{
@@ -836,7 +847,7 @@ def post_collection_items(
if (request_headers.get(
'Content-Type') or request_headers.get(
'content-type')) != 'application/query-cql-json':
msg = ('Invalid body content-type')
msg = 'Invalid body content-type'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidHeaderValue', msg)
@@ -872,16 +883,18 @@ def post_collection_items(
geometry_column_name=provider_def.get('geom_field')
)
except Exception:
msg = f'Bad CQL string : {data}'
msg = 'Bad CQL text'
LOGGER.error(f'{msg}: {data}')
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
else:
LOGGER.debug('processing Elasticsearch CQL_JSON data')
LOGGER.debug('processing CQL_JSON data')
try:
filter_ = CQLModel.parse_raw(data)
except Exception:
msg = f'Bad CQL string : {data}'
msg = 'Bad CQL text'
LOGGER.error(f'{msg}: {data}')
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
+94 -6
View File
@@ -46,6 +46,7 @@ from http import HTTPStatus
import json
import logging
from typing import Tuple
import urllib.parse
from pygeoapi import l10n
from pygeoapi.util import (
@@ -240,10 +241,51 @@ def get_jobs(api: API, request: APIRequest,
headers = request.get_response_headers(SYSTEM_LOCALE,
**api.api_headers)
LOGGER.debug('Processing limit parameter')
try:
limit = int(request.params.get('limit'))
if limit <= 0:
msg = 'limit value should be strictly positive'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
except TypeError:
limit = int(api.config['server']['limit'])
LOGGER.debug('returning all jobs')
except ValueError:
msg = 'limit value should be an integer'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
LOGGER.debug('Processing offset parameter')
try:
offset = int(request.params.get('offset'))
if offset < 0:
msg = 'offset value should be positive or zero'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
except TypeError as err:
LOGGER.warning(err)
offset = 0
except ValueError:
msg = 'offset value should be an integer'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
if job_id is None:
jobs = sorted(api.manager.get_jobs(),
jobs_data = api.manager.get_jobs(limit=limit, offset=offset)
# TODO: For pagination to work, the provider has to do the sorting.
# Here we do sort again in case the provider doesn't support
# pagination yet and always returns all jobs.
jobs = sorted(jobs_data['jobs'],
key=lambda k: k['job_start_datetime'],
reverse=True)
numberMatched = jobs_data['numberMatched']
else:
try:
jobs = [api.manager.get_job(job_id)]
@@ -251,6 +293,7 @@ def get_jobs(api: API, request: APIRequest,
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, request.format,
'InvalidParameterValue', job_id)
numberMatched = 1
serialized_jobs = {
'jobs': [],
@@ -309,6 +352,44 @@ def get_jobs(api: API, request: APIRequest,
serialized_jobs['jobs'].append(job2)
serialized_query_params = ''
for k, v in request.params.items():
if k not in ('f', 'offset'):
serialized_query_params += '&'
serialized_query_params += urllib.parse.quote(k, safe='')
serialized_query_params += '='
serialized_query_params += urllib.parse.quote(str(v), safe=',')
uri = f'{api.base_url}/jobs'
if offset > 0:
prev = max(0, offset - limit)
serialized_jobs['links'].append(
{
'href': f'{uri}?offset={prev}{serialized_query_params}',
'type': FORMAT_TYPES[F_JSON],
'rel': 'prev',
'title': l10n.translate('Items (prev)', request.locale),
})
next_link = False
if numberMatched > (limit + offset):
next_link = True
elif len(jobs) == limit:
next_link = True
if next_link:
next_ = offset + limit
next_href = f'{uri}?offset={next_}{serialized_query_params}'
serialized_jobs['links'].append(
{
'href': next_href,
'rel': 'next',
'type': FORMAT_TYPES[F_JSON],
'title': l10n.translate('Items (next)', request.locale),
})
if job_id is None:
j2_template = 'jobs/index.html'
else:
@@ -318,6 +399,7 @@ def get_jobs(api: API, request: APIRequest,
if request.format == F_HTML:
data = {
'jobs': serialized_jobs,
'offset': offset,
'now': datetime.now(timezone.utc).strftime(DATETIME_FORMAT)
}
response = render_j2_template(api.tpl_config, j2_template, data,
@@ -379,6 +461,8 @@ def execute_process(api: API, request: APIRequest,
requested_outputs = data.get('outputs')
LOGGER.debug(f'outputs: {requested_outputs}')
requested_response = data.get('response', 'raw')
subscriber = None
subscriber_dict = data.get('subscriber')
if subscriber_dict:
@@ -407,10 +491,14 @@ def execute_process(api: API, request: APIRequest,
result = api.manager.execute_process(
process_id, data_dict, execution_mode=execution_mode,
requested_outputs=requested_outputs,
subscriber=subscriber)
subscriber=subscriber,
requested_response=requested_response)
job_id, mime_type, outputs, status, additional_headers = result
headers.update(additional_headers or {})
headers['Location'] = f'{api.base_url}/jobs/{job_id}'
if api.manager.is_async:
headers['Location'] = f'{api.base_url}/jobs/{job_id}'
except ProcessorExecuteError as err:
return api.get_exception(
err.http_status_code, headers,
@@ -420,11 +508,11 @@ def execute_process(api: API, request: APIRequest,
if status == JobStatus.failed:
response = outputs
if data.get('response', 'raw') == 'raw':
if requested_response == 'raw':
headers['Content-Type'] = mime_type
response = outputs
elif status not in (JobStatus.failed, JobStatus.accepted):
response['outputs'] = [outputs]
response = outputs
if status == JobStatus.accepted:
http_status = HTTPStatus.CREATED
@@ -433,7 +521,7 @@ def execute_process(api: API, request: APIRequest,
else:
http_status = HTTPStatus.OK
if mime_type == 'application/json':
if mime_type == 'application/json' or requested_response == 'document':
response2 = to_json(response, api.pretty_print)
else:
response2 = response
+6 -6
View File
@@ -77,7 +77,7 @@ ADMIN_BLUEPRINT = Blueprint('admin', __name__, static_folder=STATIC_FOLDER)
if CONFIG['server'].get('cors', False):
try:
from flask_cors import CORS
CORS(APP)
CORS(APP, CORS_EXPOSE_HEADERS=['*'])
except ModuleNotFoundError:
print('Python package flask-cors required for CORS support')
@@ -279,11 +279,7 @@ def collection_items(collection_id, item_id=None):
"""
if item_id is None:
if request.method == 'GET': # list items
return execute_from_flask(itemtypes_api.get_collection_items,
request, collection_id,
skip_valid_check=True)
elif request.method == 'POST': # filter or manage items
if request.method == 'POST': # filter or manage items
if request.content_type is not None:
if request.content_type == 'application/geo+json':
return execute_from_flask(
@@ -298,6 +294,10 @@ def collection_items(collection_id, item_id=None):
return execute_from_flask(
itemtypes_api.manage_collection_item, request, 'options',
collection_id, skip_valid_check=True)
else: # GET: list items
return execute_from_flask(itemtypes_api.get_collection_items,
request, collection_id,
skip_valid_check=True)
elif request.method == 'DELETE':
return execute_from_flask(itemtypes_api.manage_collection_item,
+4 -4
View File
@@ -27,11 +27,10 @@
#
# =================================================================
import csv
import io
import logging
import unicodecsv as csv
from pygeoapi.formatter.base import BaseFormatter, FormatterSerializationError
LOGGER = logging.getLogger(__name__)
@@ -83,10 +82,11 @@ class CSVFormatter(BaseFormatter):
# TODO: implement wkt geometry serialization
LOGGER.debug('not a point geometry, skipping')
print("JJJ", fields)
LOGGER.debug(f'CSV fields: {fields}')
try:
output = io.BytesIO()
output = io.StringIO()
writer = csv.DictWriter(output, fields)
writer.writeheader()
@@ -101,7 +101,7 @@ class CSVFormatter(BaseFormatter):
LOGGER.error(err)
raise FormatterSerializationError('Error writing CSV output')
return output.getvalue()
return output.getvalue().encode('utf-8')
def __repr__(self):
return f'<CSVFormatter> {self.name}'
+58 -5
View File
@@ -134,6 +134,52 @@ def gen_response_object(description: str, media_type: str,
return response
def gen_contact(cfg: dict) -> dict:
"""
Generates an OpenAPI contact object with OGC extensions
based on OGC API - Records contact
:param cfg: `dict` of configuration
:returns: `dict` of OpenAPI contact object
"""
contact = {
'name': cfg['metadata']['provider']['name'],
'url': cfg['metadata']['provider']['url'],
'email': cfg['metadata']['contact']['email']
}
contact['x-ogc-serviceContact'] = {
'name': cfg['metadata']['contact']['name'],
'position': cfg['metadata']['contact']['position'],
'addresses': [{
'deliveryPoint': [cfg['metadata']['contact']['address']],
'city': cfg['metadata']['contact']['city'],
'administrativeArea': cfg['metadata']['contact']['stateorprovince'], # noqa
'postalCode': cfg['metadata']['contact']['postalcode'],
'country': cfg['metadata']['contact']['country']
}],
'phones': [{
'type': 'main', 'value': cfg['metadata']['contact']['phone']
}, {
'type': 'fax', 'value': cfg['metadata']['contact']['fax']
}],
'emails': [{
'value': cfg['metadata']['contact']['email']
}],
'contactInstructions': cfg['metadata']['contact']['instructions'],
'links': [{
'type': 'text/html',
'href': cfg['metadata']['contact']['url']
}],
'hoursOfService': cfg['metadata']['contact']['hours'],
'roles': [cfg['metadata']['contact']['role']]
}
return contact
def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
"""
Generates an OpenAPI 3.0 Document
@@ -167,11 +213,7 @@ def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
'x-keywords': l10n.translate(cfg['metadata']['identification']['keywords'], locale_), # noqa
'termsOfService':
cfg['metadata']['identification']['terms_of_service'],
'contact': {
'name': cfg['metadata']['provider']['name'],
'url': cfg['metadata']['provider']['url'],
'email': cfg['metadata']['contact']['email']
},
'contact': gen_contact(cfg),
'license': {
'name': cfg['metadata']['license']['name'],
'url': cfg['metadata']['license']['url']
@@ -903,6 +945,17 @@ def load_openapi_document() -> dict:
pygeoapi_openapi = os.environ.get('PYGEOAPI_OPENAPI')
if pygeoapi_openapi is None:
msg = 'PYGEOAPI_OPENAPI environment not set'
LOGGER.error(msg)
raise RuntimeError(msg)
if not os.path.exists(pygeoapi_openapi):
msg = (f'OpenAPI document {pygeoapi_openapi} does not exist. '
'Please generate before starting pygeoapi')
LOGGER.error(msg)
raise RuntimeError(msg)
with open(pygeoapi_openapi, encoding='utf8') as ff:
if pygeoapi_openapi.endswith(('.yaml', '.yml')):
openapi_ = yaml_load(ff)
+4 -2
View File
@@ -51,10 +51,12 @@ PLUGINS = {
'MapScript': 'pygeoapi.provider.mapscript_.MapScriptProvider',
'MongoDB': 'pygeoapi.provider.mongo.MongoProvider',
'MVT-tippecanoe': 'pygeoapi.provider.mvt_tippecanoe.MVTTippecanoeProvider', # noqa: E501
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider', # noqa: E501
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider', # noqa: E501
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider',
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider',
'OracleDB': 'pygeoapi.provider.oracle.OracleProvider',
'OGR': 'pygeoapi.provider.ogr.OGRProvider',
'OpenSearch': 'pygeoapi.provider.opensearch_.OpenSearchProvider',
'Parquet': 'pygeoapi.provider.parquet.ParquetProvider',
'PostgreSQL': 'pygeoapi.provider.postgresql.PostgreSQLProvider',
'rasterio': 'pygeoapi.provider.rasterio_.RasterioProvider',
'SensorThings': 'pygeoapi.provider.sensorthings.SensorThingsProvider',
+71 -33
View File
@@ -54,6 +54,7 @@ from pygeoapi.util import (
JobStatus,
ProcessExecutionMode,
RequestedProcessExecutionMode,
RequestedResponse,
Subscriber
)
@@ -107,14 +108,21 @@ class BaseManager:
else:
return load_plugin('process', process_conf['processor'])
def get_jobs(self, status: JobStatus = None) -> list:
def get_jobs(self,
status: JobStatus = None,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> dict:
"""
Get process jobs, optionally filtered by status
:param status: job status (accepted, running, successful,
failed, results) (default is all)
:param limit: number of jobs to return
:param offset: pagination offset
:returns: `list` of jobs (identifier, status, process identifier)
:returns: dict of list of jobs (identifier, status, process identifier)
and numberMatched
"""
raise NotImplementedError()
@@ -187,6 +195,7 @@ class BaseManager:
data_dict: dict,
requested_outputs: Optional[dict] = None,
subscriber: Optional[Subscriber] = None,
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
) -> Tuple[str, None, JobStatus]:
"""
This private execution handler executes a process in a background
@@ -197,27 +206,34 @@ class BaseManager:
:param p: `pygeoapi.process` object
:param job_id: job identifier
:param data_dict: `dict` of data parameters
:param requested_outputs: `dict` specify the subset of required
outputs - defaults to all outputs.
The value of any key may be an object and include the property
`transmissionMode` - defaults to `value`.
Note: 'optional' is for backward compatibility.
:param requested_outputs: `dict` optionally specifying the subset of
required outputs - defaults to all outputs.
The value of any key may be an object and
include the property `transmissionMode`
(defaults to `value`)
Note: 'optional' is for backward
compatibility.
:param subscriber: optional `Subscriber` specifying callback URLs
:param requested_response: `RequestedResponse` optionally specifying
raw or document (default is `raw`)
:returns: tuple of None (i.e. initial response payload)
and JobStatus.accepted (i.e. initial job status)
"""
_process = dummy.Process(
target=self._execute_handler_sync,
args=(p, job_id, data_dict, requested_outputs, subscriber)
)
args = (p, job_id, data_dict, requested_outputs, subscriber,
requested_response)
_process = dummy.Process(target=self._execute_handler_sync, args=args)
_process.start()
return 'application/json', None, JobStatus.accepted
def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
data_dict: dict,
requested_outputs: Optional[dict] = None,
subscriber: Optional[Subscriber] = None,
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
) -> Tuple[str, Any, JobStatus]:
"""
Synchronous execution handler
@@ -229,15 +245,27 @@ class BaseManager:
:param p: `pygeoapi.process` object
:param job_id: job identifier
:param data_dict: `dict` of data parameters
:param requested_outputs: `dict` specify the subset of required
outputs - defaults to all outputs.
The value of any key may be an object and include the property
`transmissionMode` - defaults to `value`.
Note: 'optional' is for backward compatibility.
:param requested_outputs: `dict` optionally specifying the subset of
required outputs - defaults to all outputs.
The value of any key may be an object and
include the property `transmissionMode`
(defaults to `value`)
Note: 'optional' is for backward
compatibility.
:param subscriber: optional `Subscriber` specifying callback URLs
:param requested_response: `RequestedResponse` optionally specifying
raw or document (default is `raw`)
:returns: tuple of MIME type, response payload and status
"""
extra_execute_parameters = {}
# only pass requested_outputs if supported,
# otherwise this breaks existing processes
if p.supports_outputs:
extra_execute_parameters['outputs'] = requested_outputs
self._send_in_progress_notification(subscriber)
try:
@@ -248,13 +276,12 @@ class BaseManager:
job_filename = None
current_status = JobStatus.running
jfmt, outputs = p.execute(
data_dict,
# only pass requested_outputs if supported,
# otherwise this breaks existing processes
**({'outputs': requested_outputs}
if p.supports_outputs else {})
)
jfmt, outputs = p.execute(data_dict, **extra_execute_parameters)
if requested_response == RequestedResponse.document.value:
outputs = {
'outputs': [outputs]
}
self.update_job(job_id, {
'status': current_status.value,
@@ -330,7 +357,8 @@ class BaseManager:
data_dict: dict,
execution_mode: Optional[RequestedProcessExecutionMode] = None,
requested_outputs: Optional[dict] = None,
subscriber: Optional[Subscriber] = None
subscriber: Optional[Subscriber] = None,
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
) -> Tuple[str, Any, JobStatus, Optional[Dict[str, str]]]:
"""
Default process execution handler
@@ -339,12 +367,17 @@ class BaseManager:
:param data_dict: `dict` of data parameters
:param execution_mode: `str` optionally specifying sync or async
processing.
:param requested_outputs: `dict` optionally specify the subset of
required outputs - defaults to all outputs.
The value of any key may be an object and include the property
`transmissionMode` - defaults to `value`.
Note: 'optional' is for backward compatibility.
:param requested_outputs: `dict` optionally specifying the subset of
required outputs - defaults to all outputs.
The value of any key may be an object and
include the property `transmissionMode`
(default is `value`)
Note: 'optional' is for backward
compatibility.
:param subscriber: `Subscriber` optionally specifying callback urls
:param requested_response: `RequestedResponse` optionally specifying
raw or document (default is `raw`)
:raises UnknownProcessError: if the input process_id does not
correspond to a known process
@@ -356,6 +389,9 @@ class BaseManager:
job_id = str(uuid.uuid1())
processor = self.get_processor(process_id)
processor.set_job_id(job_id)
extra_execute_handler_parameters = {
'requested_response': requested_response
}
if execution_mode == RequestedProcessExecutionMode.respond_async:
job_control_options = processor.metadata.get(
@@ -406,6 +442,11 @@ class BaseManager:
}
self.add_job(job_metadata)
# only pass subscriber if supported, otherwise this breaks
# existing managers
if self.supports_subscribing:
extra_execute_handler_parameters['subscriber'] = subscriber
# TODO: handler's response could also be allowed to include more HTTP
# headers
mime_type, outputs, status = handler(
@@ -413,10 +454,7 @@ class BaseManager:
job_id,
data_dict,
requested_outputs,
# only pass subscriber if supported, otherwise this breaks existing
# managers
**({'subscriber': subscriber} if self.supports_subscribing else {})
)
**extra_execute_handler_parameters)
return job_id, mime_type, outputs, status, response_headers
+30 -7
View File
@@ -33,8 +33,9 @@ import uuid
from pygeoapi.process.manager.base import BaseManager
from pygeoapi.util import (
RequestedProcessExecutionMode,
JobStatus,
RequestedProcessExecutionMode,
RequestedResponse,
Subscriber
)
@@ -55,17 +56,21 @@ class DummyManager(BaseManager):
super().__init__(manager_def)
def get_jobs(self, status: JobStatus = None) -> list:
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
) -> dict:
"""
Get process jobs, optionally filtered by status
:param status: job status (accepted, running, successful,
failed, results) (default is all)
:param limit: number of jobs to return
:param offset: pagination offset
:returns: `list` of jobs (identifier, status, process identifier)
:returns: dict of list of jobs (identifier, status, process identifier)
and numberMatched
"""
return []
return {'jobs': [], 'numberMatched': 0}
def execute_process(
self,
@@ -73,7 +78,8 @@ class DummyManager(BaseManager):
data_dict: dict,
execution_mode: Optional[RequestedProcessExecutionMode] = None,
requested_outputs: Optional[dict] = None,
subscriber: Optional[Subscriber] = None
subscriber: Optional[Subscriber] = None,
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
) -> Tuple[str, str, Any, JobStatus, Optional[Dict[str, str]]]:
"""
Default process execution handler
@@ -81,9 +87,19 @@ class DummyManager(BaseManager):
:param process_id: process identifier
:param data_dict: `dict` of data parameters
:param execution_mode: requested execution mode
:param requested_outputs: `dict` optionally specify the subset of
required outputs - defaults to all outputs.
The value of any key may be an object and include the property
`transmissionMode` - defaults to `value`.
Note: 'optional' is for backward compatibility.
:param subscriber: `Subscriber` optionally specifying callback urls
:param requested_response: `RequestedResponse` optionally specifying
raw or document (default is `raw`)
:raises UnknownProcessError: if the input process_id does not
correspond to a known process
:returns: tuple of job_id, MIME type, response payload, status and
optionally additional HTTP headers to include in the
optionally additional HTTP headers to include in the final
response
"""
@@ -100,7 +116,8 @@ class DummyManager(BaseManager):
self._send_in_progress_notification(subscriber)
processor = self.get_processor(process_id)
try:
jfmt, outputs = processor.execute(data_dict)
jfmt, outputs = processor.execute(
data_dict, outputs=requested_outputs)
current_status = JobStatus.successful
self._send_success_notification(subscriber, outputs)
except Exception as err:
@@ -111,6 +128,12 @@ class DummyManager(BaseManager):
current_status = JobStatus.failed
LOGGER.exception(err)
self._send_failed_notification(subscriber)
if requested_response == RequestedResponse.document.value:
outputs = {
'outputs': [outputs]
}
job_id = str(uuid.uuid1())
return job_id, jfmt, outputs, current_status, response_headers
+16 -4
View File
@@ -32,6 +32,7 @@ import traceback
from pymongo import MongoClient
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
from pygeoapi.process.base import (
JobNotFoundError,
JobResultNotFoundError,
@@ -70,7 +71,7 @@ class MongoDBManager(BaseManager):
exc_info=(traceback))
return False
def get_jobs(self, status=None):
def get_jobs(self, status=None, limit=None, offset=None):
try:
self._connect()
database = self.db.job_manager_pygeoapi
@@ -80,7 +81,10 @@ class MongoDBManager(BaseManager):
else:
jobs = list(collection.find({}))
LOGGER.info("JOBMANAGER - MongoDB jobs queried")
return jobs
return {
'jobs': jobs,
'numberMatched': len(jobs)
}
except Exception:
LOGGER.error("JOBMANAGER - get_jobs error",
exc_info=(traceback))
@@ -148,8 +152,16 @@ class MongoDBManager(BaseManager):
if entry["status"] != "successful":
LOGGER.info("JOBMANAGER - job not finished or failed")
return (None,)
with open(entry["location"], "r") as file:
data = json.load(file)
if not entry["location"]:
LOGGER.warning(f"job {job_id!r} - unknown result location")
raise JobResultNotFoundError()
if entry["mimetype"] in (None, FORMAT_TYPES[F_JSON],
FORMAT_TYPES[F_JSONLD]):
with open(entry["location"], "r") as file:
data = json.load(file)
else:
with open(entry["location"], "rb") as file:
data = file.read()
LOGGER.info("JOBMANAGER - MongoDB job result queried")
return entry["mimetype"], data
except Exception as err:
+28 -9
View File
@@ -46,8 +46,10 @@ from pathlib import Path
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
from pygeoapi.process.base import (
JobNotFoundError,
JobResultNotFoundError,
@@ -83,12 +85,18 @@ class PostgreSQLManager(BaseManager):
self.db_search_path = tuple(self.connection.get('search_path',
['public']))
except Exception:
self.db_search_path = 'public'
self.db_search_path = ('public',)
try:
LOGGER.debug('Connecting to database')
if isinstance(self.connection, str):
self._engine = get_engine(self.connection)
_url = make_url(self.connection)
self._engine = get_engine(
_url.host,
_url.port,
_url.database,
_url.username,
_url.password)
else:
self._engine = get_engine(**self.connection)
except Exception as err:
@@ -109,16 +117,18 @@ class PostgreSQLManager(BaseManager):
LOGGER.error(f'{msg}: {err}')
raise ProcessorGenericError(msg)
def get_jobs(self, status: JobStatus = None) -> list:
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
) -> dict:
"""
Get jobs
:param status: job status (accepted, running, successful,
failed, results) (default is all)
:param limit: number of jobs to return
:param offset: pagination offset
:returns: 'list` of jobs (type (default='process'), identifier,
status, process_id, job_start_datetime, job_end_datetime, location,
mimetype, message, progress)
:returns: dict of list of jobs (identifier, status, process identifier)
and numberMatched
"""
LOGGER.debug('Querying for jobs')
@@ -128,7 +138,11 @@ class PostgreSQLManager(BaseManager):
column = getattr(self.table_model, 'status')
results = results.filter(column == status.value)
return [r.__dict__ for r in results.all()]
jobs = [r.__dict__ for r in results.all()]
return {
'jobs': jobs,
'numberMatched': len(jobs)
}
def add_job(self, job_metadata: dict) -> str:
"""
@@ -279,8 +293,13 @@ class PostgreSQLManager(BaseManager):
else:
try:
location = Path(location)
with location.open(encoding='utf-8') as fh:
result = json.load(fh)
if mimetype in (None, FORMAT_TYPES[F_JSON],
FORMAT_TYPES[F_JSONLD]):
with location.open('r', encoding='utf-8') as fh:
result = json.load(fh)
else:
with location.open('rb') as fh:
result = fh.read()
except (TypeError, FileNotFoundError, json.JSONDecodeError):
raise JobResultNotFoundError()
else:
+26 -5
View File
@@ -37,6 +37,7 @@ from typing import Any, Tuple
import tinydb
from filelock import FileLock
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
from pygeoapi.process.base import (
JobNotFoundError,
JobResultNotFoundError,
@@ -82,20 +83,35 @@ class TinyDBManager(BaseManager):
return True
def get_jobs(self, status: JobStatus = None) -> list:
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
) -> dict:
"""
Get jobs
:param status: job status (accepted, running, successful,
failed, results) (default is all)
:param limit: number of jobs to return
:param offset: pagination offset
:returns: 'list` of jobs (identifier, status, process identifier)
:returns: dict of list of jobs (identifier, status, process identifier)
and numberMatched
"""
with self._db() as db:
jobs_list = db.all()
return jobs_list
number_matched = len(jobs_list)
if offset:
jobs_list = jobs_list[offset:]
if limit:
jobs_list = jobs_list[:limit]
return {
'jobs': jobs_list,
'numberMatched': number_matched
}
def add_job(self, job_metadata: dict) -> str:
"""
@@ -196,8 +212,13 @@ class TinyDBManager(BaseManager):
else:
try:
location = Path(location)
with location.open('r', encoding='utf-8') as filehandler:
result = json.load(filehandler)
if mimetype in (None, FORMAT_TYPES[F_JSON],
FORMAT_TYPES[F_JSONLD]):
with location.open('r', encoding='utf-8') as filehandler:
result = json.load(filehandler)
else:
with location.open('rb') as filehandler:
result = filehandler.read()
except (TypeError, FileNotFoundError, json.JSONDecodeError):
raise JobResultNotFoundError()
else:
+20 -2
View File
@@ -73,7 +73,7 @@ class BaseProvider:
self.title_field = provider_def.get('title_field')
self.properties = provider_def.get('properties', [])
self.file_types = provider_def.get('file_types', [])
self.fields = {}
self._fields = {}
self.filename = None
# for coverage providers
@@ -85,13 +85,31 @@ class BaseProvider:
"""
Get provider field information (names, types)
Example response: {'field1': 'string', 'field2': 'number'}}
Example response:
{'field1': {'type': 'string'}, 'field2': {'type': 'number'}}
:returns: dict of field names and their associated JSON Schema types
"""
raise NotImplementedError()
@property
def fields(self) -> dict:
"""
Store provider field information (names, types)
Example response:
{'field1': {'type': 'string'}, 'field2': {'type': 'number'}}
:returns: dict of dicts (field names and their
associated JSON Schema definitions)
"""
if hasattr(self, '_fields'):
return self._fields
else:
return self.get_fields()
def get_schema(self, schema_type: SchemaType = SchemaType.item):
"""
Get provider schema model
+10 -1
View File
@@ -29,10 +29,14 @@
import logging
from pygeoapi.provider.base import BaseProvider
from pygeoapi.provider.base import BaseProvider, ProviderInvalidDataError
LOGGER = logging.getLogger(__name__)
EDR_QUERY_TYPES = ['position', 'radius', 'area', 'cube',
'trajectory', 'corridor', 'items',
'locations', 'instances']
class BaseEDRProvider(BaseProvider):
"""Base EDR Provider"""
@@ -55,6 +59,11 @@ class BaseEDRProvider(BaseProvider):
@classmethod
def register(cls):
def inner(fn):
if fn.__name__ not in EDR_QUERY_TYPES:
msg = 'Invalid EDR Query type'
LOGGER.error(msg)
raise ProviderInvalidDataError(msg)
cls.query_types.append(fn.__name__)
return fn
return inner
+22 -23
View File
@@ -54,7 +54,7 @@ class CSVProvider(BaseProvider):
super().__init__(provider_def)
self.geometry_x = provider_def['geometry']['x_field']
self.geometry_y = provider_def['geometry']['y_field']
self.fields = self.get_fields()
self.get_fields()
def get_fields(self):
"""
@@ -62,32 +62,31 @@ class CSVProvider(BaseProvider):
:returns: dict of fields
"""
if not self._fields:
LOGGER.debug('Treating all columns as string types')
with open(self.data) as ff:
LOGGER.debug('Serializing DictReader')
data_ = csv.DictReader(ff)
LOGGER.debug('Treating all columns as string types')
with open(self.data) as ff:
LOGGER.debug('Serializing DictReader')
data_ = csv.DictReader(ff)
fields = {}
row = next(data_)
row = next(data_)
for key, value in row.items():
LOGGER.debug(f'key: {key}, value: {value}')
value2 = get_typed_value(value)
if key in [self.geometry_x, self.geometry_y]:
continue
if key == self.id_field:
type_ = 'string'
elif isinstance(value2, float):
type_ = 'number'
elif isinstance(value2, int):
type_ = 'integer'
else:
type_ = 'string'
for key, value in row.items():
LOGGER.debug(f'key: {key}, value: {value}')
value2 = get_typed_value(value)
if key in [self.geometry_x, self.geometry_y]:
continue
if key == self.id_field:
type_ = 'string'
elif isinstance(value2, float):
type_ = 'number'
elif isinstance(value2, int):
type_ = 'integer'
else:
type_ = 'string'
self._fields[key] = {'type': type_}
fields[key] = {'type': type_}
return fields
return self._fields
def _load(self, offset=0, limit=10, resulttype='results',
identifier=None, bbox=[], datetime_=None, properties=[],
+10 -9
View File
@@ -69,7 +69,8 @@ class CSWFacadeProvider(BaseProvider):
'language': ('dc:language', 'language')
}
self.fields = self.get_fields()
self._fields = {}
self.get_fields()
def get_fields(self):
"""
@@ -78,17 +79,17 @@ class CSWFacadeProvider(BaseProvider):
:returns: dict of fields
"""
fields = {}
date_fields = ['date', 'created', 'updated']
if not self._fields:
date_fields = ['date', 'created', 'updated']
for key in self.record_mappings.keys():
LOGGER.debug(f'key: {key}')
fields[key] = {'type': 'string'}
for key in self.record_mappings.keys():
LOGGER.debug(f'key: {key}')
self._fields[key] = {'type': 'string'}
if key in date_fields:
fields[key]['format'] = 'date-time'
if key in date_fields:
self._fields[key]['format'] = 'date-time'
return fields
return self._fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
+32 -30
View File
@@ -87,7 +87,7 @@ class ElasticsearchProvider(BaseProvider):
LOGGER.debug('Grabbing field information')
try:
self.fields = self.get_fields()
self.get_fields()
except exceptions.NotFoundError as err:
LOGGER.error(err)
raise ProviderQueryError(err)
@@ -98,38 +98,40 @@ class ElasticsearchProvider(BaseProvider):
:returns: dict of fields
"""
if not self._fields:
ii = self.es.indices.get(index=self.index_name,
allow_no_indices=False)
fields_ = {}
ii = self.es.indices.get(index=self.index_name, allow_no_indices=False)
LOGGER.debug(f'Response: {ii}')
try:
if '*' not in self.index_name:
p = ii[self.index_name]['mappings']['properties']['properties']
else:
LOGGER.debug('Wildcard index; setting from first match')
index_name_ = list(ii.keys())[0]
p = ii[index_name_]['mappings']['properties']['properties']
except KeyError:
LOGGER.warning('Trying for alias')
alias_name = next(iter(ii))
p = ii[alias_name]['mappings']['properties']['properties']
except IndexError:
LOGGER.warning('could not get fields; returning empty set')
return {}
for k, v in p['properties'].items():
if 'type' in v:
if v['type'] == 'text':
fields_[k] = {'type': 'string'}
elif v['type'] == 'date':
fields_[k] = {'type': 'string', 'format': 'date'}
elif v['type'] in ('float', 'long'):
fields_[k] = {'type': 'number', 'format': v['type']}
LOGGER.debug(f'Response: {ii}')
try:
if '*' not in self.index_name:
mappings = ii[self.index_name]['mappings']
p = mappings['properties']['properties']
else:
fields_[k] = {'type': v['type']}
LOGGER.debug('Wildcard index; setting from first match')
index_name_ = list(ii.keys())[0]
p = ii[index_name_]['mappings']['properties']['properties']
except KeyError:
LOGGER.warning('Trying for alias')
alias_name = next(iter(ii))
p = ii[alias_name]['mappings']['properties']['properties']
except IndexError:
LOGGER.warning('could not get fields; returning empty set')
return {}
return fields_
for k, v in p['properties'].items():
if 'type' in v:
if v['type'] == 'text':
self._fields[k] = {'type': 'string'}
elif v['type'] == 'date':
self._fields[k] = {'type': 'string', 'format': 'date'}
elif v['type'] in ('float', 'long'):
self._fields[k] = {'type': 'number',
'format': v['type']}
else:
self._fields[k] = {'type': v['type']}
return self._fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
+13 -12
View File
@@ -62,24 +62,25 @@ class TabledapProvider(BaseProvider):
LOGGER.debug('Setting provider query filters')
self.filters = self.options.get('filters')
self.fields = self.get_fields()
self.get_fields()
def get_fields(self):
LOGGER.debug('Fetching one feature for field definitions')
properties = self.query(limit=1)['features'][0]['properties']
if not self._fields:
LOGGER.debug('Fetching one feature for field definitions')
properties = self.query(limit=1)['features'][0]['properties']
for key, value in properties.items():
LOGGER.debug(f'Field: {key}={value}')
for key, value in properties.items():
LOGGER.debug(f'Field: {key}={value}')
data_type = type(value).__name__
data_type = type(value).__name__
if data_type == 'str':
data_type = 'string'
if data_type == 'float':
data_type = 'number'
properties[key] = {'type': data_type}
if data_type == 'str':
data_type = 'string'
if data_type == 'float':
data_type = 'number'
self._fields[key] = {'type': data_type}
return properties
return self._fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
+7 -7
View File
@@ -62,8 +62,9 @@ class ESRIServiceProvider(BaseProvider):
self.crs = provider_def.get('crs', '4326')
self.username = provider_def.get('username')
self.password = provider_def.get('password')
self.token_url = provider_def.get('token_service', ARCGIS_URL)
self.token_referer = provider_def.get('referer', GENERATE_TOKEN_URL)
self.token = None
self.session = Session()
self.login()
@@ -76,7 +77,7 @@ class ESRIServiceProvider(BaseProvider):
:returns: `dict` of fields
"""
if not self.fields:
if not self._fields:
# Load fields
params = {'f': 'pjson'}
resp = self.get_response(self.data, params=params)
@@ -102,9 +103,9 @@ class ESRIServiceProvider(BaseProvider):
raise ProviderTypeError(msg)
for _ in resp['fields']:
self.fields.update({_['name']: {'type': _['type']}})
self._fields.update({_['name']: {'type': _['type']}})
return self.fields
return self._fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
@@ -194,16 +195,15 @@ class ESRIServiceProvider(BaseProvider):
msg = 'Missing ESRI login information, not setting token'
LOGGER.debug(msg)
return
params = {
'f': 'pjson',
'username': self.username,
'password': self.password,
'referer': ARCGIS_URL
'referer': self.token_referer
}
LOGGER.debug('Logging in')
with self.session.post(GENERATE_TOKEN_URL, data=params) as r:
with self.session.post(self.token_url, data=params) as r:
self.token = r.json().get('token')
# https://enterprise.arcgis.com/en/server/latest/administer/windows/about-arcgis-tokens.htm
self.session.headers.update({
+18 -17
View File
@@ -68,7 +68,7 @@ class GeoJSONProvider(BaseProvider):
"""initializer"""
super().__init__(provider_def)
self.fields = self.get_fields()
self.get_fields()
def get_fields(self):
"""
@@ -77,23 +77,24 @@ class GeoJSONProvider(BaseProvider):
:returns: dict of fields
"""
fields = {}
LOGGER.debug('Treating all columns as string types')
if os.path.exists(self.data):
with open(self.data) as src:
data = json.loads(src.read())
for key, value in data['features'][0]['properties'].items():
if isinstance(value, float):
type_ = 'number'
elif isinstance(value, int):
type_ = 'integer'
else:
type_ = 'string'
if not self._fields:
LOGGER.debug('Treating all columns as string types')
if os.path.exists(self.data):
with open(self.data) as src:
data = json.loads(src.read())
for key, value in data['features'][0]['properties'].items():
if isinstance(value, float):
type_ = 'number'
elif isinstance(value, int):
type_ = 'integer'
else:
type_ = 'string'
fields[key] = {'type': type_}
else:
LOGGER.warning(f'File {self.data} does not exist.')
return fields
self._fields[key] = {'type': type_}
else:
LOGGER.warning(f'File {self.data} does not exist.')
return self._fields
def _load(self, skip_geometry=None, properties=[], select_properties=[]):
"""Load and validate the source GeoJSON file
+16 -17
View File
@@ -66,7 +66,7 @@ class MongoProvider(BaseProvider):
self.featuredb = dbclient.get_default_database()
self.collection = provider_def['collection']
self.featuredb[self.collection].create_index([("geometry", GEOSPHERE)])
self.fields = self.get_fields()
self.get_fields()
def get_fields(self):
"""
@@ -75,25 +75,24 @@ class MongoProvider(BaseProvider):
:returns: dict of fields
"""
pipeline = [
{"$project": {"properties": 1}},
{"$unwind": "$properties"},
{"$group": {"_id": "$properties", "count": {"$sum": 1}}},
{"$project": {"_id": 1}}
]
if not self._fields:
pipeline = [
{"$project": {"properties": 1}},
{"$unwind": "$properties"},
{"$group": {"_id": "$properties", "count": {"$sum": 1}}},
{"$project": {"_id": 1}}
]
result = list(self.featuredb[self.collection].aggregate(pipeline))
result = list(self.featuredb[self.collection].aggregate(pipeline))
# prepare a dictionary with fields
# set the field type to 'string'.
# by operating without a schema, mongo can query any data type.
fields = {}
# prepare a dictionary with fields
# set the field type to 'string'.
# by operating without a schema, mongo can query any data type.
for i in result:
for key in result[0]['_id'].keys():
self._fields[key] = {'type': 'string'}
for i in result:
for key in result[0]['_id'].keys():
fields[key] = {'type': 'string'}
return fields
return self._fields
def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1,
skip_geometry=False):
+30 -30
View File
@@ -188,7 +188,7 @@ class OGRProvider(BaseProvider):
self.conn = None
LOGGER.debug('Grabbing field information')
self.fields = self.get_fields()
self.get_fields()
def _list_open_options(self):
return [
@@ -260,43 +260,43 @@ class OGRProvider(BaseProvider):
:returns: dict of fields
"""
fields = {}
try:
layer_defn = self._get_layer().GetLayerDefn()
for fld in range(layer_defn.GetFieldCount()):
field_defn = layer_defn.GetFieldDefn(fld)
fieldName = field_defn.GetName()
fieldTypeCode = field_defn.GetType()
fieldType = field_defn.GetFieldTypeName(fieldTypeCode)
if not self._fields:
try:
layer_defn = self._get_layer().GetLayerDefn()
for fld in range(layer_defn.GetFieldCount()):
field_defn = layer_defn.GetFieldDefn(fld)
fieldName = field_defn.GetName()
fieldTypeCode = field_defn.GetType()
fieldType = field_defn.GetFieldTypeName(fieldTypeCode)
fieldName2 = fieldType.lower()
fieldName2 = fieldType.lower()
if fieldName2 == 'integer64':
fieldName2 = 'integer'
elif fieldName2 == 'real':
fieldName2 = 'number'
if fieldName2 == 'integer64':
fieldName2 = 'integer'
elif fieldName2 == 'real':
fieldName2 = 'number'
fields[fieldName] = {'type': fieldName2}
self._fields[fieldName] = {'type': fieldName2}
if fieldName2 == 'datetime':
fields[fieldName] = {
'type': 'string',
'format': 'date-time'
}
if fieldName2 == 'datetime':
self._fields[fieldName] = {
'type': 'string',
'format': 'date-time'
}
# fieldWidth = layer_defn.GetFieldDefn(fld).GetWidth()
# GetPrecision = layer_defn.GetFieldDefn(fld).GetPrecision()
# fieldWidth = layer_defn.GetFieldDefn(fld).GetWidth()
# GetPrecision = layer_defn.GetFieldDefn(fld).GetPrecision() # noqa
except RuntimeError as err:
LOGGER.error(err)
raise ProviderConnectionError(err)
except Exception as err:
LOGGER.error(err)
except RuntimeError as err:
LOGGER.error(err)
raise ProviderConnectionError(err)
except Exception as err:
LOGGER.error(err)
finally:
self._close()
finally:
self._close()
return fields
return self._fields
def query(self, offset=0, limit=10, resulttype='results',
bbox=[], datetime_=None, properties=[], sortby=[],
+742
View File
@@ -0,0 +1,742 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# Francesco Bartoli <xbartolone@gmail.com>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2024 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.
#
# =================================================================
from typing import Dict
from collections import OrderedDict
import json
import logging
import uuid
from opensearchpy import OpenSearch, helpers
from opensearch_dsl import Search, Q
from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
ProviderQueryError,
ProviderItemNotFoundError)
from pygeoapi.models.cql import CQLModel, get_next_node
from pygeoapi.util import get_envelope, crs_transform
LOGGER = logging.getLogger(__name__)
class OpenSearchProvider(BaseProvider):
"""OpenSearch Provider"""
def __init__(self, provider_def):
"""
Initialize object
:param provider_def: provider definition
:returns: pygeoapi.provider.opensearch_.OpenSearchProvider
"""
super().__init__(provider_def)
self.select_properties = []
self.os_host, self.index_name = self.data.rsplit('/', 1)
LOGGER.debug('Setting OpenSearch properties')
LOGGER.debug(f'host: {self.os_host}')
LOGGER.debug(f'index: {self.index_name}')
LOGGER.debug('Connecting to OpenSearch')
self.os_ = OpenSearch(self.os_host, verify_certs=0)
if not self.os_.ping():
msg = f'Cannot connect to OpenSearch: {self.os_host}'
LOGGER.error(msg)
raise ProviderConnectionError(msg)
LOGGER.debug('Determining OpenSearch version')
v = self.os_.info()['version']['number'][:3]
LOGGER.debug(f'OpenSearch version: {v}')
LOGGER.debug('Grabbing field information')
try:
self.get_fields()
except Exception as err:
LOGGER.error(err)
raise ProviderQueryError(err)
def get_fields(self):
"""
Get provider field information (names, types)
:returns: dict of fields
"""
if not self._fields:
ii = self.os_.indices.get(index=self.index_name,
allow_no_indices=False)
LOGGER.debug(f'Response: {ii}')
try:
if '*' not in self.index_name:
mappings = ii[self.index_name]['mappings']
p = mappings['properties']['properties']
else:
LOGGER.debug('Wildcard index; setting from first match')
index_name_ = list(ii.keys())[0]
p = ii[index_name_]['mappings']['properties']['properties']
except KeyError:
LOGGER.warning('Trying for alias')
alias_name = next(iter(ii))
p = ii[alias_name]['mappings']['properties']['properties']
except IndexError:
LOGGER.warning('could not get fields; returning empty set')
return {}
for k, v in p['properties'].items():
if 'type' in v:
if v['type'] == 'text':
self._fields[k] = {'type': 'string'}
elif v['type'] == 'date':
self._fields[k] = {'type': 'string', 'format': 'date'}
elif v['type'] in ('float', 'long'):
self._fields[k] = {'type': 'number',
'format': v['type']}
else:
self._fields[k] = {'type': v['type']}
return self._fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
bbox=[], datetime_=None, properties=[], sortby=[],
select_properties=[], skip_geometry=False, q=None,
filterq=None, **kwargs):
"""
query OpenSearch index
:param offset: starting record to return (default 0)
:param limit: number of records to return (default 10)
:param resulttype: return results or hit limit (default results)
:param bbox: bounding box [minx,miny,maxx,maxy]
:param datetime_: temporal (datestamp or extent)
:param properties: list of tuples (name, value)
:param sortby: list of dicts (property, order)
:param select_properties: list of property names
:param skip_geometry: bool of whether to skip geometry (default False)
:param q: full-text search term(s)
:param filterq: filter object
:returns: dict of 0..n GeoJSON features
"""
self.select_properties = select_properties
query = {'track_total_hits': True, 'query': {'bool': {'filter': []}}}
filter_ = []
feature_collection = {
'type': 'FeatureCollection',
'features': []
}
if resulttype == 'hits':
LOGGER.debug('hits only specified')
limit = 0
if bbox:
LOGGER.debug('processing bbox parameter')
minx, miny, maxx, maxy = bbox
bbox_filter = {
'geo_shape': {
'geometry': {
'shape': {
'type': 'envelope',
'coordinates': [[minx, maxy], [maxx, miny]]
},
'relation': 'intersects'
}
}
}
query['query']['bool']['filter'].append(bbox_filter)
if datetime_ is not None:
LOGGER.debug('processing datetime parameter')
if self.time_field is None:
LOGGER.error('time_field not enabled for collection')
raise ProviderQueryError()
time_field = self.mask_prop(self.time_field)
if '/' in datetime_: # envelope
LOGGER.debug('detected time range')
time_begin, time_end = datetime_.split('/')
range_ = {
'range': {
time_field: {
'gte': time_begin,
'lte': time_end
}
}
}
if time_begin == '..':
range_['range'][time_field].pop('gte')
elif time_end == '..':
range_['range'][time_field].pop('lte')
filter_.append(range_)
else: # time instant
LOGGER.debug('detected time instant')
filter_.append({'match': {time_field: datetime_}})
LOGGER.debug(filter_)
query['query']['bool']['filter'].append(*filter_)
if properties:
LOGGER.debug('processing properties')
for prop in properties:
prop_name = self.mask_prop(prop[0])
pf = {
'match': {
prop_name: {
'query': prop[1]
}
}
}
query['query']['bool']['filter'].append(pf)
if '|' not in prop[1]:
pf['match'][prop_name]['minimum_should_match'] = '100%'
if sortby:
LOGGER.debug('processing sortby')
query['sort'] = []
for sort in sortby:
LOGGER.debug(f'processing sort object: {sort}')
sp = sort['property']
if (self.fields[sp]['type'] == 'string'
and self.fields[sp].get('format') != 'date'):
LOGGER.debug('setting OpenSearch .raw on property')
sort_property = f'{self.mask_prop(sp)}.raw'
else:
sort_property = self.mask_prop(sp)
sort_order = 'asc'
if sort['order'] == '-':
sort_order = 'desc'
sort_ = {
sort_property: {
'order': sort_order
}
}
query['sort'].append(sort_)
if q is not None:
LOGGER.debug('Adding free-text search')
query['query']['bool']['must'] = {'query_string': {'query': q}}
query['_source'] = {
'excludes': [
'properties._metadata-payload',
'properties._metadata-schema',
'properties._metadata-format'
]
}
if self.properties or self.select_properties:
LOGGER.debug('filtering properties')
all_properties = self.get_properties()
query['_source'] = {
'includes': list(map(self.mask_prop, all_properties))
}
query['_source']['includes'].append('id')
query['_source']['includes'].append('type')
query['_source']['includes'].append('geometry')
if skip_geometry:
LOGGER.debug('excluding geometry')
try:
query['_source']['excludes'] = ['geometry']
except KeyError:
query['_source'] = {'excludes': ['geometry']}
try:
LOGGER.debug('querying OpenSearch')
if filterq:
LOGGER.debug(f'adding cql object: {filterq.json()}')
query = update_query(input_query=query, cql=filterq)
LOGGER.debug(json.dumps(query, indent=4))
LOGGER.debug('Testing for OpenSearch scrolling')
if offset + limit > 10000:
gen = helpers.scan(client=self.os_, query=query,
preserve_order=True,
index=self.index_name)
results = {'hits': {'total': limit, 'hits': []}}
for i in range(offset + limit):
try:
if i >= offset:
results['hits']['hits'].append(next(gen))
else:
next(gen)
except StopIteration:
break
matched = len(results['hits']['hits']) + offset
returned = len(results['hits']['hits'])
else:
es_results = self.os_.search(index=self.index_name,
from_=offset, size=limit,
body=query)
results = es_results
matched = es_results['hits']['total']['value']
returned = len(es_results['hits']['hits'])
except ValueError:
pass
feature_collection['numberMatched'] = matched
if resulttype == 'hits':
return feature_collection
feature_collection['numberReturned'] = returned
LOGGER.debug('serializing features')
for feature in results['hits']['hits']:
feature_ = self.osdoc2geojson(feature)
feature_collection['features'].append(feature_)
return feature_collection
@crs_transform
def get(self, identifier, **kwargs):
"""
Get OpenSearch document by id
:param identifier: feature id
:returns: dict of single GeoJSON feature
"""
try:
LOGGER.debug(f'Fetching identifier {identifier}')
result = self.os_.get(index=self.index_name, id=identifier)
LOGGER.debug('Serializing feature')
feature_ = self.osdoc2geojson(result)
except Exception as err:
LOGGER.debug(f'Not found via OpenSearch id query: {err}')
LOGGER.debug('Trying via a real query')
query = {
'query': {
'bool': {
'filter': [{
'match_phrase': {
'_id': identifier
}
}]
}
}
}
LOGGER.debug(f'Query: {query}')
try:
result = self.os_search(index=self.index_name, **query)
if len(result['hits']['hits']) == 0:
LOGGER.error(err)
raise ProviderItemNotFoundError(err)
LOGGER.debug('Serializing feature')
feature_ = self.osdoc2geojson(result['hits']['hits'][0])
except Exception as err2:
LOGGER.error(err2)
raise ProviderItemNotFoundError(err2)
except Exception as err:
LOGGER.error(err)
return None
return feature_
def create(self, item):
"""
Create a new item
:param item: `dict` of new item
:returns: identifier of created item
"""
identifier, json_data = self._load_and_prepare_item(
item, accept_missing_identifier=True)
if identifier is None:
# If there is no incoming identifier, allocate a random one
identifier = str(uuid.uuid4())
json_data["id"] = identifier
LOGGER.debug(f'Inserting data with identifier {identifier}')
_ = self.os_.index(index=self.index_name, id=identifier,
body=json_data)
LOGGER.debug('Item added')
return identifier
def update(self, identifier, item):
"""
Updates an existing item
:param identifier: feature id
:param item: `dict` of partial or full item
:returns: `bool` of update result
"""
LOGGER.debug(f'Updating item {identifier}')
identifier, json_data = self._load_and_prepare_item(
item, identifier, raise_if_exists=False)
_ = self.os_index(index=self.index_name, id=identifier, body=json_data)
return True
def delete(self, identifier):
"""
Deletes an existing item
:param identifier: item id
:returns: `bool` of deletion result
"""
LOGGER.debug(f'Deleting item {identifier}')
_ = self.os_delete(index=self.index_name, id=identifier)
return True
def osdoc2geojson(self, doc):
"""
generate GeoJSON `dict` from OpenSearch document
:param doc: `dict` of OpenSearch document
:returns: GeoJSON `dict`
"""
feature_ = {}
feature_thinned = {}
LOGGER.debug('Fetching id and geometry from GeoJSON document')
feature_ = doc['_source']
try:
id_ = doc['_source']['properties'][self.id_field]
except KeyError as err:
LOGGER.debug(f'Missing field: {err}')
id_ = doc['_source'].get('id', doc['_id'])
feature_['id'] = id_
feature_['geometry'] = doc['_source'].get('geometry')
if self.properties or self.select_properties:
LOGGER.debug('Filtering properties')
all_properties = self.get_properties()
feature_thinned = {
'id': id_,
'type': feature_['type'],
'geometry': feature_.get('geometry'),
'properties': OrderedDict()
}
for p in all_properties:
try:
feature_thinned['properties'][p] = feature_['properties'][p] # noqa
except KeyError as err:
LOGGER.error(err)
raise ProviderQueryError()
if feature_thinned:
return feature_thinned
else:
return feature_
def mask_prop(self, property_name):
"""
generate property name based on OpenSearch backend setup
:param property_name: property name
:returns: masked property name
"""
return f'properties.{property_name}'
def get_properties(self):
all_properties = []
LOGGER.debug(f'configured properties: {self.properties}')
LOGGER.debug(f'selected properties: {self.select_properties}')
if not self.properties and not self.select_properties:
all_properties = self.get_fields()
if self.properties and self.select_properties:
all_properties = self.properties and self.select_properties
else:
all_properties = self.properties or self.select_properties
LOGGER.debug(f'resulting properties: {all_properties}')
return all_properties
def __repr__(self):
return f'<OpenSearchProvider> {self.data}'
class OpenSearchCatalogueProvider(OpenSearchProvider):
"""OpenSearch Provider"""
def __init__(self, provider_def):
super().__init__(provider_def)
def _excludes(self):
return [
'properties._metadata-anytext'
]
def get_fields(self):
fields = super().get_fields()
for i in self._excludes():
if i in fields:
del fields[i]
fields['q'] = {'type': 'string'}
return fields
def query(self, offset=0, limit=10, resulttype='results',
bbox=[], datetime_=None, properties=[], sortby=[],
select_properties=[], skip_geometry=False, q=None,
filterq=None, **kwargs):
records = super().query(
offset=offset, limit=limit,
resulttype=resulttype, bbox=bbox,
datetime_=datetime_, properties=properties,
sortby=sortby,
select_properties=select_properties,
skip_geometry=skip_geometry,
q=q)
return records
def __repr__(self):
return f'<OpenSearchCatalogueProvider> {self.data}'
class OpenSearchQueryBuilder:
def __init__(self):
self._operation = None
self.must_value = {}
self.should_value = {}
self.mustnot_value = {}
self.filter_value = {}
def must(self, must_value):
self.must_value = must_value
return self
def should(self, should_value):
self.should_value = should_value
return self
def must_not(self, mustnot_value):
self.mustnot_value = mustnot_value
return self
def filter(self, filter_value):
self.filter_value = filter_value
return self
@property
def operation(self):
return self._operation
@operation.setter
def operation(self, value):
self._operation = value
def build(self):
if self.must_value:
must_clause = self.must_value or {}
if self.should_value:
should_clause = self.should_value or {}
if self.mustnot_value:
mustnot_clause = self.mustnot_value or {}
if self.filter_value:
filter_clause = self.filter_value or {}
else:
filter_clause = {}
# to figure out how to deal with logical operations
# return match_clause & range_clause
clauses = must_clause or should_clause or mustnot_clause
filters = filter_clause
if self.operation == 'and':
res = Q(
'bool',
must=[clause for clause in clauses],
filter=[filter for filter in filters])
elif self.operation == 'or':
res = Q(
'bool',
should=[clause for clause in clauses],
filter=[filter for filter in filters])
elif self.operation == 'not':
res = Q(
'bool',
must_not=[clause for clause in clauses],
filter=[filter for filter in filters])
else:
if filters:
res = Q(
'bool',
must=[clauses],
filter=[filters])
else:
res = Q(
'bool',
must=[clauses])
return res
def _build_query(q, cql):
# this would be handled by the AST with the traverse of CQL model
op, node = get_next_node(cql.__root__)
q.operation = op
if isinstance(node, list):
query_list = []
for elem in node:
op, next_node = get_next_node(elem)
if not getattr(next_node, 'between', 0) == 0:
property = next_node.between.value.__root__.__root__.property
lower = next_node.between.lower.__root__.__root__
upper = next_node.between.upper.__root__.__root__
query_list.append(Q(
{
'range':
{
f'{property}': {
'gte': lower, 'lte': upper
}
}
}
))
if not getattr(next_node, '__root__', 0) == 0:
scalars = tuple(next_node.__root__.eq.__root__)
property = scalars[0].__root__.property
value = scalars[1].__root__.__root__
query_list.append(Q(
{'match': {f'{property}': f'{value}'}}
))
q.must(query_list)
elif not getattr(node, 'between', 0) == 0:
property = node.between.value.__root__.__root__.property
lower = None
if not getattr(node.between.lower,
'__root__', 0) == 0:
lower = node.between.lower.__root__.__root__
upper = None
if not getattr(node.between.upper,
'__root__', 0) == 0:
upper = node.between.upper.__root__.__root__
query = Q(
{
'range':
{
f'{property}': {
'gte': lower, 'lte': upper
}
}
}
)
q.must(query)
elif not getattr(node, '__root__', 0) == 0:
next_op, next_node = get_next_node(node)
if not getattr(next_node, 'eq', 0) == 0:
scalars = tuple(next_node.eq.__root__)
property = scalars[0].__root__.property
value = scalars[1].__root__.__root__
query = Q(
{'match': {f'{property}': f'{value}'}}
)
q.must(query)
elif not getattr(node, 'intersects', 0) == 0:
property = node.intersects.__root__[0].__root__.property
if property == 'geometry':
geom_type = node.intersects.__root__[
1].__root__.__root__.__root__.type
if geom_type == 'Polygon':
coordinates = node.intersects.__root__[
1].__root__.__root__.__root__.coordinates
coords_list = [
poly_coords.__root__ for poly_coords in coordinates[0]
]
filter_ = Q(
{
'geo_shape': {
'geometry': {
'shape': {
'type': 'envelope',
'coordinates': get_envelope(
coords_list)
},
'relation': 'intersects'
}
}
}
)
query_all = Q(
{'match_all': {}}
)
q.must(query_all)
q.filter(filter_)
return q.build()
def update_query(input_query: Dict, cql: CQLModel):
s = Search.from_dict(input_query)
query = OpenSearchQueryBuilder()
output_query = _build_query(query, cql)
s = s.query(output_query)
LOGGER.debug(f'Enhanced query: {json.dumps(s.to_dict())}')
return s.to_dict()
+41 -13
View File
@@ -66,17 +66,31 @@ class DatabaseConnection:
"""Initialize the connection pool for the class
Lock is implemented before function call at __init__"""
dsn = cls._make_dsn(conn_dict)
# Create the pool
p = oracledb.create_pool(
user=conn_dict["user"],
password=conn_dict["password"],
dsn=dsn,
min=oracle_pool_min,
max=oracle_pool_max,
increment=1,
)
LOGGER.debug("Connection pool created successfully.")
connect_kwargs = {
'dsn': dsn,
'min': oracle_pool_min,
'max': oracle_pool_max,
'increment': 1
}
# Create the pool
if conn_dict.get("external_auth") == "wallet":
# If Auth is via Wallet you need to save a wallet under
# the directory returned by this bash command if apache is used
# cat /etc/passwd |grep apache
# except another directory is specified in the sqlnet.ora file
LOGGER.debug("Connection pool from wallet.")
connect_kwargs["externalauth"] = True
connect_kwargs["homogeneous"] = False
else:
LOGGER.debug("Connection pool from user and password.")
connect_kwargs["user"] = conn_dict["user"]
connect_kwargs["password"] = conn_dict["password"]
p = oracledb.create_pool(**connect_kwargs)
LOGGER.debug("Connection pool created successfully")
return p
@@ -435,12 +449,12 @@ class OracleProvider(BaseProvider):
"""
LOGGER.debug("Get available fields/properties")
if not self.fields:
if not self._fields:
with DatabaseConnection(
self.conn_dic, self.table, properties=self.properties
) as db:
self.fields = db.fields
return self.fields
self._fields = db.fields
return self._fields
def _get_where_clauses(
self,
@@ -633,6 +647,19 @@ class OracleProvider(BaseProvider):
:returns: GeoJSON FeaturesCollection
"""
LOGGER.debug(f"properties contains: {properties}")
# NOTE: properties contains field keys plus extra params
# need to split them up here
filtered_properties = []
extra_params = {}
for (key, value) in properties:
if key in self.fields.keys():
filtered_properties.append((key, value))
else:
extra_params[key] = value
properties = filtered_properties
# Check mandatory filter properties
property_dict = dict(properties)
@@ -790,6 +817,7 @@ class OracleProvider(BaseProvider):
q,
language,
filterq,
extra_params=extra_params
)
# Clean up placeholders that aren't used by the
+458
View File
@@ -0,0 +1,458 @@
# =================================================================
#
# Authors: Leo Ghignone <leo.ghignone@gmail.com>
#
# Copyright (c) 2024 Leo Ghignone
#
# 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 itertools import chain
import json
import logging
from dateutil.parser import isoparse
import geopandas as gpd
import pyarrow
import pyarrow.compute as pc
import pyarrow.dataset
import s3fs
from pygeoapi.provider.base import (
BaseProvider,
ProviderConnectionError,
ProviderGenericError,
ProviderItemNotFoundError,
ProviderQueryError,
)
from pygeoapi.util import crs_transform
LOGGER = logging.getLogger(__name__)
def arrow_to_pandas_type(arrow_type):
pd_type = arrow_type.to_pandas_dtype()
try:
# Needed for specific types such as dtype('<M8[ns]')
pd_type = pd_type.type
except AttributeError:
pd_type = pd_type
return pd_type
class ParquetProvider(BaseProvider):
def __init__(self, provider_def):
"""
Initialize object
# Typical ParquetProvider YAML config:
provider:
name: Parquet
data:
source: s3://example.com/parquet_directory/
id_field: gml_id
:param provider_def: provider definition
:returns: pygeoapi.provider.parquet.ParquetProvider
"""
super().__init__(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"
LOGGER.error(msg)
raise Exception(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:
self.fs = None
# Build pyarrow dataset pointing to the data
self.ds = pyarrow.dataset.dataset(self.source, filesystem=self.fs)
LOGGER.debug('Grabbing field information')
self.fields = self.get_fields() # Must be set to visualise queryables
# Column names for bounding box data.
if None in [self.x_field, self.y_field]:
self.has_geometry = False
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
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]
# 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]['crs']
or 'EPSG:4326')
def _read_parquet(self, return_scanner=False, **kwargs):
"""
Scan a Parquet dataset with the given arguments
:returns: generator of RecordBatch with the queried values
"""
scanner = pyarrow.dataset.Scanner.from_dataset(self.ds, **kwargs)
batches = scanner.to_batches()
if return_scanner:
return batches, scanner
else:
return batches
def get_fields(self):
"""
Get provider field information (names, types)
:returns: dict of fields
"""
fields = dict()
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
field_type = str(field_type)
converted_type = None
converted_format = None
if field_type.startswith(('int', 'uint')):
converted_type = 'integer'
converted_format = field_type
elif field_type == 'double' or field_type.startswith('float'):
converted_type = 'number'
converted_format = field_type
elif field_type == 'string':
converted_type = 'string'
elif field_type == 'bool':
converted_type = 'boolean'
elif field_type.startswith('timestamp'):
converted_type = 'string'
converted_format = 'date-time'
else:
LOGGER.error(f'Unsupported field type {field_type}')
if converted_format is None:
fields[field_name] = {'type': converted_type}
else:
fields[field_name] = {
'type': converted_type,
'format': converted_format,
}
return fields
@crs_transform
def query(
self,
offset=0,
limit=10,
resulttype='results',
bbox=[],
datetime_=None,
properties=[],
select_properties=[],
skip_geometry=False,
q=None,
**kwargs,
):
"""
Query Parquet source
:param offset: starting record to return (default 0)
:param limit: number of records to return (default 10)
:param resulttype: return results or hit limit (default results)
:param bbox: bounding box [minx,miny,maxx,maxy]
:param datetime_: temporal (datestamp or extent) following ISO-8601
:param properties: list of tuples (field, comparison, value)
:param select_properties: list of property names
:param skip_geometry: bool of whether to skip geometry (default False)
:returns: dict of 0..n GeoJSON features
"""
result = None
try:
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.'
)
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 datetime_ is not None:
if self.time_field is None:
msg = (
'Dataset does not have a time field, '
'querying by datetime is not supported.'
)
raise ProviderQueryError(msg)
timefield = pc.field(self.time_field)
if '/' in datetime_:
begin, end = datetime_.split('/')
if begin != '..':
begin = isoparse(begin)
filter = filter & (timefield >= begin)
if end != '..':
end = isoparse(end)
filter = filter & (timefield <= end)
else:
target_time = isoparse(datetime_)
filter = filter & (timefield == target_time)
if properties:
LOGGER.debug('processing properties')
for name, value in properties:
field = self.ds.schema.field(name)
pd_type = arrow_to_pandas_type(field.type)
expr = pc.field(name) == pc.scalar(pd_type(value))
filter = filter & expr
if len(select_properties) == 0:
select_properties = self.ds.schema.names
else: # Load id and geometry together with any specified columns
if self.has_geometry and 'geometry' not in select_properties:
select_properties.append('geometry')
if self.id_field not in select_properties:
select_properties.insert(0, self.id_field)
if skip_geometry:
select_properties.remove('geometry')
# Make response based on resulttype specified
if resulttype == 'hits':
LOGGER.debug('hits only specified')
result = self._response_feature_hits(filter)
elif resulttype == 'results':
LOGGER.debug('results specified')
result = self._response_feature_collection(
filter, offset, limit, columns=select_properties
)
else:
LOGGER.error(f'Invalid resulttype: {resulttype}')
except RuntimeError as err:
LOGGER.error(err)
raise ProviderQueryError(err)
except ProviderConnectionError as err:
LOGGER.error(err)
raise ProviderConnectionError(err)
except Exception as err:
LOGGER.error(err)
raise ProviderGenericError(err)
return result
@crs_transform
def get(self, identifier, **kwargs):
"""
Get Feature by id
:param identifier: feature id
: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)
batches = self._read_parquet(
filter=(
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}'
row = batch.to_pandas()
break
else:
raise ProviderItemNotFoundError(f'ID {identifier} not found')
if self.has_geometry:
geom = gpd.GeoSeries.from_wkb(row['geometry'], crs=self.crs)
else:
geom = [None]
gdf = gpd.GeoDataFrame(row, geometry=geom)
LOGGER.debug('results computed')
# Grab the collection from geopandas geo_interface
result = gdf.__geo_interface__['features'][0]
except RuntimeError as err:
LOGGER.error(err)
raise ProviderQueryError(err)
except ProviderConnectionError as err:
LOGGER.error(err)
raise ProviderConnectionError(err)
except ProviderItemNotFoundError as err:
LOGGER.error(err)
raise ProviderItemNotFoundError(err)
except Exception as err:
LOGGER.error(err)
raise ProviderGenericError(err)
return result
def __repr__(self):
return f'<ParquetProvider> {self.data}'
def _response_feature_collection(self, filter, offset, limit,
columns=None):
"""
Assembles output from query as
GeoJSON FeatureCollection structure.
:returns: GeoJSON FeatureCollection
"""
LOGGER.debug(f'offset:{offset}, limit:{limit}')
try:
batches, scanner = self._read_parquet(
filter=filter, columns=columns, return_scanner=True
)
# Discard batches until offset is reached
counted = 0
for batch in batches:
if counted + batch.num_rows > offset:
# Slice current batch to start from the requested row
batch = batch.slice(offset=offset - counted)
# Build a new generator yielding the current batch
# and all following ones
batches = chain([batch], batches)
break
else:
counted += batch.num_rows
# batches is a generator, it will now be either fully spent
# or set to the new generator starting from offset
# Get the next `limit+1` rows
# The extra row is used to check if a "next" link is needed
# (when numberMatched > offset + limit)
batches_list = []
read = 0
for batch in batches:
read += batch.num_rows
if read > limit:
batches_list.append(batch.slice(0, limit + 1))
break
else:
batches_list.append(batch)
# Passing schema from scanner in case no rows are returned
table = pyarrow.Table.from_batches(
batches_list, schema=scanner.projected_schema
)
rp = table.to_pandas()
number_matched = offset + len(rp)
# Remove the extra row
if len(rp) > limit:
rp = rp.iloc[:-1]
if 'geometry' not in rp.columns:
# We need a null geometry column to create a GeoDataFrame
rp['geometry'] = None
geom = gpd.GeoSeries.from_wkb(rp['geometry'])
else:
geom = gpd.GeoSeries.from_wkb(rp['geometry'], crs=self.crs)
gdf = gpd.GeoDataFrame(rp, geometry=geom)
LOGGER.debug('results computed')
result = gdf.__geo_interface__
# Add numberMatched to generate "next" link
result['numberMatched'] = number_matched
return result
except RuntimeError as error:
LOGGER.error(error)
raise error
def _response_feature_hits(self, filter):
"""
Assembles GeoJSON hits from row count
:returns: GeoJSON FeaturesCollection
"""
try:
scanner = pyarrow.dataset.Scanner.from_dataset(self.ds,
filter=filter)
return {
'type': 'FeatureCollection',
'numberMatched': scanner.count_rows(),
'features': [],
}
except Exception as error:
LOGGER.error(error)
raise error
+14 -14
View File
@@ -62,7 +62,8 @@ import pyproj
import shapely
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
from sqlalchemy.engine import URL
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.exc import ConstraintColumnNotFoundError, \
InvalidRequestError, OperationalError
from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import Session, load_only
from sqlalchemy.sql.expression import and_
@@ -124,7 +125,7 @@ class PostgreSQLProvider(BaseProvider):
)
LOGGER.debug(f'DB connection: {repr(self._engine.url)}')
self.fields = self.get_fields()
self.get_fields()
def query(self, offset=0, limit=10, resulttype='results',
bbox=[], datetime_=None, properties=[], sortby=[],
@@ -204,8 +205,6 @@ class PostgreSQLProvider(BaseProvider):
LOGGER.debug('Get available fields/properties')
fields = {}
# sql-schema only allows these types, so we need to map from sqlalchemy
# string, number, integer, object, array, boolean, null,
# https://json-schema.org/understanding-json-schema/reference/type.html
@@ -248,17 +247,18 @@ class PostgreSQLProvider(BaseProvider):
LOGGER.debug('No string format detected')
return None
for column in self.table_model.__table__.columns:
LOGGER.debug(f'Testing {column.name}')
if column.name == self.geom:
continue
if not self._fields:
for column in self.table_model.__table__.columns:
LOGGER.debug(f'Testing {column.name}')
if column.name == self.geom:
continue
fields[str(column.name)] = {
'type': _column_type_to_json_schema_type(column.type),
'format': _column_format_to_json_schema_format(column.type)
}
self._fields[str(column.name)] = {
'type': _column_type_to_json_schema_type(column.type),
'format': _column_format_to_json_schema_format(column.type)
}
return fields
return self._fields
def get(self, identifier, crs_transform_spec=None, **kwargs):
"""
@@ -516,7 +516,7 @@ def get_table_model(
sqlalchemy_table_def = metadata.tables[f'{schema}.{table_name}']
try:
sqlalchemy_table_def.append_constraint(PrimaryKeyConstraint(id_field))
except KeyError:
except (ConstraintColumnNotFoundError, KeyError):
raise ProviderQueryError(
f"No such id_field column ({id_field}) on {schema}.{table_name}.")
+30 -30
View File
@@ -59,38 +59,39 @@ class RasterioProvider(BaseProvider):
self.axes = self._coverage_properties['axes']
self.crs = self._coverage_properties['bbox_crs']
self.num_bands = self._coverage_properties['num_bands']
self.fields = self.get_fields()
self.get_fields()
self.native_format = provider_def['format']['name']
except Exception as err:
LOGGER.warning(err)
raise ProviderConnectionError(err)
def get_fields(self):
fields = {}
if not self._fields:
for i, dtype in zip(self._data.indexes, self._data.dtypes):
LOGGER.debug(f'Adding field for band {i}')
i2 = str(i)
for i, dtype in zip(self._data.indexes, self._data.dtypes):
LOGGER.debug(f'Adding field for band {i}')
i2 = str(i)
parameter = _get_parameter_metadata(
self._data.profile['driver'], self._data.tags(i))
parameter = _get_parameter_metadata(
self._data.profile['driver'], self._data.tags(i))
name = parameter['description']
units = parameter.get('unit_label')
name = parameter['description']
units = parameter.get('unit_label')
dtype2 = dtype
if dtype.startswith('float'):
dtype2 = 'number'
elif dtype.startswith('int'):
dtype2 = 'integer'
dtype2 = dtype
if dtype.startswith('float'):
dtype2 = 'number'
self._fields[i2] = {
'title': name,
'type': dtype2,
'_meta': self._data.tags(i)
}
if units is not None:
self._fields[i2]['x-ogc-unit'] = units
fields[i2] = {
'title': name,
'type': dtype2,
'_meta': self._data.tags(i)
}
if units is not None:
fields[i2]['x-ogc-unit'] = units
return fields
return self._fields
def query(self, properties=[], subsets={}, bbox=None, bbox_crs=4326,
datetime_=None, format_='json', **kwargs):
@@ -241,16 +242,15 @@ class RasterioProvider(BaseProvider):
out_meta['units'] = _data.units
LOGGER.debug('Serializing data in memory')
with MemoryFile() as memfile:
with memfile.open(**out_meta) as dest:
dest.write(out_image)
if format_ == 'json':
LOGGER.debug('Creating output in CoverageJSON')
out_meta['bands'] = args['indexes']
return self.gen_covjson(out_meta, out_image)
if format_ == 'json':
LOGGER.debug('Creating output in CoverageJSON')
out_meta['bands'] = args['indexes']
return self.gen_covjson(out_meta, out_image)
else: # return data in native format
else: # return data in native format
with MemoryFile() as memfile:
with memfile.open(**out_meta) as dest:
dest.write(out_image)
LOGGER.debug('Returning data in native format')
return memfile.read()
+147 -112
View File
@@ -30,14 +30,14 @@
# =================================================================
from json.decoder import JSONDecodeError
import os
import logging
from requests import Session
from pygeoapi.config import get_config
from pygeoapi.provider.base import (
BaseProvider, ProviderQueryError, ProviderConnectionError)
from pygeoapi.util import (
yaml_load, url_join, get_provider_default, crs_transform, get_base_url)
url_join, get_provider_default, crs_transform, get_base_url)
LOGGER = logging.getLogger(__name__)
@@ -51,10 +51,10 @@ ENTITY = {
_EXPAND = {
'Things': 'Locations,Datastreams',
'Observations': 'Datastream,FeatureOfInterest',
'ObservedProperties': 'Datastreams/Thing/Locations',
'Datastreams': """
Sensor
,ObservedProperty
,Thing
,Thing/Locations
,Observations(
$select=@iot.id;
@@ -71,6 +71,7 @@ EXPAND = {k: ''.join(v.split()).replace('_', ' ')
class SensorThingsProvider(BaseProvider):
"""SensorThings API (STA) Provider"""
expand = EXPAND
def __init__(self, provider_def):
"""
@@ -82,64 +83,12 @@ class SensorThingsProvider(BaseProvider):
:returns: pygeoapi.provider.sensorthings.SensorThingsProvider
"""
LOGGER.debug('Setting SensorThings API (STA) provider')
self.linked_entity = {}
super().__init__(provider_def)
self.data.rstrip('/')
try:
self.entity = provider_def['entity']
self._url = url_join(self.data, self.entity)
except KeyError:
LOGGER.debug('Attempting to parse Entity from provider data')
if not self._get_entity(self.data):
raise RuntimeError('Entity type required')
self.entity = self._get_entity(self.data)
self._url = self.data
self.data = self._url.rstrip(f'/{self.entity}')
self._generate_mappings(provider_def)
LOGGER.debug(f'STA endpoint: {self.data}, Entity: {self.entity}')
# Default id
if self.id_field:
LOGGER.debug(f'Using id field: {self.id_field}')
else:
LOGGER.debug('Using default @iot.id for id field')
self.id_field = '@iot.id'
# Create intra-links
self.links = {}
self.intralink = provider_def.get('intralink', False)
if self.intralink and provider_def.get('rel_link'):
# For pytest
self.rel_link = provider_def['rel_link']
elif self.intralink:
# Read from pygeoapi config
with open(os.getenv('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
CONFIG = yaml_load(fh)
self.rel_link = get_base_url(CONFIG)
for (name, rs) in CONFIG['resources'].items():
pvs = rs.get('providers')
p = get_provider_default(pvs)
e = p.get('entity') or self._get_entity(p['data'])
if any([
not pvs, # No providers in resource
not p.get('intralink'), # No configuration for intralinks
not e, # No STA entity found
self.data not in p.get('data') # No common STA endpoint
]):
continue
if p.get('uri_field'):
LOGGER.debug(f'Linking {e} with field: {p["uri_field"]}')
else:
LOGGER.debug(f'Linking {e} with collection: {name}')
self.links[e] = {
'cnm': name, # OAPI collection name,
'cid': p.get('id_field', '@iot.id'), # OAPI id_field
'uri': p.get('uri_field') # STA uri_field
}
# Start session
self.http = Session()
self.get_fields()
@@ -150,7 +99,7 @@ class SensorThingsProvider(BaseProvider):
:returns: dict of fields
"""
if not self.fields:
if not self._fields:
r = self._get_response(self._url, {'$top': 1})
try:
results = r['value'][0]
@@ -161,11 +110,11 @@ class SensorThingsProvider(BaseProvider):
for (n, v) in results.items():
if isinstance(v, (int, float)) or \
(isinstance(v, (dict, list)) and n in ENTITY):
self.fields[n] = {'type': 'number'}
self._fields[n] = {'type': 'number'}
elif isinstance(v, str):
self.fields[n] = {'type': 'string'}
self._fields[n] = {'type': 'string'}
return self.fields
return self._fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
@@ -272,17 +221,19 @@ class SensorThingsProvider(BaseProvider):
return fc
def _make_feature(self, entity, select_properties=[], skip_geometry=False):
def _make_feature(self, feature, select_properties=[], skip_geometry=False,
entity=None):
"""
Private function: Create feature from entity
:param entity: `dict` of STA entity
:param feature: `dict` of STA entity
:param select_properties: list of property names
:param skip_geometry: bool of whether to skip geometry (default False)
:param entity: SensorThings entity name
:returns: dict of GeoJSON Feature
"""
_ = entity.pop(self.id_field)
_ = feature.pop(self.id_field)
id = f"'{_}'" if isinstance(_, str) else str(_)
f = {
'type': 'Feature', 'id': id, 'properties': {}, 'geometry': None
@@ -290,28 +241,35 @@ class SensorThingsProvider(BaseProvider):
# Make geometry
if not skip_geometry:
f['geometry'] = self._geometry(entity)
f['geometry'] = self._geometry(feature, entity)
# Fill properties block
try:
f['properties'] = self._expand_properties(
entity, select_properties)
feature, select_properties, entity)
except KeyError as err:
LOGGER.error(err)
raise ProviderQueryError(err)
return f
def _get_response(self, url, params={}):
def _get_response(self, url, params={}, entity=None, expand=None):
"""
Private function: Get STA response
:param url: request url
:param params: query parameters
:param entity: SensorThings entity name
:param expand: SensorThings expand query
:returns: STA response
"""
params.update({'$expand': EXPAND[self.entity]})
if expand:
params.update({'$expand': expand})
else:
entity_ = entity or self.entity
params.update({'$expand': self.expand[entity_]})
r = self.http.get(url, params=params)
@@ -327,13 +285,15 @@ class SensorThingsProvider(BaseProvider):
return response
def _make_filter(self, properties, bbox=[], datetime_=None):
def _make_filter(self, properties, bbox=[], datetime_=None,
entity=None):
"""
Private function: Make STA filter from query properties
:param properties: list of tuples (name, value)
:param bbox: bounding box [minx,miny,maxx,maxy]
:param datetime_: temporal (datestamp or extent)
:param entity: SensorThings entity name
:returns: STA $filter string of properties
"""
@@ -345,16 +305,8 @@ class SensorThingsProvider(BaseProvider):
ret.append(f'{name} eq {value}')
if bbox:
minx, miny, maxx, maxy = bbox
bbox_ = f'POLYGON (({minx} {miny}, {maxx} {miny}, \
{maxx} {maxy}, {minx} {maxy}, {minx} {miny}))'
if self.entity == 'Things':
loc = 'Locations/location'
elif self.entity == 'Datastreams':
loc = 'Thing/Locations/location'
elif self.entity == 'Observations':
loc = 'FeatureOfInterest/feature'
ret.append(f"st_within({loc}, geography'{bbox_}')")
entity_ = entity or self.entity
ret.append(self._make_bbox(bbox, entity_))
if datetime_ is not None:
if self.time_field is None:
@@ -373,6 +325,20 @@ class SensorThingsProvider(BaseProvider):
return ' and '.join(ret)
@staticmethod
def _make_bbox(bbox, entity):
minx, miny, maxx, maxy = bbox
bbox_ = f'POLYGON(({minx} {miny},{maxx} {miny},{maxx} {maxy},{minx} {maxy},{minx} {miny}))' # noqa
if entity == 'Things':
loc = 'Locations/location'
elif entity == 'Datastreams':
loc = 'Thing/Locations/location'
elif entity == 'Observations':
loc = 'FeatureOfInterest/feature'
elif entity == 'ObservedProperties':
loc = 'Datastreams/observedArea'
return f"st_within({loc},geography'{bbox_}')"
def _make_orderby(self, sortby):
"""
Private function: Make STA filter from query properties
@@ -393,79 +359,85 @@ class SensorThingsProvider(BaseProvider):
return ','.join(ret)
def _geometry(self, entity):
def _geometry(self, feature, entity=None):
"""
Private function: Retrieve STA geometry
:param entity: SensorThings entity
:param feature: SensorThings entity
:param entity: SensorThings entity name
:returns: GeoJSON Geometry for feature
"""
entity_ = entity or self.entity
try:
if self.entity == 'Things':
return entity['Locations'][0]['location']
if entity_ == 'Things':
return feature['Locations'][0]['location']
elif self.entity == 'Observations':
return entity['FeatureOfInterest'].pop('feature')
elif entity_ == 'Observations':
return feature['FeatureOfInterest'].pop('feature')
elif self.entity == 'Datastreams':
elif entity_ == 'Datastreams':
try:
return entity['Observations'][0]['FeatureOfInterest'].pop('feature') # noqa
return feature['Observations'][0]['FeatureOfInterest'].pop('feature') # noqa
except (KeyError, IndexError):
return entity['Thing'].pop('Locations')[0]['location']
return feature['Thing'].pop('Locations')[0]['location']
elif entity_ == 'ObservedProperties':
return feature['Datastreams'][0]['Thing']['Locations'][0]['location'] # noqa
except (KeyError, IndexError):
LOGGER.warning('No geometry found')
return None
def _expand_properties(self, entity, keys=(), uri=''):
def _expand_properties(self, feature, keys=(), uri='',
entity=None):
"""
Private function: Parse STA entity into feature
:param entity: SensorThings entity
:param feature: `dict` of SensorThings entity
:param keys: keys used in properties block
:param uri: uri of STA entity
:param entity: SensorThings entity name
:returns: dict of SensorThings feature properties
"""
LOGGER.debug('Adding extra properties')
# Properties filter & display
keys = (() if not self.properties and not keys else
set(self.properties) | set(keys))
if self.entity == 'Things':
self._expand_location(entity)
elif 'Thing' in entity.keys():
self._expand_location(entity['Thing'])
entity = entity or self.entity
if entity == 'Things':
self._expand_location(feature)
elif 'Thing' in feature.keys():
self._expand_location(feature['Thing'])
# Retain URI if present
if entity.get('properties') and self.uri_field:
uri = entity['properties']
if feature.get('properties') and self.uri_field:
uri = feature['properties']
# Create intra links
LOGGER.debug('Creating intralinks')
for k, v in entity.items():
if k in self.links:
entity[k] = [self._get_uri(_v, **self.links[k]) for _v in v]
for k, v in feature.items():
if k in self.linked_entity:
feature[k] = [self._get_uri(_v, **self.linked_entity[k])
for _v in v]
LOGGER.debug(f'Created link for {k}')
elif f'{k}s' in self.links:
entity[k] = self._get_uri(v, **self.links[f'{k}s'])
elif f'{k}s' in self.linked_entity:
feature[k] = \
self._get_uri(v, **self.linked_entity[f'{k}s'])
LOGGER.debug(f'Created link for {k}')
# Make properties block
LOGGER.debug('Making properties block')
if entity.get('properties'):
entity.update(entity.pop('properties'))
if feature.get('properties'):
feature.update(feature.pop('properties'))
if keys:
ret = {k: entity.pop(k) for k in keys}
entity = ret
ret = {k: feature.pop(k) for k in keys}
feature = ret
if self.uri_field is not None and uri != '':
entity[self.uri_field] = uri
feature[self.uri_field] = uri
return entity
return feature
@staticmethod
def _expand_location(entity):
@@ -517,5 +489,68 @@ class SensorThingsProvider(BaseProvider):
else:
return ''
def _generate_mappings(self, provider_def: dict):
"""
Generate mappings for the STA entity and set up intra-links.
This function sets up the necessary mappings and configurations for
the STA entity based on the provided provider definition.
:param provider_def: `dict` of provider definition containing
configuration details for the STA entity.
"""
self.data.rstrip('/')
try:
self.entity = provider_def['entity']
self._url = url_join(self.data, self.entity)
except KeyError:
LOGGER.debug('Attempting to parse Entity from provider data')
if not self._get_entity(self.data):
raise RuntimeError('Entity type required')
self.entity = self._get_entity(self.data)
self._url = self.data
self.data = self._url.rstrip(f'/{self.entity}')
# Default id
if self.id_field:
LOGGER.debug(f'Using id field: {self.id_field}')
else:
LOGGER.debug('Using default @iot.id for id field')
self.id_field = '@iot.id'
# Create intra-links
self.intralink = provider_def.get('intralink', False)
if self.intralink and provider_def.get('rel_link'):
# For pytest
self.rel_link = provider_def['rel_link']
elif self.intralink:
# Read from pygeoapi config
CONFIG = get_config()
self.rel_link = get_base_url(CONFIG)
for name, rs in CONFIG['resources'].items():
pvs = rs.get('providers')
p = get_provider_default(pvs)
e = p.get('entity') or self._get_entity(p['data'])
if any([
not pvs, # No providers in resource
not p.get('intralink'), # No configuration for intralinks
not e, # No STA entity found
self.data not in p.get('data') # No common STA endpoint
]):
continue
if p.get('uri_field'):
LOGGER.debug(f'Linking {e} with field: {p["uri_field"]}')
else:
LOGGER.debug(f'Linking {e} with collection: {name}')
self.linked_entity[e] = {
'cnm': name, # OAPI collection name,
'cid': p.get('id_field', '@iot.id'), # OAPI id_field
'uri': p.get('uri_field') # STA uri_field
}
def __repr__(self):
return f'<SensorThingsProvider> {self.data}, {self.entity}'
+3 -3
View File
@@ -75,7 +75,7 @@ class SODAServiceProvider(BaseProvider):
:returns: dict of fields
"""
if not self.fields:
if not self._fields:
try:
[dataset] = self.client.datasets(ids=[self.resource_id])
@@ -87,9 +87,9 @@ class SODAServiceProvider(BaseProvider):
fields = self.properties or resource[FIELD_NAME]
for field in fields:
idx = resource[FIELD_NAME].index(field)
self.fields[field] = {'type': resource[DATA_TYPE][idx]}
self._fields[field] = {'type': resource[DATA_TYPE][idx]}
return self.fields
return self._fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
+3 -3
View File
@@ -88,7 +88,7 @@ class SQLiteGPKGProvider(BaseProvider):
:returns: dict of fields
"""
if not self.fields:
if not self._fields:
results = self.cursor.execute(
f'PRAGMA table_info({self.table})').fetchall()
for item in results:
@@ -100,9 +100,9 @@ class SQLiteGPKGProvider(BaseProvider):
json_type = 'string'
if json_type is not None:
self.fields[item['name']] = {'type': json_type}
self._fields[item['name']] = {'type': json_type}
return self.fields
return self._fields
def __get_where_clauses(self, properties=[], bbox=[]):
"""
+32 -30
View File
@@ -74,7 +74,7 @@ class TinyDBProvider(BaseProvider):
else:
self.db = TinyDB(self.data)
self.fields = self.get_fields()
self.get_fields()
def get_fields(self):
"""
@@ -83,38 +83,37 @@ class TinyDBProvider(BaseProvider):
:returns: dict of fields
"""
fields = {}
if not self._fields:
try:
r = self.db.all()[0]
except IndexError as err:
LOGGER.debug(err)
return {}
try:
r = self.db.all()[0]
except IndexError as err:
LOGGER.debug(err)
return fields
for key, value in r['properties'].items():
if key not in self._excludes:
typed_value = get_typed_value(str(value))
if isinstance(typed_value, float):
typed_value_type = 'number'
elif isinstance(typed_value, int):
typed_value_type = 'integer'
else:
typed_value_type = 'string'
fields[key] = {'type': typed_value_type}
try:
LOGGER.debug('Attempting to detect date types')
_ = parse_date(value)
if len(value) > 11:
fields[key]['format'] = 'date-time'
for key, value in r['properties'].items():
if key not in self._excludes:
typed_value = get_typed_value(str(value))
if isinstance(typed_value, float):
typed_value_type = 'number'
elif isinstance(typed_value, int):
typed_value_type = 'integer'
else:
fields[key]['format'] = 'date'
except Exception:
LOGGER.debug('No date types detected')
pass
typed_value_type = 'string'
return fields
self._fields[key] = {'type': typed_value_type}
try:
LOGGER.debug('Attempting to detect date types')
_ = parse_date(value)
if len(value) > 11:
self._fields[key]['format'] = 'date-time'
else:
self._fields[key]['format'] = 'date'
except Exception:
LOGGER.debug('No date types detected')
pass
return self._fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
@@ -349,7 +348,10 @@ class TinyDBCatalogueProvider(TinyDBProvider):
def __init__(self, provider_def):
super().__init__(provider_def)
LOGGER.debug('Refreshing fields')
self._excludes = ['_metadata-anytext']
self._fields = {}
self.get_fields()
def get_fields(self):
fields = super().get_fields()
+8 -4
View File
@@ -84,7 +84,9 @@ class WMSFacadeProvider(BaseProvider):
self._transparent = 'TRUE'
if crs in [4326, 'CRS;84']:
version = self.options.get('version', '1.3.0')
if crs in [4326, 'CRS;84'] and version == '1.3.0':
LOGGER.debug('Swapping 4326 axis order to WMS 1.3 mode (yx)')
bbox2 = ','.join(str(c) for c in
[bbox[1], bbox[0], bbox[3], bbox[2]])
@@ -106,12 +108,14 @@ class WMSFacadeProvider(BaseProvider):
if not transparent:
self._transparent = 'FALSE'
crs_param = 'crs' if version == '1.3.0' else 'srs'
params = {
'version': '1.3.0',
'version': version,
'service': 'WMS',
'request': 'GetMap',
'bbox': bbox2,
'crs': CRS_CODES[crs],
crs_param: CRS_CODES[crs],
'layers': self.options['layer'],
'styles': self.options.get('style', 'default'),
'width': width,
@@ -128,7 +132,7 @@ class WMSFacadeProvider(BaseProvider):
else:
request_url = '?'.join([self.data, urlencode(params)])
LOGGER.debug(f'WMS 1.3.0 request url: {request_url}')
LOGGER.debug(f'WMS {version} request url: {request_url}')
response = requests.get(request_url)
+210 -80
View File
@@ -37,12 +37,16 @@ import zipfile
import xarray
import fsspec
import numpy as np
import pyproj
from pyproj.exceptions import CRSError
from pygeoapi.api import DEFAULT_STORAGE_CRS
from pygeoapi.provider.base import (BaseProvider,
ProviderConnectionError,
ProviderNoDataError,
ProviderQueryError)
from pygeoapi.util import read_data
from pygeoapi.util import get_crs_from_uri, read_data
LOGGER = logging.getLogger(__name__)
@@ -81,35 +85,43 @@ class XarrayProvider(BaseProvider):
else:
data_to_open = self.data
self._data = open_func(data_to_open)
try:
self._data = open_func(data_to_open)
except ValueError as err:
# Manage non-cf-compliant time dimensions
if 'time' in str(err):
self._data = open_func(self.data, decode_times=False)
else:
raise err
self.storage_crs = self._parse_storage_crs(provider_def)
self._coverage_properties = self._get_coverage_properties()
self.axes = [self._coverage_properties['x_axis_label'],
self._coverage_properties['y_axis_label'],
self._coverage_properties['time_axis_label']]
self.axes = self._coverage_properties['axes']
self.fields = self.get_fields()
self.get_fields()
except Exception as err:
LOGGER.warning(err)
raise ProviderConnectionError(err)
def get_fields(self):
fields = {}
if not self._fields:
for key, value in self._data.variables.items():
if key not in self._data.coords:
LOGGER.debug('Adding variable')
dtype = value.dtype
if dtype.name.startswith('float'):
dtype = 'number'
elif dtype.name.startswith('int'):
dtype = 'integer'
for key, value in self._data.variables.items():
if len(value.shape) >= 3:
LOGGER.debug('Adding variable')
dtype = value.dtype
if dtype.name.startswith('float'):
dtype = 'number'
self._fields[key] = {
'type': dtype,
'title': value.attrs.get('long_name'),
'x-ogc-unit': value.attrs.get('units')
}
fields[key] = {
'type': dtype,
'title': value.attrs['long_name'],
'x-ogc-unit': value.attrs.get('units')
}
return fields
return self._fields
def query(self, properties=[], subsets={}, bbox=[], bbox_crs=4326,
datetime_=None, format_='json', **kwargs):
@@ -138,9 +150,9 @@ class XarrayProvider(BaseProvider):
data = self._data[[*properties]]
if any([self._coverage_properties['x_axis_label'] in subsets,
self._coverage_properties['y_axis_label'] in subsets,
self._coverage_properties['time_axis_label'] in subsets,
if any([self._coverage_properties.get('x_axis_label') in subsets,
self._coverage_properties.get('y_axis_label') in subsets,
self._coverage_properties.get('time_axis_label') in subsets,
datetime_ is not None]):
LOGGER.debug('Creating spatio-temporal subset')
@@ -159,18 +171,36 @@ class XarrayProvider(BaseProvider):
self._coverage_properties['y_axis_label'] in subsets,
len(bbox) > 0]):
msg = 'bbox and subsetting by coordinates are exclusive'
LOGGER.warning(msg)
LOGGER.error(msg)
raise ProviderQueryError(msg)
else:
query_params[self._coverage_properties['x_axis_label']] = \
slice(bbox[0], bbox[2])
query_params[self._coverage_properties['y_axis_label']] = \
slice(bbox[1], bbox[3])
x_axis_label = self._coverage_properties['x_axis_label']
x_coords = data.coords[x_axis_label]
if x_coords.values[0] > x_coords.values[-1]:
LOGGER.debug(
'Reversing slicing of x axis from high to low'
)
query_params[x_axis_label] = slice(bbox[2], bbox[0])
else:
query_params[x_axis_label] = slice(bbox[0], bbox[2])
y_axis_label = self._coverage_properties['y_axis_label']
y_coords = data.coords[y_axis_label]
if y_coords.values[0] > y_coords.values[-1]:
LOGGER.debug(
'Reversing slicing of y axis from high to low'
)
query_params[y_axis_label] = slice(bbox[3], bbox[1])
else:
query_params[y_axis_label] = slice(bbox[1], bbox[3])
LOGGER.debug('bbox_crs is not currently handled')
if datetime_ is not None:
if self._coverage_properties['time_axis_label'] in subsets:
if self._coverage_properties['time_axis_label'] is None:
msg = 'Dataset does not contain a time axis'
LOGGER.error(msg)
raise ProviderQueryError(msg)
elif self._coverage_properties['time_axis_label'] in subsets:
msg = 'datetime and temporal subsetting are exclusive'
LOGGER.error(msg)
raise ProviderQueryError(msg)
@@ -192,13 +222,15 @@ class XarrayProvider(BaseProvider):
LOGGER.warning(err)
raise ProviderQueryError(err)
if (any([data.coords[self.x_field].size == 0,
data.coords[self.y_field].size == 0,
data.coords[self.time_field].size == 0])):
if any(size == 0 for size in data.sizes.values()):
msg = 'No data found'
LOGGER.warning(msg)
raise ProviderNoDataError(msg)
if format_ == 'json':
# json does not support float32
data = _convert_float32_to_float64(data)
out_meta = {
'bbox': [
data.coords[self.x_field].values[0],
@@ -206,18 +238,20 @@ class XarrayProvider(BaseProvider):
data.coords[self.x_field].values[-1],
data.coords[self.y_field].values[-1]
],
"time": [
_to_datetime_string(data.coords[self.time_field].values[0]),
_to_datetime_string(data.coords[self.time_field].values[-1])
],
"driver": "xarray",
"height": data.sizes[self.y_field],
"width": data.sizes[self.x_field],
"time_steps": data.sizes[self.time_field],
"variables": {var_name: var.attrs
for var_name, var in data.variables.items()}
}
if self.time_field is not None:
out_meta['time'] = [
_to_datetime_string(data.coords[self.time_field].values[0]),
_to_datetime_string(data.coords[self.time_field].values[-1]),
]
out_meta["time_steps"] = data.sizes[self.time_field]
LOGGER.debug('Serializing data in memory')
if format_ == 'json':
LOGGER.debug('Creating output in CoverageJSON')
@@ -226,9 +260,11 @@ class XarrayProvider(BaseProvider):
LOGGER.debug('Returning data in native zarr format')
return _get_zarr_data(data)
else: # return data in native format
with tempfile.TemporaryFile() as fp:
with tempfile.NamedTemporaryFile() as fp:
LOGGER.debug('Returning data in native NetCDF format')
fp.write(data.to_netcdf())
data.to_netcdf(
fp.name
) # we need to pass a string to be able to use the "netcdf4" engine # noqa
fp.seek(0)
return fp.read()
@@ -238,14 +274,18 @@ class XarrayProvider(BaseProvider):
:param metadata: coverage metadata
:param data: rasterio DatasetReader object
:param fields: fields dict
:param fields: fields
:returns: dict of CoverageJSON representation
"""
LOGGER.debug('Creating CoverageJSON domain')
minx, miny, maxx, maxy = metadata['bbox']
mint, maxt = metadata['time']
selected_fields = {
key: value for key, value in self.fields.items()
if key in fields
}
try:
tmp_min = data.coords[self.y_field].values[0]
@@ -276,11 +316,6 @@ class XarrayProvider(BaseProvider):
'start': maxy,
'stop': miny,
'num': metadata['height']
},
self.time_field: {
'start': mint,
'stop': maxt,
'num': metadata['time_steps']
}
},
'referencing': [{
@@ -295,7 +330,15 @@ class XarrayProvider(BaseProvider):
'ranges': {}
}
for key, value in self.fields.items():
if self.time_field is not None:
mint, maxt = metadata['time']
cj['domain']['axes'][self.time_field] = {
'start': mint,
'stop': maxt,
'num': metadata['time_steps'],
}
for key, value in selected_fields.items():
parameter = {
'type': 'Parameter',
'description': value['title'],
@@ -313,21 +356,25 @@ class XarrayProvider(BaseProvider):
cj['parameters'][key] = parameter
data = data.fillna(None)
data = _convert_float32_to_float64(data)
try:
for key, value in self.fields.items():
for key, value in selected_fields.items():
cj['ranges'][key] = {
'type': 'NdArray',
'dataType': value['type'],
'axisNames': [
'y', 'x', self._coverage_properties['time_axis_label']
'y', 'x'
],
'shape': [metadata['height'],
metadata['width'],
metadata['time_steps']]
metadata['width']]
}
cj['ranges'][key]['values'] = data[key].values.flatten().tolist() # noqa
if self.time_field is not None:
cj['ranges'][key]['axisNames'].append(
self._coverage_properties['time_axis_label']
)
cj['ranges'][key]['shape'].append(metadata['time_steps'])
except IndexError as err:
LOGGER.warning(err)
raise ProviderQueryError('Invalid query parameter')
@@ -337,6 +384,7 @@ class XarrayProvider(BaseProvider):
def _get_coverage_properties(self):
"""
Helper function to normalize coverage properties
:param provider_def: provider definition
:returns: `dict` of coverage properties
"""
@@ -372,48 +420,61 @@ class XarrayProvider(BaseProvider):
self._data.coords[self.x_field].values[-1],
self._data.coords[self.y_field].values[-1],
],
'time_range': [
_to_datetime_string(
self._data.coords[self.time_field].values[0]
),
_to_datetime_string(
self._data.coords[self.time_field].values[-1]
)
],
'bbox_crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
'crs_type': 'GeographicCRS',
'x_axis_label': self.x_field,
'y_axis_label': self.y_field,
'time_axis_label': self.time_field,
'width': self._data.sizes[self.x_field],
'height': self._data.sizes[self.y_field],
'time': self._data.sizes[self.time_field],
'time_duration': self.get_time_coverage_duration(),
'bbox_units': 'degrees',
'resx': np.abs(self._data.coords[self.x_field].values[1]
- self._data.coords[self.x_field].values[0]),
'resy': np.abs(self._data.coords[self.y_field].values[1]
- self._data.coords[self.y_field].values[0]),
'restime': self.get_time_resolution()
'resx': np.abs(
self._data.coords[self.x_field].values[1]
- self._data.coords[self.x_field].values[0]
),
'resy': np.abs(
self._data.coords[self.y_field].values[1]
- self._data.coords[self.y_field].values[0]
),
}
if 'crs' in self._data.variables.keys():
try:
properties['bbox_crs'] = f'http://www.opengis.net/def/crs/OGC/1.3/{self._data.crs.epsg_code}' # noqa
properties['inverse_flattening'] = self._data.crs.\
inverse_flattening
if self.time_field is not None:
properties['time_axis_label'] = self.time_field
properties['time_range'] = [
_to_datetime_string(
self._data.coords[self.time_field].values[0]
),
_to_datetime_string(
self._data.coords[self.time_field].values[-1]
),
]
properties['time'] = self._data.sizes[self.time_field]
properties['time_duration'] = self.get_time_coverage_duration()
properties['restime'] = self.get_time_resolution()
# Update properties based on the xarray's CRS
epsg_code = self.storage_crs.to_epsg()
LOGGER.debug(f'{epsg_code}')
if epsg_code == 4326 or self.storage_crs == 'OGC:CRS84':
pass
LOGGER.debug('Confirmed default of WGS 84')
else:
properties['bbox_crs'] = \
f'https://www.opengis.net/def/crs/EPSG/0/{epsg_code}'
properties['inverse_flattening'] = \
self.storage_crs.ellipsoid.inverse_flattening
if self.storage_crs.is_projected:
properties['crs_type'] = 'ProjectedCRS'
except AttributeError:
pass
LOGGER.debug(f'properties: {properties}')
properties['axes'] = [
properties['x_axis_label'],
properties['y_axis_label'],
properties['time_axis_label']
properties['y_axis_label']
]
if self.time_field is not None:
properties['axes'].append(properties['time_axis_label'])
return properties
@staticmethod
@@ -440,7 +501,8 @@ class XarrayProvider(BaseProvider):
:returns: time resolution string
"""
if self._data[self.time_field].size > 1:
if self.time_field is not None \
and self._data[self.time_field].size > 1:
time_diff = (self._data[self.time_field][1] -
self._data[self.time_field][0])
@@ -457,6 +519,9 @@ class XarrayProvider(BaseProvider):
:returns: time coverage duration string
"""
if self.time_field is None:
return None
dur = self._data[self.time_field][-1] - self._data[self.time_field][0]
ms_difference = dur.values.astype('timedelta64[ms]').astype(np.double)
@@ -472,6 +537,71 @@ class XarrayProvider(BaseProvider):
return ', '.join(times)
def _parse_grid_mapping(self):
"""
Identifies grid_mapping.
:returns: name of xarray data variable that contains CRS information.
"""
LOGGER.debug('Parsing grid mapping...')
spatiotemporal_dims = (self.time_field, self.y_field, self.x_field)
LOGGER.debug(spatiotemporal_dims)
grid_mapping_name = None
for var_name, var in self._data.variables.items():
if all(dim in var.dims for dim in spatiotemporal_dims):
try:
grid_mapping_name = self._data[var_name].attrs['grid_mapping'] # noqa
LOGGER.debug(f'Grid mapping: {grid_mapping_name}')
except KeyError as err:
LOGGER.debug(err)
LOGGER.debug('No grid mapping information found.')
return grid_mapping_name
def _parse_storage_crs(
self,
provider_def: dict
) -> pyproj.CRS:
"""
Parse the storage CRS from an xarray dataset.
:param provider_def: provider definition
:returns: `pyproj.CRS` instance parsed from dataset
"""
storage_crs = None
try:
storage_crs = provider_def['storage_crs']
crs_function = pyproj.CRS.from_user_input
except KeyError as err:
LOGGER.debug(err)
LOGGER.debug('No storage_crs found. Attempting to parse the CRS.')
if storage_crs is None:
grid_mapping = self._parse_grid_mapping()
if grid_mapping is not None:
storage_crs = self._data[grid_mapping].attrs
crs_function = pyproj.CRS.from_cf
elif 'crs' in self._data.variables.keys():
storage_crs = self._data['crs'].attrs
crs_function = pyproj.CRS.from_dict
else:
storage_crs = DEFAULT_STORAGE_CRS
crs_function = get_crs_from_uri
LOGGER.debug('Failed to parse dataset CRS. Assuming WGS84.')
LOGGER.debug(f'Parsing CRS {storage_crs} with {crs_function}')
try:
crs = crs_function(storage_crs)
except CRSError as err:
LOGGER.debug(f'Unable to parse projection with pyproj: {err}')
LOGGER.debug('Assuming default WGS84.')
crs = get_crs_from_uri(DEFAULT_STORAGE_CRS)
LOGGER.debug(crs)
return crs
def _to_datetime_string(datetime_obj):
"""
@@ -554,7 +684,7 @@ def _convert_float32_to_float64(data):
for var_name in data.variables:
if data[var_name].dtype == 'float32':
og_attrs = data[var_name].attrs
data[var_name] = data[var_name].astype('float64')
data[var_name] = data[var_name].astype('float64', copy=False)
data[var_name].attrs = og_attrs
return data
+6 -6
View File
@@ -81,14 +81,14 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
wkt = kwargs.get('wkt')
if wkt is not None:
LOGGER.debug('Processing WKT')
LOGGER.debug(f'Geometry type: {wkt.type}')
if wkt.type == 'Point':
LOGGER.debug(f'Geometry type: {wkt.geom_type}')
if wkt.geom_type == 'Point':
query_params[self._coverage_properties['x_axis_label']] = wkt.x
query_params[self._coverage_properties['y_axis_label']] = wkt.y
elif wkt.type == 'LineString':
elif wkt.geom_type == 'LineString':
query_params[self._coverage_properties['x_axis_label']] = wkt.xy[0] # noqa
query_params[self._coverage_properties['y_axis_label']] = wkt.xy[1] # noqa
elif wkt.type == 'Polygon':
elif wkt.geom_type == 'Polygon':
query_params[self._coverage_properties['x_axis_label']] = slice(wkt.bounds[0], wkt.bounds[2]) # noqa
query_params[self._coverage_properties['y_axis_label']] = slice(wkt.bounds[1], wkt.bounds[3]) # noqa
pass
@@ -109,7 +109,7 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
try:
if select_properties:
self.fields = {k: v for k, v in self.fields.items() if k in select_properties} # noqa
self._fields = {k: v for k, v in self._fields.items() if k in select_properties} # noqa
data = self._data[[*select_properties]]
else:
data = self._data
@@ -206,7 +206,7 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
LOGGER.debug(f'query parameters: {query_params}')
try:
if select_properties:
self.fields = {k: v for k, v in self.fields.items() if k in select_properties} # noqa
self._fields = {k: v for k, v in self._fields.items() if k in select_properties} # noqa
data = self._data[[*select_properties]]
else:
data = self._data
+15 -8
View File
@@ -334,11 +334,7 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
if 'item_id' in request.path_params:
item_id = request.path_params['item_id']
if item_id is None:
if request.method == 'GET': # list items
return await execute_from_starlette(
itemtypes_api.get_collection_items, request, collection_id,
skip_valid_check=True)
elif request.method == 'POST': # filter or manage items
if request.method == 'POST': # filter or manage items
content_type = request.headers.get('content-type')
if content_type is not None:
if content_type == 'application/geo+json':
@@ -357,6 +353,10 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
itemtypes_api.manage_collection_item, request,
'options', collection_id, skip_valid_check=True,
)
else: # GET: list items
return await execute_from_starlette(
itemtypes_api.get_collection_items, request, collection_id,
skip_valid_check=True)
elif request.method == 'DELETE':
return await execute_from_starlette(
@@ -511,12 +511,13 @@ async def get_job_result_resource(request: Request,
api_.get_job_result_resource, request, job_id, resource)
async def get_collection_edr_query(request: Request, collection_id=None, instance_id=None): # noqa
async def get_collection_edr_query(request: Request, collection_id=None, instance_id=None, location_id=None): # noqa
"""
OGC EDR API endpoints
:param collection_id: collection identifier
:param instance_id: instance identifier
:param location_id: location id of a /locations/<location_id> query
:returns: HTTP response
"""
@@ -527,10 +528,15 @@ async def get_collection_edr_query(request: Request, collection_id=None, instanc
if 'instance_id' in request.path_params:
instance_id = request.path_params['instance_id']
query_type = request["path"].split('/')[-1] # noqa
if 'location_id' in request.path_params:
location_id = request.path_params['location_id']
query_type = 'locations'
else:
query_type = request['path'].split('/')[-1]
return await execute_from_starlette(
edr_api.get_collection_edr_query, request, collection_id,
instance_id, query_type,
instance_id, query_type, location_id,
skip_valid_check=True,
)
@@ -746,6 +752,7 @@ if CONFIG['server'].get('cors', False):
CORSMiddleware,
allow_origins=['*'],
allow_methods=['*'],
expose_headers=['*']
)
try:
+9
View File
@@ -27,6 +27,15 @@ main {
height: 400px;
}
#coverages-map {
width: 100%;
height: 80vh;
}
.c3-tooltip-container {
z-index: 300;
}
/* cancel mini-css header>button uppercase */
header button, header [type="button"], header .button, header [role="button"] {
text-transform: none;
+12 -8
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="{{ (locale|lower)[:2] }}" dir="{% trans %}text_direction{% endtrans %}" >
<head>
<meta charset="{{ config['server']['encoding'] }}">
<title>{% block title %}{% endblock %}{% if not self.title() %}{{ config['metadata']['identification']['title'] }}{% endif %}</title>
@@ -37,9 +37,9 @@
<body>
<div class="bg-light sticky-top border-bottom">
<div class="container">
<header class="d-flex flex-wrap justify-content-center py-3">
<header class="d-flex flex-wrap align-items-center py-3 justify-content-between">
<a href="{{ config['server']['url'] }}"
class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
class="d-flex align-items-center mb-3 mb-md-0 text-dark text-decoration-none">
<img src="{{ config['server']['url'] }}/static/img/logo.png"
title="{{ config['metadata']['identification']['title'] }}" style="height:40px;vertical-align: middle;" /></a>
<ul class="nav nav-pills">
@@ -66,11 +66,11 @@
{% block crumbs %}
<a href="{{ config['server']['url'] }}">{% trans %}Home{% endtrans %}</a>
{% endblock %}
<span style="float:right">
<span style="float: inline-end">
{% set links_found = namespace(json=0, jsonld=0) %}
{% for link in data['links'] %}
{% if link['rel'] == 'alternate' and link['type'] and link['type'] in ['application/json', 'application/geo+json'] %}
{% if link['rel'] == 'alternate' and link['type'] and link['type'] in ['application/json', 'application/geo+json', 'application/prs.coverage+json'] %}
{% set links_found.json = 1 %}
<a href="{{ link['href'] }}">{% trans %}json{% endtrans %}</a>
{% elif link['rel'] == 'alternate' and link['type'] and link['type'] == 'application/ld+json' %}
@@ -102,9 +102,13 @@
</div>
</div>
</main>
<footer class="sticky-bottom bg-light d-flex flex-wrap py-3 px-3 border-top">{% trans %}Powered by {% endtrans %} <a title="pygeoapi" href="https://pygeoapi.io"><img
src="{{ config['server']['url'] }}/static/img/pygeoapi.png" class="mx-1" title="pygeoapi logo"
style="height:24px;vertical-align: middle;" /></a> {{ version }}</footer>
<footer class="sticky-bottom bg-light d-flex justify-content-center align-items-center py-3 px-3 border-top">
<div class="text-center w-100">
{% trans %}Powered by {% endtrans %}
<a title="pygeoapi" href="https://pygeoapi.io"><img src="{{ config['server']['url'] }}/static/img/pygeoapi.png" class="mx-1" title="pygeoapi logo" style="height:24px;vertical-align: middle;" /></a>
{{ version }}
</div>
</footer>
{% block extrafoot %}
{% endblock %}
<script>
+247 -22
View File
@@ -8,56 +8,281 @@
{% set col_title = link['title'] %}
{% endif %}
{% endfor %}
/ <a href="{{ data['items_path']}}">{% trans %}Items{% endtrans %}</a>
/ <a href="{{ data['query_path']}}">{% trans query_type=data.query_type %}{{ query_type }}{% endtrans %}</a>
{% endblock %}
{% block extrahead %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"/>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/leaflet-coverage@0.7/leaflet-coverage.css">
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
{% if data.type == "Coverage" or data.type == "CoverageCollection" %}
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/c3@0.7.20/c3.css">
<script src="https://cdn.jsdelivr.net/npm/d3@5.16.0/dist/d3.js"></script>
<script src="https://cdn.jsdelivr.net/npm/c3@0.7.20/c3.js"></script>
<script src="https://unpkg.com/covutils@0.6/covutils.min.js"></script>
<script src="https://unpkg.com/covjson-reader@0.16/covjson-reader.src.js"></script>
<script src="https://unpkg.com/leaflet-coverage@0.7/leaflet-coverage.min.js"></script>
{% elif data.type == "Feature" or data.type == "FeatureCollection" %}
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.Default.css"/>
<script src="https://unpkg.com/leaflet.markercluster/dist/leaflet.markercluster-src.js"></script>
{% endif %}
{% endblock %}
{% block body %}
<section id="coverage">
<div id="items-map"></div>
{% if data.features or data.coverages or data.ranges or data.references %}
<div id="coverages-map"></div>
{% else %}
<div class="row col-sm-12">
<p>{% trans %}No items{% endtrans %}</p>
</div>
{% endif %}
</section>
{% endblock %}
{% block extrafoot %}
{% if data %}
<script>
var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 5);
map.addLayer(new L.TileLayer(
var map = L.map('coverages-map').setView([40, -85], 3);
var baseLayers = {
'Map': new L.TileLayer(
'{{ config['server']['map']['url'] }}', {
maxZoom: 18,
attribution: '{{ config['server']['map']['attribution'] | safe }}'
}).addTo(map)
}
{% if data.type == "Coverage" or data.type == "CoverageCollection" %}
let layerControl = L.control.layers(baseLayers, {}, {collapsed: false}).addTo(map)
let layersInControl = new Set()
let coverageLayersOnMap = new Set()
let paramSync = new C.ParameterSync({
syncProperties: {
palette: (p1, p2) => p1,
paletteExtent: (e1, e2) => e1 && e2 ? [Math.min(e1[0], e2[0]), Math.max(e1[1], e2[1])] : null
}
}).on('parameterAdd', e => {
// The virtual sync layer proxies the synced palette, paletteExtent, and parameter.
// The sync layer will fire a 'remove' event if all real layers for that parameter were removed.
let layer = e.syncLayer
if (layer.palette) {
C.legend(layer, {
position: 'bottomright'
}).addTo(map)
}
));
var layers = L.control.layers(null, null, {collapsed: false}).addTo(map)
CovJSON.read(JSON.parse('{{ data | to_json | safe }}')).then(function (cov) {
cov.parameters.forEach((p) => {
var layer = C.dataLayer(cov, {parameter: p.key})
.on('afterAdd', function () {
C.legend(layer).addTo(map)
map.fitBounds(layer.getBounds())
})
.addTo(map)
layers.addOverlay(layer, p.observedProperty.label?.en)
map.setZoom(5)
})
displayCovJSON(JSON.parse('{{ data | to_json | safe }}'), {display: true})
const truncateString = (str, maxLength) => {
str = str.replace(/\+/g, ' ');
return str.length > maxLength ? `${str.slice(0, maxLength - 3)}...` : str;
};
function displayCovJSON(obj, options = {}) {
map.fire('dataloading');
var layer = CovJSON.read(obj)
.then(cov => {
if (CovUtils.isDomain(cov)) {
cov = CovUtils.fromDomain(cov);
}
map.fire('dataload');
// add each parameter as a layer
let firstLayer;
let layerClazz = C.dataLayerClass(cov);
if (cov.coverages && !layerClazz) {
// generic collection
if (!cov.parameters) {
throw new Error('only coverage collections with a "parameters" property are supported');
}
for (let key of cov.parameters.keys()) {
let layers = cov.coverages
.filter(coverage => coverage.parameters.has(key))
.map(coverage => createLayer(coverage, { keys: [key] }));
layers.forEach(layer => map.fire('covlayercreate', { layer }));
let layerGroup = L.layerGroup(layers);
layersInControl.add(layerGroup);
layerControl.addOverlay(layerGroup, truncateString(key, 50));
if (!firstLayer) {
firstLayer = layerGroup;
// the following piece of code should be easier
// TODO extend layer group class in leaflet-coverage (like PointCollection) to provide single 'add' event
let addCount = 0;
for (let l of layers) {
l.on('afterAdd', () => {
coverageLayersOnMap.add(l);
++addCount;
if (addCount === layers.length) {
zoomToLayers(layers);
// FIXME is this the right place?? define event semantics!
map.fire('covlayeradd', { layer: l });
}
});
}
}
}
} else if (layerClazz) {
// single coverage or a coverage collection of a specific domain type
for (let key of cov.parameters.keys()) {
let opts = { keys: [key] };
let layer = createLayer(cov, opts);
map.fire('covlayercreate', { layer });
layersInControl.add(layer);
layerControl.addOverlay(layer, truncateString(key, 50));
if (!firstLayer) {
firstLayer = layer;
layer.on('afterAdd', () => {
zoomToLayers([layer])
if (!cov.coverages) {
if (isVerticalProfile(cov) || isTimeSeries(cov)) {
layer.openPopup();
}
}
});
}
layer.on('afterAdd', () => {
coverageLayersOnMap.add(layer);
map.fire('covlayeradd', { layer });
});
}
} else {
throw new Error('unsupported or missing domain type');
}
if (options.display && firstLayer) {
map.addLayer(firstLayer);
}
})
.catch(e => {
map.fire('dataload');
console.log(e);
});
}
function createLayer(cov, opts) {
let layer = C.dataLayer(cov, opts).on('afterAdd', e => {
let covLayer = e.target
// This registers the layer with the sync manager.
// By doing that, the palette and extent get unified (if existing)
// and an event gets fired if a new parameter was added.
// See the code above where ParameterSync gets instantiated.
paramSync.addLayer(covLayer)
if (!cov.coverages) {
if (covLayer.time) {
new C.TimeAxis(covLayer).addTo(map)
}
if (covLayer.vertical) {
new C.VerticalAxis(covLayer).addTo(map)
}
}
}).on('dataLoad', () => map.fire('dataload'))
.on('dataLoading', () => map.fire('dataloading'))
.on('error', e => map.fire('error', { error: e.error }))
layer.on('axisChange', () => {
layer.paletteExtent = 'subset'
})
if (cov.coverages) {
if (isVerticalProfile(cov)) {
layer.bindPopupEach(coverage => new C.VerticalProfilePlot(coverage))
} else if (isTimeSeries(cov)) {
layer.bindPopupEach(coverage => new C.TimeSeriesPlot(coverage))
}
} else {
if (isVerticalProfile(cov)) {
layer.bindPopup(new C.VerticalProfilePlot(cov))
} else if (isTimeSeries(cov)) {
layer.bindPopup(new C.TimeSeriesPlot(cov))
}
}
return layer
}
function zoomToLayers (layers) {
let bnds = layers.map(l => l.getBounds())
let bounds = L.latLngBounds(bnds)
let opts = {
padding: L.point(10, 10)
}
if (bounds.getWest() === bounds.getEast() && bounds.getSouth() === bounds.getNorth()) {
opts.maxZoom = 5
}
map.fitBounds(bounds, opts)
}
function isVerticalProfile (cov) {
return cov.domainType === C.COVJSON_VERTICALPROFILE
}
function isTimeSeries (cov) {
return cov.domainType === C.COVJSON_POINTSERIES || cov.domainType === C.COVJSON_POLYGONSERIES
}
window.api = {
map,
layers: coverageLayersOnMap
}
// Wire up coverage value popup
let valuePopup = new C.DraggableValuePopup({
className: 'leaflet-popup-draggable',
layers: [...coverageLayersOnMap]
})
map.on('click', function (e) {
new C.DraggableValuePopup({
layers: [layer]
}).setLatLng(e.latlng).openOn(map)
function closeValuePopup () {
if (map.hasLayer(valuePopup)) {
map.closePopup(valuePopup)
}
}
// click event needed for Grid layer (can't use bindPopup there)
map.on('singleclick', e => {
valuePopup.setLatLng(e.latlng).openOn(map)
})
map.on('covlayercreate', e => {
// some layers already have a plot popup bound to it, ignore those
if (!e.layer.getPopup()) {
e.layer.bindPopup(valuePopup)
}
})
map.on('covlayeradd', e => {
valuePopup.addCoverageLayer(e.layer)
})
map.on('covlayerremove', e => {
valuePopup.removeCoverageLayer(e.layer)
})
map.on('error', e => {
if (e.error?.message) {
editor.setError(e.error.message)
}
})
{% elif data.type == "Feature" or data.type == "FeatureCollection" %}
var geojson_data = {{ data | to_json | safe }};
var items = new L.GeoJSON(geojson_data, {
onEachFeature: function (feature, layer) {
var html = '<span>' + {% if data['title_field'] %} feature['properties']['{{ data['title_field'] }}'] {% else %} feature.id {% endif %} + '</span>';
layer.bindPopup(html);
}
});
var markers = L.markerClusterGroup({
disableClusteringAtZoom: 9,
chunkedLoading: true,
chunkInterval: 500,
});
markers.clearLayers().addLayer(items);
map.addLayer(markers);
map.fitBounds(items.getBounds(), {maxZoom: 15});
{% endif %}
</script>
{% endif %}
{% endblock %}
@@ -22,7 +22,6 @@
<section id="items"></section>
<section id="collection">
<h1>{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% endfor %}</h1>
<p>{% trans %}Items in this collection{% endtrans %}.</p>
</section>
<section id="items">
{% if data['features'] %}
@@ -35,7 +34,9 @@
<div class="col-sm-12">
<div class="row">
<div class="col-sm-12">
{% trans %}Warning: Higher limits not recommended!{% endtrans %}
{% if data['numberMatched'] %}
<p>{% trans %}Items in this collection{% endtrans %}: {{ data['numberMatched'] }}</p>
{% endif %}
</div>
</div>
<div class="row">
@@ -47,6 +48,7 @@
<option value="1000">1,000</option>
<option value="2000">2,000</option>
</select>
<p>{% trans %}Warning: Higher limits not recommended!{% endtrans %}</p>
<script>
var select = document.getElementById('limits');
var defaultValue = select.getElementsByTagName('option')[0].value;
@@ -134,6 +136,12 @@
</table>
</div>
</div>
{% elif data['numberMatched'] %}
<div class="row">
<div class="col-sm-12">
<p>{% trans %}Items in this collection{% endtrans %}: {{ data['numberMatched'] }}</p>
</div>
</div>
{% else %}
<div class="row col-sm-12">
<p>{% trans %}No items{% endtrans %}</p>
@@ -161,7 +169,6 @@
layer.bindPopup(html);
}
});
{% if data['features'][0]['geometry']['type'] == 'Point' %}
var markers = L.markerClusterGroup({
disableClusteringAtZoom: 9,
chunkedLoading: true,
@@ -169,9 +176,6 @@
});
markers.clearLayers().addLayer(items);
map.addLayer(markers);
{% else %}
map.addLayer(items);
{% endif %}
map.fitBounds(items.getBounds());
</script>
@@ -1,5 +1,5 @@
{% extends "_base.html" %}
{% set ptitle = data['properties'][data['title_field']] or '_(Item) '.format(data['id']) %}
{% set ptitle = data['properties'][data['title_field']] or data['id'] | string %}
{% block desc %}{{ data.get('properties',{}).get('description', {}) | string | truncate(250) }}{% endblock %}
{% block tags %}{{ data['properties'].get('themes', [{}])[0].get('concepts', []) | join(',') }}{% endblock %}
{# Optionally renders an img element, otherwise standard value or link rendering #}
@@ -2,8 +2,8 @@
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
{% block crumbs %}{{ super() }}
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
/ <a href="./{{ data['id'] }}">{{ data['title'] | truncate( 25 ) }}</a>
/ <a href="./{{ data['id'] }}queryables">{% trans %}Queryables{% endtrans %}</a>
/ <a href="{{ data['dataset_path'] }}">{{ data['title'] | truncate( 25 ) }}</a>
/ <a href="{{ data['dataset_path'] }}/queryables">{% trans %}Queryables{% endtrans %}</a>
{% endblock %}
{% block body %}
<section id="collection">
+2 -2
View File
@@ -2,8 +2,8 @@
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
{% block crumbs %}{{ super() }}
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
/ <a href="./{{ data['id'] }}">{{ data['title'] | truncate( 25 ) }}</a>
/ <a href="./{{ data['id'] }}schema">{% trans %}Schema{% endtrans %}</a>
/ <a href="{{ data['dataset_path'] }}">{{ data['title'] | truncate( 25 ) }}</a>
/ <a href="{{ data['dataset_path'] }}/schema">{% trans %}Schema{% endtrans %}</a>
{% endblock %}
{% block body %}
<section id="collection-schema">
+33
View File
@@ -48,5 +48,38 @@
</table>
</div>
</div>
<div class="row">
<div class="col-sm-12">
{% trans %}Limit{% endtrans %}:
<select id="limits">
<option value="{{ config['server']['limit'] }}">{{ config['server']['limit'] }} ({% trans %}default{% endtrans %})</option>
<option value="100">100</option>
<option value="1000">1,000</option>
<option value="2000">2,000</option>
</select>
<script>
var select = document.getElementById('limits');
var defaultValue = select.getElementsByTagName('option')[0].value;
let params = (new URL(document.location)).searchParams;
select.value = params.get('limit') || defaultValue;
select.addEventListener('change', ev => {
var limit = ev.target.value;
document.location.search = `limit=${limit}`;
});
</script>
</div>
</div>
<div class="row">
<div class="col-sm-12">
{% for link in data['jobs']['links'] %}
{% if link['rel'] == 'prev' and data['offset'] > 0 %}
<a role="button" href="{{ link['href'] }}">{% trans %}Prev{% endtrans %}</a>
{% elif link['rel'] == 'next' and data['jobs']['jobs'] %}
<a role="button" href="{{ link['href'] }}">{% trans %}Next{% endtrans %}</a>
{% endif %}
{% endfor %}
</div>
</div>
</section>
{% endblock %}
+6 -1
View File
@@ -168,7 +168,7 @@ def yaml_load(fh: IO) -> dict:
# # https://stackoverflow.com/a/55301129
env_matcher = re.compile(
r'.*?\$\{(?P<varname>\w+)(:-(?P<default>[^}]+))?\}')
r'.*?\$\{(?P<varname>\w+)(:-(?P<default>[^}]*))?\}')
def env_constructor(loader, node):
result = ""
@@ -597,6 +597,11 @@ class RequestedProcessExecutionMode(Enum):
respond_async = 'respond-async'
class RequestedResponse(Enum):
raw = 'raw'
document = 'document'
class JobStatus(Enum):
"""
Enum for the job status options specified in the WPS 2.0 specification
+5 -1
View File
@@ -6,11 +6,15 @@ elasticsearch-dsl
fiona
GDAL<=3.8.4
geoalchemy2
geopandas
netCDF4
numpy
numpy==2.0.1
opensearch-dsl
opensearch-py
oracledb
pandas
psycopg2
pyarrow
pygeofilter[backend-sqlalchemy]
pygeoif
pygeometa
+1 -2
View File
@@ -14,6 +14,5 @@ PyYAML
rasterio
requests
shapely
SQLAlchemy<2.0.0
SQLAlchemy
tinydb
unicodecsv
+5 -2
View File
@@ -389,6 +389,9 @@ def test_api(config, api_, openapi):
assert rsp_headers['Content-Language'] == 'en-US'
assert code == HTTPStatus.BAD_REQUEST
response = json.loads(response)
assert response['description'] == 'Invalid format requested'
assert api_.get_collections_url() == 'http://localhost:5000/collections'
@@ -572,7 +575,7 @@ def test_conformance(config, api_):
assert isinstance(root, dict)
assert 'conformsTo' in root
assert len(root['conformsTo']) == 37
assert len(root['conformsTo']) == 42
assert 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs' \
in root['conformsTo']
@@ -601,7 +604,7 @@ def test_describe_collections(config, api_):
collections = json.loads(response)
assert len(collections) == 2
assert len(collections['collections']) == 9
assert len(collections['collections']) == 10
assert len(collections['links']) == 3
rsp_headers, code, response = api_.describe_collections(req, 'foo')
+20
View File
@@ -62,6 +62,11 @@ def test_get_collection_queryables(config, api_):
api_, req, 'notfound')
assert code == HTTPStatus.NOT_FOUND
req = mock_api_request()
rsp_headers, code, response = get_collection_queryables(
api_, req, 'mapserver_world_map')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'f': 'html'})
rsp_headers, code, response = get_collection_queryables(api_, req, 'obs')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
@@ -74,6 +79,14 @@ def test_get_collection_queryables(config, api_):
assert 'properties' in queryables
assert len(queryables['properties']) == 5
req = mock_api_request({'f': 'json'})
rsp_headers, code, response = get_collection_queryables(api_, req, 'canada-metadata') # noqa
assert rsp_headers['Content-Type'] == 'application/schema+json'
queryables = json.loads(response)
assert 'properties' in queryables
assert len(queryables['properties']) == 10
# test with provider filtered properties
api_.config['resources']['obs']['providers'][0]['properties'] = ['stn_id']
@@ -573,6 +586,13 @@ def test_get_collection_item(config, api_):
assert 'prev' not in feature['links']
assert 'next' not in feature['links']
req = mock_api_request()
rsp_headers, code, response = get_collection_item(api_, req, 'norway_pop',
'790')
feature = json.loads(response)
assert feature['properties']['name'] == 'Ålesund'
def test_get_collection_item_json_ld(config, api_):
req = mock_api_request({'f': 'jsonld'})
+63 -2
View File
@@ -39,7 +39,7 @@ 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,
describe_processes, execute_process, delete_job, get_job_result, get_jobs
)
from tests.util import mock_api_request
@@ -198,6 +198,12 @@ def test_execute_process(config, api_):
'failedUri': 'https://example.com/failed',
}
}
req_body_8 = {
'inputs': {
'name': 'Test document'
},
'response': 'document'
}
cleanup_jobs = set()
@@ -346,6 +352,14 @@ def test_execute_process(config, api_):
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_8)
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
response = json.loads(response)
assert code == HTTPStatus.OK
assert 'outputs' in response
assert isinstance(response['outputs'], list)
# Cleanup
time.sleep(2) # Allow time for any outstanding async jobs
for _, job_id in cleanup_jobs:
@@ -428,4 +442,51 @@ def test_get_job_result(api_):
)
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == 'application/json'
assert json.loads(response)['value'] == "Hello Sync Test!"
assert json.loads(response)['value'] == 'Hello Sync Test!'
def test_get_jobs_single(api_):
job_id = _execute_a_job(api_)
headers, code, response = get_jobs(api_, mock_api_request(), job_id=job_id)
assert code == HTTPStatus.OK
job = json.loads(response)
assert job['jobID'] == job_id
assert job['status'] == 'successful'
def test_get_jobs_pagination(api_):
# generate test jobs for querying
for _ in range(11):
_execute_a_job(api_)
# test default pagination limit
headers, code, response = get_jobs(api_, mock_api_request(), job_id=None)
job_response = json.loads(response)
assert len(job_response['jobs']) == 10
assert next(
link for link in job_response['links'] if link['rel'] == 'next'
)['href'].endswith('/jobs?offset=10')
headers, code, response = get_jobs(
api_,
mock_api_request({'limit': 10, 'offset': 9}),
job_id=None)
job_response_offset = json.loads(response)
# check to get 1 same job id with an offset of 9 and limit of 10
same_job_ids = {job['jobID'] for job in job_response['jobs']}.intersection(
{job['jobID'] for job in job_response_offset['jobs']}
)
assert len(same_job_ids) == 1
assert next(
link for link in job_response_offset['links'] if link['rel'] == 'prev'
)['href'].endswith('/jobs?offset=0&limit=10')
# test custom limit
headers, code, response = get_jobs(
api_,
mock_api_request({'limit': 20}),
job_id=None)
job_response = json.loads(response)
# might be more than 11 due to test interaction
assert len(job_response['jobs']) > 10
+3 -3
View File
@@ -13,8 +13,8 @@
"pretty_print": true,
"limit": 10,
"map": {
"url": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png",
"attribution": "<a href=\"https://wikimediafoundation.org/wiki/Maps_Terms_of_Use\">Wikimedia maps</a> | Map data &copy; <a href=\"https://openstreetmap.org/copyright\">OpenStreetMap contributors</a>"
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"attribution": "&copy; <a href=\"https://openstreetmap.org/copyright\">OpenStreetMap contributors</a>"
}
},
"logging": {
@@ -69,4 +69,4 @@
}
}
}
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -95,5 +95,5 @@ es.options(request_timeout=90).indices.create(
with open(sys.argv[1], encoding='utf-8') as fh:
d = json.load(fh)
# call generator function to yield features into ES build API
# call generator function to yield features into ES bulk API
helpers.bulk(es, gendata(d))
+102
View File
@@ -0,0 +1,102 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2024 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 json
from pathlib import Path
import sys
from opensearchpy import OpenSearch, helpers
os_ = OpenSearch(['localhost:9209'])
if len(sys.argv) < 3:
print(f'Usage: {sys.argv[0]} <path/to/data.geojson> <id-field>')
sys.exit(1)
index_name = Path(sys.argv[1]).stem.lower()
id_field = sys.argv[2]
if os_.indices.exists(index=index_name):
os_.indices.delete(index=index_name)
# index settings
body = {
'settings': {
'index': {
'number_of_shards': 1,
'number_of_replicas': 0
}
},
'mappings': {
'properties': {
'geometry': {
'type': 'geo_shape'
},
'properties': {
'properties': {
'nameascii': {
'type': 'text',
'fields': {
'raw': {
'type': 'keyword'
}
}
}
}
}
}
}
}
def gendata(data):
"""
Generator function to yield features
"""
for f in data['features']:
try:
f['properties'][id_field] = int(f['properties'][id_field])
except ValueError:
f['properties'][id_field] = f['properties'][id_field]
yield {
"_index": index_name,
"_id": f['properties'][id_field],
"_source": f
}
# create index
os_.indices.create(index=index_name, body=body)
with open(sys.argv[1], encoding='utf-8') as fh:
d = json.load(fh)
# call generator function to yield features into OpenSearch bulk API
helpers.bulk(os_, gendata(d))
+2 -4
View File
@@ -41,10 +41,8 @@ server:
pretty_print: true
limit: 10
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: <a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia
maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap
contributors</a>
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
# manager:
# name: TinyDB
# connection: /tmp/pygeoapi-process-manager.db
+2 -2
View File
@@ -46,8 +46,8 @@ server:
limit: 10
# templates: /path/to/templates
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
manager:
name: TinyDB
connection: /tmp/pygeoapi-test-process-manager.db
+2 -2
View File
@@ -44,8 +44,8 @@ server:
limit: 10
# templates: /path/to/templates
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
manager:
name: TinyDB
connection: /tmp/pygeoapi-test-process-manager.db
+5 -3
View File
@@ -31,7 +31,7 @@ server:
bind:
host: 0.0.0.0
port: ${PYGEOAPI_PORT}
url: http://localhost:5000/
url: ${PYGEOAPI_URL:-http://localhost:5000/}
mimetype: application/json; charset=UTF-8
encoding: utf-8
language: en-US
@@ -41,8 +41,10 @@ server:
limit: 10
# templates: /path/to/templates
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
api_rules: # optional API design rules to which pygeoapi should adhere
url_prefix: ${PYGEOAPI_PREFIX:-}
logging:
level: DEBUG
@@ -44,8 +44,8 @@ server:
limit: 10
# templates: /path/to/templates
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
manager:
name: TinyDB
connection: /tmp/pygeoapi-test-process-manager.db
+2 -2
View File
@@ -41,8 +41,8 @@ server:
limit: 10
# templates: /path/to/templates
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
logging:
level: DEBUG
@@ -44,8 +44,8 @@ server:
limit: 10
# templates: /path/to/templates
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
manager:
name: PostgreSQL
connection:
+40 -2
View File
@@ -44,8 +44,8 @@ server:
limit: 10
# templates: /path/to/templates
map:
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data &copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
manager:
name: TinyDB
connection: /tmp/pygeoapi-test-process-manager.db
@@ -398,6 +398,44 @@ resources:
name: png
mimetype: image/png
canada-metadata:
type: collection
title:
en: Open Canada sample data
fr: Exemple de donn\u00e9es Canada Ouvert
description:
en: Sample metadata records from open.canada.ca
fr: Exemples d'enregistrements de m\u00e9tadonn\u00e9es sur ouvert.canada.ca
keywords:
en:
- canada
- open data
fr:
- canada
- donn\u00e9es ouvertes
links:
- type: text/html
rel: canonical
title: information
href: https://open.canada.ca/en/open-data
hreflang: en-CA
- type: text/html
rel: alternate
title: informations
href: https://ouvert.canada.ca/fr/donnees-ouvertes
hreflang: fr-CA
extents:
spatial:
bbox: [-180,-90,180,90]
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
providers:
- type: record
name: TinyDBCatalogue
data: tests/data/open.canada.ca/sample-records.tinydb
id_field: externalId
time_field: created
title_field: title
hello-world:
type: process
processor:
+11
View File
@@ -54,8 +54,19 @@ def test_config_envvars():
assert isinstance(config, dict)
assert config['server']['bind']['port'] == 5001
assert config['server']['url'] == 'http://localhost:5000/'
assert config['metadata']['identification']['title'] == \
'pygeoapi default instance my title'
assert config['server']['api_rules']['url_prefix'] == ''
os.environ['PYGEOAPI_URL'] = 'https://localhost:5000'
os.environ['PYGEOAPI_PREFIX'] = 'v1'
with open(get_test_file_path('pygeoapi-test-config-envvars.yml')) as fh:
config = yaml_load(fh)
assert config['server']['url'] == 'https://localhost:5000'
assert config['server']['api_rules']['url_prefix'] == 'v1'
os.environ.pop('PYGEOAPI_PORT')
+29 -1
View File
@@ -30,9 +30,11 @@ from typing import Dict
import pytest
from pygeoapi.process.base import UnknownProcessError
from pygeoapi.process.base import UnknownProcessError, JobNotFoundError
from pygeoapi.process.manager.base import get_manager
from .util import get_test_file_path
@pytest.fixture()
def config() -> Dict:
@@ -41,6 +43,7 @@ def config() -> Dict:
'manager': {
'name': 'TinyDB',
'output_dir': '/tmp',
'connection': '/tmp/pygeoapi-process-manager-test.db'
}
},
'resources': {
@@ -71,3 +74,28 @@ def test_get_processor_raises_exception(config):
manager = get_manager(config)
with pytest.raises(expected_exception=UnknownProcessError):
manager.get_processor('foo')
def test_get_job_result_binary(config):
manager = get_manager(config)
nc_file = get_test_file_path("tests/data/coads_sst.nc")
job_id = "15eeae38-608c-11ef-81c8-0242ac130002"
job_metadata = {
"type": "process",
"identifier": job_id,
"process_id": "dummy",
"job_start_datetime": "2024-08-22T12:00:00.000000Z",
"job_end_datetime": "2024-08-22T12:00:01.000000Z",
"status": "successful",
"location": nc_file,
"mimetype": "application/x-netcdf",
"message": "Job complete",
"progress": 100
}
try:
manager.get_job(job_id)
except JobNotFoundError:
manager.add_job(job_metadata)
mimetype, result = manager.get_job_result(job_id)
assert mimetype == "application/x-netcdf"
assert isinstance(result, bytes)
+318
View File
@@ -0,0 +1,318 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2024 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.opensearch_ import OpenSearchProvider
from pygeoapi.models.cql import CQLModel
@pytest.fixture()
def config():
return {
'name': 'OpenSearch',
'type': 'feature',
'data': 'http://localhost:9209/ne_110m_populated_places_simple', # noqa
'id_field': 'geonameid'
}
@pytest.fixture()
def config_ordered_properties():
return {
'name': 'OpenSearch',
'type': 'feature',
'data': 'http://localhost:9209/ne_110m_populated_places_simple', # noqa
'id_field': 'geonameid',
'properties': [
'adm0name',
'adm1name'
]
}
@pytest.fixture()
def config_cql():
return {
'name': 'OpenSearch',
'type': 'feature',
'data': 'http://localhost:9209/nhsl_hazard_threat_all_indicators_s_bc', # noqa
'id_field': 'Sauid'
}
@pytest.fixture()
def between():
between_ = {
"between": {
"value": {"property": "properties.pop_max"},
"lower": 10000,
"upper": 100000
}
}
return CQLModel.parse_obj(between_)
@pytest.fixture()
def between_upper():
between_ = {
"between": {
"value": {"property": "properties.pop_max"},
"upper": 100000
}
}
return CQLModel.parse_obj(between_)
@pytest.fixture()
def between_lower():
between_ = {
"between": {
"value": {"property": "properties.pop_max"},
"lower": 10000
}
}
return CQLModel.parse_obj(between_)
@pytest.fixture()
def eq():
eq_ = {
"eq": [
{"property": "properties.featurecla"},
"Admin-0 capital"
]
}
return CQLModel.parse_obj(eq_)
@pytest.fixture()
def _and(eq, between):
and_ = {
"and": [
{
"between": {
"value": {
"property": "properties.pop_max"
},
"lower": 100000,
"upper": 1000000
}
},
{
"eq": [
{"property": "properties.featurecla"},
"Admin-0 capital"
]
}
]
}
return CQLModel.parse_obj(and_)
@pytest.fixture()
def intersects():
intersects = {"intersects": [
{"property": "geometry"},
{
"type": "Polygon",
"coordinates": [
[
[10.497565, 41.520355],
[10.497565, 43.308645],
[15.111823, 43.308645],
[15.111823, 41.520355],
[10.497565, 41.520355]
]
]
}
]}
return CQLModel.parse_obj(intersects)
def test_query(config):
p = OpenSearchProvider(config)
fields = p.get_fields()
assert len(fields) == 37
assert fields['scalerank']['type'] == 'number'
assert fields['scalerank']['format'] == 'long'
assert fields['changed']['type'] == 'number'
assert fields['changed']['format'] == 'float'
assert fields['ls_name']['type'] == 'string'
results = p.query()
assert len(results['features']) == 10
assert results['numberMatched'] == 242
assert results['numberReturned'] == 10
assert results['features'][0]['id'] == 6691831
assert results['features'][0]['properties']['nameascii'] == 'Vatican City'
results = p.query(properties=[('nameascii', 'Vatican City')])
assert len(results['features']) == 1
assert results['numberMatched'] == 1
assert results['numberReturned'] == 1
results = p.query(limit=1)
assert len(results['features']) == 1
assert results['features'][0]['id'] == 6691831
results = p.query(offset=2, limit=1)
assert len(results['features']) == 1
assert results['features'][0]['id'] == 3042030
results = p.query(sortby=[{'property': 'nameascii', 'order': '+'}])
assert results['features'][0]['properties']['nameascii'] == 'Abidjan'
results = p.query(sortby=[{'property': 'nameascii', 'order': '-'}])
assert results['features'][0]['properties']['nameascii'] == 'Zagreb'
results = p.query(sortby=[{'property': 'scalerank', 'order': '+'}])
assert results['features'][0]['properties']['scalerank'] == 0
results = p.query(sortby=[{'property': 'scalerank', 'order': '-'}])
assert results['features'][0]['properties']['scalerank'] == 8
assert len(results['features'][0]['properties']) == 37
results = p.query(sortby=[{'property': 'nameascii', 'order': '-'}],
limit=10001)
assert results['features'][0]['properties']['nameascii'] == 'Zagreb'
assert len(results['features']) == 242
assert results['numberMatched'] == 242
assert results['numberReturned'] == 242
results = p.query(select_properties=['nameascii'])
assert len(results['features'][0]['properties']) == 1
results = p.query(select_properties=['nameascii', 'scalerank'])
assert len(results['features'][0]['properties']) == 2
results = p.query(skip_geometry=True)
assert results['features'][0]['geometry'] is None
config['properties'] = ['nameascii']
p = OpenSearchProvider(config)
results = p.query()
assert len(results['features'][0]['properties']) == 1
def test_query_ordered_properties(config_ordered_properties):
p = OpenSearchProvider(config_ordered_properties)
result = p.query()
feature_properties = list(result['features'][0]['properties'].keys())
assert feature_properties == ['adm0name', 'adm1name']
def test_get(config):
p = OpenSearchProvider(config)
result = p.get('3413829')
assert result['id'] == 3413829
assert result['properties']['ls_name'] == 'Reykjavik'
def test_get_not_existing_item_raise_exception(config):
"""Testing query for a not existing object"""
p = OpenSearchProvider(config)
with pytest.raises(ProviderItemNotFoundError):
p.get('404')
def test_post_cql_json_between_query(config, between):
"""Testing cql json query for a between object"""
p = OpenSearchProvider(config)
results = p.query(limit=100, filterq=between)
assert len(results['features']) == 23
assert results['numberMatched'] == 23
assert results['numberReturned'] == 23
for item in results['features']:
assert 10000 <= item["properties"]["pop_max"] <= 100000
def test_post_cql_json_between_lte_query(config, between_upper):
"""Testing cql json query for a between object"""
p = OpenSearchProvider(config)
results = p.query(limit=100, filterq=between_upper)
assert len(results['features']) == 28
assert results['numberMatched'] == 28
assert results['numberReturned'] == 28
for item in results['features']:
assert item["properties"]["pop_max"] <= 100000
def test_post_cql_json_between_gte_query(config, between_lower):
"""Testing cql json query for a between object"""
p = OpenSearchProvider(config)
results = p.query(limit=500, filterq=between_lower)
assert len(results['features']) == 237
assert results['numberMatched'] == 237
assert results['numberReturned'] == 237
for item in results['features']:
assert 10000 <= item["properties"]["pop_max"]
def test_post_cql_json_eq_query(config, eq):
"""Testing cql json query for an eq object"""
p = OpenSearchProvider(config)
results = p.query(limit=500, filterq=eq)
assert len(results['features']) == 235
def test_post_cql_json_and_query(config, _and):
"""Testing cql json query for an and object"""
p = OpenSearchProvider(config)
results = p.query(limit=1000, filterq=_and)
assert len(results['features']) == 77
def test_post_cql_json_intersects_query(config, intersects):
"""Testing cql json query for an intersects object"""
p = OpenSearchProvider(config)
results = p.query(limit=100, filterq=intersects)
assert len(results['features']) == 2
+12
View File
@@ -62,8 +62,11 @@ class SqlManipulator:
q,
language,
filterq,
extra_params
):
sql = "ID = 10 AND :foo != :bar"
if extra_params.get("custom-auth") == "forbidden":
sql = f"{sql} AND 'auth' = 'you are not allowed'"
if sql_query.find(" WHERE ") == -1:
sql_query = sql_query.replace("#WHERE#", f" WHERE {sql}")
@@ -632,6 +635,15 @@ def test_query_mandatory_properties_must_be_specified(config):
p.query(properties=[("id", "123")])
def test_extra_params_are_passed_to_sql_manipulator(config_manipulator):
extra_params = [("custom-auth", "forbidden")]
p = OracleProvider(config_manipulator)
response = p.query(properties=extra_params)
assert not response['features']
@pytest.fixture()
def database_connection_pool(config_db_conn):
os.environ["ORACLE_POOL_MIN"] = "2" # noqa: F841
+211
View File
@@ -0,0 +1,211 @@
# =================================================================
#
# Authors: Leo Ghignone <leo.ghignone@gmail.com>
#
# Copyright (c) 2024 Leo Ghignone
#
# 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.parquet import ParquetProvider
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')
@pytest.fixture()
def config_parquet():
return {
'name': 'Parquet',
'type': 'feature',
'data': {
'source_type': 'Parquet',
'source': path,
},
'id_field': 'id',
'time_field': 'time',
'x_field': 'lon',
'y_field': 'lat',
}
@pytest.fixture()
def config_parquet_nogeom_notime():
return {
'name': 'ParquetNoGeomNoTime',
'type': 'feature',
'data': {
'source_type': 'Parquet',
'source': path_nogeom,
},
'id_field': 'id'
}
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
+9
View File
@@ -103,6 +103,15 @@ def _create_delete_request(job_id, locales):
return APIRequest.with_data(req, locales)
def test_api_connection_rfc3986(config, openapi):
connection = config['server']['manager']['connection']
connection_string = (
f"postgresql://{connection['user']}:{connection['password']}"
f"@{connection['host']}:{connection['port']}/{connection['database']}")
config['server']['manager']['connection'] = connection_string
API(config, openapi)
def test_job_sync_hello_world(api_, config):
"""
Create a new job for hello-world,
+2 -2
View File
@@ -556,7 +556,7 @@ def test_get_collection_items_postgresql_cql_bad_cql(pg_api_, bad_cql):
assert code == HTTPStatus.BAD_REQUEST
error_response = json.loads(response)
assert error_response['code'] == 'InvalidParameterValue'
assert error_response['description'] == f'Bad CQL string : {bad_cql}'
assert error_response['description'] == 'Bad CQL text'
def test_post_collection_items_postgresql_cql(pg_api_):
@@ -642,7 +642,7 @@ def test_post_collection_items_postgresql_cql_bad_cql(pg_api_, bad_cql):
assert code == HTTPStatus.BAD_REQUEST
error_response = json.loads(response)
assert error_response['code'] == 'InvalidParameterValue'
assert error_response['description'].startswith('Bad CQL string')
assert error_response['description'] == 'Bad CQL text'
def test_get_collection_items_postgresql_crs(pg_api_):
+1 -1
View File
@@ -172,7 +172,7 @@ def test_path_basename():
def test_filter_dict_by_key_value(config):
collections = util.filter_dict_by_key_value(config['resources'],
'type', 'collection')
assert len(collections) == 9
assert len(collections) == 10
notfound = util.filter_dict_by_key_value(config['resources'],
'type', 'foo')
+26
View File
@@ -30,6 +30,7 @@
from numpy import float64, int64
import pytest
import xarray as xr
from pygeoapi.provider.xarray_ import XarrayProvider
from pygeoapi.util import json_serial
@@ -53,6 +54,20 @@ def config():
}
@pytest.fixture()
def config_no_time(tmp_path):
ds = xr.open_zarr(path)
ds = ds.sel(time=ds.time[0])
ds = ds.drop_vars('time')
ds.to_zarr(tmp_path / 'no_time.zarr')
return {
'name': 'zarr',
'type': 'coverage',
'data': str(tmp_path / 'no_time.zarr'),
'format': {'name': 'zarr', 'mimetype': 'application/zip'},
}
def test_provider(config):
p = XarrayProvider(config)
@@ -85,3 +100,14 @@ def test_numpy_json_serial():
d = float64(500.00000005)
assert json_serial(d) == 500.00000005
def test_no_time(config_no_time):
p = XarrayProvider(config_no_time)
assert len(p.fields) == 4
assert p.axes == ['lon', 'lat']
coverage = p.query(format='json')
assert sorted(coverage['domain']['axes'].keys()) == ['x', 'y']