Skip to content

Commit b6639b1

Browse files
committedMar 7, 2023
init
1 parent 6de6648 commit b6639b1

18 files changed

+927
-0
lines changed
 

‎.dockerignore

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Ignore cdk folder
2+
cdk.out
3+
.history
4+
.tox
5+
.git

‎.gitignore

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Lambda deployment package
2+
package.zip
3+
4+
# Byte-compiled / optimized / DLL files
5+
__pycache__/
6+
*.py[cod]
7+
*$py.class
8+
9+
# C extensions
10+
*.so
11+
12+
# Distribution / packaging
13+
.Python
14+
env/
15+
build/
16+
develop-eggs/
17+
dist/
18+
downloads/
19+
eggs/
20+
.eggs/
21+
lib/
22+
lib64/
23+
parts/
24+
sdist/
25+
var/
26+
wheels/
27+
*.egg-info/
28+
.installed.cfg
29+
*.egg
30+
31+
# PyInstaller
32+
# Usually these files are written by a python script from a template
33+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
34+
*.manifest
35+
*.spec
36+
37+
# Installer logs
38+
pip-log.txt
39+
pip-delete-this-directory.txt
40+
41+
# Unit test / coverage reports
42+
htmlcov/
43+
.tox/
44+
.coverage
45+
.coverage.*
46+
.cache
47+
nosetests.xml
48+
coverage.xml
49+
*.cover
50+
.hypothesis/
51+
52+
# Translations
53+
*.mo
54+
*.pot
55+
56+
# Django stuff:
57+
*.log
58+
local_settings.py
59+
60+
# Flask stuff:
61+
instance/
62+
.webassets-cache
63+
64+
# Scrapy stuff:
65+
.scrapy
66+
67+
# Sphinx documentation
68+
docs/_build/
69+
70+
# PyBuilder
71+
target/
72+
73+
# Jupyter Notebook
74+
.ipynb_checkpoints
75+
76+
# pyenv
77+
.python-version
78+
79+
# celery beat schedule file
80+
celerybeat-schedule
81+
82+
# SageMath parsed files
83+
*.sage.py
84+
85+
# dotenv
86+
.env
87+
.env.*
88+
89+
# virtualenv
90+
.venv
91+
venv/
92+
ENV/
93+
94+
# Spyder project settings
95+
.spyderproject
96+
.spyproject
97+
98+
# Rope project settings
99+
.ropeproject
100+
101+
# mkdocs documentation
102+
/site
103+
104+
# mypy
105+
.mypy_cache/
106+
107+
cdk.out/

‎.pre-commit-config.yaml

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
repos:
2+
- repo: https://github.com/abravalheri/validate-pyproject
3+
rev: v0.12.1
4+
hooks:
5+
- id: validate-pyproject
6+
7+
- repo: https://github.com/psf/black
8+
rev: 22.12.0
9+
hooks:
10+
- id: black
11+
language_version: python
12+
13+
- repo: https://github.com/PyCQA/isort
14+
rev: 5.12.0
15+
hooks:
16+
- id: isort
17+
language_version: python
18+
19+
- repo: https://github.com/charliermarsh/ruff-pre-commit
20+
rev: v0.0.238
21+
hooks:
22+
- id: ruff
23+
args: ["--fix"]
24+
25+
- repo: https://github.com/pre-commit/mirrors-mypy
26+
rev: v0.991
27+
hooks:
28+
- id: mypy
29+
language_version: python
30+
exclude: tests/.*
31+
# additional_dependencies:
32+
# - types-simplejson
33+
# - types-attrs
34+
# - types-cachetools

‎README.md

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## titiler-xarray
2+
3+
## Deploy
4+
5+
```bash
6+
# Install AWS CDK requirements
7+
$ pip install -r requirements-dev.txt
8+
$ npm install
9+
10+
# Create AWS env
11+
$ AWS_DEFAULT_REGION=us-west-2 AWS_REGION=us-west-2 npm run cdk bootstrap
12+
13+
# Deploy app
14+
$ AWS_DEFAULT_REGION=us-west-2 AWS_REGION=us-west-2 npm run cdk deploy
15+
```

