diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml index 514e31496..2ab892874 100644 --- a/.github/workflows/cicd.yaml +++ b/.github/workflows/cicd.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] timeout-minutes: 20 services: @@ -48,7 +48,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Lint code - if: ${{ matrix.python-version == 3.8 }} + if: ${{ matrix.python-version == 3.9 }} run: | python -m pip install pre-commit pre-commit run --all-files diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml index b29f0732a..f3d1d951d 100644 --- a/.github/workflows/deploy_mkdocs.yml +++ b/.github/workflows/deploy_mkdocs.yml @@ -21,10 +21,10 @@ jobs: - name: Checkout main uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 193edc5c7..51588b36c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,6 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.1.1 hooks: - id: black diff --git a/CHANGES.md b/CHANGES.md index 16bc9a809..a864e6d5b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,9 @@ ## [Unreleased] +* Removing support for Python 3.8 +* Update to pydantic v2 and stac_pydantic v3 + ## [2.4.9] - 2023-11-17 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cadb694e9..fd77b3798 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ $ cd stac-fastapi $ pip install -e stac_fastapi/api[dev] ``` -**Python3.8 only** +**Python3.9 only** This repo is set to use `pre-commit` to run *ruff*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code. diff --git a/Dockerfile b/Dockerfile index 2187ac53e..294e13d86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-slim as base +FROM python:3.9-slim as base # Any python libraries that require system libraries to be installed will likely # need the following packages in order to build diff --git a/Dockerfile.docs b/Dockerfile.docs index e3c7447e5..24c2a7770 100644 --- a/Dockerfile.docs +++ b/Dockerfile.docs @@ -1,4 +1,4 @@ -FROM python:3.8-slim +FROM python:3.9-slim # build-essential is required to build a wheel for ciso8601 RUN apt update && apt install -y build-essential diff --git a/mkdocs.yml b/mkdocs.yml index 60374dc3b..4811370ac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,7 +68,6 @@ nav: - requests: api/stac_fastapi/types/requests.md - rfc3339: api/stac_fastapi/types/rfc3339.md - search: api/stac_fastapi/types/search.md - - stac: api/stac_fastapi/types/stac.md - version: api/stac_fastapi/types/version.md - Development - Contributing: "contributing.md" - Release Notes: "release-notes.md" diff --git a/stac_fastapi/api/setup.py b/stac_fastapi/api/setup.py index 1e3b8002f..2e9ba8549 100644 --- a/stac_fastapi/api/setup.py +++ b/stac_fastapi/api/setup.py @@ -6,9 +6,6 @@ desc = f.read() install_requires = [ - "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", "brotli_asgi", "stac-fastapi.types", ] @@ -32,12 +29,12 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "License :: OSI Approved :: MIT License", ], keywords="STAC FastAPI COG", diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 28fff912c..17585f552 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -1,5 +1,6 @@ """Fastapi app creation.""" -from typing import Any, Dict, List, Optional, Tuple, Type, Union + +from typing import Any, Optional, Tuple, Type, Union import attr from brotli_asgi import BrotliMiddleware @@ -9,7 +10,7 @@ from stac_pydantic import Collection, Item, ItemCollection from stac_pydantic.api import ConformanceClasses, LandingPage from stac_pydantic.api.collections import Collections -from stac_pydantic.version import STAC_VERSION +from stac_pydantic.api.version import STAC_API_VERSION from starlette.responses import JSONResponse, Response from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers @@ -67,8 +68,8 @@ class StacApi: settings: ApiSettings = attr.ib() client: Union[AsyncBaseCoreClient, BaseCoreClient] = attr.ib() - extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) - exceptions: Dict[Type[Exception], int] = attr.ib( + extensions: list[ApiExtension] = attr.ib(default=attr.Factory(list)) + exceptions: dict[Type[Exception], int] = attr.ib( default=attr.Factory(lambda: DEFAULT_STATUS_CODES) ) app: FastAPI = attr.ib( @@ -85,7 +86,7 @@ class StacApi: router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) title: str = attr.ib(default="stac-fastapi") api_version: str = attr.ib(default="0.1") - stac_version: str = attr.ib(default=STAC_VERSION) + stac_version: str = attr.ib(default=STAC_API_VERSION) description: str = attr.ib(default="stac-fastapi") search_get_request_model: Type[BaseSearchGetRequest] = attr.ib( default=BaseSearchGetRequest @@ -95,12 +96,12 @@ class StacApi: ) pagination_extension = attr.ib(default=TokenPaginationExtension) response_class: Type[Response] = attr.ib(default=JSONResponse) - middlewares: List = attr.ib( + middlewares: list = attr.ib( default=attr.Factory( lambda: [BrotliMiddleware, CORSMiddleware, ProxyHeaderMiddleware] ) ) - route_dependencies: List[Tuple[List[Scope], List[Depends]]] = attr.ib(default=[]) + route_dependencies: list[Tuple[list[Scope], list[Depends]]] = attr.ib(default=[]) def get_extension(self, extension: Type[ApiExtension]) -> Optional[ApiExtension]: """Get an extension. @@ -125,9 +126,9 @@ def register_landing_page(self): self.router.add_api_route( name="Landing Page", path="/", - response_model=LandingPage - if self.settings.enable_response_models - else None, + response_model=( + LandingPage if self.settings.enable_response_models else None + ), response_class=self.response_class, response_model_exclude_unset=False, response_model_exclude_none=True, @@ -146,9 +147,9 @@ def register_conformance_classes(self): self.router.add_api_route( name="Conformance Classes", path="/conformance", - response_model=ConformanceClasses - if self.settings.enable_response_models - else None, + response_model=( + ConformanceClasses if self.settings.enable_response_models else None + ), response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -187,9 +188,11 @@ def register_post_search(self): self.router.add_api_route( name="Search", path="/search", - response_model=(ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None, + response_model=( + (ItemCollection if not fields_ext else None) + if self.settings.enable_response_models + else None + ), response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -209,9 +212,11 @@ def register_get_search(self): self.router.add_api_route( name="Search", path="/search", - response_model=(ItemCollection if not fields_ext else None) - if self.settings.enable_response_models - else None, + response_model=( + (ItemCollection if not fields_ext else None) + if self.settings.enable_response_models + else None + ), response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -230,9 +235,9 @@ def register_get_collections(self): self.router.add_api_route( name="Get Collections", path="/collections", - response_model=Collections - if self.settings.enable_response_models - else None, + response_model=( + Collections if self.settings.enable_response_models else None + ), response_class=self.response_class, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -280,9 +285,9 @@ def register_get_item_collection(self): self.router.add_api_route( name="Get ItemCollection", path="/collections/{collection_id}/items", - response_model=ItemCollection - if self.settings.enable_response_models - else None, + response_model=( + ItemCollection if self.settings.enable_response_models else None + ), response_class=GeoJSONResponse, response_model_exclude_unset=True, response_model_exclude_none=True, @@ -318,7 +323,7 @@ def register_core(self): self.register_get_collection() self.register_get_item_collection() - def customize_openapi(self) -> Optional[Dict[str, Any]]: + def customize_openapi(self) -> Optional[dict[str, Any]]: """Customize openapi schema.""" if self.app.openapi_schema: return self.app.openapi_schema @@ -346,7 +351,7 @@ async def ping(): self.app.include_router(mgmt_router, tags=["Liveliness/Readiness"]) def add_route_dependencies( - self, scopes: List[Scope], dependencies=List[Depends] + self, scopes: list[Scope], dependencies=list[Depends] ) -> None: """Add custom dependencies to routes. diff --git a/stac_fastapi/api/stac_fastapi/api/config.py b/stac_fastapi/api/stac_fastapi/api/config.py index e6e4d882a..3918421ff 100644 --- a/stac_fastapi/api/stac_fastapi/api/config.py +++ b/stac_fastapi/api/stac_fastapi/api/config.py @@ -1,4 +1,5 @@ """Application settings.""" + import enum diff --git a/stac_fastapi/api/stac_fastapi/api/errors.py b/stac_fastapi/api/stac_fastapi/api/errors.py index 3f052bd31..96c7a66a9 100644 --- a/stac_fastapi/api/stac_fastapi/api/errors.py +++ b/stac_fastapi/api/stac_fastapi/api/errors.py @@ -1,13 +1,14 @@ """Error handling.""" import logging -from typing import Callable, Dict, Type, TypedDict +from typing import Callable, Type from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from starlette import status from starlette.requests import Request from starlette.responses import JSONResponse +from typing_extensions import TypedDict from stac_fastapi.types.errors import ( ConflictError, @@ -67,7 +68,7 @@ def handler(request: Request, exc: Exception): def add_exception_handlers( - app: FastAPI, status_codes: Dict[Type[Exception], int] + app: FastAPI, status_codes: dict[Type[Exception], int] ) -> None: """Add exception handlers to the FastAPI application. diff --git a/stac_fastapi/api/stac_fastapi/api/middleware.py b/stac_fastapi/api/stac_fastapi/api/middleware.py index 3ed67d6c9..e379f5851 100644 --- a/stac_fastapi/api/stac_fastapi/api/middleware.py +++ b/stac_fastapi/api/stac_fastapi/api/middleware.py @@ -1,8 +1,9 @@ """Api middleware.""" + import re import typing from http.client import HTTP_PORT, HTTPS_PORT -from typing import List, Tuple +from typing import Tuple from starlette.middleware.cors import CORSMiddleware as _CORSMiddleware from starlette.types import ASGIApp, Receive, Scope, Send @@ -126,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]]: return [ (name, value) for name, value in scope["headers"] diff --git a/stac_fastapi/api/stac_fastapi/api/models.py b/stac_fastapi/api/stac_fastapi/api/models.py index 3d33b4e18..488e7b82d 100644 --- a/stac_fastapi/api/stac_fastapi/api/models.py +++ b/stac_fastapi/api/stac_fastapi/api/models.py @@ -4,9 +4,8 @@ from typing import Optional, Type, Union import attr -from fastapi import Body, Path +from fastapi import Path from pydantic import BaseModel, create_model -from pydantic.fields import UndefinedType from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.search import ( @@ -44,31 +43,8 @@ def create_request_model( # Handle POST requests elif all([issubclass(m, BaseModel) for m in models]): for model in models: - for k, v in model.__fields__.items(): - field_info = v.field_info - body = Body( - None - if isinstance(field_info.default, UndefinedType) - else field_info.default, - default_factory=field_info.default_factory, - alias=field_info.alias, - alias_priority=field_info.alias_priority, - title=field_info.title, - description=field_info.description, - const=field_info.const, - gt=field_info.gt, - ge=field_info.ge, - lt=field_info.lt, - le=field_info.le, - multiple_of=field_info.multiple_of, - min_items=field_info.min_items, - max_items=field_info.max_items, - min_length=field_info.min_length, - max_length=field_info.max_length, - regex=field_info.regex, - extra=field_info.extra, - ) - fields[k] = (v.outer_type_, body) + for k, field_info in model.model_fields.items(): + fields[k] = (field_info.annotation, field_info) return create_model(model_name, **fields, __base__=base_model) raise TypeError("Mixed Request Model types. Check extension request types.") diff --git a/stac_fastapi/api/stac_fastapi/api/openapi.py b/stac_fastapi/api/stac_fastapi/api/openapi.py index a38a70bae..84d72dab1 100644 --- a/stac_fastapi/api/stac_fastapi/api/openapi.py +++ b/stac_fastapi/api/stac_fastapi/api/openapi.py @@ -1,4 +1,5 @@ """openapi.""" + import warnings from fastapi import FastAPI @@ -43,9 +44,9 @@ async def patched_openapi_endpoint(req: Request) -> Response: # Get the response from the old endpoint function response: JSONResponse = await old_endpoint(req) # Update the content type header in place - response.headers[ - "content-type" - ] = "application/vnd.oai.openapi+json;version=3.0" + response.headers["content-type"] = ( + "application/vnd.oai.openapi+json;version=3.0" + ) # Return the updated response return response diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index f4eb759af..6632b9cdc 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -1,7 +1,8 @@ """Route factories.""" + import functools import inspect -from typing import Any, Callable, Dict, List, Optional, Type, TypedDict, Union +from typing import Any, Callable, Optional, Type, Union from fastapi import Depends, params from fastapi.dependencies.utils import get_parameterless_sub_dependant @@ -11,6 +12,7 @@ from starlette.responses import JSONResponse, Response from starlette.routing import BaseRoute, Match from starlette.status import HTTP_204_NO_CONTENT +from typing_extensions import TypedDict from stac_fastapi.api.models import APIRequest @@ -36,7 +38,7 @@ async def run(*args, **kwargs): def create_async_endpoint( func: Callable, - request_model: Union[Type[APIRequest], Type[BaseModel], Dict], + request_model: Union[Type[APIRequest], Type[BaseModel], dict], response_class: Type[Response] = JSONResponse, ): """Wrap a function in a coroutine which may be used to create a FastAPI endpoint. @@ -72,7 +74,7 @@ async def _endpoint( async def _endpoint( request: Request, - request_data: Dict[str, Any], # type:ignore + request_data: dict[str, Any], # type:ignore ): """Endpoint.""" return _wrap_response( @@ -92,7 +94,7 @@ class Scope(TypedDict, total=False): def add_route_dependencies( - routes: List[BaseRoute], scopes: List[Scope], dependencies=List[params.Depends] + routes: list[BaseRoute], scopes: list[Scope], dependencies=list[params.Depends] ) -> None: """Add dependencies to routes. diff --git a/stac_fastapi/api/stac_fastapi/api/version.py b/stac_fastapi/api/stac_fastapi/api/version.py index bb0c7c379..54c068f57 100644 --- a/stac_fastapi/api/stac_fastapi/api/version.py +++ b/stac_fastapi/api/stac_fastapi/api/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.4.9" diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index ce49cef89..9896cd4e8 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -1,4 +1,8 @@ +import json + +import pytest from fastapi import Depends, HTTPException, security, status +from stac_pydantic import Collection, Item from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi @@ -41,7 +45,7 @@ def _assert_dependency_applied(api, routes): method=route["method"].lower(), url=path, auth=("bob", "dobbs"), - content='{"dummy": "payload"}', + content=route.get("payload"), headers={"content-type": "application/json"}, ) assert ( @@ -58,27 +62,53 @@ def test_openapi_content_type(self): == "application/vnd.oai.openapi+json;version=3.0" ) - def test_build_api_with_route_dependencies(self): + def test_build_api_with_route_dependencies( + self, collection: Collection, item: Item + ): routes = [ - {"path": "/collections", "method": "POST"}, - {"path": "/collections", "method": "PUT"}, + {"path": "/collections", "method": "POST", "payload": collection}, + {"path": "/collections", "method": "PUT", "payload": collection}, {"path": "/collections/{collectionId}", "method": "DELETE"}, - {"path": "/collections/{collectionId}/items", "method": "POST"}, - {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"}, - {"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"}, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + }, ] dependencies = [Depends(must_be_bob)] api = self._build_api(route_dependencies=[(routes, dependencies)]) self._assert_dependency_applied(api, routes) - def test_add_route_dependencies_after_building_api(self): + def test_add_route_dependencies_after_building_api( + self, collection: Collection, item: Item + ): routes = [ - {"path": "/collections", "method": "POST"}, - {"path": "/collections", "method": "PUT"}, + {"path": "/collections", "method": "POST", "payload": collection}, + {"path": "/collections", "method": "PUT", "payload": collection}, {"path": "/collections/{collectionId}", "method": "DELETE"}, - {"path": "/collections/{collectionId}/items", "method": "POST"}, - {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"}, - {"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"}, + { + "path": "/collections/{collectionId}/items", + "method": "POST", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "PUT", + "payload": item, + }, + { + "path": "/collections/{collectionId}/items/{itemId}", + "method": "DELETE", + }, ] api = self._build_api() api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)]) @@ -86,23 +116,17 @@ def test_add_route_dependencies_after_building_api(self): class DummyCoreClient(core.BaseCoreClient): - def all_collections(self, *args, **kwargs): - ... + def all_collections(self, *args, **kwargs): ... - def get_collection(self, *args, **kwargs): - ... + def get_collection(self, *args, **kwargs): ... - def get_item(self, *args, **kwargs): - ... + def get_item(self, *args, **kwargs): ... - def get_search(self, *args, **kwargs): - ... + def get_search(self, *args, **kwargs): ... - def post_search(self, *args, **kwargs): - ... + def post_search(self, *args, **kwargs): ... - def item_collection(self, *args, **kwargs): - ... + def item_collection(self, *args, **kwargs): ... class DummyTransactionsClient(core.BaseTransactionsClient): @@ -138,3 +162,39 @@ def must_be_bob( detail="You're not Bob", headers={"WWW-Authenticate": "Basic"}, ) + + +@pytest.fixture +def collection(): + return json.dumps( + { + "id": "test_collection", + "title": "Test collection", + "description": "This is a test collection", + "license": "open", + "keywords": [], + "extent": { + "spatial": {"bbox": [[-105, 40, -105, 40]]}, + "temporal": {"interval": [["2023-02-12T12:00:00Z", None]]}, + }, + "links": [], + } + ) + + +@pytest.fixture +def item(): + return json.dumps( + { + "type": "Feature", + "stac_version": "1.0.0", + "stac_extensions": [], + "id": "test_item", + "geometry": {"type": "Point", "coordinates": [-105, 40]}, + "bbox": [-105, 40, -105, 40], + "properties": {"datetime": "2023-02-12T12:00:00Z"}, + "links": [], + "assets": {}, + "collection": "test_collection", + } + ) diff --git a/stac_fastapi/extensions/setup.py b/stac_fastapi/extensions/setup.py index a70ea5855..6b10a1ac0 100644 --- a/stac_fastapi/extensions/setup.py +++ b/stac_fastapi/extensions/setup.py @@ -6,10 +6,6 @@ desc = f.read() install_requires = [ - "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", - "stac-fastapi.types", "stac-fastapi.api", ] @@ -30,12 +26,12 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "License :: OSI Approved :: MIT License", ], keywords="STAC FastAPI COG", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py index 96317fe4a..74f15ed0a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/__init__.py @@ -1,4 +1,5 @@ """stac_api.extensions.core module.""" + from .context import ContextExtension from .fields import FieldsExtension from .filter import FilterExtension diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py index 90faae914..58ec39d4a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/context.py @@ -1,5 +1,6 @@ """Context extension.""" -from typing import List, Optional + +from typing import Optional import attr from fastapi import FastAPI @@ -17,7 +18,7 @@ class ContextExtension(ApiExtension): https://github.com/stac-api-extensions/context """ - conformance_classes: List[str] = attr.ib( + conformance_classes: list[str] = attr.ib( factory=lambda: ["https://api.stacspec.org/v1.0.0-rc.2/item-search#context"] ) schema_href: Optional[str] = attr.ib( diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py index b9a246b63..087d01b7a 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/__init__.py @@ -1,6 +1,5 @@ """Fields extension module.""" - from .fields import FieldsExtension __all__ = ["FieldsExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py index df4cd44de..39b62f227 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/fields.py @@ -1,5 +1,6 @@ """Fields extension.""" -from typing import List, Optional, Set + +from typing import Optional import attr from fastapi import FastAPI @@ -31,10 +32,10 @@ class FieldsExtension(ApiExtension): GET = FieldsExtensionGetRequest POST = FieldsExtensionPostRequest - conformance_classes: List[str] = attr.ib( + conformance_classes: list[str] = attr.ib( factory=lambda: ["https://api.stacspec.org/v1.0.0/item-search#fields"] ) - default_includes: Set[str] = attr.ib( + default_includes: set[str] = attr.ib( factory=lambda: { "id", "type", diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py index 4cfbd3293..903f10595 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/fields/request.py @@ -1,6 +1,6 @@ """Request models for the fields extension.""" -from typing import Dict, Optional, Set +from typing import Optional import attr from pydantic import BaseModel, Field @@ -17,11 +17,11 @@ class PostFieldsExtension(BaseModel): exclude: set of fields to exclude. """ - include: Optional[Set[str]] = set() - exclude: Optional[Set[str]] = set() + include: Optional[set[str]] = set() + exclude: Optional[set[str]] = set() @staticmethod - def _get_field_dict(fields: Optional[Set[str]]) -> Dict: + def _get_field_dict(fields: Optional[set[str]]) -> dict: """Pydantic include/excludes notation. Internal method to create a dictionary for advanced include or exclude @@ -42,7 +42,7 @@ def _get_field_dict(fields: Optional[Set[str]]) -> Dict: return field_dict @property - def filter_fields(self) -> Dict: + def filter_fields(self) -> dict: """Create pydantic include/exclude expression. Create dictionary of fields to include/exclude on model export based on diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py index 78256bfd2..256f3e06e 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/__init__.py @@ -1,6 +1,5 @@ """Filter extension module.""" - from .filter import FilterExtension __all__ = ["FilterExtension"] diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py index 2f875907a..878f1cd74 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/filter.py @@ -1,7 +1,7 @@ # encoding: utf-8 """Filter Extension.""" from enum import Enum -from typing import List, Type, Union +from typing import Type, Union import attr from fastapi import APIRouter, FastAPI @@ -70,7 +70,7 @@ class FilterExtension(ApiExtension): client: Union[AsyncBaseFiltersClient, BaseFiltersClient] = attr.ib( factory=BaseFiltersClient ) - conformance_classes: List[str] = attr.ib( + conformance_classes: list[str] = attr.ib( default=[ FilterConformanceClasses.FILTER, FilterConformanceClasses.FEATURES_FILTER, 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 1fcd6b0b9..fd7f029de 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/filter/request.py @@ -1,7 +1,7 @@ """Filter extension request models.""" from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Optional import attr from pydantic import BaseModel, Field @@ -36,6 +36,6 @@ class FilterExtensionGetRequest(APIRequest): class FilterExtensionPostRequest(BaseModel): """Filter extension POST request model.""" - filter: Optional[Dict[str, Any]] = None + filter: Optional[dict[str, Any]] = None filter_crs: Optional[str] = Field(alias="filter-crs", default=None) filter_lang: Optional[FilterLang] = Field(alias="filter-lang", default="cql-json") diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py index 296e9ae6a..c7fdf19c2 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/pagination.py @@ -1,6 +1,6 @@ """Pagination API extension.""" -from typing import List, Optional +from typing import Optional import attr from fastapi import FastAPI @@ -22,7 +22,7 @@ class PaginationExtension(ApiExtension): GET = GETPagination POST = POSTPagination - conformance_classes: List[str] = attr.ib(factory=list) + conformance_classes: list[str] = attr.ib(factory=list) schema_href: Optional[str] = attr.ib(default=None) def register(self, app: FastAPI) -> None: diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py index d3fa10391..c8b0ef3ed 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/pagination/token_pagination.py @@ -1,6 +1,6 @@ """Token pagination API extension.""" -from typing import List, Optional +from typing import Optional import attr from fastapi import FastAPI @@ -22,7 +22,7 @@ class TokenPaginationExtension(ApiExtension): GET = GETTokenPagination POST = POSTTokenPagination - conformance_classes: List[str] = attr.ib(factory=list) + conformance_classes: list[str] = attr.ib(factory=list) schema_href: Optional[str] = attr.ib(default=None) def register(self, app: FastAPI) -> None: diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py index 3e85b406d..f70d9b056 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/query.py @@ -1,5 +1,6 @@ """Query extension.""" -from typing import List, Optional + +from typing import Optional import attr from fastapi import FastAPI @@ -22,7 +23,7 @@ class QueryExtension(ApiExtension): GET = QueryExtensionGetRequest POST = QueryExtensionPostRequest - conformance_classes: List[str] = attr.ib( + conformance_classes: list[str] = attr.ib( factory=lambda: ["https://api.stacspec.org/v1.0.0/item-search#query"] ) schema_href: Optional[str] = attr.ib(default=None) 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 8b282884a..867b7fc57 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/query/request.py @@ -1,6 +1,6 @@ """Request model for the Query extension.""" -from typing import Any, Dict, Optional +from typing import Any, Optional import attr from pydantic import BaseModel @@ -18,4 +18,4 @@ class QueryExtensionGetRequest(APIRequest): class QueryExtensionPostRequest(BaseModel): """Query Extension POST request model.""" - query: Optional[Dict[str, Dict[str, Any]]] + query: Optional[dict[str, dict[str, Any]]] = None 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 c19f40dba..5ab523e25 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/request.py @@ -1,7 +1,7 @@ # encoding: utf-8 """Request model for the Sort Extension.""" -from typing import List, Optional +from typing import Optional import attr from pydantic import BaseModel @@ -20,4 +20,4 @@ class SortExtensionGetRequest(APIRequest): class SortExtensionPostRequest(BaseModel): """Sortby parameter for POST requests.""" - sortby: Optional[List[PostSortModel]] + sortby: Optional[list[PostSortModel]] = None diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py index 5dd96cfa6..35b616658 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/sort/sort.py @@ -1,5 +1,6 @@ """Sort extension.""" -from typing import List, Optional + +from typing import Optional import attr from fastapi import FastAPI @@ -21,7 +22,7 @@ class SortExtension(ApiExtension): GET = SortExtensionGetRequest POST = SortExtensionPostRequest - conformance_classes: List[str] = attr.ib( + conformance_classes: list[str] = attr.ib( factory=lambda: ["https://api.stacspec.org/v1.0.0/item-search#sort"] ) schema_href: Optional[str] = attr.ib(default=None) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py index 480ff09b5..5932e5270 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/core/transaction.py @@ -1,14 +1,14 @@ """Transaction extension.""" -from typing import List, Optional, Type, Union + +from typing import Optional, Type, Union import attr from fastapi import APIRouter, Body, FastAPI -from stac_pydantic import Collection, Item +from stac_pydantic import Collection, Item, ItemCollection from starlette.responses import JSONResponse, Response from stac_fastapi.api.models import CollectionUri, ItemUri from stac_fastapi.api.routes import create_async_endpoint -from stac_fastapi.types import stac as stac_types from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import AsyncBaseTransactionsClient, BaseTransactionsClient from stac_fastapi.types.extension import ApiExtension @@ -18,16 +18,14 @@ class PostItem(CollectionUri): """Create Item.""" - item: Union[stac_types.Item, stac_types.ItemCollection] = attr.ib( - default=Body(None) - ) + item: Union[Item, ItemCollection] = attr.ib(default=Body(None)) @attr.s class PutItem(ItemUri): """Update Item.""" - item: stac_types.Item = attr.ib(default=Body(None)) + item: Item = attr.ib(default=Body(None)) @attr.s @@ -51,7 +49,7 @@ class TransactionExtension(ApiExtension): client: Union[AsyncBaseTransactionsClient, BaseTransactionsClient] = attr.ib() settings: ApiSettings = attr.ib() - conformance_classes: List[str] = attr.ib( + conformance_classes: list[str] = attr.ib( factory=lambda: [ "https://api.stacspec.org/v1.0.0-rc.3/ogcapi-features/extensions/transaction", ] @@ -111,9 +109,7 @@ def register_create_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["POST"], - endpoint=create_async_endpoint( - self.client.create_collection, stac_types.Collection - ), + endpoint=create_async_endpoint(self.client.create_collection, Collection), ) def register_update_collection(self): @@ -126,9 +122,7 @@ def register_update_collection(self): response_model_exclude_unset=True, response_model_exclude_none=True, methods=["PUT"], - endpoint=create_async_endpoint( - self.client.update_collection, stac_types.Collection - ), + endpoint=create_async_endpoint(self.client.update_collection, Collection), ) def register_delete_collection(self): diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py index ab7349e60..d35c4c8f9 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/__init__.py @@ -1,4 +1,5 @@ """stac_api.extensions.third_party module.""" + from .bulk_transactions import BulkTransactionExtension __all__ = ("BulkTransactionExtension",) diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py index 9fa96ff2b..e326649ab 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/third_party/bulk_transactions.py @@ -1,7 +1,8 @@ """Bulk transactions extension.""" + import abc from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import Any, Optional, Union import attr from fastapi import APIRouter, FastAPI @@ -22,7 +23,7 @@ class BulkTransactionMethod(str, Enum): class Items(BaseModel): """A group of STAC Item objects, in the form of a dictionary from Item.id -> Item.""" - items: Dict[str, Any] + items: dict[str, Any] method: BulkTransactionMethod = BulkTransactionMethod.INSERT def __iter__(self): @@ -109,10 +110,10 @@ class BulkTransactionExtension(ApiExtension): } """ - client: Union[ - AsyncBaseBulkTransactionsClient, BaseBulkTransactionsClient - ] = attr.ib() - conformance_classes: List[str] = attr.ib(default=list()) + client: Union[AsyncBaseBulkTransactionsClient, BaseBulkTransactionsClient] = ( + attr.ib() + ) + conformance_classes: list[str] = attr.ib(default=list()) schema_href: Optional[str] = attr.ib(default=None) def register(self, app: FastAPI) -> None: diff --git a/stac_fastapi/extensions/stac_fastapi/extensions/version.py b/stac_fastapi/extensions/stac_fastapi/extensions/version.py index bb0c7c379..54c068f57 100644 --- a/stac_fastapi/extensions/stac_fastapi/extensions/version.py +++ b/stac_fastapi/extensions/stac_fastapi/extensions/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.4.9" diff --git a/stac_fastapi/extensions/tests/test_transaction.py b/stac_fastapi/extensions/tests/test_transaction.py index fc5acc2cf..b2f59ae47 100644 --- a/stac_fastapi/extensions/tests/test_transaction.py +++ b/stac_fastapi/extensions/tests/test_transaction.py @@ -2,13 +2,14 @@ from typing import Iterator, Union import pytest +from stac_pydantic.item import Item +from stac_pydantic.item_collection import ItemCollection from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi from stac_fastapi.extensions.core import TransactionExtension from stac_fastapi.types.config import ApiSettings from stac_fastapi.types.core import BaseCoreClient, BaseTransactionsClient -from stac_fastapi.types.stac import Item, ItemCollection class DummyCoreClient(BaseCoreClient): @@ -35,7 +36,7 @@ class DummyTransactionsClient(BaseTransactionsClient): """Defines a pattern for implementing the STAC transaction extension.""" def create_item(self, item: Union[Item, ItemCollection], *args, **kwargs): - return {"created": True, "type": item["type"]} + return {"created": True, "type": item.type} def update_item(self, *args, **kwargs): raise NotImplementedError @@ -114,7 +115,7 @@ def item() -> Item: "id": "test_item", "geometry": {"type": "Point", "coordinates": [-105, 40]}, "bbox": [-105, 40, -105, 40], - "properties": {}, + "properties": {"datetime": "2023-02-12T12:00:00Z"}, "links": [], "assets": {}, "collection": "test_collection", diff --git a/stac_fastapi/types/setup.py b/stac_fastapi/types/setup.py index 9a06fda95..4cee32145 100644 --- a/stac_fastapi/types/setup.py +++ b/stac_fastapi/types/setup.py @@ -6,12 +6,13 @@ desc = f.read() install_requires = [ - "fastapi>=0.73.0", + "fastapi>=0.109.0", "attrs", - "pydantic[dotenv]<2", - "stac_pydantic==2.0.*", - "pystac==1.*", + "pydantic<3", + "stac_pydantic==3.0.*", + "pystac[validation]==1.*", "iso8601>=1.0.2,<2.2.0", + "pydantic-settings==2.1.0", ] extra_reqs = { @@ -31,12 +32,12 @@ description="An implementation of STAC API based on the FastAPI framework.", long_description=desc, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", classifiers=[ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "License :: OSI Approved :: MIT License", ], keywords="STAC FastAPI COG", diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index b3f22fb65..9a0622c58 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -1,7 +1,8 @@ """stac_fastapi.types.config module.""" -from typing import Optional, Set -from pydantic import BaseSettings +from typing import Optional + +from pydantic_settings import BaseSettings, SettingsConfigDict class ApiSettings(BaseSettings): @@ -20,7 +21,7 @@ class ApiSettings(BaseSettings): # TODO: Remove `default_includes` attribute so we can use # `pydantic.BaseSettings` instead - default_includes: Optional[Set[str]] = None + default_includes: Optional[set[str]] = None app_host: str = "0.0.0.0" app_port: int = 8000 @@ -29,12 +30,7 @@ class ApiSettings(BaseSettings): openapi_url: str = "/api" docs_url: str = "/api.html" - - class Config: - """Model config (https://pydantic-docs.helpmanual.io/usage/model_config/).""" - - extra = "allow" - env_file = ".env" + model_config = SettingsConfigDict(extra="allow", env_file=".env") class Settings: diff --git a/stac_fastapi/types/stac_fastapi/types/conformance.py b/stac_fastapi/types/stac_fastapi/types/conformance.py index 13836aaf5..840584c1b 100644 --- a/stac_fastapi/types/stac_fastapi/types/conformance.py +++ b/stac_fastapi/types/stac_fastapi/types/conformance.py @@ -1,4 +1,5 @@ """Conformance Classes.""" + from enum import Enum diff --git a/stac_fastapi/types/stac_fastapi/types/core.py b/stac_fastapi/types/stac_fastapi/types/core.py index 5798de968..72921f682 100644 --- a/stac_fastapi/types/stac_fastapi/types/core.py +++ b/stac_fastapi/types/stac_fastapi/types/core.py @@ -1,25 +1,25 @@ """Base clients.""" + import abc from datetime import datetime -from typing import Any, Dict, List, Optional, Union +from typing import Any, Optional, Union from urllib.parse import urljoin import attr from fastapi import Request +from stac_pydantic import Collection, Item, ItemCollection, api from stac_pydantic.links import Relations from stac_pydantic.shared import MimeTypes from stac_pydantic.version import STAC_VERSION from starlette.responses import Response -from stac_fastapi.types import stac as stac_types from stac_fastapi.types.conformance import BASE_CONFORMANCE_CLASSES from stac_fastapi.types.extension import ApiExtension from stac_fastapi.types.requests import get_base_url from stac_fastapi.types.search import BaseSearchPostRequest -from stac_fastapi.types.stac import Conformance NumType = Union[float, int] -StacType = Dict[str, Any] +StacType = dict[str, Any] @attr.s # type:ignore @@ -30,9 +30,9 @@ class BaseTransactionsClient(abc.ABC): def create_item( self, collection_id: str, - item: Union[stac_types.Item, stac_types.ItemCollection], + item: Union[Item, ItemCollection], **kwargs, - ) -> Optional[Union[stac_types.Item, Response, None]]: + ) -> Optional[Union[Item, Response, None]]: """Create a new item. Called with `POST /collections/{collection_id}/items`. @@ -48,8 +48,8 @@ def create_item( @abc.abstractmethod def update_item( - self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: + self, collection_id: str, item_id: str, item: Item, **kwargs + ) -> Optional[Union[Item, Response]]: """Perform a complete update on an existing item. Called with `PUT /collections/{collection_id}/items`. It is expected @@ -69,7 +69,7 @@ def update_item( @abc.abstractmethod def delete_item( self, item_id: str, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: + ) -> Optional[Union[Item, Response]]: """Delete an item from a collection. Called with `DELETE /collections/{collection_id}/items/{item_id}` @@ -85,8 +85,8 @@ def delete_item( @abc.abstractmethod def create_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Create a new collection. Called with `POST /collections`. @@ -101,8 +101,8 @@ def create_collection( @abc.abstractmethod def update_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Perform a complete update on an existing collection. Called with `PUT /collections`. It is expected that this item already @@ -122,7 +122,7 @@ def update_collection( @abc.abstractmethod def delete_collection( self, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + ) -> Optional[Union[Collection, Response]]: """Delete a collection. Called with `DELETE /collections/{collection_id}` @@ -144,9 +144,9 @@ class AsyncBaseTransactionsClient(abc.ABC): async def create_item( self, collection_id: str, - item: Union[stac_types.Item, stac_types.ItemCollection], + item: Union[Item, ItemCollection], **kwargs, - ) -> Optional[Union[stac_types.Item, Response, None]]: + ) -> Optional[Union[Item, Response, None]]: """Create a new item. Called with `POST /collections/{collection_id}/items`. @@ -162,8 +162,8 @@ async def create_item( @abc.abstractmethod async def update_item( - self, collection_id: str, item_id: str, item: stac_types.Item, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: + self, collection_id: str, item_id: str, item: Item, **kwargs + ) -> Optional[Union[Item, Response]]: """Perform a complete update on an existing item. Called with `PUT /collections/{collection_id}/items`. It is expected @@ -182,7 +182,7 @@ async def update_item( @abc.abstractmethod async def delete_item( self, item_id: str, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Item, Response]]: + ) -> Optional[Union[Item, Response]]: """Delete an item from a collection. Called with `DELETE /collections/{collection_id}/items/{item_id}` @@ -198,8 +198,8 @@ async def delete_item( @abc.abstractmethod async def create_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Create a new collection. Called with `POST /collections`. @@ -214,8 +214,8 @@ async def create_collection( @abc.abstractmethod async def update_collection( - self, collection: stac_types.Collection, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + self, collection: Collection, **kwargs + ) -> Optional[Union[Collection, Response]]: """Perform a complete update on an existing collection. Called with `PUT /collections`. It is expected that this item already @@ -234,7 +234,7 @@ async def update_collection( @abc.abstractmethod async def delete_collection( self, collection_id: str, **kwargs - ) -> Optional[Union[stac_types.Collection, Response]]: + ) -> Optional[Union[Collection, Response]]: """Delete a collection. Called with `DELETE /collections/{collection_id}` @@ -260,10 +260,10 @@ class LandingPageMixin(abc.ABC): def _landing_page( self, base_url: str, - conformance_classes: List[str], - extension_schemas: List[str], - ) -> stac_types.LandingPage: - landing_page = stac_types.LandingPage( + conformance_classes: list[str], + extension_schemas: list[str], + ) -> api.LandingPage: + landing_page = api.LandingPage( type="Catalog", id=self.landing_page_id, title=self.title, @@ -320,13 +320,13 @@ class BaseCoreClient(LandingPageMixin, abc.ABC): extensions: list of registered api extensions. """ - base_conformance_classes: List[str] = attr.ib( + base_conformance_classes: list[str] = attr.ib( factory=lambda: BASE_CONFORMANCE_CLASSES ) - extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) + extensions: list[ApiExtension] = attr.ib(default=attr.Factory(list)) post_request_model = attr.ib(default=BaseSearchPostRequest) - def conformance_classes(self) -> List[str]: + def conformance_classes(self) -> list[str]: """Generate conformance classes by adding extension conformance to base conformance classes.""" base_conformance_classes = self.base_conformance_classes.copy() @@ -351,7 +351,7 @@ def list_conformance_classes(self): return base_conformance - def landing_page(self, **kwargs) -> stac_types.LandingPage: + def landing_page(self, **kwargs) -> api.LandingPage: """Landing page. Called with `GET /`. @@ -405,7 +405,7 @@ def landing_page(self, **kwargs) -> stac_types.LandingPage: return landing_page - def conformance(self, **kwargs) -> stac_types.Conformance: + def conformance(self, **kwargs) -> api.ConformanceClasses: """Conformance classes. Called with `GET /conformance`. @@ -413,12 +413,12 @@ def conformance(self, **kwargs) -> stac_types.Conformance: Returns: Conformance classes which the server conforms to. """ - return Conformance(conformsTo=self.conformance_classes()) + return api.ConformanceClasses(conformsTo=self.conformance_classes()) @abc.abstractmethod def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -434,18 +434,18 @@ def post_search( @abc.abstractmethod def get_search( self, - collections: Optional[List[str]] = None, - ids: Optional[List[str]] = None, - bbox: Optional[List[NumType]] = None, + collections: Optional[list[str]] = None, + ids: Optional[list[str]] = None, + bbox: Optional[list[NumType]] = None, datetime: Optional[Union[str, datetime]] = None, limit: Optional[int] = 10, query: Optional[str] = None, token: Optional[str] = None, - fields: Optional[List[str]] = None, + fields: Optional[list[str]] = None, sortby: Optional[str] = None, intersects: Optional[str] = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -456,7 +456,7 @@ def get_search( ... @abc.abstractmethod - def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Item: + def get_item(self, item_id: str, collection_id: str, **kwargs) -> api.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -471,7 +471,7 @@ def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac_types.Ite ... @abc.abstractmethod - def all_collections(self, **kwargs) -> stac_types.Collections: + def all_collections(self, **kwargs) -> api.Collections: """Get all available collections. Called with `GET /collections`. @@ -482,7 +482,7 @@ def all_collections(self, **kwargs) -> stac_types.Collections: ... @abc.abstractmethod - def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: + def get_collection(self, collection_id: str, **kwargs) -> api.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -499,12 +499,12 @@ def get_collection(self, collection_id: str, **kwargs) -> stac_types.Collection: def item_collection( self, collection_id: str, - bbox: Optional[List[NumType]] = None, + bbox: Optional[list[NumType]] = None, datetime: Optional[Union[str, datetime]] = None, limit: int = 10, token: str = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` @@ -528,13 +528,13 @@ class AsyncBaseCoreClient(LandingPageMixin, abc.ABC): extensions: list of registered api extensions. """ - base_conformance_classes: List[str] = attr.ib( + base_conformance_classes: list[str] = attr.ib( factory=lambda: BASE_CONFORMANCE_CLASSES ) - extensions: List[ApiExtension] = attr.ib(default=attr.Factory(list)) + extensions: list[ApiExtension] = attr.ib(default=attr.Factory(list)) post_request_model = attr.ib(default=BaseSearchPostRequest) - def conformance_classes(self) -> List[str]: + def conformance_classes(self) -> list[str]: """Generate conformance classes by adding extension conformance to base conformance classes.""" conformance_classes = self.base_conformance_classes.copy() @@ -549,7 +549,7 @@ def extension_is_enabled(self, extension: str) -> bool: """Check if an api extension is enabled.""" return any([type(ext).__name__ == extension for ext in self.extensions]) - async def landing_page(self, **kwargs) -> stac_types.LandingPage: + async def landing_page(self, **kwargs) -> api.LandingPage: """Landing page. Called with `GET /`. @@ -601,7 +601,7 @@ async def landing_page(self, **kwargs) -> stac_types.LandingPage: return landing_page - async def conformance(self, **kwargs) -> stac_types.Conformance: + async def conformance(self, **kwargs) -> api.ConformanceClasses: """Conformance classes. Called with `GET /conformance`. @@ -609,12 +609,12 @@ async def conformance(self, **kwargs) -> stac_types.Conformance: Returns: Conformance classes which the server conforms to. """ - return Conformance(conformsTo=self.conformance_classes()) + return api.ConformanceClasses(conformsTo=self.conformance_classes()) @abc.abstractmethod async def post_search( self, search_request: BaseSearchPostRequest, **kwargs - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Cross catalog search (POST). Called with `POST /search`. @@ -630,18 +630,18 @@ async def post_search( @abc.abstractmethod async def get_search( self, - collections: Optional[List[str]] = None, - ids: Optional[List[str]] = None, - bbox: Optional[List[NumType]] = None, + collections: Optional[list[str]] = None, + ids: Optional[list[str]] = None, + bbox: Optional[list[NumType]] = None, datetime: Optional[Union[str, datetime]] = None, limit: Optional[int] = 10, query: Optional[str] = None, token: Optional[str] = None, - fields: Optional[List[str]] = None, + fields: Optional[list[str]] = None, sortby: Optional[str] = None, intersects: Optional[str] = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Cross catalog search (GET). Called with `GET /search`. @@ -652,9 +652,7 @@ async def get_search( ... @abc.abstractmethod - async def get_item( - self, item_id: str, collection_id: str, **kwargs - ) -> stac_types.Item: + async def get_item(self, item_id: str, collection_id: str, **kwargs) -> api.Item: """Get item by id. Called with `GET /collections/{collection_id}/items/{item_id}`. @@ -669,7 +667,7 @@ async def get_item( ... @abc.abstractmethod - async def all_collections(self, **kwargs) -> stac_types.Collections: + async def all_collections(self, **kwargs) -> api.Collections: """Get all available collections. Called with `GET /collections`. @@ -680,9 +678,7 @@ async def all_collections(self, **kwargs) -> stac_types.Collections: ... @abc.abstractmethod - async def get_collection( - self, collection_id: str, **kwargs - ) -> stac_types.Collection: + async def get_collection(self, collection_id: str, **kwargs) -> api.Collection: """Get collection by id. Called with `GET /collections/{collection_id}`. @@ -699,12 +695,12 @@ async def get_collection( async def item_collection( self, collection_id: str, - bbox: Optional[List[NumType]] = None, + bbox: Optional[list[NumType]] = None, datetime: Optional[Union[str, datetime]] = None, limit: int = 10, token: str = None, **kwargs, - ) -> stac_types.ItemCollection: + ) -> api.ItemCollection: """Get all items from a specific collection. Called with `GET /collections/{collection_id}/items` @@ -726,7 +722,7 @@ class AsyncBaseFiltersClient(abc.ABC): async def get_queryables( self, collection_id: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get the queryables available for the given collection_id. If collection_id is None, returns the intersection of all queryables over all @@ -752,7 +748,7 @@ class BaseFiltersClient(abc.ABC): def get_queryables( self, collection_id: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get the queryables available for the given collection_id. If collection_id is None, returns the intersection of all queryables over all diff --git a/stac_fastapi/types/stac_fastapi/types/extension.py b/stac_fastapi/types/stac_fastapi/types/extension.py index 732a907bf..11b5138f7 100644 --- a/stac_fastapi/types/stac_fastapi/types/extension.py +++ b/stac_fastapi/types/stac_fastapi/types/extension.py @@ -1,6 +1,7 @@ """Base api extension.""" + import abc -from typing import List, Optional +from typing import Optional import attr from fastapi import FastAPI @@ -21,7 +22,7 @@ def get_request_model(self, verb: Optional[str] = "GET") -> Optional[BaseModel]: """ return getattr(self, verb) - conformance_classes: List[str] = attr.ib(factory=list) + conformance_classes: list[str] = attr.ib(factory=list) schema_href: Optional[str] = attr.ib(default=None) @abc.abstractmethod diff --git a/stac_fastapi/types/stac_fastapi/types/links.py b/stac_fastapi/types/stac_fastapi/types/links.py index 2a4c954d0..52b99ecb6 100644 --- a/stac_fastapi/types/stac_fastapi/types/links.py +++ b/stac_fastapi/types/stac_fastapi/types/links.py @@ -1,6 +1,6 @@ """Link helpers.""" -from typing import Any, Dict, List +from typing import Any from urllib.parse import urljoin import attr @@ -13,12 +13,12 @@ INFERRED_LINK_RELS = ["self", "item", "parent", "collection", "root"] -def filter_links(links: List[Dict]) -> List[Dict]: +def filter_links(links: list[dict]) -> list[dict]: """Remove inferred links.""" return [link for link in links if link["rel"] not in INFERRED_LINK_RELS] -def resolve_links(links: list, base_url: str) -> List[Dict]: +def resolve_links(links: list, base_url: str) -> list[dict]: """Convert relative links to absolute links.""" filtered_links = filter_links(links) for link in filtered_links: @@ -33,7 +33,7 @@ class BaseLinks: collection_id: str = attr.ib() base_url: str = attr.ib() - def root(self) -> Dict[str, Any]: + def root(self) -> dict[str, Any]: """Return the catalog root.""" return dict(rel=Relations.root, type=MimeTypes.json, href=self.base_url) @@ -42,7 +42,7 @@ def root(self) -> Dict[str, Any]: class CollectionLinks(BaseLinks): """Create inferred links specific to collections.""" - def self(self) -> Dict[str, Any]: + def self(self) -> dict[str, Any]: """Create the `self` link.""" return dict( rel=Relations.self, @@ -50,11 +50,11 @@ def self(self) -> Dict[str, Any]: href=urljoin(self.base_url, f"collections/{self.collection_id}"), ) - def parent(self) -> Dict[str, Any]: + def parent(self) -> dict[str, Any]: """Create the `parent` link.""" return dict(rel=Relations.parent, type=MimeTypes.json, href=self.base_url) - def items(self) -> Dict[str, Any]: + def items(self) -> dict[str, Any]: """Create the `items` link.""" return dict( rel="items", @@ -62,7 +62,7 @@ def items(self) -> Dict[str, Any]: href=urljoin(self.base_url, f"collections/{self.collection_id}/items"), ) - def create_links(self) -> List[Dict[str, Any]]: + def create_links(self) -> list[dict[str, Any]]: """Return all inferred links.""" return [self.self(), self.parent(), self.items(), self.root()] @@ -73,7 +73,7 @@ class ItemLinks(BaseLinks): item_id: str = attr.ib() - def self(self) -> Dict[str, Any]: + def self(self) -> dict[str, Any]: """Create the `self` link.""" return dict( rel=Relations.self, @@ -84,7 +84,7 @@ def self(self) -> Dict[str, Any]: ), ) - def parent(self) -> Dict[str, Any]: + def parent(self) -> dict[str, Any]: """Create the `parent` link.""" return dict( rel=Relations.parent, @@ -92,7 +92,7 @@ def parent(self) -> Dict[str, Any]: href=urljoin(self.base_url, f"collections/{self.collection_id}"), ) - def collection(self) -> Dict[str, Any]: + def collection(self) -> dict[str, Any]: """Create the `collection` link.""" return dict( rel=Relations.collection, @@ -100,7 +100,7 @@ def collection(self) -> Dict[str, Any]: href=urljoin(self.base_url, f"collections/{self.collection_id}"), ) - def create_links(self) -> List[Dict[str, Any]]: + def create_links(self) -> list[dict[str, Any]]: """Return all inferred links.""" links = [ self.self(), diff --git a/stac_fastapi/types/stac_fastapi/types/rfc3339.py b/stac_fastapi/types/stac_fastapi/types/rfc3339.py index 3c4cee30d..6e953ebd4 100644 --- a/stac_fastapi/types/stac_fastapi/types/rfc3339.py +++ b/stac_fastapi/types/stac_fastapi/types/rfc3339.py @@ -1,4 +1,5 @@ """rfc3339.""" + import re from datetime import datetime, timezone from typing import Optional, Tuple diff --git a/stac_fastapi/types/stac_fastapi/types/search.py b/stac_fastapi/types/stac_fastapi/types/search.py index 8d086a9be..f049ccd86 100644 --- a/stac_fastapi/types/stac_fastapi/types/search.py +++ b/stac_fastapi/types/stac_fastapi/types/search.py @@ -5,10 +5,9 @@ import abc import operator -from datetime import datetime from enum import auto from types import DynamicClassAttribute -from typing import Any, Callable, Dict, Generator, List, Optional, Union +from typing import Any, Callable, Optional, Union import attr from geojson_pydantic.geometries import ( @@ -20,38 +19,26 @@ Polygon, _GeometryBase, ) -from pydantic import BaseModel, ConstrainedInt, validator -from pydantic.errors import NumberNotGtError -from pydantic.validators import int_validator +from pydantic import BaseModel, PositiveInt, field_validator, model_validator +from pydantic.functional_validators import AfterValidator from stac_pydantic.shared import BBox from stac_pydantic.utils import AutoValueEnum +from typing_extensions import Annotated, Self from stac_fastapi.types.rfc3339 import rfc3339_str_to_datetime, str_to_interval -# Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287 -NumType = Union[float, int] - -class Limit(ConstrainedInt): - """An positive integer that maxes out at 10,000.""" - - ge: int = 1 - le: int = 10_000 +def check_limit(v: int) -> int: + """Validate input value.""" + if v > 10000: + return 10000 + else: + return v - @classmethod - def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: - """Yield the relevant validators.""" - yield int_validator - yield cls.validate - @classmethod - def validate(cls, value: int) -> int: - """Validate the integer value.""" - if value < cls.ge: - raise NumberNotGtError(limit_value=cls.ge) - if value > cls.le: - return cls.le - return value +Limit = Annotated[PositiveInt, AfterValidator(check_limit)] +# Be careful: https://github.com/samuelcolvin/pydantic/issues/1423#issuecomment-642797287 +NumType = Union[float, int] class Operator(str, AutoValueEnum): @@ -76,7 +63,7 @@ def operator(self) -> Callable[[Any, Any], bool]: return getattr(operator, self._value_) -def str2list(x: str) -> Optional[List]: +def str2list(x: str) -> Optional[list]: """Convert string to list base on , delimiter.""" if x: return x.split(",") @@ -86,7 +73,7 @@ def str2list(x: str) -> Optional[List]: class APIRequest(abc.ABC): """Generic API Request base class.""" - def kwargs(self) -> Dict: + def kwargs(self) -> dict: """Transform api request params into format which matches the signature of the endpoint.""" return self.__dict__ @@ -115,13 +102,13 @@ class BaseSearchPostRequest(BaseModel): https://github.com/stac-utils/stac-pydantic/pull/100 """ - collections: Optional[List[str]] - ids: Optional[List[str]] - bbox: Optional[BBox] + collections: Optional[list[str]] = None + ids: Optional[list[str]] = None + bbox: Optional[BBox] = None intersects: Optional[ Union[Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon] - ] - datetime: Optional[str] + ] = None + datetime: Optional[str] = None limit: Optional[Limit] = 10 @property @@ -136,14 +123,15 @@ def end_date(self) -> Optional[datetime]: interval = str_to_interval(self.datetime) return interval[1] if interval else None - @validator("intersects") - def validate_spatial(cls, v, values): + @model_validator(mode="before") + def validate_spatial(self) -> Self: """Check bbox and intersects are not both supplied.""" - if v and values["bbox"]: + if self.get("intersects") and self.get("bbox") is not None: raise ValueError("intersects and bbox parameters are mutually exclusive") - return v + return self - @validator("bbox") + @field_validator("bbox") + @classmethod def validate_bbox(cls, v: BBox): """Check order of supplied bbox coordinates.""" if v: @@ -173,7 +161,8 @@ def validate_bbox(cls, v: BBox): return v - @validator("datetime") + @field_validator("datetime") + @classmethod def validate_datetime(cls, v): """Validate datetime.""" if "/" in v: diff --git a/stac_fastapi/types/stac_fastapi/types/stac.py b/stac_fastapi/types/stac_fastapi/types/stac.py deleted file mode 100644 index f0876cef0..000000000 --- a/stac_fastapi/types/stac_fastapi/types/stac.py +++ /dev/null @@ -1,90 +0,0 @@ -"""STAC types.""" -import sys -from typing import Any, Dict, List, Literal, Optional, Union - -# Avoids a Pydantic error: -# TypeError: You should use `typing_extensions.TypedDict` instead of -# `typing.TypedDict` with Python < 3.9.2. Without it, there is no way to -# differentiate required and optional fields when subclassed. -if sys.version_info < (3, 9, 2): - from typing_extensions import TypedDict -else: - from typing import TypedDict - -NumType = Union[float, int] - - -class LandingPage(TypedDict, total=False): - """STAC Landing Page.""" - - type: str - stac_version: str - stac_extensions: Optional[List[str]] - id: str - title: str - description: str - conformsTo: List[str] - links: List[Dict[str, Any]] - - -class Conformance(TypedDict): - """STAC Conformance Classes.""" - - conformsTo: List[str] - - -class Catalog(TypedDict, total=False): - """STAC Catalog.""" - - type: str - stac_version: str - stac_extensions: Optional[List[str]] - id: str - title: Optional[str] - description: str - links: List[Dict[str, Any]] - - -class Collection(Catalog, total=False): - """STAC Collection.""" - - keywords: List[str] - license: str - providers: List[Dict[str, Any]] - extent: Dict[str, Any] - summaries: Dict[str, Any] - assets: Dict[str, Any] - - -class Item(TypedDict, total=False): - """STAC Item.""" - - type: Literal["Feature"] - stac_version: str - stac_extensions: Optional[List[str]] - id: str - geometry: Dict[str, Any] - bbox: List[NumType] - properties: Dict[str, Any] - links: List[Dict[str, Any]] - assets: Dict[str, Any] - collection: str - - -class ItemCollection(TypedDict, total=False): - """STAC Item Collection.""" - - type: Literal["FeatureCollection"] - features: List[Item] - links: List[Dict[str, Any]] - context: Optional[Dict[str, int]] - - -class Collections(TypedDict, total=False): - """All collections endpoint. - - https://github.com/radiantearth/stac-api-spec/tree/master/collections - """ - - collections: List[Collection] - links: List[Dict[str, Any]] diff --git a/stac_fastapi/types/stac_fastapi/types/version.py b/stac_fastapi/types/stac_fastapi/types/version.py index bb0c7c379..54c068f57 100644 --- a/stac_fastapi/types/stac_fastapi/types/version.py +++ b/stac_fastapi/types/stac_fastapi/types/version.py @@ -1,2 +1,3 @@ """Library version.""" + __version__ = "2.4.9"