From e40f637e0a65c6e49f78d98cf22b475ded305510 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 6 May 2025 09:19:01 +0200 Subject: [PATCH 1/8] update geojson-pydantic req --- CHANGES.md | 4 ++++ pyproject.toml | 2 +- tipg/factory.py | 26 +++++++++++++++----------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 990b4523..45357412 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,10 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin ## [unreleased] +## [1.1.0] - 2025-05-06 + +* update geojson-pydantic requirement to `>=1.0,<3.0` + ## [1.0.1] - 2025-03-17 * fix typo when using catalog_ttl diff --git a/pyproject.toml b/pyproject.toml index 02fd5574..283a4629 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "morecantile>=5.0,<7.0", "pydantic>=2.4,<3.0", "pydantic-settings~=2.0", - "geojson-pydantic>=1.0,<2.0", + "geojson-pydantic>=1.0,<3.0", "pygeofilter>=0.2.0,<0.3.0", "ciso8601~=2.3", "starlette-cramjam>=0.4,<0.5", diff --git a/tipg/factory.py b/tipg/factory.py index 11550946..46dd0d8d 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -44,11 +44,15 @@ ) from tipg.errors import MissingGeometryColumn, NoPrimaryKey, NotFound from tipg.resources.enums import MediaType -from tipg.resources.response import GeoJSONResponse, SchemaJSONResponse, orjsonDumps +from tipg.resources.response import ( + GeoJSONResponse, + ORJSONResponse, + SchemaJSONResponse, + orjsonDumps, +) from tipg.settings import FeaturesSettings, MVTSettings, TMSSettings from fastapi import APIRouter, Depends, Path, Query -from fastapi.responses import ORJSONResponse from starlette.datastructures import QueryParams from starlette.requests import Request @@ -596,17 +600,17 @@ def collections( ) for collection in collection_list["collections"] ], - ) + ).model_dump(exclude_none=True, mode="json") if output_type == MediaType.html: return self._create_html_response( request, - data.model_dump(exclude_none=True, mode="json"), + data, template_name="collections", title="Collections list", ) - return data + return ORJSONResponse(data) def _collection_route(self): @self.router.get( @@ -689,17 +693,17 @@ def collection( ), *self._additional_collection_tiles_links(request, collection), ], - ) + ).model_dump(exclude_none=True, mode="json") if output_type == MediaType.html: return self._create_html_response( request, - data.model_dump(exclude_none=True, mode="json"), + data, template_name="collection", title=f"{collection.id} collection", ) - return data + return ORJSONResponse(data) def _queryables_route(self): @self.router.get( @@ -735,17 +739,17 @@ def queryables( title=collection.id, link=self_url + qs, properties=collection.queryables, - ) + ).model_dump(exclude_none=True, mode="json", by_alias=True) if output_type == MediaType.html: return self._create_html_response( request, - data.model_dump(exclude_none=True, mode="json"), + data, template_name="queryables", title=f"{collection.id} queryables", ) - return data + return SchemaJSONResponse(data) def _items_route(self): # noqa: C901 @self.router.get( From eaeb5cb5b1bca0077c7b88cc0c004311212c806b Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 6 May 2025 09:26:48 +0200 Subject: [PATCH 2/8] update mapbox-vector-tile --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 283a4629..614ee470 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ test = [ "pytest-benchmark", "httpx", "pytest-postgresql", - "mapbox-vector-tile", + "mapbox-vector-tile>=2.1", "protobuf>=3.0,<4.0", "numpy", ] From 1dc98e16e336bebf0e7359a00062968eb87ca137 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 6 May 2025 09:29:15 +0200 Subject: [PATCH 3/8] remove protobuf --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 614ee470..f692edee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ test = [ "httpx", "pytest-postgresql", "mapbox-vector-tile>=2.1", - "protobuf>=3.0,<4.0", + "protobuf", "numpy", ] dev = [ From 619b32a5ab79848b9b55bcef64854de3357daf18 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 6 May 2025 09:29:20 +0200 Subject: [PATCH 4/8] remove protobuf --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f692edee..9431c8d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,6 @@ test = [ "httpx", "pytest-postgresql", "mapbox-vector-tile>=2.1", - "protobuf", "numpy", ] dev = [ From f436e436c9419c64b5a9fd22cb3dc716c0e9e01c Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 6 May 2025 09:37:46 +0200 Subject: [PATCH 5/8] update changelog --- CHANGES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 45357412..c71ec26c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -394,7 +394,8 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin - Initial release -[unreleased]: https://github.com/developmentseed/tipg/compare/1.0.1...HEAD +[unreleased]: https://github.com/developmentseed/tipg/compare/1.1.0...HEAD +[1.1.0]: https://github.com/developmentseed/tipg/compare/1.0.1...1.1.0 [1.0.1]: https://github.com/developmentseed/tipg/compare/1.0.0...1.0.1 [1.0.0]: https://github.com/developmentseed/tipg/compare/0.10.0...1.0.0 [0.10.1]: https://github.com/developmentseed/tipg/compare/0.10.0...0.10.1 From 871ee56e38281dbfeec493fc20d08b7b60e922cd Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 6 May 2025 09:37:52 +0200 Subject: [PATCH 6/8] =?UTF-8?q?Bump=20version:=201.0.1=20=E2=86=92=201.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- tipg/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9431c8d7..b0ece9c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,7 @@ filterwarnings = [ ] [tool.bumpversion] -current_version = "1.0.1" +current_version = "1.1.0" search = "{current_version}" replace = "{new_version}" diff --git a/tipg/__init__.py b/tipg/__init__.py index dde05d2e..61decb9a 100644 --- a/tipg/__init__.py +++ b/tipg/__init__.py @@ -1,3 +1,3 @@ """tipg.""" -__version__ = "1.0.1" +__version__ = "1.1.0" From b47b89acde1177baa8853864a560fb49af92c431 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Fri, 9 May 2025 14:28:33 -0500 Subject: [PATCH 7/8] add extraProperties field in collection that holds dictionary, purposed to store metadata related to the collection --- tipg/factory.py | 115 ++++++++++++++++++++++++++++++------------------ tipg/model.py | 3 +- 2 files changed, 74 insertions(+), 44 deletions(-) diff --git a/tipg/factory.py b/tipg/factory.py index 46dd0d8d..0665a596 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -503,7 +503,7 @@ def _collections_route(self): # noqa: C901 }, tags=["OGC Features API"], ) - def collections( + async def collections( request: Request, collection_list: Annotated[ CollectionList, @@ -555,51 +555,65 @@ def collections( ), ) + collections=[] + for collection in collection_list["collections"]: + # First come first serve to get the collection properties + extra_properties_dict={} + if (callable(collection.features)): + try: + item_list = await collection.features( + request + ) + extra_properties_prefix = "_fid" + extra_properties = item_list['items'][0]['properties'] + extra_properties_dict = dict(map(lambda key: (key, extra_properties[key]), filter(lambda key: extra_properties_prefix in key, extra_properties))) + except Exception as err: + print(err) + collections.append(model.Collection( + id=collection.id, + title=collection.id, + description=collection.description, + extent=collection.extent, + extraProperties=extra_properties_dict, + links=[ + model.Link( + href=self.url_for( + request, + "collection", + collectionId=collection.id, + ), + rel="collection", + type=MediaType.json, + ), + model.Link( + href=self.url_for( + request, + "items", + collectionId=collection.id, + ), + rel="items", + type=MediaType.geojson, + ), + model.Link( + href=self.url_for( + request, + "queryables", + collectionId=collection.id, + ), + rel="queryables", + type=MediaType.schemajson, + ), + *self._additional_collection_tiles_links( + request, collection + ), + ] + )) + data = model.Collections( links=links, numberMatched=collection_list["matched"], numberReturned=len(collection_list["collections"]), - collections=[ - model.Collection( - id=collection.id, - title=collection.id, - description=collection.description, - extent=collection.extent, - links=[ - model.Link( - href=self.url_for( - request, - "collection", - collectionId=collection.id, - ), - rel="collection", - type=MediaType.json, - ), - model.Link( - href=self.url_for( - request, - "items", - collectionId=collection.id, - ), - rel="items", - type=MediaType.geojson, - ), - model.Link( - href=self.url_for( - request, - "queryables", - collectionId=collection.id, - ), - rel="queryables", - type=MediaType.schemajson, - ), - *self._additional_collection_tiles_links( - request, collection - ), - ], - ) - for collection in collection_list["collections"] - ], + collections=collections ).model_dump(exclude_none=True, mode="json") if output_type == MediaType.html: @@ -628,17 +642,32 @@ def _collection_route(self): }, tags=["OGC Features API"], ) - def collection( + async def collection( request: Request, collection: Annotated[Collection, Depends(self.collection_dependency)], output_type: Annotated[Optional[MediaType], Depends(OutputType)] = None, ): """Metadata for a feature collection.""" + + # First come first serve to get the collection properties + extra_properties_dict={} + if (callable(collection.features)): + try: + item_list = await collection.features( + request + ) + extra_properties_prefix = "_fid" + extra_properties = item_list['items'][0]['properties'] + extra_properties_dict = dict(map(lambda key: (key, extra_properties[key]), filter(lambda key: extra_properties_prefix in key, extra_properties))) + except Exception as err: + print(err) + data = model.Collection( id=collection.id, title=collection.title, description=collection.description, extent=collection.extent, + extraProperties=extra_properties_dict, links=[ model.Link( title="Collection", diff --git a/tipg/model.py b/tipg/model.py index 0028e971..1cd54340 100644 --- a/tipg/model.py +++ b/tipg/model.py @@ -1,7 +1,7 @@ """tipg models.""" from datetime import datetime -from typing import Annotated, Dict, List, Literal, Optional, Set, Tuple, Union +from typing import Annotated, Any, Dict, List, Literal, Optional, Set, Tuple, Union from geojson_pydantic.features import Feature, FeatureCollection from morecantile.models import CRSType @@ -145,6 +145,7 @@ class Collection(BaseModel): extent: Optional[Extent] = None itemType: str = "feature" crs: List[str] = ["http://www.opengis.net/def/crs/OGC/1.3/CRS84"] + extraProperties: Optional[Dict[str, Any]] = None model_config = {"extra": "ignore"} From a536eeda411b02193377adcfe33615ea3db4f863 Mon Sep 17 00:00:00 2001 From: Sanjog Thapa Date: Fri, 9 May 2025 16:22:51 -0500 Subject: [PATCH 8/8] add extra properties to collection and collection list via dependency injection --- tipg/dependencies.py | 91 +++++++++++++++++++++++++++++++ tipg/factory.py | 127 ++++++++++++++++++++----------------------- 2 files changed, 149 insertions(+), 69 deletions(-) diff --git a/tipg/dependencies.py b/tipg/dependencies.py index a82feeb7..f94251b2 100644 --- a/tipg/dependencies.py +++ b/tipg/dependencies.py @@ -502,3 +502,94 @@ def CollectionsParams( next=offset + returned if matched - returned > offset else None, prev=max(offset - limit, 0) if offset else None, ) + +ExtraProperties = Dict[str, any] + +async def CollectionExtraProperties( + request: Request, + collection: Annotated[Collection, Depends(CollectionParams)], +) -> ExtraProperties: + """ + Extracts extra properties from the first feature of a collection. (First Item has the highest priority) + - This is done because there is no separate table to store the collection specific information. + To elaborate, a schema table represents the whole collection and a rows represents items. + So, If we add any columns with prefix "collection_properties_", we use that as extra properties. + - This is needed to store metadata information about the collection. + - This is named extraProperties as there was another key named properties defined already for the Collection. + + This function attempts to retrieve features from the provided collection. + If successful, it inspects the properties of the first feature and + filters them to include only those whose keys contain the prefix "collection_properties_". + + Args: + request: The incoming Starlette/FastAPI request object. + collection: The collection object, typically resolved by FastAPI's + dependency injection system using `CollectionParams`. + + Returns: + A dictionary containing the extra properties (keys containing "collection_properties_") + from the first feature of the collection. Returns an empty dictionary + if the collection features are not callable, if no features are found, + or if an error occurs during processing. + """ + # First come first serve to get the collection properties + extra_properties_dict={} + extra_properties_prefix = "collection_properties_" + + if (callable(collection.features)): + try: + item_list = await collection.features( + request + ) + extra_properties = item_list['items'][0]['properties'] + extra_properties_dict = dict(map(lambda key: (key, extra_properties[key]), filter(lambda key: extra_properties_prefix in key, extra_properties))) + except Exception as err: + print(err) + return extra_properties_dict + +CollectionsExtraPropertiesDict = Dict[str, ExtraProperties] + +async def CollectionsExtraProperties( + request: Request, + collections: Annotated[CollectionList, Depends(CollectionsParams)], +) -> CollectionsExtraPropertiesDict: + """ + For all the list of available collection, + Extracts extra properties from the first feature of a collection. (First Item has the highest priority) + - This is done because there is no separate table to store the collection specific information. + To elaborate, a schema table represents the whole collection and a rows represents items. + So, If we add any columns with prefix "collection_properties_", we use that as extra properties. + - This is needed to store metadata information about the collection. + - This is named extraProperties as there was another key named properties defined already for the Collection. + + This function attempts to retrieve features from the provided collection. + If successful, it inspects the properties of the first feature and + filters them to include only those whose keys contain the prefix "collection_properties_". + + Args: + request: The incoming Starlette/FastAPI request object. + collections: The collection list, typically resolved by FastAPI's + dependency injection system using `CollectionsParams`. + + Returns: + A dictionary containing the collection.id as key and extra properties (keys containing "collection_properties_") as value + """ + collections_extra_properties = {} + extra_properties_prefix = "collection_properties_" + + for collection in collections["collections"]: + collection_id = collection.id + extra_properties_dict={} + + if (callable(collection.features)): + try: + item_list = await collection.features( + request + ) + extra_properties = item_list['items'][0]['properties'] + extra_properties_dict = dict(map(lambda key: (key, extra_properties[key]), filter(lambda key: extra_properties_prefix in key, extra_properties))) + except Exception as err: + print(err) + collections_extra_properties[collection_id] = extra_properties_dict + + return collections_extra_properties diff --git a/tipg/factory.py b/tipg/factory.py index 0665a596..51fafcc5 100644 --- a/tipg/factory.py +++ b/tipg/factory.py @@ -28,6 +28,10 @@ from tipg.collections import Collection, CollectionList from tipg.dependencies import ( CollectionParams, + ExtraProperties, + CollectionExtraProperties, + CollectionsExtraPropertiesDict, + CollectionsExtraProperties, CollectionsParams, ItemsOutputType, OutputType, @@ -187,6 +191,9 @@ class EndpointsFactory(metaclass=abc.ABCMeta): # collection dependency collection_dependency: Callable[..., Collection] = CollectionParams + # collection extra-properties dependency needed for collection metadata + collection_extra_properties: Callable[..., ExtraProperties] = CollectionExtraProperties + # Router Prefix is needed to find the path for routes when prefixed # e.g if you mount the route with `/foo` prefix, set router_prefix to foo router_prefix: str = "" @@ -363,6 +370,9 @@ class OGCFeaturesFactory(EndpointsFactory): # collections dependency collections_dependency: Callable[..., CollectionList] = CollectionsParams + # collections extra-properties dependency needed for collection metadata list + collections_extra_properties: Callable[..., CollectionsExtraPropertiesDict] = CollectionsExtraProperties + @property def conforms_to(self) -> List[str]: """Factory conformances.""" @@ -509,6 +519,10 @@ async def collections( CollectionList, Depends(self.collections_dependency), ], + collections_extra_properties_dictionary: Annotated[ + CollectionsExtraPropertiesDict, + Depends(self.collections_extra_properties) + ], output_type: Annotated[ Optional[MediaType], Depends(OutputType), @@ -555,65 +569,52 @@ async def collections( ), ) - collections=[] - for collection in collection_list["collections"]: - # First come first serve to get the collection properties - extra_properties_dict={} - if (callable(collection.features)): - try: - item_list = await collection.features( - request - ) - extra_properties_prefix = "_fid" - extra_properties = item_list['items'][0]['properties'] - extra_properties_dict = dict(map(lambda key: (key, extra_properties[key]), filter(lambda key: extra_properties_prefix in key, extra_properties))) - except Exception as err: - print(err) - collections.append(model.Collection( - id=collection.id, - title=collection.id, - description=collection.description, - extent=collection.extent, - extraProperties=extra_properties_dict, - links=[ - model.Link( - href=self.url_for( - request, - "collection", - collectionId=collection.id, - ), - rel="collection", - type=MediaType.json, - ), - model.Link( - href=self.url_for( - request, - "items", - collectionId=collection.id, - ), - rel="items", - type=MediaType.geojson, - ), - model.Link( - href=self.url_for( - request, - "queryables", - collectionId=collection.id, - ), - rel="queryables", - type=MediaType.schemajson, - ), - *self._additional_collection_tiles_links( - request, collection - ), - ] - )) - data = model.Collections( links=links, numberMatched=collection_list["matched"], numberReturned=len(collection_list["collections"]), - collections=collections + collections=[ + model.Collection( + id=collection.id, + title=collection.id, + description=collection.description, + extent=collection.extent, + extraProperties=collections_extra_properties_dictionary[collection.id], + links=[ + model.Link( + href=self.url_for( + request, + "collection", + collectionId=collection.id, + ), + rel="collection", + type=MediaType.json, + ), + model.Link( + href=self.url_for( + request, + "items", + collectionId=collection.id, + ), + rel="items", + type=MediaType.geojson, + ), + model.Link( + href=self.url_for( + request, + "queryables", + collectionId=collection.id, + ), + rel="queryables", + type=MediaType.schemajson, + ), + *self._additional_collection_tiles_links( + request, collection + ), + ] + ) + for collection in collection_list["collections"] + ] ).model_dump(exclude_none=True, mode="json") if output_type == MediaType.html: @@ -645,29 +646,17 @@ def _collection_route(self): async def collection( request: Request, collection: Annotated[Collection, Depends(self.collection_dependency)], + extraProperties: Annotated[Dict, Depends(self.collection_extra_properties)], output_type: Annotated[Optional[MediaType], Depends(OutputType)] = None, ): """Metadata for a feature collection.""" - # First come first serve to get the collection properties - extra_properties_dict={} - if (callable(collection.features)): - try: - item_list = await collection.features( - request - ) - extra_properties_prefix = "_fid" - extra_properties = item_list['items'][0]['properties'] - extra_properties_dict = dict(map(lambda key: (key, extra_properties[key]), filter(lambda key: extra_properties_prefix in key, extra_properties))) - except Exception as err: - print(err) - data = model.Collection( id=collection.id, title=collection.title, description=collection.description, extent=collection.extent, - extraProperties=extra_properties_dict, + extraProperties=extraProperties, links=[ model.Link( title="Collection",