‎cdk.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"app": "python3 stack/app.py"
3+
}

‎package.json

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "cdk-deploy",
3+
"version": "0.1.0",
4+
"description": "Dependencies for CDK deployment",
5+
"license": "MIT",
6+
"private": true,
7+
"dependencies": {
8+
"cdk": "2.53.0"
9+
},
10+
"scripts": {
11+
"cdk": "cdk"
12+
}
13+
}

‎pyproject.toml

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
[project]
2+
name = "titiler.xarray"
3+
description = "TiTiler extension for xarray."
4+
readme = "README.md"
5+
requires-python = ">=3.8"
6+
authors = [
7+
{name = "Vincent Sarago", email = "vincent@developmentseed.com"},
8+
]
9+
license = {text = "MIT"}
10+
keywords = [
11+
"TiTiler",
12+
"zarr",
13+
"xarray",
14+
"Fastapi",
15+
]
16+
classifiers = [
17+
"Intended Audience :: Information Technology",
18+
"Intended Audience :: Science/Research",
19+
"License :: OSI Approved :: MIT License",
20+
"Programming Language :: Python :: 3.8",
21+
"Programming Language :: Python :: 3.9",
22+
"Programming Language :: Python :: 3.10",
23+
"Programming Language :: Python :: 3.11",
24+
"Topic :: Scientific/Engineering :: GIS",
25+
]
26+
dynamic = ["version"]
27+
dependencies = [
28+
"xarray",
29+
"rioxarray",
30+
"titiler.core>=0.11.1,<0.12",
31+
"starlette-cramjam>=0.3,<0.4",
32+
"python-dotenv",
33+
]
34+
35+
[project.optional-dependencies]
36+
test = [
37+
"pytest",
38+
"pytest-cov",
39+
"pytest-asyncio",
40+
"httpx",
41+
]
42+
dev = [
43+
"pre-commit"
44+
]
45+
46+
[project.urls]
47+
Homepage = "https://github.com/developmentseed/titiler-xarray"
48+
Issues = "https://github.com/developmentseed/titiler-xarray/issues"
49+
Source = "https://github.com/developmentseed/titiler-xarray"
50+
51+
[tool.coverage.run]
52+
branch = true
53+
parallel = true
54+
55+
[tool.coverage.report]
56+
exclude_lines = [
57+
"no cov",
58+
"if __name__ == .__main__.:",
59+
"if TYPE_CHECKING:",
60+
]
61+
62+
[tool.isort]
63+
profile = "black"
64+
known_first_party = ["titiler"]
65+
default_section = "THIRDPARTY"
66+
67+
[tool.ruff]
68+
select = [
69+
"D1", # pydocstyle errors
70+
"E", # pycodestyle errors
71+
"W", # pycodestyle warnings
72+
"C", # flake8-comprehensions
73+
"B", # flake8-bugbear
74+
]
75+
ignore = [
76+
"E501", # line too long, handled by black
77+
"B008", # do not perform function calls in argument defaults
78+
"B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10
79+
]
80+
81+
[tool.mypy]
82+
no_implicit_optional = true
83+
strict_optional = true
84+
namespace_packages = true
85+
explicit_package_bases = true
86+
87+
[build-system]
88+
requires = ["pdm-pep517"]
89+
build-backend = "pdm.pep517.api"
90+
91+
[tool.pdm.version]
92+
source = "file"
93+
path = "titiler/xarray/__init__.py"
94+
95+
[tool.pdm.build]
96+
includes = ["titiler/xarray"]
97+
excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"]

‎requirements-dev.txt

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
aws-cdk.core==1.181.0
2+
aws-cdk.aws_lambda==1.181.0
3+
aws-cdk.aws_apigatewayv2==1.181.0
4+
aws-cdk.aws_apigatewayv2_integrations==1.181.0

