Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* add support for python version 3.13
* bump minimum python version to 3.11
* update docker image to python:3.13
* Upgrade to become compatible with titiler.core/titiler.mosaic v0.19 (author @jverrydt, https://github.com/developmentseed/titiler-stacapi/pull/32)

## [0.3.3] - 2025-11-06

Expand Down Expand Up @@ -39,6 +40,8 @@
* initial release

[Unreleased]: <https://github.com/developmentseed/titiler-stacapi/compare/0.3.0..main>
[0.3.2]: <https://github.com/developmentseed/titiler-stacapi/compare/0.3.1..0.3.2>
[0.3.1]: <https://github.com/developmentseed/titiler-stacapi/compare/0.3.0..0.3.1>
[0.3.0]: <https://github.com/developmentseed/titiler-stacapi/compare/0.2.0..0.3.0>
[0.2.0]: <https://github.com/developmentseed/titiler-stacapi/compare/0.1.1..0.2.0>
[0.1.1]: <https://github.com/developmentseed/titiler-stacapi/compare/0.1.0..0.1.1>
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ classifiers = [
dynamic = ["version"]
dependencies = [
"orjson",
"titiler.core>=0.17.0,<0.19",
"titiler.mosaic>=0.17.0,<0.19",
"titiler.core>=0.19.0,<0.20",
"titiler.mosaic>=0.19.0,<0.20",
"pystac-client",
"pydantic>=2.4,<3.0",
"pydantic-settings~=2.0",
Expand Down Expand Up @@ -96,7 +96,7 @@ known_third_party = [
]
default_section = "THIRDPARTY"

[tool.ruff]
[tool.ruff.lint]
select = [
"D1", # pydocstyle errors
"E", # pycodestyle errors
Expand Down
60 changes: 31 additions & 29 deletions titiler/stacapi/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
import json
import os
from copy import copy
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Literal, Optional, Type
from urllib.parse import urlencode

import jinja2
import rasterio
from attrs import define, field
from cachetools import TTLCache, cached
from cachetools.keys import hashkey
from cogeo_mosaic.backends import BaseBackend
Expand Down Expand Up @@ -38,7 +38,7 @@
DefaultDependency,
TileParams,
)
from titiler.core.factory import BaseTilerFactory, img_endpoint_params
from titiler.core.factory import TilerFactory, img_endpoint_params
from titiler.core.models.mapbox import TileJSON
from titiler.core.resources.enums import ImageType, MediaType, OptionalHeader
from titiler.core.resources.responses import GeoJSONResponse, XMLResponse
Expand Down Expand Up @@ -95,17 +95,17 @@ def get_dependency_params(*, dependency: Callable, query_params: Dict) -> Any:
return


@dataclass
class MosaicTilerFactory(BaseTilerFactory):
@define(kw_only=True)
class MosaicTilerFactory(TilerFactory):
"""Custom MosaicTiler for STACAPI Mosaic Backend."""

path_dependency: Callable[..., APIParams] = STACApiParams

search_dependency: Callable[..., Dict] = STACSearchParams

# In this factory, `reader` should be a Mosaic Backend
# In this factory, `backend` should be a Mosaic Backend
# https://developmentseed.org/cogeo-mosaic/advanced/backends/
reader: Type[BaseBackend] = STACAPIBackend
backend: Type[BaseBackend] = STACAPIBackend

# Because the endpoints should work with STAC Items,
# the `layer_dependency` define which query parameters are mandatory/optional to `display` images
Expand All @@ -124,6 +124,8 @@ class MosaicTilerFactory(BaseTilerFactory):

templates: Jinja2Templates = DEFAULT_TEMPLATES

optional_headers: List[OptionalHeader] = field(factory=list)

def get_base_url(self, request: Request) -> str:
"""return endpoints base url."""
base_url = str(request.base_url).rstrip("/")
Expand Down Expand Up @@ -219,12 +221,12 @@ def tile(

tms = self.supported_tms.get(tileMatrixSetId)
with rasterio.Env(**env):
with self.reader(
with self.backend(
url=api_params["api_url"],
headers=api_params.get("headers", {}),
tms=tms,
reader_options={**reader_params},
**backend_params,
reader_options={**reader_params.as_dict()},
**backend_params.as_dict(),
) as src_dst:
if MOSAIC_STRICT_ZOOM and (
z < src_dst.minzoom or z > src_dst.maxzoom
Expand All @@ -242,9 +244,9 @@ def tile(
tilesize=scale * 256,
pixel_selection=pixel_selection,
threads=MOSAIC_THREADS,
**tile_params,
**layer_params,
**dataset_params,
**tile_params.as_dict(),
**layer_params.as_dict(),
**dataset_params.as_dict(),
)

if post_process:
Expand All @@ -260,7 +262,7 @@ def tile(
image,
output_format=format,
colormap=colormap,
**render_params,
**render_params.as_dict(),
)

headers: Dict[str, str] = {}
Expand Down Expand Up @@ -724,15 +726,15 @@ def get_layer_from_collections( # noqa: C901
return layers


@dataclass
class OGCWMTSFactory(BaseTilerFactory):
@define(kw_only=True)
class OGCWMTSFactory(TilerFactory):
"""Create /wmts endpoint"""

path_dependency: Callable[..., APIParams] = STACApiParams

# In this factory, `reader` should be a Mosaic Backend
# https://developmentseed.org/cogeo-mosaic/advanced/backends/
reader: Type[BaseBackend] = STACAPIBackend
backend: Type[BaseBackend] = STACAPIBackend

query_dependency: Callable[..., Any] = STACQueryParams

Expand All @@ -750,7 +752,7 @@ class OGCWMTSFactory(BaseTilerFactory):
backend_dependency: Type[DefaultDependency] = DefaultDependency

supported_format: List[str] = field(
default_factory=lambda: [
factory=lambda: [
"image/png",
"image/jpeg",
"image/jpg",
Expand All @@ -760,7 +762,7 @@ class OGCWMTSFactory(BaseTilerFactory):
]
)

supported_version: List[str] = field(default_factory=lambda: ["1.0.0"])
supported_version: List[str] = field(factory=lambda: ["1.0.0"])

templates: Jinja2Templates = DEFAULT_TEMPLATES

Expand Down Expand Up @@ -798,7 +800,7 @@ def get_tile( # noqa: C901
y = int(req["tilerow"])

tms = self.supported_tms.get(tms_id)
with self.reader(
with self.backend(
url=stac_url,
headers=headers,
tms=tms,
Expand Down Expand Up @@ -868,9 +870,9 @@ def get_tile( # noqa: C901
search_query=search_query,
pixel_selection=pixel_selection,
threads=MOSAIC_THREADS,
**tile_params,
**layer_params,
**dataset_params,
**tile_params.as_dict(),
**layer_params.as_dict(),
**dataset_params.as_dict(),
)

if post_process := get_dependency_params(
Expand Down Expand Up @@ -1387,12 +1389,12 @@ def WMTS_getTile(

tms = self.supported_tms.get(tileMatrixSetId)
with rasterio.Env(**env):
with self.reader(
with self.backend(
url=api_params["api_url"],
headers=api_params.get("headers", {}),
tms=tms,
reader_options={**reader_params},
**backend_params,
reader_options={**reader_params.as_dict()},
**backend_params.as_dict(),
) as src_dst:
if MOSAIC_STRICT_ZOOM and (
z < src_dst.minzoom or z > src_dst.maxzoom
Expand All @@ -1410,9 +1412,9 @@ def WMTS_getTile(
tilesize=256,
pixel_selection=pixel_selection,
threads=MOSAIC_THREADS,
**tile_params,
**layer_params,
**dataset_params,
**tile_params.as_dict(),
**layer_params.as_dict(),
**dataset_params.as_dict(),
)

if post_process:
Expand All @@ -1428,7 +1430,7 @@ def WMTS_getTile(
image,
output_format=format,
colormap=colormap,
**render_params,
**render_params.as_dict(),
)

return Response(content, media_type=media_type)
9 changes: 6 additions & 3 deletions titiler/stacapi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from typing import Any, Dict, List, Optional

import jinja2
import morecantile
from fastapi import Depends, FastAPI
from fastapi.responses import ORJSONResponse
from morecantile import TileMatrixSets
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.templating import Jinja2Templates
Expand Down Expand Up @@ -84,9 +86,11 @@

###############################################################################
# OGC WMTS Endpoints
supported_tms = TileMatrixSets(
{"WebMercatorQuad": morecantile.tms.get("WebMercatorQuad")}
)
wmts = OGCWMTSFactory(
path_dependency=STACApiParams,
templates=templates,
path_dependency=STACApiParams, templates=templates, supported_tms=supported_tms
)
app.include_router(
wmts.router,
Expand Down Expand Up @@ -118,7 +122,6 @@
stac = MultiBaseTilerFactory(
reader=STACReader,
path_dependency=ItemIdParams,
optional_headers=optional_headers,
router_prefix="/collections/{collection_id}/items/{item_id}",
add_viewer=True,
)
Expand Down
68 changes: 8 additions & 60 deletions titiler/stacapi/reader.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
"""Custom STAC reader."""

from typing import Any, Dict, Optional, Set, Type
from typing import Any, Dict, Optional, Sequence, Set, Type

import attr
import pystac
import rasterio
from morecantile import TileMatrixSet
from rasterio.crs import CRS
from rio_tiler.constants import WEB_MERCATOR_TMS, WGS84_CRS
from rio_tiler.errors import InvalidAssetName
from rio_tiler.constants import WEB_MERCATOR_TMS
from rio_tiler.io import BaseReader, Reader, stac
from rio_tiler.types import AssetInfo

from titiler.stacapi.settings import STACSettings

Expand All @@ -28,76 +25,27 @@ class STACReader(stac.STACReader):
input: pystac.Item = attr.ib()

tms: TileMatrixSet = attr.ib(default=WEB_MERCATOR_TMS)
minzoom: int = attr.ib()
maxzoom: int = attr.ib()

geographic_crs: CRS = attr.ib(default=WGS84_CRS)
minzoom: int = attr.ib(default=None)
maxzoom: int = attr.ib(default=None)

include_assets: Optional[Set[str]] = attr.ib(default=None)
exclude_assets: Optional[Set[str]] = attr.ib(default=None)

include_asset_types: Set[str] = attr.ib(default=stac.DEFAULT_VALID_TYPE)
exclude_asset_types: Optional[Set[str]] = attr.ib(default=None)

assets: Sequence[str] = attr.ib(init=False)
default_assets: Optional[Sequence[str]] = attr.ib(default=None)

reader: Type[BaseReader] = attr.ib(default=Reader)
reader_options: Dict = attr.ib(factory=dict)

fetch_options: Dict = attr.ib(factory=dict)

ctx: Any = attr.ib(default=rasterio.Env)

item: pystac.Item = attr.ib(init=False)
fetch_options: Dict = attr.ib(factory=dict)

def __attrs_post_init__(self):
"""Fetch STAC Item and get list of valid assets."""
self.item = self.input
super().__attrs_post_init__()

@minzoom.default
def _minzoom(self):
return self.tms.minzoom

@maxzoom.default
def _maxzoom(self):
return self.tms.maxzoom

def _get_asset_info(self, asset: str) -> AssetInfo:
"""Validate asset names and return asset's url.

Args:
asset (str): STAC asset name.

Returns:
str: STAC asset href.

"""
if asset not in self.assets:
raise InvalidAssetName(
f"'{asset}' is not valid, should be one of {self.assets}"
)

asset_info = self.item.assets[asset]
extras = asset_info.extra_fields

url = asset_info.get_absolute_href() or asset_info.href
if alternate := stac_config.alternate_url:
url = asset_info.to_dict()["alternate"][alternate]["href"]

info = AssetInfo(
url=url,
metadata=extras,
)

if head := extras.get("file:header_size"):
info["env"] = {"GDAL_INGESTED_BYTES_AT_OPEN": head}

if bands := extras.get("raster:bands"):
stats = [
(b["statistics"]["minimum"], b["statistics"]["maximum"])
for b in bands
if {"minimum", "maximum"}.issubset(b.get("statistics", {}))
]
if len(stats) == len(bands):
info["dataset_statistics"] = stats

return info
Loading