From 8d3b3809388ce291ab9a8b5747159189edd81701 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Thu, 17 Apr 2025 19:58:36 +0200 Subject: [PATCH 1/5] improve typing --- .../api/stac_fastapi/api/middleware.py | 4 +- .../extensions/core/aggregation/types.py | 33 ++++++++-------- .../core/collection_search/request.py | 4 +- .../extensions/core/filter/request.py | 2 +- .../extensions/core/free_text/request.py | 6 ++- .../extensions/core/query/request.py | 2 +- .../extensions/core/sort/request.py | 2 +- stac_fastapi/types/stac_fastapi/types/core.py | 4 +- .../types/stac_fastapi/types/extension.py | 2 +- .../types/stac_fastapi/types/rfc3339.py | 2 +- .../types/stac_fastapi/types/search.py | 23 ++++++----- stac_fastapi/types/stac_fastapi/types/stac.py | 39 +++++++------------ 12 files changed, 60 insertions(+), 63 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/middleware.py b/stac_fastapi/api/stac_fastapi/api/middleware.py index b0965bd5e..0e7ece7e9 100644 --- a/stac_fastapi/api/stac_fastapi/api/middleware.py +++ b/stac_fastapi/api/stac_fastapi/api/middleware.py @@ -82,7 +82,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.app(scope, receive, send) - def _get_forwarded_url_parts(self, scope: Scope) -> Tuple[str]: + def _get_forwarded_url_parts(self, scope: Scope) -> Tuple[str, str, str]: proto = scope.get("scheme", "http") header_host = self._get_header_value_by_name(scope, "host") if header_host is None: @@ -127,7 +127,7 @@ def _get_header_value_by_name( @staticmethod def _replace_header_value_by_name( scope: Scope, header_name: str, new_value: str - ) -> List[Tuple[str]]: + ) -> List[Tuple[str, str]]: return [ (name, value) for name, value in scope["headers"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py index 428b65225..fa98eb6fb 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/aggregation/types.py @@ -2,33 +2,34 @@ from typing import Any, Dict, List, Literal, Optional, Union -from pydantic import Field -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict from stac_fastapi.types.rfc3339 import DateTimeType +Bucket = TypedDict( + "Bucket", + { + "key": str, + "data_type": str, + "frequency": NotRequired[Dict], + # we can't use the `class Bucket` notation because `from` is a reserved key + "from": NotRequired[Union[int, float]], + "to": NotRequired[Optional[Union[int, float]]], + }, +) -class Bucket(TypedDict, total=False): - """A STAC aggregation bucket.""" - key: str - data_type: str - frequency: Optional[Dict] = None - _from: Optional[Union[int, float]] = Field(alias="from", default=None) - to: Optional[Optional[Union[int, float]]] = None - - -class Aggregation(TypedDict, total=False): +class Aggregation(TypedDict): """A STAC aggregation.""" name: str data_type: str - buckets: Optional[List[Bucket]] = None - overflow: Optional[int] = None - value: Optional[Union[str, int, DateTimeType]] = None + buckets: NotRequired[List[Bucket]] + overflow: NotRequired[int] + value: NotRequired[Union[str, int, DateTimeType]] -class AggregationCollection(TypedDict, total=False): +class AggregationCollection(TypedDict): """STAC Item Aggregation Collection.""" type: Literal["AggregationCollection"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py index e5727697a..74490bad7 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py @@ -37,7 +37,7 @@ class BaseCollectionSearchGetRequest(APIRequest, DatetimeMixin): class BaseCollectionSearchPostRequest(BaseModel): """Collection-Search POST model.""" - bbox: Optional[BBox] = Field( + bbox: Optional[BBox] = Field( # type: ignore default=None, description="Only return items intersecting this bounding box. Mutually exclusive with **intersects**.", # noqa: E501 openapi_examples={ @@ -45,7 +45,7 @@ class BaseCollectionSearchPostRequest(BaseModel): "Montreal": {"value": "-73.896103,45.364690,-73.413734,45.674283"}, }, ) - datetime: Optional[str] = Field( + datetime: Optional[str] = Field( # type: ignore default=None, description="""Only return items that have a temporal property that intersects this value.\n Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""", # noqa: E501 diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index 02fb3b592..b72f6a75a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -50,7 +50,7 @@ class FilterExtensionGetRequest(APIRequest): class FilterExtensionPostRequest(BaseModel): """Filter extension POST request model.""" - filter_expr: Optional[Dict[str, Any]] = Field( + filter_expr: Optional[Dict[str, Any]] = Field( # type: ignore default=None, alias="filter", description="A CQL filter expression for filtering items.", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py index 9dae2aa8e..67de422d3 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/free_text/request.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from typing_extensions import Annotated -from stac_fastapi.types.search import APIRequest, str2list +from stac_fastapi.types.search import APIRequest def _ft_converter( @@ -22,7 +22,9 @@ def _ft_converter( ), ] = None, ) -> Optional[List[str]]: - return str2list(val) + if val: + return val.split(",") + return None @attr.s diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py index 8844d4fc8..b500e33c9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -29,7 +29,7 @@ class QueryExtensionGetRequest(APIRequest): class QueryExtensionPostRequest(BaseModel): """Query Extension POST request model.""" - query: Optional[Dict[str, Dict[str, Any]]] = Field( + query: Optional[Dict[str, Dict[str, Any]]] = Field( # type: ignore None, description="Allows additional filtering based on the properties of Item objects", # noqa: E501 openapi_examples={ diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py index 938df5957..b1eb9321d 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -37,7 +37,7 @@ class SortExtensionGetRequest(APIRequest): class SortExtensionPostRequest(BaseModel): """Sortby parameter for POST requests.""" - sortby: Optional[List[PostSortModel]] = Field( + sortby: Optional[List[PostSortModel]] = Field( # type: ignore None, description="An array of property (field) names, and direction in form of '{'field': '', 'direction':''}'", # noqa: E501 openapi_examples={ diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 99ef87f9c..f90b72823 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -533,7 +533,7 @@ def item_collection( bbox: Optional[BBox] = None, datetime: Optional[str] = None, limit: int = 10, - token: str = None, + token: Optional[str] = None, **kwargs, ) -> stac.ItemCollection: """Get all items from a specific collection. @@ -744,7 +744,7 @@ async def item_collection( bbox: Optional[BBox] = None, datetime: Optional[str] = None, limit: int = 10, - token: str = None, + token: Optional[str] = None, **kwargs, ) -> stac.ItemCollection: """Get all items from a specific collection. diff --git a/stac_fastapi/types/stac_fastapi/types/extension.py b/stac_fastapi/types/stac_fastapi/types/extension.py index 55a4a123c..22fed6068 100644 --- a/stac_fastapi/types/stac_fastapi/types/extension.py +++ b/stac_fastapi/types/stac_fastapi/types/extension.py @@ -15,7 +15,7 @@ class ApiExtension(abc.ABC): GET = None POST = None - def get_request_model(self, verb: Optional[str] = "GET") -> Optional[BaseModel]: + def get_request_model(self, verb: str = "GET") -> Optional[BaseModel]: """Return the request model for the extension.method. The model can differ based on HTTP verb diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index 77ec993dd..681a491aa 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -145,7 +145,7 @@ def str_to_interval(interval: Optional[str]) -> Optional[DateTimeType]: status_code=400, detail="Start datetime cannot be before end datetime." ) - return start, end + return start, end # type: ignore def now_in_utc() -> datetime: diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 7c13c1b19..eca695c61 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -31,14 +31,11 @@ def str2list(x: str) -> Optional[List[str]]: return None -def str2bbox(x: str) -> Optional[BBox]: +def str2bbox(x: str) -> BBox: """Convert string to BBox based on , delimiter.""" - if x: - t = tuple(float(v) for v in str2list(x)) - assert len(t) in [4, 6], f"BBox '{x}' must have 4 or 6 values." - return t - - return None + t = tuple(float(v) for v in x.split(",")) + assert len(t) in [4, 6], f"BBox '{x}' must have 4 or 6 values." + return t def _collection_converter( @@ -54,7 +51,9 @@ def _collection_converter( ), ] = None, ) -> Optional[List[str]]: - return str2list(val) + if val: + return val.split(",") + return None def _ids_converter( @@ -70,7 +69,9 @@ def _ids_converter( ), ] = None, ) -> Optional[List[str]]: - return str2list(val) + if val: + return val.split(",") + return None def _bbox_converter( @@ -85,7 +86,9 @@ def _bbox_converter( ), ] = None, ) -> Optional[BBox]: - return str2bbox(val) + if val: + return str2bbox(val) + return None def _validate_datetime(instance, attribute, value): diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py index 38fdf737e..7cfdb3d65 100644 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ b/stac_fastapi/types/stac_fastapi/types/stac.py @@ -1,35 +1,26 @@ """STAC types.""" -import sys -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Union from stac_pydantic.shared import BBox - -# Avoids a Pydantic error: -# TypeError: You should use `typing_extensions.TypedDict` instead of -# `typing.TypedDict` with Python < 3.12.0. Without it, there is no way to -# differentiate required and optional fields when subclassed. -if sys.version_info < (3, 12, 0): - from typing_extensions import TypedDict -else: - from typing import TypedDict +from typing_extensions import NotRequired, TypedDict NumType = Union[float, int] -class Catalog(TypedDict, total=False): +class Catalog(TypedDict): """STAC Catalog.""" type: str stac_version: str - stac_extensions: Optional[List[str]] + stac_extensions: NotRequired[List[str]] id: str - title: Optional[str] + title: NotRequired[str] description: str links: List[Dict[str, Any]] -class LandingPage(Catalog, total=False): +class LandingPage(Catalog): """STAC Landing Page.""" conformsTo: List[str] @@ -41,7 +32,7 @@ class Conformance(TypedDict): conformsTo: List[str] -class Collection(Catalog, total=False): +class Collection(Catalog): """STAC Collection.""" keywords: List[str] @@ -52,12 +43,12 @@ class Collection(Catalog, total=False): assets: Dict[str, Any] -class Item(TypedDict, total=False): +class Item(TypedDict): """STAC Item.""" type: Literal["Feature"] stac_version: str - stac_extensions: Optional[List[str]] + stac_extensions: NotRequired[List[str]] id: str geometry: Dict[str, Any] bbox: BBox @@ -67,22 +58,22 @@ class Item(TypedDict, total=False): collection: str -class ItemCollection(TypedDict, total=False): +class ItemCollection(TypedDict): """STAC Item Collection.""" type: Literal["FeatureCollection"] features: List[Item] links: List[Dict[str, Any]] - numberMatched: Optional[int] - numberReturned: Optional[int] + numberMatched: NotRequired[int] + numberReturned: NotRequired[int] -class Collections(TypedDict, total=False): +class Collections(TypedDict): """All collections endpoint. https://github.com/radiantearth/stac-api-spec/tree/master/collections """ collections: List[Collection] links: List[Dict[str, Any]] - numberMatched: Optional[int] = None - numberReturned: Optional[int] = None + numberMatched: NotRequired[int] + numberReturned: NotRequired[int] From 25ee9db7f3ace706045fd7006d257e0c6205afd9 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 17 Apr 2025 20:12:01 +0200 Subject: [PATCH 2/5] Apply suggestions from code review --- .../stac_fastapi/extensions/core/collection_search/request.py | 4 ++-- .../extensions/stac_fastapi/extensions/core/filter/request.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py index 74490bad7..e5727697a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/collection_search/request.py @@ -37,7 +37,7 @@ class BaseCollectionSearchGetRequest(APIRequest, DatetimeMixin): class BaseCollectionSearchPostRequest(BaseModel): """Collection-Search POST model.""" - bbox: Optional[BBox] = Field( # type: ignore + bbox: Optional[BBox] = Field( default=None, description="Only return items intersecting this bounding box. Mutually exclusive with **intersects**.", # noqa: E501 openapi_examples={ @@ -45,7 +45,7 @@ class BaseCollectionSearchPostRequest(BaseModel): "Montreal": {"value": "-73.896103,45.364690,-73.413734,45.674283"}, }, ) - datetime: Optional[str] = Field( # type: ignore + datetime: Optional[str] = Field( default=None, description="""Only return items that have a temporal property that intersects this value.\n Either a date-time or an interval, open or closed. Date and time expressions adhere to RFC 3339. Open intervals are expressed using double-dots.""", # noqa: E501 diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py index b72f6a75a..02fb3b592 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -50,7 +50,7 @@ class FilterExtensionGetRequest(APIRequest): class FilterExtensionPostRequest(BaseModel): """Filter extension POST request model.""" - filter_expr: Optional[Dict[str, Any]] = Field( # type: ignore + filter_expr: Optional[Dict[str, Any]] = Field( default=None, alias="filter", description="A CQL filter expression for filtering items.", From f7ca02f7afb78db4f7ac0caac55a2053b1338375 Mon Sep 17 00:00:00 2001 From: Vincent Sarago Date: Thu, 17 Apr 2025 20:13:20 +0200 Subject: [PATCH 3/5] Apply suggestions from code review --- .../extensions/stac_fastapi/extensions/core/query/request.py | 2 +- .../extensions/stac_fastapi/extensions/core/sort/request.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py index b500e33c9..8844d4fc8 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -29,7 +29,7 @@ class QueryExtensionGetRequest(APIRequest): class QueryExtensionPostRequest(BaseModel): """Query Extension POST request model.""" - query: Optional[Dict[str, Dict[str, Any]]] = Field( # type: ignore + query: Optional[Dict[str, Dict[str, Any]]] = Field( None, description="Allows additional filtering based on the properties of Item objects", # noqa: E501 openapi_examples={ diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py index b1eb9321d..938df5957 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -37,7 +37,7 @@ class SortExtensionGetRequest(APIRequest): class SortExtensionPostRequest(BaseModel): """Sortby parameter for POST requests.""" - sortby: Optional[List[PostSortModel]] = Field( # type: ignore + sortby: Optional[List[PostSortModel]] = Field( None, description="An array of property (field) names, and direction in form of '{'field': '', 'direction':''}'", # noqa: E501 openapi_examples={ From d6ef8fa841457c2f5b209d881005d29be32ecca4 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 18 Apr 2025 09:46:25 +0200 Subject: [PATCH 4/5] revert optional output for str2bbox --- stac_fastapi/types/stac_fastapi/types/search.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index eca695c61..f52f4530e 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -31,11 +31,14 @@ def str2list(x: str) -> Optional[List[str]]: return None -def str2bbox(x: str) -> BBox: +def str2bbox(x: str) -> Optional[BBox]: """Convert string to BBox based on , delimiter.""" - t = tuple(float(v) for v in x.split(",")) - assert len(t) in [4, 6], f"BBox '{x}' must have 4 or 6 values." - return t + if x: + t = tuple(float(v) for v in x.split(",")) + assert len(t) in [4, 6], f"BBox '{x}' must have 4 or 6 values." + return t + + return None def _collection_converter( From 3eecf4243f46bfe9dc2518f86b4c3bd76ae3d43b Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 18 Apr 2025 10:18:57 +0200 Subject: [PATCH 5/5] update changelog --- CHANGES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.md b/CHANGES.md index 25a8eaea5..0a904d9a5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ ### Fixed - Remove defaults in OpenAPI schemas +- Type Hints for TypedDict ### Added