‎stack/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""AWS App."""

‎stack/app.py

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Construct App."""
2+
3+
import os
4+
from typing import Any, Dict, List, Optional
5+
6+
from aws_cdk import aws_apigatewayv2 as apigw
7+
from aws_cdk import aws_apigatewayv2_integrations as apigw_integrations
8+
from aws_cdk import aws_iam as iam
9+
from aws_cdk import aws_lambda
10+
from aws_cdk import aws_logs as logs
11+
from aws_cdk import core
12+
from config import StackSettings
13+
14+
settings = StackSettings()
15+
16+
17+
DEFAULT_ENV = {
18+
"GDAL_CACHEMAX": "200", # 200 mb
19+
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
20+
"GDAL_INGESTED_BYTES_AT_OPEN": "32768", # get more bytes when opening the files.
21+
"GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES",
22+
"GDAL_HTTP_MULTIPLEX": "YES",
23+
"GDAL_HTTP_VERSION": "2",
24+
"PYTHONWARNINGS": "ignore",
25+
"VSI_CACHE": "TRUE",
26+
"VSI_CACHE_SIZE": "5000000", # 5 MB (per file-handle)
27+
}
28+
29+
30+
class LambdaStack(core.Stack):
31+
"""Lambda Stack"""
32+
33+
def __init__(
34+
self,
35+
scope: core.Construct,
36+
id: str,
37+
memory: int = 1024,
38+
timeout: int = 30,
39+
runtime: aws_lambda.Runtime = aws_lambda.Runtime.PYTHON_3_9,
40+
concurrent: Optional[int] = None,
41+
permissions: Optional[List[iam.PolicyStatement]] = None,
42+
environment: Optional[Dict] = None,
43+
code_dir: str = "./",
44+
**kwargs: Any,
45+
) -> None:
46+
"""Define stack."""
47+
super().__init__(scope, id, *kwargs)
48+
49+
permissions = permissions or []
50+
environment = environment or {}
51+
52+
lambda_function = aws_lambda.Function(
53+
self,
54+
f"{id}-lambda",
55+
runtime=runtime,
56+
code=aws_lambda.Code.from_docker_build(
57+
path=os.path.abspath(code_dir),
58+
file="lambda/Dockerfile",
59+
),
60+
handler="handler.handler",
61+
memory_size=memory,
62+
reserved_concurrent_executions=concurrent,
63+
timeout=core.Duration.seconds(timeout),
64+
environment={**DEFAULT_ENV, **environment},
65+
log_retention=logs.RetentionDays.ONE_WEEK,
66+
)
67+
68+
for perm in permissions:
69+
lambda_function.add_to_role_policy(perm)
70+
71+
api = apigw.HttpApi(
72+
self,
73+
f"{id}-endpoint",
74+
default_integration=apigw_integrations.HttpLambdaIntegration(
75+
f"{id}-integration", handler=lambda_function
76+
),
77+
)
78+
core.CfnOutput(self, "Endpoint", value=api.url)
79+
80+
81+
app = core.App()
82+
83+
perms = []
84+
if settings.buckets:
85+
perms.append(
86+
iam.PolicyStatement(
87+
actions=["s3:GetObject"],
88+
resources=[f"arn:aws:s3:::{bucket}*" for bucket in settings.buckets],
89+
)
90+
)
91+
92+
# Tag infrastructure
93+
for key, value in {
94+
"Project": settings.name,
95+
"Stack": settings.stage,
96+
"Owner": settings.owner,
97+
"Client": settings.client,
98+
}.items():
99+
if value:
100+
core.Tag.add(app, key, value)
101+
102+
103+
LambdaStack(
104+
app,
105+
f"{settings.name}-{settings.stage}",
106+
memory=settings.memory,
107+
timeout=settings.timeout,
108+
concurrent=settings.max_concurrent,
109+
permissions=perms,
110+
env=settings.additional_env,
111+
)
112+
113+
app.synth()

