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
141 changed files with 4940 additions and 6936 deletions
-551
View File
@@ -1,551 +0,0 @@
components:
parameters:
bbox:
description: Only features that have a geometry that intersects the bounding
box are selected.The bounding box is provided as four or six numbers, depending
on whether the coordinate reference system includes a vertical axis (height
or depth).
explode: false
in: query
name: bbox
required: false
schema:
items:
type: number
maxItems: 6
minItems: 4
type: array
style: form
bbox-crs:
description: Indicates the coordinate reference system for the given bbox coordinates.
explode: false
in: query
name: bbox-crs
required: false
schema:
format: uri
type: string
style: form
bbox-crs-epsg:
description: Indicates the EPSG for the given bbox coordinates.
explode: false
in: query
name: bbox-crs
required: false
schema:
default: 4326
type: integer
style: form
crs:
description: Indicates the coordinate reference system for the results.
explode: false
in: query
name: crs
required: false
schema:
format: uri
type: string
style: form
f:
description: The optional f parameter indicates the output format which the
server shall provide as part of the response document. The default format
is GeoJSON.
explode: false
in: query
name: f
required: false
schema:
default: json
enum:
- json
- html
- jsonld
type: string
style: form
lang:
description: The optional lang parameter instructs the server return a response
in a certain language, if supported. If the language is not among the available
values, the Accept-Language header language will be used if it is supported.
If the header is missing, the default server language is used. Note that providers
may only support a single language (or often no language at all), that can
be different from the server language. Language strings can be written in
a complex (e.g. "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7"), simple (e.g. "de")
or locale-like (e.g. "de-CH" or "fr_BE") fashion.
in: query
name: lang
required: false
schema:
default: en-US
enum:
- en-US
- fr-CA
type: string
offset:
description: The optional offset parameter indicates the index within the result
set from which the server shall begin presenting results in the response document. The
first element has an index of 0 (default).
explode: false
in: query
name: offset
required: false
schema:
default: 0
minimum: 0
type: integer
style: form
resourceId:
description: Configuration resource identifier
in: path
name: resourceId
required: true
schema:
type: string
skipGeometry:
description: This option can be used to skip response geometries for each feature.
explode: false
in: query
name: skipGeometry
required: false
schema:
default: false
type: boolean
style: form
vendorSpecificParameters:
description: Additional "free-form" parameters that are not explicitly defined
in: query
name: vendorSpecificParameters
schema:
additionalProperties: true
type: object
style: form
responses:
'200':
description: successful operation
'204':
description: no content
Queryables:
content:
application/json:
schema:
$ref: '#/components/schemas/queryables'
description: successful queryables operation
Tiles:
content:
application/json:
schema:
$ref: '#/components/schemas/tiles'
description: Retrieves the tiles description for this collection
default:
content:
application/json:
schema:
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/schemas/exception.yaml
description: Unexpected error
schemas:
queryable:
properties:
description:
description: a human-readable narrative describing the queryable
type: string
language:
default:
- en
description: the language used for the title and description
type: string
queryable:
description: the token that may be used in a CQL predicate
type: string
title:
description: a human readable title for the queryable
type: string
type:
description: the data type of the queryable
type: string
type-ref:
description: a reference to the formal definition of the type
format: url
type: string
required:
- queryable
- type
type: object
queryables:
properties:
queryables:
items:
$ref: '#/components/schemas/queryable'
type: array
required:
- queryables
type: object
tilematrixsetlink:
properties:
tileMatrixSet:
type: string
tileMatrixSetURI:
type: string
required:
- tileMatrixSet
type: object
tiles:
properties:
links:
items:
$ref: https://schemas.opengis.net/ogcapi/tiles/part1/1.0/openapi/ogcapi-tiles-1.yaml#/components/schemas/link
type: array
tileMatrixSetLinks:
items:
$ref: '#/components/schemas/tilematrixsetlink'
type: array
required:
- tileMatrixSetLinks
- links
type: object
info:
contact:
email: you@example.org
name: Organization Name
url: https://pygeoapi.io
description: pygeoapi provides an API to geospatial data
license:
name: CC-BY 4.0 license
url: https://creativecommons.org/licenses/by/4.0/
termsOfService: https://creativecommons.org/licenses/by/4.0/
title: Speckle pygeoapi instance
version: 0.18.dev0
x-keywords:
- geospatial
- data
- api
openapi: 3.0.2
paths:
/:
get:
description: Landing page
operationId: getLandingPage
parameters:
- $ref: '#/components/parameters/f'
- $ref: '#/components/parameters/lang'
responses:
'200':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage
'400':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
'500':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
summary: Landing page
tags:
- server
/collections:
get:
description: Collections
operationId: getCollections
parameters:
- $ref: '#/components/parameters/f'
- $ref: '#/components/parameters/lang'
responses:
'200':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage
'400':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
'500':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
summary: Collections
tags:
- server
/collections/speckle:
get:
description: Latest version of Speckle Model data
operationId: describeSpeckleCollection
parameters:
- $ref: '#/components/parameters/f'
- $ref: '#/components/parameters/lang'
responses:
'200':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Collection
'400':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
'404':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound
'500':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
summary: Get Speckle data metadata
tags:
- speckle
/collections/speckle/items:
get:
description: Latest version of Speckle Model data
operationId: getSpeckleFeatures
parameters:
- $ref: '#/components/parameters/f'
- $ref: '#/components/parameters/lang'
- $ref: '#/components/parameters/bbox'
- $ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/limit
- $ref: '#/components/parameters/crs'
- $ref: '#/components/parameters/bbox-crs'
- description: The properties that should be included for each feature. The
parameter value is a comma-separated list of property names.
explode: false
in: query
name: properties
required: false
schema:
items:
enum: []
type: string
type: array
style: form
- $ref: '#/components/parameters/vendorSpecificParameters'
- $ref: '#/components/parameters/skipGeometry'
- $ref: https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi/parameters/sortby.yaml
- $ref: '#/components/parameters/offset'
responses:
'200':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Features
'400':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
'404':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound
'500':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
summary: Get Speckle data items
tags:
- speckle
options:
description: Latest version of Speckle Model data
operationId: optionsSpeckleFeatures
responses:
'200':
description: options response
summary: Options for Speckle data items
tags:
- speckle
/collections/speckle/items/{featureId}:
get:
description: Latest version of Speckle Model data
operationId: getSpeckleFeature
parameters:
- $ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId
- $ref: '#/components/parameters/crs'
- $ref: '#/components/parameters/f'
- $ref: '#/components/parameters/lang'
responses:
'200':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/Feature
'400':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
'404':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/NotFound
'500':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
summary: Get Speckle data item by id
tags:
- speckle
options:
description: Latest version of Speckle Model data
operationId: optionsSpeckleFeature
parameters:
- $ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/parameters/featureId
responses:
'200':
description: options response
summary: Options for Speckle data item by id
tags:
- speckle
/conformance:
get:
description: API conformance definition
operationId: getConformanceDeclaration
parameters:
- $ref: '#/components/parameters/f'
- $ref: '#/components/parameters/lang'
responses:
'200':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/LandingPage
'400':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
'500':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/ServerError
summary: API conformance definition
tags:
- server
/jobs:
get:
description: Retrieve a list of jobs
operationId: getJobs
responses:
'200':
$ref: '#/components/responses/200'
'404':
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
default:
$ref: '#/components/responses/default'
summary: Retrieve jobs list
tags:
- jobs
/jobs/{jobId}:
delete:
description: Cancel / delete job
operationId: deleteJob
parameters:
- &id001
description: job identifier
in: path
name: jobId
required: true
schema:
type: string
responses:
'204':
$ref: '#/components/responses/204'
'404':
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
default:
$ref: '#/components/responses/default'
summary: Cancel / delete job
tags:
- jobs
get:
description: Retrieve job details
operationId: getJob
parameters:
- *id001
- $ref: '#/components/parameters/f'
responses:
'200':
$ref: '#/components/responses/200'
'404':
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
default:
$ref: '#/components/responses/default'
summary: Retrieve job details
tags:
- jobs
/jobs/{jobId}/results:
get:
description: Retrieve job results
operationId: getJobResults
parameters:
- *id001
- $ref: '#/components/parameters/f'
responses:
'200':
$ref: '#/components/responses/200'
'404':
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
default:
$ref: '#/components/responses/default'
summary: Retrieve job results
tags:
- jobs
/openapi:
get:
description: This document
operationId: getOpenapi
parameters:
- $ref: '#/components/parameters/f'
- $ref: '#/components/parameters/lang'
- description: UI to render the OpenAPI document
explode: false
in: query
name: ui
required: false
schema:
default: swagger
enum:
- swagger
- redoc
type: string
style: form
responses:
'200':
$ref: '#/components/responses/200'
'400':
$ref: https://schemas.opengis.net\ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml#/components/responses/InvalidParameter
default:
$ref: '#/components/responses/default'
summary: This document
tags:
- server
/processes:
get:
description: Processes
operationId: getProcesses
parameters:
- $ref: '#/components/parameters/f'
responses:
'200':
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ProcessList.yaml
default:
$ref: '#/components/responses/default'
summary: Processes
tags:
- server
/processes/hello-world:
get:
description: An example process that takes a name as input, and echoes it back
as output. Intended to demonstrate a simple process with a single literal
input.
operationId: describeHello-worldProcess
parameters:
- $ref: '#/components/parameters/f'
responses:
'200':
$ref: '#/components/responses/200'
default:
$ref: '#/components/responses/default'
summary: Get process metadata
tags:
- hello-world
/processes/hello-world/execution:
post:
description: An example process that takes a name as input, and echoes it back
as output. Intended to demonstrate a simple process with a single literal
input.
operationId: executeHello-worldJob
requestBody:
content:
application/json:
example:
inputs:
message: An optional message.
name: World
schema:
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/schemas/execute.yaml
description: Mandatory execute request JSON
required: true
responses:
'200':
$ref: '#/components/responses/200'
'201':
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ExecuteAsync.yaml
'404':
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/NotFound.yaml
'500':
$ref: https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/responses/ServerError.yaml
default:
$ref: '#/components/responses/default'
summary: Process Hello World execution
tags:
- hello-world
servers:
- description: pygeoapi provides an API to geospatial data
url: https://geo.speckle.systems
tags:
- description: pygeoapi provides an API to geospatial data
externalDocs:
description: information
url: https://example.org
name: server
- description: Latest version of Speckle Model data
name: speckle
- name: coverages
- name: edr
- name: records
- name: features
- name: maps
- name: processes
- name: jobs
- name: tiles
- name: stac
+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 -3
View File
@@ -1,6 +1,3 @@
access_log
error_log*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -107,6 +104,7 @@ ENV/
*.openapi.yml
# development setup examples
example-config.yml
example-openapi.yml
# misc
-3
View File
@@ -1,3 +0,0 @@
geo.speckle.systems {
reverse_proxy localhost:8000
}
+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}"
-171
View File
@@ -8,174 +8,3 @@
[pygeoapi](https://pygeoapi.io) is a Python server implementation of the [OGC API](https://ogcapi.ogc.org) suite of standards. The project emerged as part of the next generation OGC API efforts in 2018 and provides the capability for organizations to deploy a RESTful OGC API endpoint using OpenAPI, GeoJSON, and HTML. pygeoapi is [open source](https://opensource.org/) and released under an [MIT license](https://github.com/geopython/pygeoapi/blob/master/LICENSE.md).
Please read the docs at [https://docs.pygeoapi.io](https://docs.pygeoapi.io) for more information.
# Speckle implementation of pygeoapi
## How to use Speckle data through OGC API Features
This is the test deployment of the OGC API server for public Speckle projects. It allows you to share your Speckle model as geospatial data in the format of OGC API Features / Web Feature Service, so it can be natively added to a QGIS, ArcGIS or Civil3D project, or embedded into a web map using Leaflet, OpenLayers or other libraries.
Demo page: https://geo.speckle.systems/
### How to construct a valid URL to get georeferenced Speckle layer
URL should start with 'https://geo.speckle.systems/?' followed by required and optional parameters. Parameters should be separated with '&' symbol. You can use the generated link to access OGC API dataset in your preferred software, as well as explore the data in the browser and share with others.
Use the following URL parameters to construct a link that provides Speckle data with your preferred settings::
- speckleUrl (text), required, should contain path to a specific Model in Speckle Project, e.g. 'https://app.speckle.systems/projects/55a29f3e9d/models/2d497a381d'
- dataType (text), optional, choose from: points, lines, polygons or projectcomments
- limit (positive integer), recommended, as some applications might apply their custom feature limit
- preserveAttributes (string), optional, choose from: true, false. If not set, meshes will be split into separate polygons for better display quality.
- crsAuthid (text), an authority string e.g. 'epsg:4326'. If set, LAT, LON and NORTHDEGREES arguments will be ignored.
- lat (number), in range -90 to 90
- lon (number), in range -180 to 180
- northDegrees (number), in range -180 to 180
If GIS-originated Speckle model is loaded, no location arguments are needed.
Example: [https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828](https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828)
### Troubleshooting
List of possible issues you can experience and solutions to them:
- Page or Map stays blank and Developer Tools Console shows "net::ERR_QUIC_PROTOCOL_ERROR 200 (OK)"
Solution: Try reloading the page. Otherwise, if in Google Chrome, navigate to chrome://flags/#enable-quic and change Experimental QUIC Protocol dropdown to Disabled.
- Model seems to be loaded incomplete
Solution: Check the message "feature count limited to ..." next to the Model name on the top of the page. If the message is present, try increasing the feature limit using "&limit=10000" URL parameter
- Attribute table doesn't have original feature attributes and properties
Solution: Enable the URL parameter "&preserveAttributes=true". It is disabled by default due to the faulty display of the 3-dimentional multiPolygons overlapping themselves in 2d space, when viweving in the browser on 2d map. Enabling this parameter might make the multipolygons appear "transparent" due to self-overlap.
Report any other issues here or on our [Community Forum](https://speckle.community/).
## Add Speckle Feature Layers to web-based maps and desktop apps
### Add Speckle layer in Javascript
Javascript-based mapping libraries can load speckle data as JSON using the following function:
```javascript
async function loadSpeckleData() => {
var speckle_model_url = 'https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=polygons';
const speckle_data = await fetch(speckle_model_url, {
headers: {'Accept': 'application/geo+json'}
}).then(response => response.json());
}
```
Then you can add it to the base map (e.g. using Leaflet and OpenStreetMap basemap tiles). The following example assumes an html div element with id="items-map":
```html
<script>
var map = L.map('items-map').setView([ 45 , -75 ], 5);
map.addLayer(new L.TileLayer(
'https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 22,
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a> &copy; Data: <a href="https://speckle.systems/">Speckle Systems</a>'
}
));
loadSpeckleData();
async function loadSpeckleData() => {
var speckle_model_url = 'https://geo.speckle.systems/speckle/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=polygons';
const speckle_data = await fetch(speckle_model_url, {
headers: {'Accept': 'application/geo+json'}
}).then(response => response.json());
speckle_layer = L.geoJSON(speckle_data, {
onEachFeature: function (feature, layer) {
layer.setStyle({
fillColor: feature.displayProperties['color'],
color: myFillColor,
fillOpacity: 0.8,
weight: feature.displayProperties['lineWidth'],
radius: feature.displayProperties['radius']
});
}
});
speckle_layer.addTo(map);
map.fitBounds(speckle_layer.getBounds())
};
</script>
```
Check out 'speckle_demos' folder for more Leaflet and OpenLayers implementation.
### Add Speckle WFS layer in QGIS
1. Add new WFS Layer
![image](https://github.com/user-attachments/assets/ea168853-dc97-43bf-b9f2-4d0244addb01)
2. Create New connection, specify the name and URL with mandatory "speckleUrl" parameter pointing to the Speckle Model. Preferably add the URL parameter with the custom feature limit (e.g. '&limit=10000'). Then click Detect, and the WFS Version should display "OGC API Features". Click OK.
![image](https://github.com/user-attachments/assets/8bf9f164-bdb1-455e-8298-f0c1d5dd324d)
3. Connect, select the dataset "Speckle data" and click "Add".
![image](https://github.com/user-attachments/assets/73c97729-f3b3-4192-a4cf-667ba147fc6f)
4. Loading of the data might take a minute, then you will be able to Zoom to layer and check the Attribute table. Done!
![image](https://github.com/user-attachments/assets/0708c64e-b063-4f55-b9f4-e791fc32da95)
### Add Speckle OGC API layer in ArcGIS
1. Add new OGC API Connection
![image](https://github.com/user-attachments/assets/8ae33828-93de-428d-81f8-c6115bf05d72)
2. Add URL, preferably add the URL parameter with the custom feature limit (e.g. '&limit=10000')
![image](https://github.com/user-attachments/assets/37f52ad5-b312-4292-b760-fb8c4091a45f)
3. Find Speckle Pygeoapi server in Catalog, add SpeckleData layer to Map
![image](https://github.com/user-attachments/assets/9e8461c6-a3f3-4d4c-8777-1e17f2a2f528)
### Add Speckle WFS layer in Civil3D
TODO
## Local dev
First launch:
```python
python -m venv pygeoapi_venv
cd pygeoapi_venv
Scripts\activate
cd pygeoapi
git clone https://github.com/specklesystems/pygeoapi
git checkout dev
pip install --upgrade pip
pip install -r requirements.txt
python -m pip install --upgrade specklepy==2.19.6
python -m pip install pydantic==1.10.17
python pygeoapi\provider\speckle_utils\patch\patch_specklepy.py
python setup.py install
set PYGEOAPI_CONFIG=example-config.yml // export
set PYGEOAPI_OPENAPI=example-config.yml // export
set MAPTILER_KEY_LOCAL=your_api_key // export, (if available)
pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
pygeoapi serve
```
Repeated launch:
```python
cd pygeoapi_venv
Scripts\activate
cd pygeoapi
python setup.py install
set PYGEOAPI_CONFIG=example-config.yml
set PYGEOAPI_OPENAPI=example-config.yml
set MAPTILER_KEY_LOCAL=your_api_key
pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
pygeoapi serve
```
+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)
-131
View File
@@ -1,131 +0,0 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2020 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# =================================================================
server:
bind:
host: 0.0.0.0
port: 8000
url: https://geo.speckle.systems
mimetype: application/json; charset=UTF-8
encoding: utf-8
gzip: false
languages:
# First language is the default language
- en-US
- fr-CA
# cors: true
pretty_print: true
limit: 10000
# templates:
# path: /path/to/Jinja2/templates
# static: /path/to/static/folder # css/js/img
map:
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
# output_dir: /tmp/
# ogc_schemas_location: /opt/schemas.opengis.net
logging:
level: ERROR
#logfile: /tmp/pygeoapi.log
metadata:
identification:
title:
en: Speckle pygeoapi instance
description:
en: pygeoapi provides an API to geospatial data
keywords:
en:
- geospatial
- data
- api
keywords_type: theme
terms_of_service: https://creativecommons.org/licenses/by/4.0/
url: https://example.org
license:
name: CC-BY 4.0 license
url: https://creativecommons.org/licenses/by/4.0/
provider:
name: Organization Name
url: https://pygeoapi.io
contact:
name: Lastname, Firstname
position: Position Title
address: Mailing Address
city: City
stateorprovince: Administrative Area
postalcode: Zip or Postal Code
country: Country
phone: +xx-xxx-xxx-xxxx
fax: +xx-xxx-xxx-xxxx
email: you@example.org
url: https://app.speckle.systems/
hours: Mo-Fr 08:00-17:00
instructions: During hours of service. Off on weekends.
role: pointOfContact
resources:
speckle:
type: collection
title:
en: Speckle data
description:
en: Latest version of Speckle Model data
keywords:
en:
- 3d data
- speckle
links:
- type: text/html
rel: canonical
title: information
href: https://speckle.systems
hreflang: en-US
extents:
spatial:
bbox: [-180,-90,180,90]
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 #https://www.opengis.net/def/crs/OGC/1.3/AUTO42001?lat=0&lon=0 # projected CRS
temporal:
begin: 2011-11-11T11:11:11Z
end: null # or empty (either means open ended)
providers:
- type: feature
name: Speckle
data: # some data
id_field: id
title_field: id
hello-world:
type: process
processor:
name: HelloWorld
+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:
+14 -2
View File
@@ -75,11 +75,12 @@ LOGGER = logging.getLogger(__name__)
#: Return headers for requests (e.g:X-Powered-By)
HEADERS = {
'Content-Type': 'application/json',
# 'X-Powered-By': f'pygeoapi {__version__}'
'X-Powered-By': f'pygeoapi {__version__}'
}
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 -50
View File
@@ -121,24 +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'))
p._load()
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',
@@ -183,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',
@@ -305,18 +304,7 @@ def get_collection_items(
provider_type = 'feature'
provider_def = get_provider_by_type(
collections[dataset]['providers'], provider_type)
# clear data if no URL params
load_data = False
for item in request.params:
if item.lower() == 'speckleurl' and len(request.params[item])>40 and ('speckleurl=' + request.params[item]) in provider_def['data'].lower():
load_data = True
break
if load_data is False:
provider_def['data'] = ""
p = load_plugin('provider', provider_def)
except ProviderTypeError:
try:
provider_type = 'record'
@@ -392,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')
@@ -456,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)
@@ -543,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(
{
@@ -566,14 +565,7 @@ def get_collection_items(
content['timeStamp'] = datetime.utcnow().strftime(
'%Y-%m-%dT%H:%M:%S.%fZ')
# Save passed parameters
url_saved_as_data = collections[dataset]['providers'][0]['data']
url_props = []
if isinstance(url_saved_as_data, str):
url_props = url_saved_as_data.lower().split("&")
# Set response language to requested provider locale
# (if it supports language) and/or otherwise the requested pygeoapi
# locale (or fallback default locale)
@@ -855,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)
@@ -891,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
+12 -65
View File
@@ -29,7 +29,6 @@
#
# =================================================================
import copy
import click
import json
from jsonschema import validate as jsonschema_validate
@@ -37,15 +36,12 @@ import logging
import os
import yaml
from flask import Request
from pygeoapi.util import to_json, yaml_load, THISDIR
LOGGER = logging.getLogger(__name__)
CONFIG = {}
def get_config(raw: bool = False, request: Request = None) -> dict:
def get_config(raw: bool = False) -> dict:
"""
Get pygeoapi configurations
@@ -54,71 +50,22 @@ def get_config(raw: bool = False, request: Request = None) -> dict:
:returns: `dict` of pygeoapi configuration
"""
if not os.environ.get("PYGEOAPI_CONFIG"):
raise RuntimeError("PYGEOAPI_CONFIG environment variable not set")
map_api_key_local = os.environ.get("MAPTILER_KEY_LOCAL")
map_api_key_speckle = os.environ.get("MAPTILER_KEY_SPECKLE")
global CONFIG
if not os.environ.get('PYGEOAPI_CONFIG'):
raise RuntimeError('PYGEOAPI_CONFIG environment variable not set')
config_file = os.environ.get("PYGEOAPI_CONFIG")
with open(config_file, encoding="utf8") as fh:
with open(os.environ.get('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
if raw:
config_yaml = yaml.safe_load(fh)
CONFIG = yaml.safe_load(fh)
else:
config_yaml = yaml_load(fh)
# assign valid dictionnaries to Speckle resources
speckle_collection_received = copy.deepcopy(config_yaml["resources"]["speckle"])
# for the first time only: assign YAML value to CONFIG. Otherwise, don't modify
if CONFIG == {}:
CONFIG = config_yaml
url_valid = False
speckle_url = ""
if request is not None:
url = request.url.split("?")[-1]
if "projects" in url and "models" in url:
url_valid = True
speckle_url = url
# if a key found, replace basemap URL to MapTiler
# make sure to restrict the usage for the key
if ".speckle.systems" in request.url.split("?")[0] and map_api_key_speckle and len(map_api_key_speckle)>=20:
CONFIG["server"]["map"]["url"] = r'https://api.maptiler.com/maps/dataviz/{z}/{x}/{y}.png' + f'?key={map_api_key_speckle}'
CONFIG["server"]["map"]["key"] = f'{map_api_key_speckle}'
CONFIG["server"]["map"]["attribution"] = r'<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>'
elif map_api_key_local and len(map_api_key_local)>=20:
CONFIG["server"]["map"]["url"] = r'https://api.maptiler.com/maps/dataviz/{z}/{x}/{y}.png' + f'?key={map_api_key_local}'
CONFIG["server"]["map"]["key"] = f'{map_api_key_local}'
CONFIG["server"]["map"]["attribution"] = r'<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>'
else:
CONFIG["server"]["map"]["url"] = r'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
CONFIG["server"]["map"]["key"] = ""
CONFIG["server"]["map"]["attribution"] = r'&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
# once Speckle URL is found, set it as a provider
if url_valid:
# speckle_collection_pts["title"]["en"] = "Some Points"
# assign speckle url and get the data
speckle_collection_received["providers"][0]["data"] = speckle_url
CONFIG["resources"] = {
"speckle": speckle_collection_received,
}
CONFIG = yaml_load(fh)
return CONFIG
def load_schema() -> dict:
"""Reads the JSON schema YAML file."""
""" Reads the JSON schema YAML file. """
schema_file = THISDIR / "schemas" / "config" / "pygeoapi-config-0.x.yml"
schema_file = THISDIR / 'schemas' / 'config' / 'pygeoapi-config-0.x.yml'
with schema_file.open() as fh2:
return yaml_load(fh2)
@@ -146,18 +93,18 @@ def config():
@click.command()
@click.pass_context
@click.option("--config", "-c", "config_file", help="configuration file")
@click.option('--config', '-c', 'config_file', help='configuration file')
def validate(ctx, config_file):
"""Validate configuration"""
if config_file is None:
raise click.ClickException("--config/-c required")
raise click.ClickException('--config/-c required')
with open(config_file) as ff:
click.echo(f"Validating {config_file}")
click.echo(f'Validating {config_file}')
instance = yaml_load(ff)
validate_config(instance)
click.echo("Valid configuration")
click.echo('Valid configuration')
config.add_command(validate)
+12 -150
View File
@@ -34,13 +34,8 @@ import os
from typing import Union
import click
from datetime import datetime, timezone
from flask import (Flask, Blueprint, make_response, request,
send_from_directory, Response, Request, stream_with_context)
from http import HTTPStatus
import json
from urllib.request import urlopen
send_from_directory, Response, Request)
from pygeoapi.api import API, APIRequest, apply_gzip
import pygeoapi.api.coverages as coverages_api
@@ -50,10 +45,9 @@ import pygeoapi.api.maps as maps_api
import pygeoapi.api.processes as processes_api
import pygeoapi.api.stac as stac_api
import pygeoapi.api.tiles as tiles_api
from pygeoapi.provider.speckle_utils.legal import COUNTRY_CODES
from pygeoapi.openapi import load_openapi_document
from pygeoapi.config import get_config
from pygeoapi.util import get_mimetype, get_api_rules, render_j2_template
from pygeoapi.util import get_mimetype, get_api_rules
CONFIG = get_config()
@@ -83,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')
@@ -156,9 +150,6 @@ def execute_from_flask(api_function, request: Request, *args,
:returns: A Response instance
"""
CONFIG = get_config(request=request)
api_ = API(CONFIG, OPENAPI)
api_request = APIRequest.from_flask(request, api_.locales)
content: Union[str, bytes]
@@ -173,62 +164,6 @@ def execute_from_flask(api_function, request: Request, *args,
return get_response((headers, status, content))
def handle_client(url_route: str):
# if called fromm the browser, Exceptions from this function will result in infinite load
agent = request.headers.get('User-Agent')
if request.environ.get('HTTP_X_FORWARDED_FOR') is None:
ip_address = request.environ['REMOTE_ADDR']
else:
ip_address = request.environ['HTTP_X_FORWARDED_FOR']
if agent is not None and "(https://www.checklyhq.com)" not in agent:
print(f"_______________________{datetime.now().astimezone(timezone.utc)} _URL access")
print(f"_Agent {url_route}: {agent}")
print(f"_IP Address: {ip_address}")
print(f"_Request URL: {request.url}")
request.url += f"&userAgent={agent}"
# by Agent:
if agent is not None and ("YaBrowser/" in agent or "yandex" in agent.lower()):
raise ValueError("Your browser is not supported.")
# by IP:
try:
url = 'https://ipinfo.io/' + ip_address + '/json'
res = urlopen(url)
data = json.load(res)
if isinstance(data, dict) and isinstance(data["country"], str):
if data["country"].lower() in COUNTRY_CODES:
raise PermissionError("Review Speckle Terms and Conditions")
else:
print(f"Error validating client: DATA {data}")
except Exception as e:
print(f"Error validating client from start: {e}")
def generate():
collection_id = "speckle"
yield loading_screen().data
handle_client("/")
CONFIG = get_config(request=request)
api_ = API(CONFIG, OPENAPI)
try:
browser_response = execute_from_flask(itemtypes_api.get_collection_items,
request, collection_id,
skip_valid_check=True)
yield browser_response.data
except PermissionError as ex:
raise ex
except Exception as ex:
yield error_screen(ex).data
@BLUEPRINT.route('/')
def landing_page():
"""
@@ -236,45 +171,8 @@ def landing_page():
:returns: HTTP response
"""
agent = request.headers.get('User-Agent')
browser_agent = False
browser_list = ["Chrome", "Safari", "Firefox", "Edg/", "Trident/"]
for br in browser_list:
if agent is not None and br in agent:
browser_agent = True
break
# if requested from the browser, return this, otherwise ignore IF statement
if request.method == 'GET' and browser_agent: # list items
return Response(stream_with_context(generate()))
# for non-browsers
handle_client("/")
CONFIG = get_config(request=request)
api_ = API(CONFIG, OPENAPI)
return get_response(api_.landing_page(request))
def error_screen(ex: Exception):
"""
Loading empty page
:returns: HTTP response
"""
content = render_j2_template(api_.tpl_config, 'error_screen.html',{"exception": ex})
return get_response((request.headers, HTTPStatus.OK, content))
def loading_screen():
"""
Loading empty page
:returns: HTTP response
"""
content = render_j2_template(api_.tpl_config, 'loading_screen.html',{'url': CONFIG["server"]["url"]})
return get_response((request.headers, HTTPStatus.OK, content))
@BLUEPRINT.route('/openapi')
def openapi():
@@ -283,8 +181,7 @@ def openapi():
:returns: HTTP response
"""
# raise NotImplementedError()
return get_response(api_.openapi_(request))
@@ -295,8 +192,7 @@ def conformance():
:returns: HTTP response
"""
# raise NotImplementedError()
return get_response(api_.conformance(request))
@@ -310,7 +206,6 @@ def get_tilematrix_set(tileMatrixSetId=None):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(tiles_api.tilematrixset, request,
tileMatrixSetId)
@@ -323,7 +218,6 @@ def get_tilematrix_sets():
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(tiles_api.tilematrixsets, request)
@@ -337,21 +231,10 @@ def collections(collection_id=None):
:returns: HTTP response
"""
handle_client("/collections")
return get_response(api_.describe_collections(request, collection_id))
@BLUEPRINT.route('/speckle')
def speckle_collection():
handle_client("/speckle")
collection_id="speckle"
return collection_items(collection_id=collection_id)
@BLUEPRINT.route('/collections/<path:collection_id>/schema')
def collection_schema(collection_id):
"""
@@ -362,7 +245,6 @@ def collection_schema(collection_id):
:returns: HTTP response
"""
# raise NotImplementedError()
return get_response(api_.get_collection_schema(request, collection_id))
@@ -376,12 +258,10 @@ def collection_queryables(collection_id=None):
:returns: HTTP response
"""
# raise NotImplementedError()
return execute_from_flask(itemtypes_api.get_collection_queryables, request,
collection_id)
# @BLUEPRINT.route('/')
@BLUEPRINT.route('/collections/<path:collection_id>/items',
methods=['GET', 'POST', 'OPTIONS'],
provide_automatic_options=False)
@@ -397,17 +277,9 @@ def collection_items(collection_id, item_id=None):
:returns: HTTP response
"""
handle_client(f"/collections/{collection_id}/items")
collection_id = 'speckle'
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(
@@ -422,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,
@@ -449,7 +325,7 @@ def collection_coverage(collection_id):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(coverages_api.get_collection_coverage, request,
collection_id, skip_valid_check=True)
@@ -464,7 +340,6 @@ def get_collection_tiles(collection_id=None):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(tiles_api.get_collection_tiles, request,
collection_id)
@@ -481,7 +356,6 @@ def get_collection_tiles_metadata(collection_id=None, tileMatrixSetId=None):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(tiles_api.get_collection_tiles_metadata,
request, collection_id, tileMatrixSetId,
skip_valid_check=True)
@@ -503,7 +377,6 @@ def get_collection_tiles_data(collection_id=None, tileMatrixSetId=None,
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(
tiles_api.get_collection_tiles_data,
request, collection_id, tileMatrixSetId, tileMatrix, tileRow, tileCol,
@@ -523,7 +396,6 @@ def collection_map(collection_id, style_id=None):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(
maps_api.get_collection_map, request, collection_id, style_id
)
@@ -540,7 +412,6 @@ def get_processes(process_id=None):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(processes_api.describe_processes, request,
process_id)
@@ -557,7 +428,6 @@ def get_jobs(job_id=None):
:returns: HTTP response
"""
raise NotImplementedError()
if job_id is None:
return execute_from_flask(processes_api.get_jobs, request)
else:
@@ -578,7 +448,6 @@ def execute_process_jobs(process_id):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(processes_api.execute_process, request,
process_id)
@@ -594,7 +463,6 @@ def get_job_result(job_id=None):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(processes_api.get_job_result, request, job_id)
@@ -610,7 +478,6 @@ def get_job_result_resource(job_id, resource):
:returns: HTTP response
"""
raise NotImplementedError()
# TODO: this does not seem to exist?
return get_response(api_.get_job_result_resource(
request, job_id, resource))
@@ -664,7 +531,6 @@ def stac_catalog_root():
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(stac_api.get_stac_root, request)
@@ -678,7 +544,6 @@ def stac_catalog_path(path):
:returns: HTTP response
"""
raise NotImplementedError()
return execute_from_flask(stac_api.get_stac_path, request, path)
@@ -690,7 +555,6 @@ def admin_config():
:returns: HTTP response
"""
raise NotImplementedError()
if request.method == 'GET':
return get_response(admin_.get_config(request))
@@ -709,7 +573,6 @@ def admin_config_resources():
:returns: HTTP response
"""
raise NotImplementedError()
if request.method == 'GET':
return get_response(admin_.get_resources(request))
@@ -727,7 +590,6 @@ def admin_config_resource(resource_id):
:returns: HTTP response
"""
raise NotImplementedError()
if request.method == 'GET':
return get_response(admin_.get_resource(request, resource_id))
+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}'
-1
View File
@@ -410,7 +410,6 @@ def set_response_language(headers: dict, *locale_: Locale):
LOGGER.debug(f'Setting Content-Language to {loc_str}')
headers['Content-Language'] = loc_str
headers['Access-Control-Allow-Origin'] = "*"
def add_locale(url, locale_) -> str:
+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 -3
View File
@@ -51,16 +51,17 @@ 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',
'SQLiteGPKG': 'pygeoapi.provider.sqlite.SQLiteGPKGProvider',
'Socrata': 'pygeoapi.provider.socrata.SODAServiceProvider',
'Speckle': 'pygeoapi.provider.speckle.SpeckleProvider',
'TinyDB': 'pygeoapi.provider.tinydb_.TinyDBProvider',
'TinyDBCatalogue': 'pygeoapi.provider.tinydb_.TinyDBCatalogueProvider',
'WMSFacade': 'pygeoapi.provider.wms_facade.WMSFacadeProvider',
+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',
-521
View File
@@ -1,521 +0,0 @@
# =================================================================
#
# Authors: Matthew Perry <perrygeo@gmail.com>
#
# Copyright (c) 2018 Matthew Perry
# Copyright (c) 2022 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# =================================================================
from datetime import datetime
import json
import logging
import os
import sys
from typing import Any, Dict, List, Optional, Tuple, Union
import uuid
from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError
from pygeoapi.util import crs_transform
LOGGER = logging.getLogger(__name__)
HOST_APP = "pygeoapi"
class SpeckleProvider(BaseProvider):
"""Provider class for Speckle server data
This is meant to be simple
(no external services, no dependencies, no schema)
at the expense of performance
(no indexing, full serialization roundtrip on each request)
Not thread safe, a single server process is assumed
This implementation uses the feature 'id' heavily
and will override any 'id' provided in the original data.
The feature 'properties' will be preserved.
TODO:
* query method should take bbox
* instead of methods returning FeatureCollections,
we should be yielding Features and aggregating in the view
* there are strict id semantics; all features in the input GeoJSON file
must be present and be unique strings. Otherwise it will break.
* How to raise errors in the provider implementation such that
* appropriate HTTP responses will be raised
"""
def __init__(self, provider_def):
"""initializer"""
super().__init__(provider_def)
if self.data is None:
self.data = ""
# raise ValueError(
# "Please provide Speckle project link as an argument, e.g.: 'http://localhost:5000/?limit=100000&https://app.speckle.systems/projects/55a29f3e9d/models/2d497a381d'"
# )
from subprocess import run
from pygeoapi.provider.speckle_utils.patch.patch_specklepy import patch_specklepy
try:
import specklepy
except ModuleNotFoundError:
completed_process = run(
[
self.get_python_path(),
"-m",
"pip",
"install",
"--upgrade",
"specklepy==2.19.6",
],
capture_output=True,
)
completed_process = run(
[
self.get_python_path(),
"-m",
"pip",
"install",
"pydantic==1.10.17",
],
capture_output=True,
)
if completed_process.returncode != 0:
m = f"Failed to install dependenices through pip, got {completed_process.returncode} as return code. Full log: {completed_process}"
print(m)
print(completed_process.stdout)
print(completed_process.stderr)
raise Exception(m)
patch_specklepy()
# assign global values
self.url: str = self.data # to store the value and check if self.data has changed
self.speckle_url = self.url.lower().split("speckleurl=")[-1].split("&")[0].split("@")[0].split("?")[0]
self.speckle_data = None
self.project_name = ""
self.project_id = ""
self.model_name = ""
self.sourceApp = ""
self.crs = None
self.crs_dict = None
self.commit_gis = False
self.url_params = {"url_data_type":"", "url_preserve_attributes":"", "url_crs_authid":"", "url_lat":"","url_lon":"","url_north_degrees":"","url_limit":""}
self.times = {}
self.country_code = ""
self.requested_data_type: str = "polygons (default)" # points, lines, polygons, projectcomments
self.preserve_attributes: str = "true (default)"
self.lat: float = 48.76755913928929 #51.52486388756923
self.lon: float = 11.408741923664028 #0.1621445437168942
self.north_degrees: float = 0
self.crs_authid = ""
self.limit = 10000
self.user_agent = ""
self.missing_url = ""
self.limit_message = ""
self.extent = [-180,-90,180,90]
self.extent3d = [-180,-90,0,180,90,1000]
self.material_color_proxies = {}
def get_fields(self):
"""
Get provider field information (names, types)
:returns: dict of fields
"""
fields = {}
LOGGER.debug("Treating all columns as string types")
if self.speckle_data is None:
self._load()
# check if the object was extracted
if isinstance(self.speckle_data, Dict):
if len(self.speckle_data["features"]) == 0:
return fields
for key, value in self.speckle_data["features"][0]["properties"].items():
if isinstance(value, float):
type_ = "number"
elif isinstance(value, int):
type_ = "integer"
else:
type_ = "string"
fields[key] = {"type": type_}
return fields
def _load(self, skip_geometry=None, properties=[], select_properties=[]):
"""Load and validate Speckle data"""
from pygeoapi.provider.speckle_utils.url_utils import get_set_url_parameters
if self.data == "":
return
get_set_url_parameters(self) # possible ValueError
# check if it's a new request (self.data was updated and doesn't match self.url)
new_request = False
if self.url != self.data:
new_request = True
self.url = self.data
# check if self.data was updated OR if features were not created yet
if (
new_request is True
or self.speckle_data is None
or (
isinstance(self.speckle_data, dict)
and hasattr(self.speckle_data, "features")
and len(self.speckle_data["features"]) > 0
and not hasattr(self.speckle_data["features"][0], "properties")
)
):
self.speckle_data = self.load_speckle_data()
self.fields = self.get_fields()
# filter by properties if set
if properties:
self.speckle_data["features"] = [
f
for f in self.speckle_data["features"]
if all([str(f["properties"][p[0]]) == str(p[1]) for p in properties])
] # noqa
# All features must have ids, TODO must be unique strings
if isinstance(self.speckle_data, str):
raise Exception(self.speckle_data)
for i in self.speckle_data["features"]:
# for some reason dictionary is changed to list of links
try:
i["properties"]
except:
self.speckle_data = None
return self._load()
if "id" not in i and self.id_field in i["properties"]:
i["id"] = i["properties"][self.id_field]
if skip_geometry:
i["geometry"] = None
if self.properties or select_properties:
i["properties"] = {
k: v
for k, v in i["properties"].items()
if k in set(self.properties) | set(select_properties)
} # noqa
return self.speckle_data
@crs_transform
def query(
self,
offset=0,
limit=10,
resulttype="results",
bbox=[],
datetime_=None,
properties=[],
sortby=[],
select_properties=[],
skip_geometry=False,
q=None,
**kwargs,
):
"""
query the provider
: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)
:returns: FeatureCollection dict of 0..n GeoJSON features
"""
# TODO filter by bbox without resorting to third-party libs
data = self._load(
skip_geometry=skip_geometry,
properties=properties,
select_properties=select_properties,
)
if data is None:
return {"features":[], "comments":[], "extent": [-180,-90,180,90]}
# add URL parameters
data['speckle_url'] = self.speckle_url
data['requested_data_type'] = self.requested_data_type
data['preserve_attributes'] = self.preserve_attributes
data['crs_authid'] = self.crs_authid
data['lat'] = self.lat
data['lon'] = self.lon
data['north_degrees'] = self.north_degrees
data['limit'] = self.limit
data['missing_url'] = self.missing_url
data["numberMatched"] = len(data["features"])
if resulttype == "hits":
data["features"] = []
data["comments"] = []
data["extent"] = [-180,-90,180,90]
else:
data["features"] = data["features"][offset : offset + limit]
data["numberReturned"] = len(data["features"])
return data
@crs_transform
def get(self, identifier, **kwargs):
"""
query the provider by id
:param identifier: feature id
:returns: dict of single GeoJSON feature
"""
all_data = self._load()
# if matches
for feature in all_data["features"]:
if str(feature.get("id")) == identifier:
return feature
# default, no match
err = f"item {identifier} not found"
LOGGER.error(err)
raise ProviderItemNotFoundError(err)
def create(self, new_feature):
"""Create a new feature
:param new_feature: new GeoJSON feature dictionary
"""
raise NotImplementedError("Creating features is not supported")
def update(self, identifier, new_feature):
"""Updates an existing feature id with new_feature
:param identifier: feature id
:param new_feature: new GeoJSON feature dictionary
"""
raise NotImplementedError("Updating features is not supported")
def delete(self, identifier):
"""Deletes an existing feature
:param identifier: feature id
"""
raise NotImplementedError("Deleting features is not supported")
def __repr__(self):
return f"<SpeckleProvider> {self.data}"
def load_speckle_data(self: str) -> Dict:
"""Receive and process Speckle data, return geojson."""
from datetime import datetime, timezone
from pygeoapi.provider.speckle_utils.server_utils import get_stream_branch, get_client, get_comments, set_actions
from specklepy.objects.base import Base
from specklepy.logging.exceptions import SpeckleException
from specklepy.api import operations
from specklepy.core.api.wrapper import StreamWrapper
from specklepy.core.api.client import SpeckleClient
from specklepy.logging.metrics import set_host_app
from specklepy.transports.server import ServerTransport
set_host_app(HOST_APP, "0.0.99")
# get URL that will not trigget Client init
url_proj: str = self.speckle_url.split("models")[0]
wrapper: StreamWrapper = StreamWrapper(url_proj)
# set actual branch
wrapper.model_id = self.speckle_url.split("models/")[1].split(" ")[0].split("/")[0].split("&")[0].split(",")[0].split(";")[0].split("@")[0]
# get stream and branch data
client = get_client(wrapper, url_proj)
stream, branch = get_stream_branch(self, client, wrapper)
if stream is None:
raise ValueError(f"Project from URL '{url_proj}' not found")
if branch is None:
raise ValueError(f"Model '{wrapper.model_id}' of the project '{stream['name']}' not found")
if self.requested_data_type == "projectcomments":
comments = get_comments(client, wrapper.stream_id, wrapper.model_id)
# commit_obj = Base() # still need to receive object to get the CRS
else:
comments = {}
# set the Model name
self.project_id = wrapper.stream_id
self.project_name = stream['name']
self.model_name = branch['name']
commit = branch["commits"]["items"][0]
objId = commit["referencedObject"]
self.sourceApp = commit["sourceApplication"]
transport = ServerTransport(client=client, account=client.account, stream_id=wrapper.stream_id)
if transport == None:
raise SpeckleException("Transport not found")
# receive commit
set_actions(self, client)
try:
commit_obj = operations.receive(objId, transport, None)
except Exception as ex:
# e.g. SpeckleException: Can't get object b53a53697a/f8ce82b242e05eeaab4c6c59fb25e4a0: HTTP error 404 ()
raise ex
client.commit.received(
wrapper.stream_id,
commit["id"],
source_application="pygeoapi",
message="Received commit in pygeoapi",
)
print(f"_{datetime.now().astimezone(timezone.utc)} _Rendering model '{branch['name']}' of the project '{stream['name']}'")
speckle_data = self.traverse_data(commit_obj, comments)
set_actions(self, client, "GEO post-receive")
speckle_data["features"].extend(speckle_data["comments"])
speckle_data["comments"] = []
speckle_data["project_id"] = wrapper.stream_id
speckle_data["project"] = stream['name']
speckle_data["model"] = branch['name']
speckle_data["model_last_version_date"] = datetime.strptime(commit['createdAt'].replace("T", " ").replace("Z","").split(".")[0], '%Y-%m-%d %H:%M:%S')
speckle_data["model_id"] = wrapper.model_id
speckle_data["extent"] = self.extent
speckle_data["extent3d"] = self.extent3d
speckle_data["limit_message"] = self.limit_message
return speckle_data
def traverse_data(self, commit_obj, comments) -> Dict:
"""Traverse Speckle commit and return geojson with features."""
from specklepy.objects.geometry import Point, Line, Curve, Arc, Circle, Ellipse, Polyline, Polycurve, Mesh, Brep
from specklepy.objects.GIS.layers import VectorLayer
from specklepy.objects.GIS.geometry import GisPolygonElement
from specklepy.objects.GIS.GisFeature import GisFeature
from specklepy.objects.graph_traversal.traversal import (
GraphTraversal,
TraversalRule,
)
from pygeoapi.provider.speckle_utils.crs_utils import get_set_crs_settings
from pygeoapi.provider.speckle_utils.feature_utils import create_features
from pygeoapi.provider.speckle_utils.display_utils import isDisplayable, set_default_color, get_material_color_proxies
supported_classes = [GisFeature, GisPolygonElement, Mesh, Brep, Point, Line, Polyline, Curve, Arc, Circle, Ellipse, Polycurve]
supported_types = [y().speckle_type for y in supported_classes]
supported_types.extend([
"Objects.Other.Revit.RevitInstance",
"Objects.BuiltElements.Revit.RevitWall",
"Objects.BuiltElements.Revit.RevitFloor",
"Objects.BuiltElements.Revit.RevitStair",
"Objects.BuiltElements.Revit.RevitColumn",
"Objects.BuiltElements.Revit.RevitBeam",
"Objects.BuiltElements.Revit.RevitElement",
"Objects.BuiltElements.Revit.RevitRebar"])
# traverse commit
data: Dict[str, Any] = {
"type": "FeatureCollection",
"features": [],
"comments": [],
"extent": [-180,-90,180,90],
"model_crs": "-",
}
# rule to keep traversing the object's "x" attribute "item" (both conditions need to be fulfilled)
# 1. if the item type is not in supported (convertible) types or is GIS VectorLayer
# 2. if the item's value is a list or a GH object
rule = TraversalRule(
[lambda _: True],
lambda x: [
item
for item in x.get_member_names()
if (x.speckle_type.split(":")[-1] not in supported_types or isinstance(x, VectorLayer))
and (isinstance(getattr(x, item, None), list) or (self.sourceApp is not None and "grasshopper" in self.sourceApp.lower() and x.speckle_type == "Base") )
],
)
# for the context list, save the displayable objects and Layers (for getting CRS for now)
context_list = [x for x in GraphTraversal([rule]).traverse(commit_obj) if isDisplayable(x.current) or x.current.speckle_type.endswith("VectorLayer")]
get_set_crs_settings(self, commit_obj, context_list, data)
set_default_color(context_list)
self.material_color_proxies: dict = get_material_color_proxies(commit_obj)
create_features(self, context_list, comments, data)
# sort features by height
#if len(data['features']) == len(data['heights']):
#feat_array = np.array(data['features'])
#heights_array = np.array(data['heights'])
#inds = heights_array.argsort()
#sorted = feat_array[inds].tolist()
time1 = datetime.now()
sorted_list = sorted(data['features'], key=lambda d: d['max_height'])
for i, _ in enumerate(sorted_list):
sorted_list[i]["properties"]["FID"] = i+1
data['features'] = sorted_list
time2 = datetime.now()
time_operation = (time2-time1).total_seconds()
self.times["time_sort"] = time_operation
# print(f"Sorting time: {time_operation}")
return data
def get_python_path(self) -> str:
"""Get current Python executable path."""
if sys.platform.startswith("linux"):
return sys.executable
pythonExec = os.path.dirname(sys.executable)
if sys.platform == "win32":
pythonExec += "\\python"
else:
pythonExec += "/bin/python3"
return pythonExec
@@ -1,477 +0,0 @@
import math
from typing import Dict, List, Tuple
def convert_point(f_base: "Point", coords, coord_counts):
"""Convert Point."""
coords.append([f_base.x, f_base.y, f_base.z])
coord_counts.append([1])
def convert_line(f_base: "Line", coords, coord_counts):
"""Convert Line."""
start = [f_base.start.x, f_base.start.y, f_base.start.z]
end = [f_base.end.x, f_base.end.y, f_base.end.z]
coords.extend([start, end])
coord_counts.append([2])
def convert_polyline(f_base: "Polyline", coords, coord_counts):
"""Convert Polyline."""
coord_counts.append([])
local_coords = [] # to keep track of just the current polyline
local_poly_count = 0
for pt in f_base.as_points():
coords.append([pt.x, pt.y, pt.z])
local_coords.append([pt.x, pt.y, pt.z])
local_poly_count += 1
# closing point
if local_poly_count>2 and f_base.closed is True and local_coords[0] != local_coords[-1]:
coords.append(local_coords[0])
local_poly_count += 1
coord_counts[-1].append(local_poly_count)
def convert_arc(f_base: "Arc", coords, coord_counts):
"""Convert Arc."""
if f_base.plane is None or f_base.plane.normal.z == 0:
normal = 1
else:
normal = f_base.plane.normal.z
# calculate angles and interval
interval, angle1, angle2 = getArcRadianAngle(f_base)
if (angle1 > angle2 and normal == -1) or (angle2 > angle1 and normal == 1):
pass
if angle1 > angle2 and normal == 1:
interval = abs((2 * math.pi - angle1) + angle2)
if angle2 > angle1 and normal == -1:
interval = abs((2 * math.pi - angle2) + angle1)
# set a (random) point density: 24 per 1 rad
pointsNum = math.floor(abs(interval)) * 24
if pointsNum < 4:
pointsNum = 4
# assign coordinates
coord_counts.append([])
local_poly_count = 0
for i in range(0, pointsNum + 1):
k = i / pointsNum # reset values to fraction
angle = angle1 + k * interval * normal
x=f_base.plane.origin.x + f_base.radius * math.cos(angle)
y=f_base.plane.origin.y + f_base.radius * math.sin(angle)
z=f_base.plane.origin.z
coords.append([x, y, z])
local_poly_count += 1
coord_counts[-1].append(local_poly_count)
def convert_circle(f_base: "Circle", coords, coord_counts):
"""Convert Circle."""
if f_base.plane is None or f_base.plane.normal.z == 0:
normal = 1
else:
normal = f_base.plane.normal.z
# set a (random) point density: 24 per 1 rad
interval = 2 * math.pi
pointsNum = math.floor(abs(interval)) * 24
if pointsNum < 4:
pointsNum = 4
# assign coordinates
coord_counts.append([])
local_poly_count = 0
for i in range(0, pointsNum + 1):
k = i / pointsNum # reset values to fraction
angle = k * interval * normal
x=f_base.plane.origin.x + f_base.radius * math.cos(angle)
y=f_base.plane.origin.y + f_base.radius * math.sin(angle)
z=f_base.plane.origin.z
coords.append([x, y, z])
local_poly_count += 1
coord_counts[-1].append(local_poly_count)
def convert_polycurve(f_base: "Polycurve", coords, coord_counts):
"""Convert Polycurve."""
flat_coords = []
flat_coord_count = [0]
# put together results from all segment conversions
for segm in f_base.segments:
convert_icurve(segm, coords, coord_counts)
if len(coord_counts)==0:
continue
flat_coords.extend(coords)
flat_coord_count[-1] += coord_counts[-1][-1]
coords = flat_coords
coord_counts = flat_coord_count
def convert_curve(f_base: "Curve", coords, coord_counts):
"""Convert Curve using its Polyline displayValue."""
return convert_polyline(f_base.displayValue, coords, coord_counts)
def convert_icurve(f_base: "Base", coords, coord_counts):
"""Convert any ICurve."""
from specklepy.objects.geometry import Line, Polyline, Arc, Curve, Circle, Polycurve, Mesh, Brep
if isinstance(f_base, Line):
convert_line(f_base, coords, coord_counts)
elif isinstance(f_base, Polyline):
convert_polyline(f_base, coords, coord_counts)
elif isinstance(f_base, Curve):
convert_curve(f_base, coords, coord_counts)
elif isinstance(f_base, Arc):
convert_arc(f_base, coords, coord_counts)
elif isinstance(f_base, Circle):
convert_circle(f_base, coords, coord_counts)
elif isinstance(f_base, Polycurve):
convert_polycurve(f_base, coords, coord_counts)
def convert_mesh_or_brep(f_base: "Base", coords, coord_counts):
"""Convert Mesh object or Mesh derived from Brep display value."""
from specklepy.objects.geometry import Mesh, Brep
faces = []
vertices = []
# get faces and vertices
if isinstance(f_base, Mesh):
faces = f_base.faces
vertices = f_base.vertices
elif isinstance(f_base, Brep):
if f_base.displayValue is None or (
isinstance(f_base.displayValue, list)
and len(f_base.displayValue) == 0
):
geometry = {}
return
elif isinstance(f_base.displayValue, list):
faces = f_base.displayValue[0].faces
vertices = f_base.displayValue[0].vertices
else:
faces = f_base.displayValue.faces
vertices = f_base.displayValue.vertices
# add coordinates
count: int = 0
for i, pt_count in enumerate(faces):
if i != count:
continue
# old encoding
if pt_count == 0:
pt_count = 3
elif pt_count == 1:
pt_count = 4
local_coords_count = [pt_count]
local_coords = []
for vertex_index in faces[count + 1 : count + 1 + pt_count]:
x = vertices[vertex_index * 3]
y = vertices[vertex_index * 3 + 1]
z = vertices[vertex_index * 3 + 2]
local_coords.append([x, y, z])
count += pt_count + 1
valid: bool = fix_polygon_orientation(local_coords, True)
#if valid:
coords.extend(local_coords)
coord_counts.append(local_coords_count)
def convert_polygon(polygon: "Base", coords, coord_counts):
"""Convert GisPolygonGeometry."""
coord_counts.append([])
local_coords_count = 0
local_coords = []
for pt in polygon.boundary.as_points():
local_coords.append([pt.x, pt.y, pt.z])
local_coords_count += 1
valid: bool = fix_polygon_orientation(local_coords, True)
#if valid:
coords.extend(local_coords)
coord_counts[-1].append(local_coords_count)
for void in polygon.voids:
local_coords_count = 0
local_coords = []
for pt_void in void.as_points():
local_coords.append([pt_void.x, pt_void.y, pt_void.z])
local_coords_count += 1
valid: bool = fix_polygon_orientation(local_coords, False)
#if valid:
coords.extend(local_coords)
coord_counts[-1].append(local_coords_count)
def convert_hatch(hatch: "Base", coords, coord_counts):
"""Convert Hatch."""
coord_counts.append([])
loops: list = hatch["loops"]
boundary = None
voids = []
for loop in loops:
if len(loops)==1 or loop["Type"] == 1: # Outer
boundary = loop["Curve"]
else:
voids.append(loop["Curve"])
if boundary is None:
return
# record coordinates
local_coords_count = []
local_coords = []
convert_icurve(boundary, local_coords, local_coords_count)
valid: bool = fix_polygon_orientation(local_coords, True)
#if valid:
coords.extend(local_coords)
coord_counts.extend(local_coords_count)
for void in voids:
local_coords_count = []
local_coords = []
convert_icurve(void, local_coords, local_coords_count)
valid: bool = fix_polygon_orientation(local_coords, False)
#if valid:
coords.extend(local_coords)
coord_counts.extend(local_coords_count)
def assign_geometry(self: "SpeckleProvider", feature: Dict, f_base) -> Tuple[ List[List[List[float]]], List[List[None| List[int]]] ]:
"""Assign geom type and convert object coords into flat lists of coordinates and schema."""
from specklepy.objects.geometry import Base, Point, Line, Polyline, Arc, Curve, Circle, Polycurve, Mesh, Brep
from specklepy.objects.GIS.geometry import GisPolygonGeometry
geometry = feature["geometry"]
coords = []
coord_counts = []
if isinstance(f_base, Base) and f_base.speckle_type.endswith("Feature") and len(f_base["geometry"]) > 0: # isinstance(f_base, GisFeature) and len(f_base.geometry) > 0:
# GisFeature doesn't deserialize properly, need to check for speckle_type
if self.requested_data_type == "points" and isinstance(f_base["geometry"][0], Point):
geometry["type"] = "MultiPoint"
coord_counts.append(None) # as an indicator of a Multi..type
for geom in f_base["geometry"]:
convert_point(geom, coords, coord_counts)
elif self.requested_data_type == "lines" and isinstance(f_base["geometry"][0], Polyline):
geometry["type"] = "MultiLineString"
coord_counts.append(None)
for geom in f_base["geometry"]:
convert_polyline(geom, coords, coord_counts)
elif self.requested_data_type.startswith("polygons") and isinstance(f_base["geometry"][0], GisPolygonGeometry):
geometry["type"] = "MultiPolygon"
coord_counts.append(None)
polygon_3d = False
for mesh in f_base["displayValue"]:
for i, coord in enumerate(mesh.vertices):
if i>60:
break
if i%3 !=0:
continue
elif coord != 0:
polygon_3d = True
break
if polygon_3d is False:
for geom in f_base["geometry"]:
convert_polygon(geom, coords, coord_counts)
else:
for geom in f_base["displayValue"]:
convert_mesh_or_brep(geom, coords, coord_counts)
elif self.requested_data_type == "points":
if isinstance(f_base, Point):
geometry["type"] = "MultiPoint"
coord_counts.append(None) # as an indicator of a Multi..type
convert_point(f_base, coords, coord_counts)
elif isinstance(f_base, Base) and f_base.speckle_type.endswith("PointElement"):
raise TypeError(f"Deprecated speckleType {f_base.speckle_type}. Try loading more recent data.")
elif self.requested_data_type == "lines":
if (isinstance(f_base, Line) or
isinstance(f_base, Polyline) or
isinstance(f_base, Curve) or
isinstance(f_base, Arc) or
isinstance(f_base, Circle) or
isinstance(f_base, Polycurve)):
geometry["type"] = "LineString"
convert_icurve(f_base, coords, coord_counts)
elif isinstance(f_base, Base) and f_base.speckle_type.endswith("LineElement"):
raise TypeError(f"Deprecated speckleType {f_base.speckle_type}. Try loading more recent data.")
elif self.requested_data_type.startswith("polygons"):
if isinstance(f_base, Base) and f_base.speckle_type.endswith(".Hatch"):
geometry["type"] = "MultiPolygon"
coord_counts.append(None)
convert_hatch(f_base, coords, coord_counts)
elif isinstance(f_base, Mesh) or isinstance(f_base, Brep):
geometry["type"] = "MultiPolygon"
coord_counts.append(None) # as an indicator of a Multi..type
convert_mesh_or_brep(f_base, coords, coord_counts)
elif isinstance(f_base, Base) and f_base.speckle_type.endswith("PolygonElement"):
raise TypeError(f"Deprecated speckleType {f_base.speckle_type}. Try loading more recent data.")
elif self.requested_data_type == "projectcomments":
if isinstance(f_base, List): # comment position
geometry["type"] = "MultiPoint"
coord_counts.append(None) # as an indicator of a Multi..type
coords.append([f_base[0], f_base[1], f_base[2]])
coord_counts.append([1])
else:
geometry = {}
# print(f"Unsupported geometry type: {f_base.speckle_type}")
return coords, coord_counts
def getArcRadianAngle(arc: "Arc") -> List[float]:
"""Calculate start & end angle, and interval of an Arc."""
interval = None
normal = arc.plane.normal.z
angle1, angle2 = getArcAngles(arc)
if angle1 is None or angle2 is None:
return None
interval = abs(angle2 - angle1)
if (angle1 > angle2 and normal == -1) or (angle2 > angle1 and normal == 1):
pass
if angle1 > angle2 and normal == 1:
interval = abs((2 * math.pi - angle1) + angle2)
if angle2 > angle1 and normal == -1:
interval = abs((2 * math.pi - angle2) + angle1)
return interval, angle1, angle2
def getArcAngles(poly: "Arc") -> Tuple[float | None]:
if poly.startPoint.x == poly.plane.origin.x:
angle1 = math.pi / 2
else:
angle1 = math.atan(
abs(
(poly.startPoint.y - poly.plane.origin.y)
/ (poly.startPoint.x - poly.plane.origin.x)
)
) # between 0 and pi/2
if (
poly.plane.origin.x < poly.startPoint.x
and poly.plane.origin.y > poly.startPoint.y
):
angle1 = 2 * math.pi - angle1
if (
poly.plane.origin.x > poly.startPoint.x
and poly.plane.origin.y > poly.startPoint.y
):
angle1 = math.pi + angle1
if (
poly.plane.origin.x > poly.startPoint.x
and poly.plane.origin.y < poly.startPoint.y
):
angle1 = math.pi - angle1
if poly.endPoint.x == poly.plane.origin.x:
angle2 = math.pi / 2
else:
angle2 = math.atan(
abs(
(poly.endPoint.y - poly.plane.origin.y)
/ (poly.endPoint.x - poly.plane.origin.x)
)
) # between 0 and pi/2
if (
poly.plane.origin.x < poly.endPoint.x
and poly.plane.origin.y > poly.endPoint.y
):
angle2 = 2 * math.pi - angle2
if (
poly.plane.origin.x > poly.endPoint.x
and poly.plane.origin.y > poly.endPoint.y
):
angle2 = math.pi + angle2
if (
poly.plane.origin.x > poly.endPoint.x
and poly.plane.origin.y < poly.endPoint.y
):
angle2 = math.pi - angle2
return angle1, angle2
def fix_polygon_orientation(
polygon_pts: List[List[float]], clockwise: bool = True
) -> bool:
"""Changes orientation to clockwise (or counter-) and returns False if polygon has no footprint."""
max_number_of_points = 1000
coef = int(len(polygon_pts)/max_number_of_points) if len(polygon_pts)>max_number_of_points else 1
sum_orientation = 0
for k, _ in enumerate(polygon_pts):
index = k + 1
if k == len(polygon_pts) - 1:
index = 0
try:
pt = polygon_pts[k * coef]
pt2 = polygon_pts[index * coef]
sum_orientation += (pt2[0] - pt[0]) * (pt2[1] + pt[1]) # if Speckle Points
except IndexError:
break
if clockwise is True and sum_orientation < 0:
polygon_pts.reverse()
elif clockwise is False and sum_orientation > 0:
polygon_pts.reverse()
if sum_orientation ==0:
return False
return True
@@ -1,149 +0,0 @@
import copy
import math
from typing import List
from pygeoapi.provider.speckle_utils.legal import COUNTRY_CODES, STATES, POSTCODES
def reproject_bulk(self, all_coords: List[List[List[float]]], all_coord_counts: List[List[None| List[int]]], geometries) -> None:
"""Reproject coordinates and assign to corresponding geometries."""
from datetime import datetime
# reproject all coords
time1 = datetime.now()
flat_coords = reproject_2d_coords_list(self, all_coords)
time2 = datetime.now()
time_operation = (time2-time1).total_seconds()
self.times["time_reproject"] = time_operation
validate_coords(self, flat_coords[0])
if len(flat_coords)>2:
validate_coords(self, flat_coords[len(flat_coords)-1])
# define type of features
feat_coord_group_is_multi = [True if None in x else False for x in all_coord_counts]
feat_coord_group_counts = [[ y for y in x if y is not None] for x in all_coord_counts]
feat_coord_group_counts_per_part = [[ sum(y) for y in x if y is not None] for x in all_coord_counts]
feat_coord_group_flat_counts: List[int] = [sum([ sum(y) for y in x if y is not None]) for x in all_coord_counts]
feat_coord_groups = [flat_coords[sum(feat_coord_group_flat_counts[:i]):sum(feat_coord_group_flat_counts[:i])+x] for i, x in enumerate(feat_coord_group_flat_counts)]
for i, geometry in enumerate(geometries):
geometry["coordinates"] = []
if feat_coord_group_is_multi[i] is False:
if geometry["type"] == "Point":
geometry["coordinates"].extend(feat_coord_groups[i][0])
else:
geometry["coordinates"].extend(feat_coord_groups[i])
else:
polygon_parts = []
local_coords_count: List[List[int]] = feat_coord_group_counts[i]
local_coords_count_flat: List[int] = feat_coord_group_counts_per_part[i]
local_flat_coords: List[int] = feat_coord_groups[i]
for c, poly_part_count_lists in enumerate(local_coords_count):
poly_part = []
start_index = sum(local_coords_count_flat[:c]) if c!=0 else 0 # all used coords in all parts
for part_count in poly_part_count_lists:
range_coords_indices = range(start_index, start_index + part_count)
if geometry["type"] == "MultiPoint":
poly_part.extend([local_flat_coords[ind] for ind in range_coords_indices])
else:
new_list = []
for ind in range_coords_indices:
try:
new_list.append(local_flat_coords[ind])
except Exception as e: # corrupted geometry, ignore altogether
new_list = []
break
if len(new_list)>0:
poly_part.append(new_list)
start_index += part_count
if geometry["type"] in ["MultiPoint","MultiLineString"] :
polygon_parts.extend(poly_part)
else:
polygon_parts.append(poly_part)
geometry["coordinates"].extend(polygon_parts)
time3 = datetime.now()
time_operation = (time3-time2).total_seconds()
self.times["time_reconstruct_geometry"] = time_operation
# print(f"Construct back geometry time: {time_operation}")
def reproject_2d_coords_list(self, coords_in: List[List[float]]) -> List[List[float]]:
"""Return coordinates in a CRS of SpeckleProvider."""
from pyproj import Transformer
from pyproj import CRS
coords_offset = offset_rotate(self, copy.deepcopy(coords_in))
transformer = Transformer.from_crs(
self.crs,
CRS.from_user_input(4326),
always_xy=True,
)
transformed = [[pt[0], pt[1], pt[2]] for pt in transformer.itransform(coords_offset)]
all_x = [x[0] for x in transformed]
all_y = [x[1] for x in transformed]
all_z = [x[2] for x in transformed]
self.extent = [min(all_x), min(all_y), max(all_x), max(all_y)]
self.extent3d = [min(all_x), min(all_y), min(all_z), max(all_x), max(all_y), max(all_z)]
return transformed
def offset_rotate(self, coords_in: List[list]) -> List[List[float]]:
"""Apply offset and rotation to coordinates, according to SpeckleProvider CRS_dict."""
from specklepy.objects.units import get_scale_factor_from_string
scale_factor = 1
if isinstance(self.crs_dict["units_native"], str):
scale_factor = get_scale_factor_from_string(self.crs_dict["units_native"], "m")
final_coords = []
for coord in coords_in:
a = self.crs_dict["rotation"] * math.pi / 180
x2 = coord[0] * math.cos(a) - coord[1] * math.sin(a)
y2 = coord[0] * math.sin(a) + coord[1] * math.cos(a)
final_coords.append(
[
scale_factor * (x2 + self.crs_dict["offset_x"]),
scale_factor * (y2 + self.crs_dict["offset_y"]),
scale_factor * (coord[2]),
]
)
return final_coords
def validate_coords(self, coords):
from geopy.geocoders import Nominatim
country_code = ""
state = ""
postcode = ""
try:
geolocator = Nominatim(user_agent="specklePygeoapi")
coord = f"{coords[1]}, {coords[0]}"
location = geolocator.reverse(coord, exactly_one=True)
if location is not None:
address = location.raw['address']
country_code = address.get('country_code', '')
state = address.get('state', '')
postcode = address.get('postcode', '')
except Exception as e:
print(f"Error validating project location: {e}")
self.country_code = country_code
if country_code in COUNTRY_CODES or state in STATES or postcode in POSTCODES:
print(f"Validating project location: blocked LAT LON {coords[1]}, {coords[0]}, {country_code}, {state}, {postcode}")
raise PermissionError("Review Speckle Terms and Conditions")
@@ -1,102 +0,0 @@
from typing import Dict, List
def create_crs_from_wkt(self: "SpeckleProvider", wkt: str | None) -> None:
"""Create and assign CRS object from WKT string."""
from pyproj import CRS
self.crs = CRS.from_user_input(wkt)
def create_crs_from_authid(self: "SpeckleProvider", authid: str | None) -> None:
"""Create and assign CRS object from Authority ID."""
from pyproj import CRS
crs_obj = CRS.from_string(authid)
self.crs = crs_obj
def create_crs_default(self: "SpeckleProvider") -> None:
"""Create and assign custom CRS using SpeckleProvider Lat & Lon."""
from pyproj import CRS
wkt = f'PROJCS["SpeckleCRS_latlon_{self.lat}_{self.lon}", GEOGCS["GCS_WGS_1984", DATUM["D_WGS_1984", SPHEROID["WGS_1984", 6378137.0, 298.257223563]], PRIMEM["Greenwich", 0.0], UNIT["Degree", 0.0174532925199433]], PROJECTION["Transverse_Mercator"], PARAMETER["False_Easting", 0.0], PARAMETER["False_Northing", 0.0], PARAMETER["Central_Meridian", {self.lon}], PARAMETER["Scale_Factor", 1.0], PARAMETER["Latitude_Of_Origin", {self.lat}], UNIT["Meter", 1.0]]'
crs_obj = CRS.from_user_input(wkt)
self.crs = crs_obj
def create_crs_dict(self: "SpeckleProvider", offset_x, offset_y, displayUnits: str | None) -> None:
"""Create and assign CRS_dict of SpeckleProvider."""
if self.crs is not None:
self.crs_dict = {
"wkt": self.crs.to_wkt(),
"offset_x": offset_x,
"offset_y": offset_y,
"rotation": self.north_degrees,
"units_native": displayUnits,
"obj": self.crs,
}
def get_set_crs_settings(self: "SpeckleProvider", commit_obj: "Base", context_list: List["TraversalContext"], data: Dict) -> None:
"""Assign CRS object and Dict to SpeckleProvider."""
from pygeoapi.provider.speckle_utils.display_utils import get_display_units
from specklepy.objects.GIS.CRS import CRS
assign_coordinate_system_to_geojson(data)
root_objects = []
try:
root_objects = [commit_obj] + commit_obj.elements + [c.current for c in context_list]
except AttributeError as ex:
pass # old commit structure
# iterate Speckle objects to get CRS, DisplayUnits, offsets, rotation
crs = None
displayUnits = None
offset_x = 0
offset_y = 0
for item in root_objects:
if (
crs is None
and hasattr(item, "crs")
and isinstance(item["crs"], CRS)
):
crs = item["crs"]
displayUnits = crs["units_native"]
offset_x = crs["offset_x"]
offset_y = crs["offset_y"]
self.north_degrees = crs["rotation"]
create_crs_from_wkt(self, crs["wkt"])
self.commit_gis = True
if self.crs.to_authority() is not None:
data["model_crs"] = f"{self.crs.to_authority()}, {self.crs.name} "
else:
data["model_crs"] = f"{self.crs.to_proj4()}"
break
# if CRS not found, create default one and get model units for scaling
if self.crs is None:
create_crs_default(self)
if displayUnits is None:
displayUnits = get_display_units(context_list)
create_crs_dict(self, offset_x, offset_y, displayUnits)
def assign_coordinate_system_to_geojson(data: Dict):
crs = {
"crs": {
"type": "name",
"properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"},
}
}
data["crs"] = crs
@@ -1,480 +0,0 @@
from typing import Dict, List, Tuple
DEFAULT_COLOR = (255 << 24) + (150 << 16) + (150 << 8) + 150
def find_list_of_display_obj(obj) -> List[Tuple["Base", "Base"]]:
"""Get displayable object."""
list_of_display_obj_colors: List = []
# find displayValue if available
displayValue = obj
if hasattr(obj, 'displayValue'):
displayValue = getattr(obj, 'displayValue')
elif hasattr(obj, '@displayValue'):
displayValue = getattr(obj, '@displayValue')
# return List of displayValues
if not isinstance(displayValue, List):
displayValue = [displayValue]
# for Features, return original convertible object and a first item from displayValue
if obj.speckle_type.endswith("Feature"):
if len(displayValue)==0:
return ([(obj, obj)])
else:
return([(obj, displayValue[0])])
separated_display_values: List[Tuple] = separate_display_vals(displayValue)
for item, item_original in separated_display_values:
if item is None:
continue
# read displayObj Colors directly from the obj itself, unless its GisFeature or Revit Element: then keep reading from displayValue
if obj.speckle_type.endswith("Feature") or "BuiltElements.Revit" in obj.speckle_type:
displayValForColor = item_original
else:
displayValForColor = obj
list_of_display_obj_colors.append((item, displayValForColor))
return list_of_display_obj_colors
def separate_display_vals(displayValue: List) -> List[Tuple["Base"]]:
"""Return multiple split geometries."""
from specklepy.objects.geometry import Mesh
display_objs = []
for i, item in enumerate(displayValue):
if isinstance(item, Mesh):
count = 0
all_count = len(item.faces)
sub_meshes = []
for _ in item.faces:
if count < all_count:
faces = []
verts = []
colors = []
vert_num = item.faces[count]
if vert_num == 0:
vert_num = 3
elif vert_num == 1:
vert_num = 4
faces.append(vert_num)
faces.extend([ x for x in list(range(vert_num))])
try:
for ind in range(vert_num):
face_vert_index = count+1+ind
#print(face_vert_index)
vert_index = item.faces[face_vert_index]
new_vert = item.vertices[3*vert_index : 3*vert_index + 3]
verts.extend(new_vert)
if isinstance(item.colors, List) and len(item.colors) > vert_index:
color = item.colors[vert_index]
colors.append(color)
count += vert_num+1
if len(colors)>0:
mesh = Mesh.create(faces= faces, vertices=verts, colors=colors)
else:
mesh = Mesh.create(faces= faces, vertices=verts)
sub_meshes.append((mesh, item))
except IndexError: # corrupted mesh, drop altogether
sub_meshes = []
break
display_objs.extend(sub_meshes)
elif item is not None:
display_objs.append((item, item))
return display_objs
def isDisplayable(obj: "Base") -> bool:
if is_primitive(obj):
return True
if obj.speckle_type.endswith("Feature"):
return True
displayValue = None
if hasattr(obj, 'displayValue'):
displayValue = getattr(obj, 'displayValue')
elif hasattr(obj, '@displayValue'):
displayValue = getattr(obj, '@displayValue')
# merge to sigle object, if List
if isinstance(displayValue, List):
return True
return False
def find_display_obj(obj) -> Tuple["Base", "Base"]:
"""Get displayable object."""
displayValObj = obj
displayValForColor = obj
# find displayValue if available
displayValue = obj
if hasattr(obj, 'displayValue'):
displayValue = getattr(obj, 'displayValue')
elif hasattr(obj, '@displayValue'):
displayValue = getattr(obj, '@displayValue')
# merge to sigle object, if List
if isinstance(displayValue, List):
displayValue = get_single_display_object(displayValue)
# read displayObj Colors directly from the obj itself, unless its GisFeature or Revit Element: then keep reading from displayValue
if not obj.speckle_type.endswith("Feature") and "BuiltElements.Revit" not in obj.speckle_type:
displayValForColor = obj
else:
displayValForColor = displayValue
# return convertible types as is
if is_convertible(obj):
displayValObj = obj
else:
displayValObj = displayValue
return displayValObj, displayValForColor
def is_convertible(obj) -> bool:
"""Check if the object can be converted directly."""
from specklepy.objects.geometry import Base, Point, Line, Polyline, Arc, Circle, Curve, Polycurve, Mesh, Brep
if ( (isinstance(obj, Base) and obj.speckle_type.endswith("Feature")) or
isinstance(obj, Point) or
isinstance(obj, Line) or
isinstance(obj, Polyline) or
isinstance(obj, Arc) or
isinstance(obj, Circle) or
isinstance(obj, Curve) or
isinstance(obj, Polycurve) or
isinstance(obj, Mesh) or
isinstance(obj, Brep)):
return True
return False
def is_primitive(obj) -> bool:
"""Check if the object can be converted directly."""
from specklepy.objects.geometry import Polyline, Point, Line, Arc, Circle, Curve, Polycurve, Mesh, Brep
if (
isinstance(obj, Point) or
isinstance(obj, Line) or
isinstance(obj, Polyline) or
isinstance(obj, Arc) or
isinstance(obj, Circle) or
isinstance(obj, Curve) or
isinstance(obj, Mesh)
):
return True
return False
def get_single_display_object(displayValForColor: List) -> "Base":
"""Get a merged Mesh or a first item from displayValue list."""
from specklepy.objects.geometry import Mesh
faces = []
verts = []
colors = []
for i, item in enumerate(displayValForColor):
if isinstance(item, Mesh):
start_vert_count = int(len(verts)/3)
# only add colors if existing and incoming colors are valid (same length as vertices)
if len(colors) == start_vert_count and isinstance(item.colors, List) and len(item.colors)== int(len(item.vertices)/3)>0:
colors.extend(item.colors)
else:
colors = []
verts.extend(item.vertices)
count = 0
for _ in item.faces:
try:
vert_num = item.faces[count]
faces.append(vert_num)
faces.extend([ x+start_vert_count for x in item.faces[count+1 : count+1+vert_num]])
count += vert_num+1
except IndexError:
break
elif item is not None:
return item
mesh = Mesh.create(faces= faces, vertices=verts, colors=colors)
if isinstance(displayValForColor, List) and len(displayValForColor)>0:
for prop in displayValForColor[0].get_member_names():
if prop not in ["colors", "vertices", "faces"]:
mesh[prop] = getattr(displayValForColor[0], prop)
displayValForColor = mesh
return displayValForColor
def get_display_units(context_list: List["TraversalContext"]) -> None | str:
"""Get units from either of displayable objects."""
from specklepy.objects.geometry import Base
displayUnits = None
for item in context_list:
if hasattr(item.current, "displayValue"):
try:
displayVal = item.current["displayValue"]
except:
displayVal = item.current.displayValue
if isinstance(displayVal, list) and len(displayVal)>0:
displayUnits = displayVal[0].units
break
elif isinstance(displayVal, Base):
displayUnits = item.current.units
break
else:
if item.current.units is not None:
displayUnits = item.current.units
break
return displayUnits
def get_material_color_proxies(root_obj) -> Dict:
"""Get colors and object IDs using ColorProxies and renderMaterialProxies."""
obj_colors = {}
# first, get colors
try:
colorProxies = root_obj["colorProxies"]
if isinstance(colorProxies, List):
for proxy in colorProxies:
color = proxy.value
a, r, g, b = get_r_g_b(color)
color = f'rgba({r},{g},{b},{a})'
for obj in proxy.objects:
obj_colors[obj] = color
except:
pass
# overwrite with materials if available
try:
materialProxies = root_obj["renderMaterialProxies"]
if isinstance(materialProxies, List):
for proxy in materialProxies:
material = proxy.value
color = material['diffuse']
opacity = material['opacity']
a, r, g, b = get_r_g_b(color)
if opacity is not None and isinstance(opacity, float):
a_test = int(255* opacity)
if 0 <= a_test <= 255:
a = a_test
color = f'rgba({r},{g},{b},{a})'
for obj in proxy.objects:
obj_colors[obj] = color
except:
pass
return obj_colors
def set_default_color(context_list: List["TraversalContext"]) -> None:
"""Get and set the default color."""
from specklepy.objects.GIS.layers import VectorLayer
global DEFAULT_COLOR
DEFAULT_COLOR = (255 << 24) + (150 << 16) + (150 << 8) + 150
for item in context_list:
# for GIS-commits, use default blue color
if isinstance(item.current, VectorLayer) or (item.parent is not None and isinstance(item.parent.current, VectorLayer)):
DEFAULT_COLOR = (255 << 24) + (10 << 16) + (132 << 8) + 255 # speckle blue, speckle_blue
break
def getAllParents(tc: "TraversalContext"):
all_tc = [tc]
while True:
try:
parent = tc.parent
if parent:
all_tc.append(parent)
tc = parent
else:
break
except:
break
return all_tc
def assign_color(self: "SpeckleProvider", obj_display_tc: "TraversalContext", props: Dict) -> None:
"""Get and assign color to feature displayProperties."""
from specklepy.objects.geometry import Mesh, Brep
# initialize Speckle Blue color
color = DEFAULT_COLOR
opacity = None
obj_display = obj_display_tc.current
try:
# first, choose if get color from the parent obj or displayValue
if hasattr(obj_display, 'displayStyle') or hasattr(obj_display, '@displayStyle') or hasattr(obj_display, 'renderMaterial') or hasattr(obj_display, '@renderMaterial'):
obj_display = obj_display_tc.current
else:
# this option will be not very reliable:
# there could be different colors for diff displayValues in the list
if hasattr(obj_display, 'displayValue'):
try:
displayVal = obj_display['displayValue']
except:
displayVal = obj_display.displayValue
if isinstance(displayVal, list) and len(displayVal)>0:
obj_display = displayVal[0]
elif hasattr(obj_display, '@displayValue') and isinstance(obj_display['@displayValue'], list) and len(obj_display['@displayValue'])>0:
obj_display = obj_display['@displayValue'][0]
# prioritize renderMaterials for Meshes & Brep
if isinstance(obj_display, Mesh) or isinstance(obj_display, Brep):
# print(obj_display.get_member_names())
if hasattr(obj_display, 'renderMaterial'):
try:
renderMaterial = obj_display['renderMaterial']
except:
renderMaterial = obj_display.renderMaterial
color = renderMaterial['diffuse']
opacity = renderMaterial['opacity']
elif hasattr(obj_display, '@renderMaterial'):
color = obj_display['@renderMaterial']['diffuse']
opacity = obj_display['@renderMaterial']['opacity']
elif isinstance(obj_display, Mesh) and isinstance(obj_display.colors, List) and len(obj_display.colors)>1:
colors_number = 0
all_colors = []
for c in obj_display.colors:
if c not in all_colors:
colors_number += 1
all_colors.append(c)
if colors_number>1:
all_a = 0
all_r = 0
all_g = 0
all_b = 0
for col in all_colors:
a, r, g, b = get_r_g_b(col)
all_a += a
all_r += r
all_g += g
all_b += b
color = (
(int(all_a/len(obj_display.colors)) << 24) + (int(all_r/len(obj_display.colors)) << 16)
+ (int(all_g/len(obj_display.colors)) << 8) + int(all_b/len(obj_display.colors))
)
else:
color = obj_display.colors[0]
elif hasattr(obj_display, 'displayStyle'):
color = obj_display['displayStyle']['color']
elif hasattr(obj_display, '@displayStyle'):
color = obj_display['@displayStyle']['color']
elif hasattr(obj_display, 'displayStyle'):
color = obj_display['displayStyle']['color']
elif hasattr(obj_display, '@displayStyle'):
color = obj_display['@displayStyle']['color']
elif hasattr(obj_display, 'renderMaterial'):
color = obj_display['renderMaterial']['diffuse']
opacity = obj_display['renderMaterial']['opacity']
elif hasattr(obj_display, '@renderMaterial'):
color = obj_display['@renderMaterial']['diffuse']
opacity = obj_display['@renderMaterial']['opacity']
except Exception as e:
print(e)
a, r, g, b = get_r_g_b(color)
if opacity is not None and isinstance(opacity, float):
a_test = int(255* opacity)
if 0 <= a_test <= 255:
a = a_test
# hex_color = '#%02x%02x%02x' % (r, g, b)
props['color'] = f'rgba({r},{g},{b},{a})'
# if still not found, check proxies:
if color == DEFAULT_COLOR:
for tc in getAllParents(obj_display_tc):
try:
color = self.material_color_proxies[tc.current.applicationId]
props['color'] = color
return
except:
pass
try:
color = self.material_color_proxies[obj_display.applicationId]
props['color'] = color
return
except:
pass
def get_r_g_b(rgb: int) -> Tuple[int, int, int]:
"""Get R, G, B values from int."""
r = g = b = 0
a = 255
try:
a = (rgb & 0xFF000000) >> 24
r = (rgb & 0xFF0000) >> 16
g = (rgb & 0xFF00) >> 8
b = rgb & 0xFF
except Exception as e:
r = g = b = 150
a = 255
return a, r, g, b
def assign_display_properties(self: "SpeckleProvider", feature: Dict, f_base: "Base", obj_display_tc: "TraversalContext") -> None:
"""Assign displayProperties to the feature."""
from specklepy.objects.geometry import Mesh, Brep
assign_color(self, obj_display_tc, feature["displayProperties"])
feature["properties"]["color"] = feature["displayProperties"]["color"]
# other properties for rendering
if isinstance(f_base, Mesh) or isinstance(f_base, Brep):
feature["displayProperties"]['lineWidth'] = 0.3
elif "Line" in feature["geometry"]["type"]:
feature["displayProperties"]['lineWidth'] = 3
else:
feature["displayProperties"]['lineWidth'] = 1
# if "Point" in feature["geometry"]["type"]:
try:
feature["displayProperties"]["radius"] = feature["properties"]["weight"]
except:
feature["displayProperties"]["radius"] = 10
@@ -1,236 +0,0 @@
from datetime import datetime
from typing import Dict, List
def initialize_features(self: "SpeckleProvider", all_coords, all_coord_counts, data, context_list, comments: Dict) -> None:
"""Create features with props and displayProps, and assign flat list of coordinates."""
from pygeoapi.provider.speckle_utils.props_utils import assign_props, assign_missing_props
from pygeoapi.provider.speckle_utils.converter_utils import assign_geometry
from pygeoapi.provider.speckle_utils.display_utils import find_display_obj, assign_display_properties, find_list_of_display_obj
from specklepy.objects.graph_traversal.traversal import TraversalContext
from specklepy.objects.other import Collection
# print(f"Creating features..")
time1 = datetime.now()
all_props = []
feature_count = 0
if self.requested_data_type != "projectcomments":
for item in context_list:
if item.current.speckle_type.endswith("Collection") or item.current.speckle_type.endswith("Layer") or item.current.speckle_type.endswith("Proxy"):
continue
if feature_count >= self.limit:
self.limit_message = f" (feature count limited to {self.limit})"
break
f_base = item.current
f_id = item.current.id
f_fid = feature_count + 1
# initialize feature
speckle_type = item.current.speckle_type
if ":" in speckle_type:
speckle_type = speckle_type.split(":")[-1]
feature: Dict = {
"type": "Feature",
#"bbox": [-180.0, -90.0, 180.0, 90.0], should not be in degrees
"geometry": {},
"displayProperties":{
"object_type": "geometry",
},
"properties": {
"id": f_id,
"FID": f_fid,
"speckle_type": speckle_type,
},
}
# feature geometry, props and displayProps
coords = []
coord_counts = []
if "true" in self.preserve_attributes:
obj_display, obj_get_color = find_display_obj(f_base)
try: # don't break the code if 1 feature fails
coords, coord_counts = assign_geometry(self, feature, obj_display)
except TypeError as ex:
raise ex
except Exception as e:
print(e)
pass
if len(coords)!=0:
all_coords.extend(coords)
all_coord_counts.append(coord_counts)
assign_props(f_base, feature["properties"])
# update list of all properties
for prop in feature["properties"]:
if prop not in all_props:
all_props.append(prop)
obj_get_color_tc = TraversalContext(obj_get_color, "", item)
assign_display_properties(self, feature, f_base, obj_get_color_tc)
feature["max_height"] = max([c[2] for c in coords])
feature["bbox"] = get_feature_bbox(coords)
data["features"].append(feature)
feature_count += 1
else:
list_of_display_obj = find_list_of_display_obj(f_base) # tuple
for k, vals in enumerate(list_of_display_obj):
obj_display, obj_get_color = vals
f_fid = feature_count + 1
feature_new: Dict = {
"type": "Feature",
#"bbox": [-180.0, -90.0, 180.0, 90.0], should not be in degrees
"geometry": {},
"displayProperties":{
"object_type": "geometry",
},
"properties": {
"id": f_id + "_" + str(k),
"FID": f_fid,
"speckle_type": item.current.speckle_type.split(":")[-1],
},
}
coords = []
coord_counts = []
try: # don't break the code if 1 feature fails
coords, coord_counts = assign_geometry(self, feature_new, obj_display)
except TypeError as ex:
raise ex
except Exception as e:
print(e)
pass
if len(coords)!=0:
all_coords.extend(coords)
all_coord_counts.append(coord_counts)
obj_get_color_tc = TraversalContext(obj_display, "", item)
assign_display_properties(self, feature_new, f_base, obj_get_color_tc)
feature_new["max_height"] = max([c[2] for c in coords])
feature_new["bbox"] = get_feature_bbox(coords)
data["features"].append(feature_new)
feature_count +=1
assign_missing_props(data["features"], all_props)
else:
####################### create comment features
for comm_id, comment in comments.items():
if len(data["comments"]) >= self.limit:
self.limit_message = f" (feature count limited to {self.limit})"
break
# initialize comment
feature: Dict = {
"type": "Feature",
"id": comm_id,
"geometry": {},
"displayProperties": {
"object_type": "comment",
},
"properties": {
"messages": [],
"text_html": "",
"resource_id": "",
"all_attachments": []
},
}
coords = []
coord_counts = []
try: # don't break the code if 1 comment fails
coords, coord_counts = assign_geometry(self, feature, comment["position"])
except Exception as e:
print(e)
pass
if len(coords)!=0:
all_coords.extend(coords)
all_coord_counts.append(coord_counts)
assign_comment_data(comment["items"], feature["properties"])
data["comments"].append(feature)
########################
if len(data["features"])==0 and len(data["comments"])==0:
raise ValueError(f"No supported features of type '{self.requested_data_type}' found. Make sure correct type is requested by adding a URL parameter (e.g. '&dataType=points').")
time2 = datetime.now()
time_operation = (time2-time1).total_seconds()
self.times["time_creating_features"] = time_operation
# print(f"Creating features time: {time_operation}")
def get_feature_bbox(coords) -> List[float]:
"""Get min max coordinates of the feature."""
x0 = min([c[0] for c in coords])
x1 = max([c[0] for c in coords])
y0 = min([c[1] for c in coords])
y1 = max([c[1] for c in coords])
return [x0, y0, x1, y1]
def assign_comment_data(comments, properties):
"""Create html text to display for the thread."""
for item in comments:
r'''
"author": author_name,
"date": created_date, # e.g. 2024-08-25T13:52:50.562Z
"text": raw_text,
"attachments": [attachments_paths],
"resource_id": string
'''
try:
formatted_time = datetime.strptime(item["date"].replace("T", " ").replace("Z","").split(".")[0], '%Y-%m-%d %H:%M:%S')
except:
formatted_time = item["date"]
properties["messages"].append(f"Author: {item["author"]}, created: {formatted_time}, text: {item["text"]}, attachments: {[img for img in item["attachments"]]}")
try:
properties["resource_id"] = item["resource_id"]
except:
pass # will not be available for replies, only first comment
properties["text_html"] += f"<b>{item["author"]}</b> at {formatted_time}: <br> &emsp; {item["text"]}<br>"
for img in item["attachments"]:
properties["text_html"] += f" <i> &emsp; '{img}'</i> <br>"
properties["all_attachments"].append(img)
properties["text_html"] += "<br>"
#properties["author"] = comment["author"]
#properties["date"] = comment["date"]
#properties["text"] = comment["text"]
#properties["attachments"] = comment["attachments"]
def create_features(self: "SpeckleProvider", context_list: List["TraversalContext"], comments: Dict, data: Dict) -> None:
"""Create features from the list of traversal context."""
from pygeoapi.provider.speckle_utils.coords_utils import reproject_bulk
all_coords = []
all_coord_counts = []
initialize_features(self, all_coords, all_coord_counts, data, context_list, comments)
all_features = data["features"] + data["comments"]
reproject_bulk(self, all_coords, all_coord_counts, [f["geometry"] for f in all_features])
@@ -1,79 +0,0 @@
import os
import sys
from typing import Optional
_user_data_env_var = "SPECKLE_USERDATA_PATH"
_application_name = "Speckle"
def user_application_data_path() -> "Path":
"""Get the platform specific user configuration folder path"""
from pathlib import Path
path_override = _path()
if path_override:
return path_override
try:
if sys.platform.startswith("win"):
app_data_path = os.getenv("APPDATA")
if not app_data_path:
raise Exception("Cannot get appdata path from environment.")
return Path(app_data_path)
else:
# try getting the standard XDG_DATA_HOME value
# as that is used as an override
app_data_path = os.getenv("XDG_DATA_HOME")
if app_data_path:
return Path(app_data_path)
else:
return ensure_folder_exists(Path.home(), ".config")
except Exception as ex:
raise Exception("Failed to initialize user application data path.", ex)
def ensure_folder_exists(base_path: "Path", folder_name: str) -> "Path":
from pathlib import Path
path = base_path.joinpath(folder_name)
path.mkdir(exist_ok=True, parents=True)
return path
def _path() -> Optional["Path"]:
from pathlib import Path
"""Read the user data path override setting."""
path_override = os.environ.get(_user_data_env_var)
if path_override:
return Path(path_override)
return None
def connector_installation_path(host_application: str) -> "Path":
connector_installation_path = user_speckle_connector_installation_path(
host_application
)
connector_installation_path.mkdir(exist_ok=True, parents=True)
# set user modules path at beginning of paths for earlier hit
if sys.path[0] != connector_installation_path:
sys.path.insert(0, str(connector_installation_path))
# print(f"Using connector installation path {connector_installation_path}")
return connector_installation_path
def user_speckle_connector_installation_path(host_application: str) -> "Path":
"""
Gets a connector specific installation folder.
In this folder we can put our connector installation and all python packages.
"""
return ensure_folder_exists(
ensure_folder_exists(
user_speckle_folder_path(), "connector_installations"
),
host_application,
)
def user_speckle_folder_path() -> "Path":
"""Get the folder where the user's Speckle data should be stored."""
return ensure_folder_exists(
user_application_data_path(), _application_name
)
-3
View File
@@ -1,3 +0,0 @@
COUNTRY_CODES = ["ru"]
STATES = ['Автономна Республіка Крим', 'Севастополь', 'Донецька область', 'Луганська область']
POSTCODES = [str(i) for i in range(95000,99999)]
@@ -1,13 +0,0 @@
from typing import List, Optional
from specklepy.objects.base import Base
class GisFeature(
Base, speckle_type="Objects.GIS.GisFeature", detachable={"displayValue"}
):
"""GIS Feature"""
geometry: Optional[List[Base]] = None
attributes: Base
displayValue: Optional[List[Base]] = None
@@ -1,439 +0,0 @@
import hashlib
import re
import warnings
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
from uuid import uuid4
from warnings import warn
import ujson
# import for serialization
from specklepy.logging.exceptions import SpeckleException, SpeckleWarning
from specklepy.objects.base import Base, DataChunk
from specklepy.transports.abstract_transport import AbstractTransport
PRIMITIVES = (int, float, str, bool)
def hash_obj(obj: Any) -> str:
return hashlib.sha256(ujson.dumps(obj).encode()).hexdigest()[:32]
def safe_json_loads(obj: str, obj_id=None) -> Any:
try:
return ujson.loads(obj)
except ValueError as err:
import json
warn(
f"Failed to deserialise object (id: {obj_id}). This is likely a ujson big"
f" int error - falling back to json. \nError: {err}",
SpeckleWarning,
)
return json.loads(obj)
class BaseObjectSerializer:
read_transport: AbstractTransport
write_transports: List[AbstractTransport]
detach_lineage: List[bool] # tracks depth and whether or not to detach
lineage: List[str] # keeps track of hash chain through the object tree
family_tree: Dict[str, Dict[str, int]]
closure_table: Dict[str, Dict[str, int]]
deserialized: Dict[
str, Base
] # holds deserialized objects so objects with same id return the same instance
def __init__(
self,
write_transports: Optional[List[AbstractTransport]] = None,
read_transport: Optional[AbstractTransport] = None,
) -> None:
self.write_transports = write_transports or []
self.read_transport = read_transport
self.detach_lineage = []
self.lineage = []
self.family_tree = {}
self.closure_table = {}
self.deserialized = {}
def write_json(self, base: Base):
"""Serializes a given base object into a json string
Arguments:
base {Base} -- the base object to be decomposed and serialized
Returns:
(str, str) -- a tuple containing the object id of the base object and
the serialized object string
"""
obj_id, obj = self.traverse_base(base)
return obj_id, ujson.dumps(obj)
def traverse_base(self, base: Base) -> Tuple[str, Dict[str, Any]]:
"""Decomposes the given base object and builds a serializable dictionary
Arguments:
base {Base} -- the base object to be decomposed and serialized
Returns:
(str, dict) -- a tuple containing the object id of the base object and
the constructed serializable dictionary
"""
self.__reset_writer()
if self.write_transports:
for wt in self.write_transports:
wt.begin_write()
obj_id, obj = self._traverse_base(base)
if self.write_transports:
for wt in self.write_transports:
wt.end_write()
return obj_id, obj
def _traverse_base(self, base: Base) -> Tuple[str, Dict]:
if not self.detach_lineage:
self.detach_lineage = [True]
self.lineage.append(uuid4().hex)
object_builder = {"id": "", "speckle_type": "Base", "totalChildrenCount": 0}
object_builder.update(speckle_type=base.speckle_type)
obj, props = base, base.get_serializable_attributes()
while props:
prop = props.pop(0)
value = getattr(obj, prop, None)
chunkable = False
detach = False
# skip props marked to be ignored with "__" or "_"
if prop.startswith(("__", "_")):
continue
# don't prepopulate id as this will mess up hashing
if prop == "id":
continue
# only bother with chunking and detaching if there is a write transport
if self.write_transports:
dynamic_chunk_match = prop.startswith("@") and re.match(
r"^@\((\d*)\)", prop
)
if dynamic_chunk_match:
chunk_size = dynamic_chunk_match.groups()[0]
base._chunkable[prop] = (
int(chunk_size) if chunk_size else base._chunk_size_default
)
chunkable = prop in base._chunkable
detach = bool(
prop.startswith("@") or prop in base._detachable or chunkable
)
# 1. handle None and primitives (ints, floats, strings, and bools)
if value is None or isinstance(value, PRIMITIVES):
object_builder[prop] = value
continue
# NOTE: for dynamic props, this won't be re-serialised as an enum but as an int
if isinstance(value, Enum):
object_builder[prop] = value.value
continue
# 2. handle Base objects
elif isinstance(value, Base):
child_obj = self.traverse_value(value, detach=detach)
if detach and self.write_transports:
ref_id = child_obj["id"]
object_builder[prop] = self.detach_helper(ref_id=ref_id)
else:
object_builder[prop] = child_obj
# 3. handle chunkable props
elif chunkable and self.write_transports:
chunks = []
max_size = base._chunkable[prop]
chunk = DataChunk()
for count, item in enumerate(value):
if count and count % max_size == 0:
chunks.append(chunk)
chunk = DataChunk()
chunk.data.append(item)
chunks.append(chunk)
chunk_refs = []
for c in chunks:
self.detach_lineage.append(detach)
ref_id, _ = self._traverse_base(c)
ref_obj = self.detach_helper(ref_id=ref_id)
chunk_refs.append(ref_obj)
object_builder[prop] = chunk_refs
# 4. handle all other cases
else:
child_obj = self.traverse_value(value, detach)
object_builder[prop] = child_obj
closure = {}
# add closures & children count to the object
detached = self.detach_lineage.pop()
if self.lineage[-1] in self.family_tree:
closure = {
ref: depth - len(self.detach_lineage)
for ref, depth in self.family_tree[self.lineage[-1]].items()
}
object_builder["totalChildrenCount"] = len(closure)
obj_id = hash_obj(object_builder)
object_builder["id"] = obj_id
if closure:
object_builder["__closure"] = self.closure_table[obj_id] = closure
# write detached or root objects to transports
if detached and self.write_transports:
for t in self.write_transports:
t.save_object(id=obj_id, serialized_object=ujson.dumps(object_builder))
del self.lineage[-1]
return obj_id, object_builder
def traverse_value(self, obj: Any, detach: bool = False) -> Any:
"""Decomposes a given object and constructs a serializable object or dictionary
Arguments:
obj {Any} -- the value to decompose
Returns:
Any -- a serializable version of the given object
"""
if obj is None:
return None
if isinstance(obj, PRIMITIVES):
return obj
# NOTE: for dynamic props, this won't be re-serialised as an enum but as an int
if isinstance(obj, Enum):
return obj.value
elif isinstance(obj, (list, tuple, set)):
if not detach:
return [self.traverse_value(o) for o in obj]
detached_list = []
for o in obj:
if isinstance(o, Base):
self.detach_lineage.append(detach)
ref_id, _ = self._traverse_base(o)
detached_list.append(self.detach_helper(ref_id=ref_id))
else:
detached_list.append(self.traverse_value(o, detach))
return detached_list
elif isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, PRIMITIVES) or v is None:
continue
else:
obj[k] = self.traverse_value(v)
return obj
elif isinstance(obj, Base):
self.detach_lineage.append(detach)
_, base_obj = self._traverse_base(obj)
return base_obj
else:
try:
return obj.dict()
except Exception:
warn(
f"Failed to handle {type(obj)} in"
" `BaseObjectSerializer.traverse_value`",
SpeckleWarning,
)
return str(obj)
def detach_helper(self, ref_id: str) -> Dict[str, str]:
"""
Helper to keep track of detached objects and their depth in the family tree
and create reference objects to place in the parent object
Arguments:
ref_id {str} -- the id of the fully traversed object
Returns:
dict -- a reference object to be inserted into the given object's parent
"""
for parent in self.lineage:
if parent not in self.family_tree:
self.family_tree[parent] = {}
if ref_id not in self.family_tree[parent] or self.family_tree[parent][
ref_id
] > len(self.detach_lineage):
self.family_tree[parent][ref_id] = len(self.detach_lineage)
return {
"referencedId": ref_id,
"speckle_type": "reference",
}
def __reset_writer(self) -> None:
"""
Reinitializes the lineage, and other variables that get used during the json
writing process
"""
self.detach_lineage = [True]
self.lineage = []
self.family_tree = {}
self.closure_table = {}
def read_json(self, obj_string: str) -> Base:
"""Recomposes a Base object from the string representation of the object
Arguments:
obj_string {str} -- the string representation of the object
Returns:
Base -- the base object with all it's children attached
"""
if not obj_string:
return None
self.deserialized = {}
obj = safe_json_loads(obj_string)
return self.recompose_base(obj=obj)
def recompose_base(self, obj: dict) -> Base:
"""Steps through a base object dictionary and recomposes the base object
Arguments:
obj {dict} -- the dictionary representation of the object
Returns:
Base -- the base object with all its children attached
"""
# make sure an obj was passed and create dict if string was somehow passed
if not obj:
return
if isinstance(obj, str):
obj = safe_json_loads(obj)
if "id" in obj and obj["id"] in self.deserialized:
return self.deserialized[obj["id"]]
if "speckle_type" in obj and obj["speckle_type"] == "reference":
obj = self.get_child(obj=obj)
speckle_type = obj.get("speckle_type")
# if speckle type is not in the object definition, it is treated as a dict
if not speckle_type:
return obj
# get the registered type from base register.
object_type = Base.get_registered_type(speckle_type)
# initialise the base object using `speckle_type` fall back to base if needed
base = object_type() if object_type else Base.of_type(speckle_type=speckle_type)
# get total children count
if "__closure" in obj:
if not self.read_transport:
raise SpeckleException(
message="Cannot resolve reference - no read transport is defined"
)
closure = obj.pop("__closure")
base.totalChildrenCount = len(closure)
for prop, value in obj.items():
# 1. handle primitives (ints, floats, strings, and bools) or None
if isinstance(value, PRIMITIVES) or value is None:
base.__setattr__(prop, value)
continue
# 2. handle referenced child objects
elif "referencedId" in value:
ref_id = value["referencedId"]
ref_obj_str = self.read_transport.get_object(id=ref_id)
if ref_obj_str:
ref_obj = safe_json_loads(ref_obj_str, ref_id)
base.__setattr__(prop, self.recompose_base(obj=ref_obj))
else:
warnings.warn(
f"Could not find the referenced child object of id `{ref_id}`"
f" in the given read transport: {self.read_transport.name}",
SpeckleWarning,
)
base.__setattr__(prop, self.handle_value(value))
# 3. handle all other cases (base objects, lists, and dicts)
else:
base.__setattr__(prop, self.handle_value(value))
if "id" in obj:
self.deserialized[obj["id"]] = base
return base
def handle_value(self, obj: Any):
"""Helper for recomposing a base object by handling the dictionary
representation's values
Arguments:
obj {Any} -- a value from the base object dictionary
Returns:
Any -- the handled value (primitive, list, dictionary, or Base)
"""
if not obj:
return obj
if isinstance(obj, PRIMITIVES):
return obj
# lists (regular and chunked)
if isinstance(obj, list):
obj_list = [self.handle_value(o) for o in obj]
if (
hasattr(obj_list[0], "speckle_type")
and "DataChunk" in obj_list[0].speckle_type
):
# handle chunked lists
data = []
for o in obj_list:
data.extend(o.data)
return data
return obj_list
# bases
if isinstance(obj, dict) and "speckle_type" in obj:
return self.recompose_base(obj=obj)
# dictionaries
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, PRIMITIVES):
continue
else:
obj[k] = self.handle_value(v)
return obj
def get_child(self, obj: Dict):
ref_id = obj["referencedId"]
ref_obj_str = self.read_transport.get_object(id=ref_id)
if not ref_obj_str:
warnings.warn(
f"Could not find the referenced child object of id `{ref_id}` in the"
f" given read transport: {self.read_transport.name}",
SpeckleWarning,
)
return obj
return safe_json_loads(ref_obj_str, ref_id)
@@ -1,140 +0,0 @@
import sys
from pathlib import Path
import shutil
import pygeoapi
def get_specklepy_path():
import specklepy
return Path(specklepy.__file__).parent
def get_pygeoapi_path():
return Path(pygeoapi.__file__).parent
def get_credentials_path():
specklepy_path = get_specklepy_path()
credentials_path = Path(specklepy_path, "core", "api", "credentials.py")
return str(credentials_path)
def get_transport_path():
specklepy_path = get_specklepy_path()
credentials_path = Path(specklepy_path, "transports", "server", "server.py")
return str(credentials_path)
def get_transport_path_src():
credentials_path = Path(get_pygeoapi_path(), "provider", "speckle_utils", "patch", "server.py")
return str(credentials_path)
def get_serializer_path():
specklepy_path = get_specklepy_path()
credentials_path = Path(specklepy_path, "serialization", "base_object_serializer.py")
return str(credentials_path)
def get_serializer_path_src():
credentials_path = Path(get_pygeoapi_path(), "provider", "speckle_utils", "patch", "base_object_serializer.py")
return str(credentials_path)
def get_gis_feature_path_src():
credentials_path = Path(get_pygeoapi_path(), "provider", "speckle_utils", "patch", "GisFeature.py")
return str(credentials_path)
def get_gis_feature_path_dst():
specklepy_path = get_specklepy_path()
credentials_path = Path(specklepy_path, "objects", "GIS", "GisFeature.py")
return str(credentials_path)
def patch_credentials():
"""Patches the installer with the correct connector version and specklepy version"""
file_path = get_credentials_path()
with open(file_path, "r") as file:
lines = file.readlines()
new_lines = []
for i, line in enumerate(lines):
if "Account.model_validate_json" in line:
line = line.replace("Account.model_validate_json", "Account.parse_raw")
new_lines.append(line)
file.close()
with open(file_path, "w") as file:
file.writelines(new_lines)
file.close()
def patch_transport():
"""Patches the installer with the correct connector version and specklepy version"""
server_data = get_transport_path_src()
file_path = get_transport_path()
with open(server_data, "r") as file:
lines = file.readlines()
file.close()
with open(file_path, "w") as file:
file.writelines(lines)
file.close()
def patch_serializer():
"""Patches the installer with the correct connector version and specklepy version"""
server_data = get_serializer_path_src()
file_path = get_serializer_path()
with open(server_data, "r") as file:
lines = file.readlines()
file.close()
with open(file_path, "w") as file:
file.writelines(lines)
file.close()
def complete_patch():
"""Patches the installer with the correct connector version and specklepy version"""
# check file 1
file_path = get_transport_path()
with open(file_path, "r") as file:
lines = file.readlines()
file.close()
if len(lines) < 184:
return False
# check file 1
file_path = get_serializer_path()
with open(file_path, "r") as file:
lines = file.readlines()
file.close()
if len(lines) < 443:
return False
return True
def copy_gis_feature():
shutil.copyfile(get_gis_feature_path_src(), get_gis_feature_path_dst())
def patch_specklepy():
#if complete_patch():
# return
patch_credentials()
copy_gis_feature()
patch_transport()
patch_serializer()
if __name__ == "__main__":
patch_specklepy()
@@ -1,187 +0,0 @@
import json
from typing import Dict, List, Optional
from warnings import warn
import requests
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account, get_account_from_token
from specklepy.logging.exceptions import SpeckleException, SpeckleWarning
from specklepy.transports.abstract_transport import AbstractTransport
from .batch_sender import BatchSender
class ServerTransport(AbstractTransport):
"""
The `ServerTransport` is the vehicle through which you transport objects to and
from a Speckle Server. Provide it to `operations.send()` or `operations.receive()`.
The `ServerTransport` can be authenticated two different ways:
1. by providing a `SpeckleClient`
2. by providing an `Account`
3. by providing a `token` and `url`
```py
from specklepy.api import operations
from specklepy.transports.server import ServerTransport
# here's the data you want to send
block = Block(length=2, height=4)
# next create the server transport - this is the vehicle through which
# you will send and receive
transport = ServerTransport(stream_id=new_stream_id, client=client)
# this serialises the block and sends it to the transport
hash = operations.send(base=block, transports=[transport])
# you can now create a commit on your stream with this object
commit_id = client.commit.create(
stream_id=new_stream_id,
obj_id=hash,
message="this is a block I made in speckle-py",
)
```
"""
def __init__(
self,
stream_id: str,
client: Optional[SpeckleClient] = None,
account: Optional[Account] = None,
token: Optional[str] = None,
url: Optional[str] = None,
name: str = "RemoteTransport",
) -> None:
super().__init__()
if client is None and account is None and token is None and url is None:
raise SpeckleException(
"You must provide either a client or a token and url to construct a"
" ServerTransport."
)
self._name = name
self.account = None
self.saved_obj_count = 0
if account:
self.account = account
url = account.serverInfo.url
elif client:
url = client.url
if not client.account.token:
warn(
SpeckleWarning(
"Unauthenticated Speckle Client provided to Server Transport"
f" for {url}. Receiving from private streams will fail."
)
)
else:
self.account = client.account
else:
self.account = get_account_from_token(token, url)
self.stream_id = stream_id
self.url = url
self.session = requests.Session()
if self.account.token is not None:
self._batch_sender = BatchSender(
self.url, self.stream_id, self.account.token, max_batch_size_mb=1
)
self.session.headers.update(
{
"Authorization": f"Bearer {self.account.token}",
"Accept": "text/plain",
}
)
@property
def name(self) -> str:
return self._name
def begin_write(self) -> None:
self.saved_obj_count = 0
def end_write(self) -> None:
self._batch_sender.flush()
def save_object(self, id: str, serialized_object: str) -> None:
self._batch_sender.send_object(id, serialized_object)
def save_object_from_transport(
self, id: str, source_transport: AbstractTransport
) -> None:
obj_string = source_transport.get_object(id=id)
self.save_object(id=id, serialized_object=obj_string)
def get_object(self, id: str) -> str:
# endpoint = f"{self.url}/objects/{self.stream_id}/{id}/single"
# r = self.session.get(endpoint, stream=True)
# _, obj = next(r.iter_lines().decode("utf-8")).split("\t")
# return obj
raise SpeckleException(
"Getting a single object using `ServerTransport.get_object()` is not"
" implemented. To get an object from the server, please use the"
" `SpeckleClient.object.get()` route",
NotImplementedError(),
)
def has_objects(self, id_list: List[str]) -> Dict[str, bool]:
return {id: False for id in id_list}
def copy_object_and_children(
self, id: str, target_transport: AbstractTransport
) -> str:
endpoint = f"{self.url}/objects/{self.stream_id}/{id}/single"
r = self.session.get(endpoint)
r.encoding = "utf-8"
if r.status_code != 200:
raise SpeckleException(
f"Can't get object {self.stream_id}/{id}: HTTP error"
f" {r.status_code} ({r.text[:1000]})"
)
root_obj_serialized = r.text
root_obj = json.loads(root_obj_serialized)
closures = root_obj.get("__closure", {})
# Check which children are not already in the target transport
children_ids = list(closures.keys())
children_found_map = target_transport.has_objects(children_ids)
new_children_ids = [
id for id in children_found_map if not children_found_map[id]
]
# save headers and assign them back later
headers = self.session.headers
self.session.headers.update(
{
"Accept": "text/plain",
}
)
# Get the new children
endpoint = f"{self.url}/api/getobjects/{self.stream_id}"
r = self.session.post(
endpoint, data={"objects": json.dumps(new_children_ids)}, stream=True
)
r.encoding = "utf-8"
lines = r.iter_lines(decode_unicode=True)
self.session.headers = headers # return previous headers
# iter through returned objects saving them as we go
target_transport.begin_write()
for line in lines:
if line:
hash, obj = line.split("\t")
target_transport.save_object(hash, obj)
target_transport.save_object(id, root_obj_serialized)
target_transport.end_write()
return root_obj_serialized
@@ -1,103 +0,0 @@
from typing import Dict, List
def assign_props(obj: "Base", props: Dict):
"""Assign properties to the feature from Base object."""
from specklepy.objects.geometry import Base
from specklepy.objects.other import RevitParameter
all_prop_names = obj.get_member_names()
dynamic_prop_names = obj.get_dynamic_member_names()
typed_prop_names = obj.get_typed_member_names()
# check if GIS object
if "attributes" in all_prop_names and isinstance(obj["attributes"], Base):
all_prop_names = obj["attributes"].get_dynamic_member_names()
for prop_name in all_prop_names:
value = getattr(obj["attributes"], prop_name)
if (prop_name
in [
"geometry",
"Speckle_ID",
"id",
]
):
pass
else:
if (
isinstance(value, Base)
or isinstance(value, List)
or isinstance(value, Dict)
):
props[prop_name] = str(value)
else:
props[prop_name] = value
return
# if Rhino:
elif "userStrings" in dynamic_prop_names and isinstance(obj["userStrings"], Base):
all_prop_names = obj["userStrings"].get_dynamic_member_names()
for prop_name in all_prop_names:
if prop_name in ["id"]:
continue
value = getattr(obj["userStrings"], prop_name)
if not isinstance(value, str):
props[prop_name] = str(value)
else:
props[prop_name] = value
return
for prop_name in obj.get_dynamic_member_names():
if (
prop_name
in [
"displayValue",
"displayStyle",
"renderMaterial",
"revitLinkedModelPath",
"id",
]
):
pass
else:
value = getattr(obj, prop_name)
if (
isinstance(value, Base)
or isinstance(value, List)
or isinstance(value, Dict)
):
props[prop_name] = str(value)
else:
props[prop_name] = value
# if Revit:
if "parameters" in all_prop_names and isinstance(obj.parameters, Base):
for prop_name in obj.parameters.get_dynamic_member_names():
if prop_name in ["id","revitLinkedModelPath"]:
continue
param = getattr(obj.parameters, prop_name)
if isinstance(param, RevitParameter):
if not isinstance(param.value, str):
props[prop_name] = str(param.value)
else:
props[prop_name] = param.value
# add after dynamic parameters
def assign_missing_props(features: Dict, all_props: List[str]) -> None:
"""Assign NA values to missing properties."""
# assign all props to all features
for feat in features:
for prop in all_props:
if prop not in list(feat["properties"].keys()):
feat["properties"][prop] = "N/A"
@@ -1,234 +0,0 @@
import os
from pathlib import Path
from typing import Dict, List, Tuple
import pygeoapi
def get_stream_branch(self: "SpeckleProvider", client: "SpeckleClient", wrapper: "StreamWrapper") -> Tuple:
"""Get stream and branch from the server."""
from specklepy.logging.exceptions import SpeckleException
branch = None
stream = client.stream.get(
id = wrapper.stream_id, branch_limit=100
)
if isinstance(stream, Exception):
raise SpeckleException(stream.message+ ", "+ self.speckle_url)
for br in stream['branches']['items']:
if br['id'] == wrapper.model_id:
branch = br
break
return stream, branch
def get_client(wrapper: "StreamWrapper", url_proj: str) -> "SpeckleClient":
"""Get unauthenticated SpeckleClient."""
from specklepy.core.api.client import SpeckleClient
# get client by URL, no authentication
client = SpeckleClient(host=wrapper.host, use_ssl=wrapper.host.startswith("https"))
client.account.serverInfo.url = url_proj.split("/projects")[0]
return client
def get_comments(client: "SpeckleClient", project_id: str, model_id: str):
"""Query comments from the Project and Model (if recorded in Comment)."""
from gql import gql
from specklepy.logging.exceptions import SpeckleException, SpeckleInvalidUnitException
# get Project data
query = gql(
"""
query Comments ($project_id: String!) {
project(id: $project_id) {
commentThreads {
totalCount
items{
id
author{
name
}
createdAt
rawText
text{
attachments{
id
fileName
fileType
fileSize
}
}
viewerResources{
modelId
}
viewerState
replies{
items{
id
author{
name
}
createdAt
rawText
text{
attachments{
id
fileName
fileType
fileSize
}
}
viewerResources{
modelId
}
viewerState
}
}
}
}
}
}
"""
)
params = {
"project_id": project_id,
}
response_data = client.httpclient.execute(query, params)
threads = response_data["project"]["commentThreads"]["items"]
threads_objs = {}
for thread in threads:
comment_data = get_info_from_comment(thread, project_id, model_id)
if comment_data is None:
continue
# unpack object
comm_id, position, author_name, created_date, raw_text, attachments_paths, res_id = comment_data
threads_objs[comm_id] = {
"position": position,
"items": [{
"author": author_name,
"date": created_date,
"text": raw_text,
"attachments": attachments_paths,
"resource_id": res_id,
}]
}
replies = thread["replies"]["items"]
for reply in replies:
reply_data = get_info_from_comment(reply, project_id, model_id)
if reply_data is None:
continue
# unpack reply
_, position, author_name_reply, created_date_reply, raw_text_reply, attachments_paths_reply, _ = reply_data
threads_objs[comm_id]["items"].append(
{
"author": author_name_reply,
"date": created_date_reply,
"text": raw_text_reply,
"attachments": attachments_paths_reply,
}
)
return threads_objs
def get_info_from_comment(comment: Dict, project_id: str, model_id: str) -> Tuple [str, List[float], str, str, str, List[str]]:
"""Get displayable data from commit."""
comm_id = comment["id"]
author_name = comment["author"]["name"]
created_date = comment["createdAt"]
raw_text = comment["rawText"]
r'''
resources = comment["viewerResources"]
model_found = 1
# assume the model is matching, only exclude if other model_id is stated
for resource in resources:
if resource["modelId"] == model_id:
break
if resource["modelId"] is not None and resource["modelId"]!="" and resource["modelId"] != model_id:
# wrong model, don't include
model_found = 0
'''
position = [0,0,0]
res_id = model_id
viewer_state = comment["viewerState"]
if viewer_state is not None: # can be None for Replies
position: List[float] = viewer_state["ui"]["selection"]
try:
res_id = viewer_state["resources"]["request"]["resourceIdString"]
except:
pass
attachments = comment["text"]["attachments"]
attachments_paths = []
for attach in attachments:
try:
file_path = get_attachment(project_id, attach["id"], attach["fileName"])
attachments_paths.append(file_path)
except:
pass # attachment was not queried successfully
#if model_found is False:
# return None
return comm_id, position, author_name, created_date, raw_text, attachments_paths, res_id
def get_attachment(project_id: str, attachment_id: str, attachment_name: str) -> Path:
import requests
import shutil
return attachment_name
file_path_obj: Path = Path(Path(pygeoapi.__file__).parent.parent, "Temp_attachments", attachment_name)
print(file_path_obj)
file_path = str(file_path_obj)
print(file_path)
if os.path.isfile(file_path) is True: # if already saved
return file_path
url = f"https://speckle.xyz/api/stream/{project_id}/blob/{attachment_id}"
headers = {"User-Agent": "Speckle Pygeoapi"}
r = requests.get(url, headers=headers, stream=True)
if r.status_code == 200:
with open(file_path, "wb") as f:
r.raw.decode_content = True
shutil.copyfileobj(r.raw, f)
return file_path
else:
raise Exception(
f"Request not successful: Response code {r.status_code}"
)
def set_actions(self: "SpeckleProvider", client: "SpeckleClient", action: str = "GEO receive"):
from specklepy.logging.metrics import track
try:
full_dict = {**self.url_params, **self.times}
full_dict["GIS commit"] = self.commit_gis
full_dict["project_id"] = f"{self.project_id}"
full_dict["sourceHostApp"] = self.sourceApp
full_dict["model"] = f"{self.project_name}, {self.model_name}"
full_dict["time_TOTAL"] = sum([x[1] for x in self.times.items()])
full_dict["model_url"] = self.speckle_url
full_dict["model_country_code"] = self.country_code
track(action, client.account, full_dict)
except Exception as ex:
print(f"_Cannot set action '{action}': {ex}")
pass
@@ -1,104 +0,0 @@
import inspect
def get_set_url_parameters(self: "SpeckleProvider"):
"""Parse and save URL parameters."""
from pygeoapi.provider.speckle_utils.crs_utils import create_crs_from_authid
crsauthid = False
if (isinstance(self.data, str)):
for item in self.data.lower().split("&"):
# if CRS authid is found, rest will be ignored
if "speckleurl=" in item:
try:
speckle_url = item.split("speckleurl=")[1]
if "/projects/" not in speckle_url or "/models/" not in speckle_url:
raise ValueError(f"Provide valid Speckle Model URL: {item}")
if speckle_url[-1] == "/":
speckle_url = speckle_url[:-1]
self.speckle_project_url = speckle_url.split("/models")[0]
except:
raise ValueError(f"Provide valid Speckle Model URL: {item}")
elif "datatype=" in item:
try:
requested_data_type = item.split("datatype=")[1]
if requested_data_type in ["points", "lines", "polygons", "projectcomments"]:
self.requested_data_type = requested_data_type
self.url_params["url_data_type"] = requested_data_type
except:
raise ValueError(f"Provide valid dataType parameter (points/lines/polygons/projectcomments): {item}")
elif "preserveattributes=" in item:
try:
preserve_attributes = item.split("preserveattributes=")[1]
if preserve_attributes in ["true", "false"]:
self.preserve_attributes = preserve_attributes
self.url_params["url_preserve_attributes"] = preserve_attributes
except:
ValueError(f"Provide valid preserverAttributes parameter (true/false): {item}")
elif "crsauthid=" in item:
crs_authid = item.split("crsauthid=")[1]
if isinstance(crs_authid, str) and len(crs_authid)>3:
crsauthid = True
self.crs_authid = crs_authid
self.url_params["url_crs_authid"] = crs_authid
elif "lat=" in item:
try:
lat = float(item.split("lat=")[1])
self.lat = lat
self.url_params["url_lat"] = lat
except:
raise ValueError(f"Invalid Lat input, must be numeric: {item}")
elif "lon=" in item:
try:
lon = float(item.split("lon=")[1])
self.lon = lon
self.url_params["url_lon"] = lon
except:
raise ValueError(f"Invalid Lon input, must be numeric: {item}")
elif "northdegrees=" in item:
try:
north_degrees = float(item.split("northdegrees=")[1])
self.north_degrees = north_degrees
self.url_params["url_north_degrees"] = north_degrees
except:
raise ValueError(f"Invalid northDegrees input, must be numeric: {item}")
elif "limit=" in item:
try:
limit = int(item.split("limit=")[1])
if limit>0:
self.limit = limit
self.url_params["url_limit"] = limit
except:
ValueError(f"Invalid limit input, must be a positive integer: {item}")
elif "useragent=" in item:
try:
agent = item.split("useragent=")[1]
self.user_agent = agent
self.url_params["user_agent"] = agent
except:
ValueError(f"Invalid limit input, must be a positive integer: {item}")
if self.speckle_url == "-":
self.missing_url = "true"
# if CRS authid is found, rest will be ignored
if crsauthid:
self.lat = str(self.lat) + " (not applied)"
self.lon = str(self.lon) + " (not applied)"
self.north_degrees = 0 # default to 0: rotation ignored when AuthId is used #str(self.north_degrees) + " (not applied)"
# if CRS parameter present, create and assign CRS:
if len(self.crs_authid)>3:
create_crs_from_authid(self, self.crs_authid)
+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:
+10 -1
View File
@@ -12,7 +12,7 @@ main {
.crumbs {
background-color:rgb(230, 230, 230);
padding: 0px;
padding: 6px;
}
.crumbs a {
@@ -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;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

+64 -120
View File
@@ -1,69 +1,5 @@
<!doctype html>
<html lang="en">
<style>
.switch {
position: absolute;
display: inline-block;
right: 1vw;
top: 10px;
width: 70px;
height: 23px;
z-index: 1000000;
}
.switch span {
position: absolute;
left: 30px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: .4s;
transition: .4s;
border-radius: 12px;
}
.slider:before {
position: absolute;
content: "";
height: 15px;
width: 15px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #2196F3;
}
input:focus + .slider {
box-shadow: 0 0 1px #2196F3;
}
input:checked + .slider:before {
-webkit-transform: translateX(17px);
-ms-transform: translateX(17px);
transform: translateX(17px);
}
</style>
<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>
@@ -71,7 +7,7 @@
<meta name="language" content="{{ config['server']['language'] }}">
<meta name="description" content="{{ config['metadata']['identification']['title'] }}">
<meta name="keywords" content="{{ config['metadata']['identification']['keywords']|join(',') }}">
<link rel="shortcut icon" href="https://github.com/specklesystems/pygeoapi/blob/dev/pygeoapi/static/img/speckle_geo.png" type="image/x-icon">
<link rel="shortcut icon" href="{{ config['server']['url'] }}/static/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="https://unpkg.com/bootstrap@5.1.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ config['server']['url'] }}/static/css/default.css">
<!--[if lt IE 9]>
@@ -99,72 +35,80 @@
{% endblock %}
</head>
<body>
<div class="bg-white sticky-top border-bottom" >
<div class="container" style="max-height:fit-content;max-width: fit-content;margin-left: 10px;">
<header class="d-flex flex-wrap justify-content-center py-2" style="text-align: left;">
<a href="{{ config['metadata']['contact']['url'] }}" target="_blank"
class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none"
style="text-align:left;">
<img src="{{ config['server']['url'] }}/static/img/speckle_cube_32.png" alt="Speckle"
title="{{ config['metadata']['identification']['title'] }}" style="height:30px;vertical-align: middle;" />
<b style="text-align:left;padding-left: 10px;">Speckle</b>
</a>
<a href="https://geo.speckle.systems/" target="_blank" style="text-align:left;padding-left: 10px;" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
> Geolocating your data
</a>
{% if (data["model"] and data["model"]!="") %}
<a href="{{data['speckle_project_url']}}" target="_blank" style="text-align:left;padding-left: 10px;" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
> {{data["project"]}} >
</a>
<a href="{{data['speckle_url']}}" target="_blank" style="text-align:left;padding-left: 10px;color:rgb(10,132,255);" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-decoration-none">
{{data["model"]}} {{data["limit_message"]}}
</a>
{% endif %}
<div class="form-group" >
<label class="switch">3D
<input id="modeSwitch" type="checkbox">
<span class="slider round"></span>
</label>
</div>
<ul class="nav nav-pills"> </ul>
<div class="bg-light sticky-top border-bottom">
<div class="container">
<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 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">
<li class="nav-item">
<a href="mailto:{{ config['metadata']['contact']['email'] }}" class="nav-link" aria-current="page">{% trans %}Contact{% endtrans%}</a>
</li>
{% if config['server']['admin'] %}
<li class="nav-item">
<a href="{{ config['server']['url'] }}/admin/config" class="nav-link" aria-current="page">{% trans %}Admin{% endtrans %}</a>
</li>
{% endif %}
<!--
Add additional menu items here
<a href="https://pygeoapi.io" class="nav-link">About</a>
-->
</ul>
</header>
</div>
<div style="max-height:fit-content;margin:0px;padding:0px;background-color: rgb(10,132,255);">
<p style="text-align: center; margin:0px;padding:5px;">
<a href = "https://docs.google.com/forms/d/e/1FAIpQLScKW2pkcWll3deXEwoV_G5ozLtuU06_prw8rf8HFuCk4tmOPQ/viewform?usp=sf_link"
style="color:rgb(255, 255, 255)" target="_blank">We would love to hear your feedback!</a>
</p>
</div>
</div>
<div class="crumbs">
</div>
<main style="background-color:WhiteSmoke;">
<div style="margin-left: 20px;margin-right: 20px;">
{% block body_map %}
{% endblock %}
</div>
<div class="container">
<div class="row">
<div class="col-sm-12">
{% block crumbs %}
<a href="{{ config['server']['url'] }}">{% trans %}Home{% endtrans %}</a>
{% endblock %}
<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', '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' %}
{% set links_found.jsonld = 1 %}
<a href="{{ link['href'] }}">{% trans %}jsonld{% endtrans %}</a>
{% endif %}
{% endfor %}
{% if links_found.json == 0 %}
<a href="?f=json">{% trans %}json{% endtrans %}</a>
{% endif %}
{% if links_found.jsonld == 0 %}
<a href="?f=jsonld">{% trans %}jsonld{% endtrans %}</a>
{% endif %}
</span>
</div>
</div>
</div>
</div>
<main>
<div class="container">
<div class="row">
<div class="col-sm-12">
<br/>
{% block body %}
{% endblock %}
</div>
</div>
</div>
</main>
<footer class="sticky-bottom bg-white 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>
</main>
<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>
@@ -162,7 +162,7 @@
]);
map.addLayer(bbox_layer);
map.fitBounds(bbox_layer.getBounds(), {maxZoom: 22});
map.fitBounds(bbox_layer.getBounds(), {maxZoom: 10});
// Allow to get bbox query parameter of a rectangular area specified by
// dragging the mouse while pressing the Ctrl key
+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 %}
+163 -735
View File
@@ -1,755 +1,183 @@
{% extends "_base.html" %}
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
{% block crumbs %}{{ super() }}
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
{% for link in data['links'] %}
{% if link.rel == 'collection' %} /
<a href="{{ data['dataset_path'] }}">{{ link['title'] | string | truncate( 25 ) }}</a>
{% set col_title = link['title'] %}
{% endif %}
{% endfor %}
/ <a href="{{ data['items_path']}}">{% trans %}Items{% endtrans %}</a>
{% endblock %}
{% block extrahead %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
<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>
<script src="https://unpkg.com/deck.gl@latest/dist.min.js"></script>
<script src="https://cdn.maptiler.com/maptiler-sdk-js/v2.2.2/maptiler-sdk.umd.js"></script>
<link href="https://cdn.maptiler.com/maptiler-sdk-js/v2.2.2/maptiler-sdk.css" rel="stylesheet" />
{% endblock %}
{% block body_map %}
<div class="row">
{% if data['speckle_url'] %}
<div id="map2d" style="height: 80vh;"></div>
<div id="map3d" style="height: 0vh;"></div>
{% else %}
<div class="form-group" >
<label class="switch">3D
<input id="modeSwitch" type="checkbox" disabled="true">
<span class="slider round"></span>
</label>
</div>
<div id="map2d" style="height: 40vh;"></div>
{% endif %}
</div>
{% endblock %}
{% block body %}
<section id="description">
<div class="row">
<p> </p>
</div>
<div class="row">
<tr>
<p>
This is the test deployment of the OGC API server for public Speckle projects.
It allows you to share your Speckle model as geospatial data in the format of
OGC API Features / Web Feature Service, so it can be natively added to a QGIS, ArcGIS
or Civil3D project, or embedded into a web map using Leaflet, OpenLayers or other libraries.
You can find more guidelines and examples on our <a href = "https://github.com/specklesystems/pygeoapi/tree/dev" target="_blank">GitHub page</a>.
</p>
</tr>
{% if not data['speckle_url'] %}
<tr>
<p>
<div style="height: fit-content;">
<p> Provide Speckle Model link as an argument to start exploring, e.g.: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828</a></p>
</div>
</p>
</tr>
{% else %}
<details>
<summary><b>Details of the current Speckle model</b></summary>
<section id="url_parameters">
{% if data['features'] %}
<div class="row">
<p> </p>
</div>
<div class="row">
<p>{% trans %}Note: if Speckle model location data is available, the relevant URL parameters will be ignored. If neither model nor URL parameters specify the location, model will be placed randomly. {% endtrans %}</p>
</div>
<div class="row">
<div class="col-md-6 col-sm-12">
<b>{% trans %}Speckle model data {% endtrans %}</b>
<div style="overflow-x: scroll;">
<table class="table table-bordered">
<thead>
</thead>
<tbody>
<tr>
<td >Project name:</td>
<td>{{ data['project'] }}</td>
</tr>
<tr>
<td >Model name:</td>
<td>{{ data['model'] }}</td>
</tr>
<tr>
<td >Last version created:</td>
<td>{{ data['model_last_version_date'] }}</td>
</tr>
<tr>
<td >Coordinate Reference System:</td>
<td>{{ data['model_crs'] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col-md-6 col-sm-12">
<b>{% trans %}URL parameters {% endtrans %}</b>
<div style="overflow-x: scroll;">
<table class="table table-bordered">
<thead>
</thead>
<tbody>
<tr>
<td >Speckle URL ('speckleurl')</td>
<td>
<a href="{{ data['speckle_url'] }}">
{{ data['speckle_url'] }}
</a>
</td>
</tr>
<tr>
<td>Requested data type ('datatype')</td>
<td>{{ data['requested_data_type'] }}</td>
</tr>
<tr>
<td>Prioritize object attributes over display quality ('preserveattributes')</td>
<td>{{ data['preserve_attributes'] }}</td>
</tr>
<tr>
<td >Coordinate Reference System ID ('crsauthid')</td>
<td>{{ data['crs_authid'] }}</td>
</tr>
<tr>
<td>Latitide ('lat')</td>
<td>{{ data['lat'] }}</td>
</tr>
<tr>
<td>Longitude ('lon')</td>
<td>{{ data['lon'] }}</td>
</tr>
<tr>
<td >Angle to True North, in degrees ('northdegrees')</td>
<td>{{ data['north_degrees'] }}</td>
</tr>
<tr>
<td >Feature limit ('limit')</td>
<td>{{ data['limit'] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="row">
<p> </p>
</div>
</section>
<section id="items">
<div class="row">
<p> </p>
</div>
<div class="row">
<b>{% trans %}Features in this model {% endtrans %}</b>
<div style="overflow-x: scroll;">
{% set props = [] %}
<table class="table table-striped table-bordered">
<thead>
<tr>
{% if data.get('uri_field') %}
{% set uri_field = data.uri_field %}
<th>{{ uri_field }}</th>
{% elif data.get('title_field') %}
{% set title_field = data.title_field %}
<th>{{ title_field }}</th>
{% else %}
<th>id</th>
{% endif %}
{% for k in data['features'][0]['properties'].keys() %}
{% if k not in [data.id_field, data.title_field, data.uri_field, 'extent'] %}
{% set props = props.append(k) %}
<th>{{ k | striptags }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
{% for ft in data.features %}
<tr>
{% set title_field = data.title_field %}
<td data-label="{{ title_field }}">
<a title="{{ ft.properties.get(title_field) }}" href="{{data['speckle_url'].split('/models')[0]}}/models/{{ft.id.split('_')[0]}}" target="_blank">
{{ ft.properties.get(title_field) | string | truncate( 35 ) }}
</a>
</td>
{% for prop in props %}
<td data-label="{{ prop }}">
{{ ft.properties.get(prop, '') | string | truncate( 35 ) }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="row">
<p>{% trans %}No items{% endtrans %}</p>
</div>
{% endif %}
</section>
</details>
<tr><p></p></tr>
{% endif %}
<tr>
<p>
You can use the current link to access OGC API dataset in your preferred software,
as well as explore the data in the browser and share with others. URL should start with 'https://geo.speckle.systems/?'
followed by required and optional parameters. Parameters should be separated with '&' symbol.
Use the following URL parameters to construct a link that provides Speckle data with your preferred settings:
</p>
</tr>
<tr>
<i>- speckleUrl</i><p> text, required, should contain path to a specific Model in Speckle Project, e.g. 'https://app.speckle.systems/projects/344f803f81/models/5582ab673e'
</p>
</tr>
<tr>
<i>- dataType</i><p> text, optional, choose from: points, lines, polygons or projectcomments
</p>
</tr>
<tr>
<i>- limit</i><p> positive integer, recommended, as some applications might apply their custom feature limit
</p>
</tr>
<tr>
<i>- preserveAttributes</i><p> string, optional, choose from: true, false. If not set, meshes will be split into separate polygons for better display quality.
</p>
</tr>
<tr>
<i>- crsAuthid</i><p> text, an authority string e.g. 'epsg:4326'. If set, LAT, LON and NORTHDEGREES arguments will be ignored.
</p>
</tr>
<tr>
<i>- lat</i><p> number, in range -90 to 90
</p>
</tr>
<tr>
<i>- lon</i><p> number, in range -180 to 180
</p>
</tr>
<tr>
<i>- northDegrees</i><p> number, in range -180 to 180
</p>
</tr>
<tr>
<p>
If GIS-originated Speckle model is loaded, no location arguments are needed.
</p>
</tr>
<tr>
</tr>
</tr><p></p>
<tr>
<tr>
<p>
Here are some examples:
</p>
</tr>
<tr>
<p>
1. QGIS polygon features: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=polygons&preserveAttributes=true">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=polygons&preserveAttributes=true</a>
</p>
</tr>
<tr>
<p>
2. QGIS point features: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/8c49788b1f&datatype=points&preserveAttributes=true">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/8c49788b1f&datatype=points&preserveAttributes=true</a>
</p>
</tr>
<tr>
<p>
3. Rhino building masses: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/64753f52b7/models/338b386787&lat=-0.031405&lon=109.335828</a>
</p>
</tr>
<tr>
<p>
4. Rhino detailed building: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/5feae56049/models/9c43d7569c&limit=100000&northDegrees=-117">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/5feae56049/models/9c43d7569c&limit=100000&northDegrees=-117</a>
</p>
</tr>
<tr>
<p>
5. Speckle project comments: <a href = "https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=projectcomments">https://geo.speckle.systems/?speckleUrl=https://app.speckle.systems/projects/344f803f81/models/5582ab673e&datatype=projectcomments</a>
</p>
</tr>
</tr><p></p>
<tr>
<tr>
<p>
<b>Note: this is not a production server.</b> It is still work in progress,
and we are very curious
to <a href = "https://speckle.community/invites/qxEmQb1QcM" target="_blank">hear about your use case and your feedback</a>
so we can make it better!
</p>
</tr>
</div>
<section id="items"></section>
<section id="collection">
<h1>{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% endfor %}</h1>
</section>
<section id="items">
{% if data['features'] %}
<div class="row">
<div class="col-sm-12 col-md-6">
<div class="row">
<div class="col-sm-12">
<div id="items-map"></div>
</div>
<div class="col-sm-12">
<div class="row">
<div class="col-sm-12">
{% if data['numberMatched'] %}
<p>{% trans %}Items in this collection{% endtrans %}: {{ data['numberMatched'] }}</p>
{% endif %}
</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>
<p>{% trans %}Warning: Higher limits not recommended!{% endtrans %}</p>
<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['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['features'] %}
<a role="button" href="{{ link['href'] }}">{% trans %}Next{% endtrans %}</a>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-12 col-md-6" style="overflow-x: scroll;">
{% set props = [] %}
<table class="table table-striped table-bordered">
<thead>
<tr>
{% if data.get('uri_field') %}
{% set uri_field = data.uri_field %}
<th>{{ uri_field }}</th>
{% elif data.get('title_field') %}
{% set title_field = data.title_field %}
<th>{{ title_field }}</th>
{% else %}
<th>id</th>
{% endif %}
{% for k in data['features'][0]['properties'].keys() %}
{% if k not in [data.id_field, data.title_field, data.uri_field, 'extent'] %}
{% set props = props.append(k) %}
<th>{{ k | striptags }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
{% for ft in data.features %}
<tr>
{% if data.get('uri_field') %}
{% set uri_field = data.uri_field %}
<td data-label="{{ uri_field }}">
<a href="{{ ft.properties.get(uri_field) }}" title="{{ ft.properties.get(uri_field) }}">
{{ ft.properties.get(uri_field) }}
</a>
</td>
{% elif data.get('title_field') %}
{% set title_field = data.title_field %}
<td data-label="{{ title_field }}">
<a href="{{ data.items_path }}/{{ ft['id'] }}" title="{{ ft.properties.get(title_field) }}">
{{ ft.properties.get(title_field) | string | truncate( 35 ) }}
</a>
</td>
{% else %}
<td data-label="id">
<a href="{{ data.items_path }}/{{ ft.id }}" title="{{ ft.id }}">
{{ ft.id | string | truncate( 12 ) }}
</a>
</td>
{% endif %}
{% for prop in props %}
<td data-label="{{ prop }}">
{{ ft.properties.get(prop, '') | string | truncate( 35 ) }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</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>
</div>
{% endif %}
</section>
{% endblock %}
{% block extrafoot %}
{% if data['features'] %}
<script>
try {
document.getElementById("loading_screen").remove();
document.getElementById("loading_screen_band").remove();
}
catch(err) {}
// attach even to modeSwitch btn
document.getElementById("modeSwitch").onclick = switchMode;
var el = document.getElementById("modeSwitch");
if (el.addEventListener)
el.addEventListener("click", switchMode, false);
else if (el.attachEvent)
el.attachEvent('onclick', switchMode);
function switchMode() {
btn = document.getElementById('modeSwitch');
if (btn.checked){
document.getElementById('map2d').style.height = '0vh';
document.getElementById('map3d').style.height = '80vh';
var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 5);
map.addLayer(new L.TileLayer(
'{{ config['server']['map']['url'] }}', {
maxZoom: 18,
attribution: '{{ config['server']['map']['attribution'] | safe }}'
}
else {
document.getElementById('map2d').style.height = '80vh';
document.getElementById('map3d').style.height = '0vh';
));
var geojson_data = {{ data['features'] | to_json | safe }};
var items = new L.GeoJSON(geojson_data, {
onEachFeature: function (feature, layer) {
var url = '{{ data['items_path'] }}/' + feature.id + '?f=html';
var html = '<span><a href="' + url + '">' + {% if data['title_field'] %} feature['properties']['{{ data['title_field'] }}'] {% else %} feature.id {% endif %} + '</a></span>';
layer.bindPopup(html);
}
}
function split_polygons(geojson_data){
features = []
geojson_data.forEach((element, index) => {
if (element.geometry.type == "MultiPolygon"){
// mesh faces are stored as parts, so most buildings might have hundreds of parts with just 1 loop or 3-4pts
all_parts = element.geometry.coordinates;
// loops are usually simple (3-4 vertices), and usually only loop
for (polyPart of all_parts){
new_coordinates = [polyPart];
new_element = {"id": element.properties.id, "type":"Feature",
"geometry": {"type": "MultiPolygon", "coordinates": new_coordinates},
"properties": element.properties,
"displayProperties": element.displayProperties };
features.push(new_element)
}
}
else {
features.push(element)
}
});
return features
}
var data = {{ data | to_json | safe }};
var geojson_data_original = {{ data['features'] | to_json | safe }};
// Leaflet 2d map
function initialize2d() {
var map = L.map('map2d', {zoomControl: false}).setView([45, 0], 2);
L.control.zoom({
position: 'topright'
}).addTo(map);
var tileLayer = new L.TileLayer(
'{{ config['server']['map']['url'] }}', {
maxZoom: 22,
minZoom: 12,
attribution: '{{ config['server']['map']['attribution'] | safe }} &copy; Data: <a href="https://speckle.systems/">Speckle Systems</a>'
}
);
geojson_data = split_polygons(geojson_data_original);
project_url = ""
try {
project_url = data['speckle_url'].split("/models")[0]
}
catch(err) {}
var items = new L.GeoJSON(geojson_data, {
filter: (feature) => {
return feature.displayProperties["object_type"] == "geometry"
},
pointToLayer: (feature, latlng) => {
return new L.circleMarker(latlng)
},
onEachFeature: function (feature, layer) {
var url = project_url + '/models/' + feature.id.split("_")[0]
var html = '<span><td><p>' + feature['properties']['speckle_type'] + '</p></td><a href="' + url + '" target="_blank">' + feature['properties']['id'].split("_")[0] + '</a></span>';
layer.bindPopup(html);
var myFillColor = feature.displayProperties['color'];
var mylineWeight = feature.displayProperties['lineWidth'];
var myRadius = feature.displayProperties['radius'];
layer.setStyle({
fillColor: myFillColor,
color: myFillColor,
fillOpacity: 0.8,
weight: mylineWeight,
radius: myRadius
});
}
}); //.addTo(map);
var comments = new L.GeoJSON(geojson_data, {
filter: (feature) => {
return feature.displayProperties["object_type"] == "comment"
},
pointToLayer: (feature, latlng) => {
return new L.marker(latlng)
},
onEachFeature: function (feature, layer) {
var url = project_url + '/models/' + feature.properties.resource_id + '#threadId=' + feature.id;
var html = '<span><td><a href="' + url + '" target="_blank">Go to thread</a></td> <td><p>' + feature['properties']['text_html'] + '</p></td> </span>';
layer.bindPopup(html);
}
}); //.addTo(map);
var group = L.featureGroup([items, comments]);
// load proper basemap for Speckle models; but only zoomed-out one for empty data
try
{
bounds = group.getBounds();
map.fitBounds(bounds);
tileLayer.addTo(map);
group.addTo(map);
}
catch (err){
tileLayer = new L.TileLayer(
'{{ config['server']['map']['url'] }}', {
maxZoom: 2,
minZoom: 2,
attribution: '{{ config['server']['map']['attribution'] | safe }} &copy; Data: <a href="https://speckle.systems/">Speckle Systems</a>'
}
);
tileLayer.addTo(map);
}
//map.addLayer(lines);
// map.setZoom(19); // in order for the tiles to load
};
// MapTiler 3d map
function hexToRgb(hex) {
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16), parseInt(result[0], 16)] : null;
};
function rgbaToArgbList(color) {
if (color == null){
return [10,132,255,255];
}
col = color.replace('rgba(','').replace(')','').split(',',4);
value = [ parseInt(col[0]), parseInt(col[1]), parseInt(col[2]), parseInt(col[3]) ];
return value;
}
function rgbaToArgbListDarker(color) {
if (color == null){
return [7,120,235,255];
}
col = color.replace('rgba(','').replace(')','').split(',',4);
value = [ parseInt(col[0])*0.95, parseInt(col[1])*0.95, parseInt(col[2])*0.95, parseInt(col[3]) ];
return value;
}
function getMessagesNumber(properties)
{
if (properties == null || properties.messages == null) {return 1}
else
{
return properties.messages.length
}
}
function getDisplayText(properties)
{
if (properties != null && properties.text_html != null){
return properties.text_html
}
else if (properties != null && properties.speckle_type != null && properties.id != null){
return `${properties.speckle_type}: ${properties.id.split("_")[0]}`
}
else{
return ``
}
}
// Callback to populate the default tooltip with content
function getTooltip({object}) {
return object && {
html: getDisplayText(object.properties),
style: {
backgroundColor: 'rgb(255,255,255)',
color: 'rgb(0,0,0)',
fontSize: '0.9em'
}
};
}
function createSVGIcon(n) {
const label = n < 10 ? n.toString() : '10+';
return `\
<svg width="100" height="100" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="rgb(10,132,255)" stroke="rgb(200,200,200)" stroke-width="2"/>
<text x="12" y="12" fill="#fff" text-anchor="middle" alignment-baseline="middle" font-family="verdana" font-size="8">${label}</text>
</svg>`;
}
// Note that a xml string cannot be directly embedded in a data URL
// it has to be either escaped or converted to base64.
function svgToDataUrl(svg) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
// You may need base64 encoding if the SVG contains certain special characters
function svgToDataUrlBase64(svg) {
return `data:image/svg+xml;base64,${btoa(svg)}`;
}
function initialize3d() {
maptilersdk.config.apiKey = '{{ config["server"]["map"]["key"] }}';
speckle_data = JSON.parse(JSON.stringify(data))
var speckle_features = []
for (let i = 0; i < speckle_data.features.length; i++) {
feature = speckle_data.features[i];
coords = feature.geometry.coordinates;
if (feature.geometry.type.includes("Polygon")) {
polygon_all_parts = []
// iterate through Polygon Parts
for (let p = 0; p < coords.length; p++) {
// check orientation of each PolygonPart, if vertical - shift points slightly
polygon_part = [];
inner = false;
for (let c = 0; c < coords[p].length; c++) {
polygon_part_loop = [];
if (c>0){
inner = true;
}
sum_orientation = 0;
polygon_pts = coords[p][c]; // usually 3 for Mesh faces
for (let k = 0; k < polygon_pts.length; k++){
index = k + 1
if (k == polygon_pts.length - 1){index = 0}
pt = polygon_pts[k]
pt2 = polygon_pts[index]
sum_orientation += (pt2[0] - pt[0]) * (pt2[1] + pt[1])
};
createdPolygon = false;
if (-0.000000001 < sum_orientation && sum_orientation <0.000000001){
coords[p][c][0][0] += 0.0000001;
coords[p][c][0][1] += 0.0000001;
coords[p][c][1][0] -= 0.0000001;
coords[p][c][1][1] -= 0.0000001;
coords[p][c][2][0] += 0.0000001;
coords[p][c][2][1] += 0.0000001;
if(polygon_pts.length==3) {
createdPolygon = true;
multipolygon_coords = coords[p][c];
polygon_part_loop = multipolygon_coords;
polygon_part = [polygon_part_loop];
polygon_all_parts.push(polygon_part);
}
else if (polygon_pts.length==4) {
createdPolygon = true;
multipolygon_coords = coords[p][c].slice(0,3);
polygon_part_loop = multipolygon_coords;
polygon_part = [polygon_part_loop];
polygon_all_parts.push(polygon_part);
/////////
multipolygon_coords = [coords[p][c][2], coords[p][c][3], coords[p][c][0]];
polygon_part_loop = multipolygon_coords;
polygon_part = [polygon_part_loop];
polygon_all_parts.push(polygon_part);
};
};
if (createdPolygon == false){ // if non-vertical, or vertical with more than 4 vertices
multipolygon_coords = coords[p][c];
polygon_part_loop = multipolygon_coords;
if (inner == false){
polygon_part = [polygon_part_loop];
polygon_all_parts.push(polygon_part);
}
else{
polygon_all_parts[polygon_all_parts.length-1].push(polygon_part_loop);
}
}
};
};
new_polygon = {"id": speckle_features.length, "type":"Feature",
"geometry": {"type": "MultiPolygon", "coordinates":polygon_all_parts},
"properties": speckle_data.features[i].properties,
"displayProperties": speckle_data.features[i].displayProperties };
new_polygon.displayProperties.lineWidth = 0.05
speckle_features.push(new_polygon);
}
else if (speckle_data.features[i].displayProperties.object_type == "comment")
{
speckle_features.push({"id": speckle_features.length, "type":"Feature",
"geometry": {"type": speckle_data.features[i].geometry.type, "coordinates":speckle_data.features[i].geometry.coordinates},
"properties": speckle_data.features[i].properties,
"displayProperties":
{
"color": 'rgba(10,132,255,255)',
"lineWidth": 2,
"radius": 10,
"object_type": "comments",
}
});
}
else
{
speckle_features.push({"id": speckle_features.length, "type":"Feature",
"geometry": {"type": speckle_data.features[i].geometry.type, "coordinates":speckle_data.features[i].geometry.coordinates},
"properties": speckle_data.features[i].properties,
"displayProperties": speckle_data.features[i].displayProperties });
}
}
var extent = speckle_data["extent"];
speckle_data.features = [];
speckle_features.forEach((element, index, array) => {
if (element.displayProperties.object_type != "comments"){
speckle_data.features.push(element);
}
});
speckle_comments = [];
speckle_features.forEach((element, index, array) => {
if (element.displayProperties.object_type == "comments"){
speckle_comments.push(element);
}
});
// Create deck.gl map
const deckgl = new deck.DeckGL({
container: 'map3d',
map: maptilersdk,
mapStyle: maptilersdk.MapStyle.STREETS.PASTEL,
initialViewState: {
longitude: extent[0] + (extent[2]-extent[0])/2,
latitude: extent[1] + (extent[3]-extent[1])/2,
zoom: 22,
minZoom: 12,
pitch: 60,
bearing: 1.469387755102039
},
controller: true,
layers: [
new deck.GeoJsonLayer({
id: 'speckle_data',
data: speckle_data,
// Styles
filled: true,
getFillColor: f => rgbaToArgbList(f.displayProperties.color),
getLineWidth: f => f.displayProperties.lineWidth,
getLineColor: f => rgbaToArgbListDarker(f.displayProperties.color),
getPointRadius: f => f.displayProperties.radius / 2,
// Interactive props
pickable: true,
autoHighlight: true,
}),
new deck.IconLayer({
id: 'IconLayer',
data: speckle_comments,
// getColor: d => [Math.sqrt(getMessagesNumber(d.properties)), 140, 0],
getIcon: d => ({
url: svgToDataUrl(createSVGIcon( getMessagesNumber(d.properties).toString() )), //'https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/icon-atlas.png',
width: 128,
height: 128
}),
getPosition: d => d.geometry.coordinates[0],
getSize: f => 50 * Math.pow(getMessagesNumber(f.properties),0.3),
pickable: true
})
],
getTooltip
});
}
initialize2d();
initialize3d();
});
var markers = L.markerClusterGroup({
disableClusteringAtZoom: 9,
chunkedLoading: true,
chunkInterval: 500,
});
markers.clearLayers().addLayer(items);
map.addLayer(markers);
map.fitBounds(items.getBounds());
</script>
{% endif %}
{% endblock %}
@@ -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 #}
@@ -125,7 +125,7 @@
var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 10);
map.addLayer(new L.TileLayer(
'{{ config['server']['map']['url'] }}', {
maxZoom: 22,
maxZoom: 18,
attribution: '{{ config['server']['map']['attribution'] | safe }}'
}
));
@@ -133,6 +133,6 @@
var items = new L.GeoJSON(geojson_data);
map.addLayer(items);
map.fitBounds(items.getBounds(), {maxZoom: 18});
map.fitBounds(items.getBounds(), {maxZoom: 15});
</script>
{% endblock %}
@@ -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">

Some files were not shown because too many files have changed in this diff Show More