‎stack/config.py

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""STACK Configs."""
2+
3+
from typing import Dict, List, Optional
4+
5+
import pydantic
6+
7+
8+
class StackSettings(pydantic.BaseSettings):
9+
"""Application settings"""
10+
11+
name: str = "titiler-xarray"
12+
stage: str = "production"
13+
14+
owner: Optional[str]
15+
client: Optional[str]
16+
project: Optional[str]
17+
18+
additional_env: Dict = {}
19+
20+
# S3 bucket names where TiTiler could do HEAD and GET Requests
21+
# specific private and public buckets MUST be added if you want to use s3:// urls
22+
# You can whitelist all bucket by setting `*`.
23+
# ref: https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-arn-format.html
24+
buckets: List = []
25+
26+
# S3 key pattern to limit the access to specific items (e.g: "my_data/*.tif")
27+
key: str = "*"
28+
29+
timeout: int = 30
30+
memory: int = 3009
31+
32+
# The maximum of concurrent executions you want to reserve for the function.
33+
# Default: - No specific limit - account limit.
34+
max_concurrent: Optional[int]
35+
36+
class Config:
37+
"""model config"""
38+
39+
env_file = "stack/.env"
40+
env_prefix = "STACK_"

‎stack/example.env

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
STACK_TIMEOUT=30
2+
STACK_MEMORY=3008
3+
4+
STACK_OWNER=vincents
5+
STACK_CLIENT=labs
6+
STACK_PROJECT=labs
7+
STACK_BUCKETS='["*"]'

‎stack/lambda/Dockerfile

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
ARG PYTHON_VERSION=3.9
2+
3+
FROM --platform=linux/amd64 public.ecr.aws/lambda/python:${PYTHON_VERSION}
4+
5+
WORKDIR /tmp
6+
7+
RUN pip install pip -U
8+
RUN pip install "titiler.application==0.11.1" "mangum>=0.10.0" -t /asset --no-binary pydantic
9+
10+
# Reduce package size and remove useless files
11+
RUN cd /asset && find . -type f -name '*.pyc' | while read f; do n=$(echo $f | sed 's/__pycache__\///' | sed 's/.cpython-[2-3][0-9]//'); cp $f $n; done;
12+
RUN cd /asset && find . -type d -a -name '__pycache__' -print0 | xargs -0 rm -rf
13+
RUN cd /asset && find . -type f -a -name '*.py' -print0 | xargs -0 rm -f
14+
RUN find /asset -type d -a -name 'tests' -print0 | xargs -0 rm -rf
15+
RUN rm -rdf /asset/numpy/doc/ /asset/boto3* /asset/botocore* /asset/bin /asset/geos_license /asset/Misc
16+
17+
COPY lambda/handler.py /asset/handler.py
18+
19+
CMD ["echo", "hello world"]

‎stack/lambda/handler.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""AWS Lambda handler."""
2+
3+
import logging
4+
5+
from mangum import Mangum
6+
7+
from titiler.xarray.main import app
8+
9+
logging.getLogger("mangum.lifespan").setLevel(logging.ERROR)
10+
logging.getLogger("mangum.http").setLevel(logging.ERROR)
11+
12+
handler = Mangum(app, lifespan="off")

‎titiler/xarray/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""titiler.xarray"""
2+
3+
__version__ = "0.1.0"

‎titiler/xarray/factory.py

+335
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
"""TiTiler.xarray factory."""
2+
3+
from dataclasses import dataclass
4+
from typing import Dict, List, Literal, Optional, Tuple, Type
5+
from urllib.parse import urlencode
6+
7+
import xarray
8+
from fastapi import Depends, Path, Query
9+
from rio_tiler.io import BaseReader, XarrayReader
10+
from rio_tiler.models import Info
11+
from starlette.requests import Request
12+
from starlette.responses import HTMLResponse, Response
13+
14+
from titiler.core.dependencies import RescalingParams
15+
from titiler.core.factory import BaseTilerFactory, img_endpoint_params, templates
16+
from titiler.core.models.mapbox import TileJSON
17+
from titiler.core.resources.enums import ImageType
18+
from titiler.core.resources.responses import JSONResponse
19+
20+
21+
@dataclass
22+
class XarrayTilerFactory(BaseTilerFactory):
23+
"""Xarray Tiler Factory."""
24+
25+
# Default reader is set to rio_tiler.io.Reader
26+
reader: Type[BaseReader] = XarrayReader
27+
28+
def register_routes(self) -> None: # noqa: C901
29+
"""Register Info / Tiles / TileJSON endoints."""
30+
31+
@self.router.get(
32+
"/variables",
33+
response_class=JSONResponse,
34+
responses={200: {"description": "Return dataset's Variables."}},
35+
)
36+
def variable_endpoint(
37+
src_path: str = Depends(self.path_dependency),
38+
) -> List[str]:
39+
"""return available variables."""
40+
with xarray.open_dataset(
41+
src_path, engine="zarr", decode_coords="all"
42+
) as src:
43+
return list(src.data_vars) # type: ignore
44+
45+
@self.router.get(
46+
"/info",
47+
response_model=Info,
48+
response_model_exclude_none=True,
49+
response_class=JSONResponse,
50+
responses={200: {"description": "Return dataset's basic info."}},
51+
)
52+
def info_endpoint(
53+
src_path: str = Depends(self.path_dependency),
54+
variable: str = Query(..., description="Xarray Variable"),
55+
show_times: bool = Query(
56+
None, description="Show info about the time dimension"
57+
),
58+
) -> Info:
59+
"""Return dataset's basic info."""
60+
show_times = show_times or False
61+
62+
with xarray.open_dataset(
63+
src_path, engine="zarr", decode_coords="all"
64+
) as src:
65+
ds = src[variable]
66+
times = []
67+
if "time" in ds.dims:
68+
times = [str(x.data) for x in ds.time]
69+
# To avoid returning huge a `band_metadata` and `band_descriptions`
70+
# we only return info of the first time slice
71+
ds = src[variable][0]
72+
73+
# Make sure we are a CRS
74+
crs = ds.rio.crs or "epsg:4326"
75+
ds.rio.write_crs(crs, inplace=True)
76+
77+
with self.reader(ds) as dst:
78+
info = dst.info().dict()
79+
80+
if times and show_times:
81+
info["count"] = len(times)
82+
info["times"] = times
83+
84+
return info
85+
86+
@self.router.get(r"/tiles/{z}/{x}/{y}", **img_endpoint_params)
87+
@self.router.get(r"/tiles/{z}/{x}/{y}.{format}", **img_endpoint_params)
88+
@self.router.get(r"/tiles/{z}/{x}/{y}@{scale}x", **img_endpoint_params)
89+
@self.router.get(r"/tiles/{z}/{x}/{y}@{scale}x.{format}", **img_endpoint_params)
90+
@self.router.get(r"/tiles/{TileMatrixSetId}/{z}/{x}/{y}", **img_endpoint_params)
91+
@self.router.get(
92+
r"/tiles/{TileMatrixSetId}/{z}/{x}/{y}.{format}", **img_endpoint_params
93+
)
94+
@self.router.get(
95+
r"/tiles/{TileMatrixSetId}/{z}/{x}/{y}@{scale}x", **img_endpoint_params
96+
)
97+
@self.router.get(
98+
r"/tiles/{TileMatrixSetId}/{z}/{x}/{y}@{scale}x.{format}",
99+
**img_endpoint_params,
100+
)
101+
def tiles_endpoint( # type: ignore
102+
z: int = Path(..., ge=0, le=30, description="TileMatrixSet zoom level"),
103+
x: int = Path(..., description="TileMatrixSet column"),
104+
y: int = Path(..., description="TileMatrixSet row"),
105+
TileMatrixSetId: Literal[ # type: ignore
106+
tuple(self.supported_tms.list())
107+
] = Query(
108+
self.default_tms,
109+
description=f"TileMatrixSet Name (default: '{self.default_tms}')",
110+
),
111+
scale: int = Query(
112+
1, gt=0, lt=4, description="Tile size scale. 1=256x256, 2=512x512..."
113+
),
114+
format: ImageType = Query(
115+
None, description="Output image type. Default is auto."
116+
),
117+
src_path: str = Depends(self.path_dependency),
118+
variable: str = Query(..., description="Xarray Variable"),
119+
time_slice: int = Query(
120+
None, description="Slice of time to read (if available)"
121+
),
122+
post_process=Depends(self.process_dependency),
123+
rescale: Optional[List[Tuple[float, ...]]] = Depends(RescalingParams),
124+
color_formula: Optional[str] = Query(
125+
None,
126+
title="Color Formula",
127+
description=(
128+
"rio-color formula (info: https://github.com/mapbox/rio-color)"
129+
),
130+
),
131+
colormap=Depends(self.colormap_dependency),
132+
render_params=Depends(self.render_dependency),
133+
) -> Response:
134+
"""Create map tile from a dataset."""
135+
tms = self.supported_tms.get(TileMatrixSetId)
136+
137+
with xarray.open_dataset(
138+
src_path, engine="zarr", decode_coords="all"
139+
) as src:
140+
ds = src[variable]
141+
if "time" in ds.dims:
142+
time_slice = time_slice or 0
143+
ds = ds[time_slice : time_slice + 1]
144+
145+
# Make sure we are a CRS
146+
crs = ds.rio.crs or "epsg:4326"
147+
ds.rio.write_crs(crs, inplace=True)
148+
149+
with self.reader(ds, tms=tms) as dst:
150+
image = dst.tile(
151+
x,
152+
y,
153+
z,
154+
tilesize=scale * 256,
155+
)
156+
157+
if post_process:
158+
image = post_process(image)
159+
160+
if rescale:
161+
image.rescale(rescale)
162+
163+
if color_formula:
164+
image.apply_color_formula(color_formula)
165+
166+
if colormap:
167+
image = image.apply_colormap(colormap)
168+
169+
if not format:
170+
format = ImageType.jpeg if image.mask.all() else ImageType.png
171+
172+
content = image.render(
173+
img_format=format.driver,
174+
**format.profile,
175+
**render_params,
176+
)
177+
178+
return Response(content, media_type=format.mediatype)
179+
180+
@self.router.get(
181+
"/tilejson.json",
182+
response_model=TileJSON,
183+
responses={200: {"description": "Return a tilejson"}},
184+
response_model_exclude_none=True,
185+
)
186+
@self.router.get(
187+
"/{TileMatrixSetId}/tilejson.json",
188+
response_model=TileJSON,
189+
responses={200: {"description": "Return a tilejson"}},
190+
response_model_exclude_none=True,
191+
)
192+
def tilejson_endpoint( # type: ignore
193+
request: Request,
194+
TileMatrixSetId: Literal[ # type: ignore
195+
tuple(self.supported_tms.list())
196+
] = Query(
197+
self.default_tms,
198+
description=f"TileMatrixSet Name (default: '{self.default_tms}')",
199+
),
200+
src_path: str = Depends(self.path_dependency),
201+
variable: str = Query(..., description="Xarray Variable"),
202+
time_slice: int = Query(
203+
None, description="Slice of time to read (if available)"
204+
), # noqa
205+
tile_format: Optional[ImageType] = Query(
206+
None, description="Output image type. Default is auto."
207+
),
208+
tile_scale: int = Query(
209+
1, gt=0, lt=4, description="Tile size scale. 1=256x256, 2=512x512..."
210+
),
211+
minzoom: Optional[int] = Query(
212+
None, description="Overwrite default minzoom."
213+
),
214+
maxzoom: Optional[int] = Query(
215+
None, description="Overwrite default maxzoom."
216+
),
217+
post_process=Depends(self.process_dependency), # noqa
218+
rescale: Optional[List[Tuple[float, ...]]] = Depends(
219+
RescalingParams
220+
), # noqa
221+
color_formula: Optional[str] = Query( # noqa
222+
None,
223+
title="Color Formula",
224+
description=(
225+
"rio-color formula (info: https://github.com/mapbox/rio-color)"
226+
),
227+
),
228+
colormap=Depends(self.colormap_dependency), # noqa
229+
render_params=Depends(self.render_dependency), # noqa
230+
) -> Dict:
231+
"""Return TileJSON document for a dataset."""
232+
route_params = {
233+
"z": "{z}",
234+
"x": "{x}",
235+
"y": "{y}",
236+
"scale": tile_scale,
237+
"TileMatrixSetId": TileMatrixSetId,
238+
}
239+
if tile_format:
240+
route_params["format"] = tile_format.value
241+
tiles_url = self.url_for(request, "tiles_endpoint", **route_params)
242+
243+
qs_key_to_remove = [
244+
"tilematrixsetid",
245+
"tile_format",
246+
"tile_scale",
247+
"minzoom",
248+
"maxzoom",
249+
]
250+
qs = [
251+
(key, value)
252+
for (key, value) in request.query_params._list
253+
if key.lower() not in qs_key_to_remove
254+
]
255+
if qs:
256+
tiles_url += f"?{urlencode(qs)}"
257+
258+
tms = self.supported_tms.get(TileMatrixSetId)
259+
260+
with xarray.open_dataset(
261+
src_path, engine="zarr", decode_coords="all"
262+
) as src:
263+
ds = src[variable]
264+
265+
# Make sure we are a CRS
266+
crs = ds.rio.crs or "epsg:4326"
267+
ds.rio.write_crs(crs, inplace=True)
268+
269+
with self.reader(ds, tms=tms) as src_dst:
270+
return {
271+
"bounds": src_dst.geographic_bounds,
272+
"minzoom": minzoom if minzoom is not None else src_dst.minzoom,
273+
"maxzoom": maxzoom if maxzoom is not None else src_dst.maxzoom,
274+
"tiles": [tiles_url],
275+
}
276+
277+
@self.router.get("/map", response_class=HTMLResponse)
278+
@self.router.get("/{TileMatrixSetId}/map", response_class=HTMLResponse)
279+
def map_viewer(
280+
request: Request,
281+
TileMatrixSetId: Literal[tuple(self.supported_tms.list())] = Query( # type: ignore
282+
self.default_tms,
283+
description=f"TileMatrixSet Name (default: '{self.default_tms}')",
284+
), # noqa
285+
src_path=Depends(self.path_dependency), # noqa
286+
variable: str = Query(..., description="Xarray Variable"), # noqa
287+
time_slice: int = Query(
288+
None, description="Slice of time to read (if available)"
289+
), # noqa
290+
tile_format: Optional[ImageType] = Query(
291+
None, description="Output image type. Default is auto."
292+
), # noqa
293+
tile_scale: int = Query(
294+
1, gt=0, lt=4, description="Tile size scale. 1=256x256, 2=512x512..."
295+
), # noqa
296+
minzoom: Optional[int] = Query(
297+
None, description="Overwrite default minzoom."
298+
), # noqa
299+
maxzoom: Optional[int] = Query(
300+
None, description="Overwrite default maxzoom."
301+
), # noqa
302+
layer_params=Depends(self.layer_dependency), # noqa
303+
dataset_params=Depends(self.dataset_dependency), # noqa
304+
post_process=Depends(self.process_dependency), # noqa
305+
rescale: Optional[List[Tuple[float, ...]]] = Depends(
306+
RescalingParams
307+
), # noqa
308+
color_formula: Optional[str] = Query( # noqa
309+
None,
310+
title="Color Formula",
311+
description="rio-color formula (info: https://github.com/mapbox/rio-color)",
312+
),
313+
colormap=Depends(self.colormap_dependency), # noqa
314+
render_params=Depends(self.render_dependency), # noqa
315+
reader_params=Depends(self.reader_dependency), # noqa
316+
env=Depends(self.environment_dependency), # noqa
317+
):
318+
"""Return map Viewer."""
319+
tilejson_url = self.url_for(
320+
request, "tilejson_endpoint", TileMatrixSetId=TileMatrixSetId
321+
)
322+
if request.query_params._list:
323+
tilejson_url += f"?{urlencode(request.query_params._list)}"
324+
325+
tms = self.supported_tms.get(TileMatrixSetId)
326+
return templates.TemplateResponse(
327+
name="index.html",
328+
context={
329+
"request": request,
330+
"tilejson_endpoint": tilejson_url,
331+
"tms": tms,
332+
"resolutions": [tms._resolution(matrix) for matrix in tms],
333+
},
334+
media_type="text/html",
335+
)

‎titiler/xarray/main.py

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""titiler app."""
2+
3+
import logging
4+
5+
from fastapi import FastAPI
6+
from rio_tiler.io import STACReader
7+
from starlette.middleware.cors import CORSMiddleware
8+
from starlette.requests import Request
9+
from starlette.responses import HTMLResponse
10+
from starlette.templating import Jinja2Templates
11+
from starlette_cramjam.middleware import CompressionMiddleware
12+
13+
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
14+
from titiler.core.factory import AlgorithmFactory, TMSFactory
15+
from titiler.core.middleware import (
16+
CacheControlMiddleware,
17+
LoggerMiddleware,
18+
TotalTimeMiddleware,
19+
)
20+
from titiler.xarray import __version__ as titiler_version
21+
from titiler.xarray.factory import XarrayTilerFactory
22+
from titiler.xarray.settings import ApiSettings
23+
24+
logging.getLogger("botocore.credentials").disabled = True
25+
logging.getLogger("botocore.utils").disabled = True
26+
logging.getLogger("rio-tiler").setLevel(logging.ERROR)
27+
28+
api_settings = ApiSettings()
29+
30+
app = FastAPI(
31+
title=api_settings.name,
32+
version=titiler_version,
33+
root_path=api_settings.root_path,
34+
)
35+
36+
###############################################################################
37+
# Tiles endpoints
38+
xarray = XarrayTilerFactory()
39+
app.include_router(xarray.router, tags=["Xarray Tiler API"])
40+
41+
###############################################################################
42+
# TileMatrixSets endpoints
43+
tms = TMSFactory()
44+
app.include_router(tms.router, tags=["Tiling Schemes"])
45+
46+
###############################################################################
47+
# Algorithms endpoints
48+
algorithms = AlgorithmFactory()
49+
app.include_router(algorithms.router, tags=["Algorithms"])
50+
51+
add_exception_handlers(app, DEFAULT_STATUS_CODES)
52+
53+
# Set all CORS enabled origins
54+
if api_settings.cors_origins:
55+
app.add_middleware(
56+
CORSMiddleware,
57+
allow_origins=api_settings.cors_origins,
58+
allow_credentials=True,
59+
allow_methods=["GET"],
60+
allow_headers=["*"],
61+
)
62+
63+
app.add_middleware(
64+
CompressionMiddleware,
65+
minimum_size=0,
66+
exclude_mediatype={
67+
"image/jpeg",
68+
"image/jpg",
69+
"image/png",
70+
"image/jp2",
71+
"image/webp",
72+
},
73+
)
74+
75+
app.add_middleware(
76+
CacheControlMiddleware,
77+
cachecontrol=api_settings.cachecontrol,
78+
exclude_path={r"/healthz"},
79+
)
80+
81+
if api_settings.debug:
82+
app.add_middleware(LoggerMiddleware, headers=True, querystrings=True)
83+
app.add_middleware(TotalTimeMiddleware)
84+
85+
86+
@app.get(
87+
"/healthz",
88+
description="Health Check.",
89+
summary="Health Check.",
90+
operation_id="healthCheck",
91+
tags=["Health Check"],
92+
)
93+
def ping():
94+
"""Health check."""
95+
return {"ping": "pong!"}

‎titiler/xarray/settings.py

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Titiler API settings."""
2+
3+
import pydantic
4+
5+
6+
class ApiSettings(pydantic.BaseSettings):
7+
"""FASTAPI application settings."""
8+
9+
name: str = "titiler-xarray"
10+
cors_origins: str = "*"
11+
cachecontrol: str = "public, max-age=3600"
12+
root_path: str = ""
13+
debug: bool = False
14+
15+
@pydantic.validator("cors_origins")
16+
def parse_cors_origin(cls, v):
17+
"""Parse CORS origins."""
18+
return [origin.strip() for origin in v.split(",")]
19+
20+
class Config:
21+
"""model config"""
22+
23+
env_file = ".env"
24+
env_prefix = "TITILER_XARRAY_"

0 commit comments

Comments
 (0)
Please sign in to comment.