Skip to content

Commit affb458

Browse files
switch titiler.xarray to obstore+zarr and add default application (#1253)
* switch titiler.xarray to obstore+zarr and add default application * remove python 3.10 and recreate uv.lock * better handle aws s3 http urls * update from comment * lower bounds for s3fs * assume https s3 object are public * update dependencies and remove consolidated
1 parent 66f3055 commit affb458

File tree

13 files changed

+932
-524
lines changed

13 files changed

+932
-524
lines changed

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ uv run pytest src/titiler/extensions --cov=titiler.extensions --cov-report=xml -
3939
# titiler.mosaic
4040
uv run pytest src/titiler/mosaic --cov=titiler.mosaic --cov-report=xml --cov-append --cov-report=term-missing
4141
42+
# titiler.xarray
43+
uv run pytest src/titiler/xarray --cov=titiler.xarray --cov-report=xml --cov-append --cov-report=term-missing
44+
4245
# titiler.application
4346
uv run pytest src/titiler/application --cov=titiler.application --cov-report=xml --cov-append --cov-report=term-missing
4447
```

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ dev = [
4242
"pytest-cov",
4343
"pytest-asyncio",
4444
"httpx",
45-
"zarr!=3.0.9",
45+
"obstore",
46+
"zarr>=3,<4.0",
4647
"h5netcdf",
4748
"fsspec",
48-
"s3fs",
49+
"s3fs>=2025.2.0",
4950
"aiohttp",
5051
"requests",
5152
"cogeo-mosaic>=8.2,<9.0",

src/titiler/xarray/examples/templates/landing.html

Lines changed: 0 additions & 42 deletions
This file was deleted.

src/titiler/xarray/pyproject.toml

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,38 +36,26 @@ dependencies = [
3636
"rio-tiler>=7.6.1,<8.0",
3737
"xarray",
3838
"rioxarray",
39+
"obstore",
40+
"zarr>=3.1,<4.0",
3941
]
4042

4143
[project.optional-dependencies]
42-
full = [
43-
"zarr!=3.0.9",
44+
fs = [
4445
"h5netcdf",
4546
"fsspec",
46-
"s3fs",
47+
"s3fs>=2025.2.0",
4748
"aiohttp",
4849
"gcsfs",
50+
"requests",
4951
]
50-
minimal = [
51-
"zarr!=3.0.9",
52-
"h5netcdf",
53-
"fsspec",
54-
]
55-
gcs = [
56-
"gcsfs",
57-
]
58-
s3 = [
59-
"s3fs",
60-
]
61-
http = [
62-
"aiohttp",
63-
]
52+
6453
[dependency-groups]
6554
test = [
6655
"pytest",
6756
"pytest-cov",
6857
"pytest-asyncio",
6958
"httpx",
70-
"zarr!=3.0.9",
7159
"h5netcdf",
7260
"fsspec",
7361
"s3fs",

src/titiler/xarray/tests/test_factory.py

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from titiler.xarray.extensions import DatasetMetadataExtension, VariablesExtension
1111
from titiler.xarray.factory import TilerFactory
12+
from titiler.xarray.io import FsReader, fs_open_dataset
1213

1314
prefix = os.path.join(os.path.dirname(__file__), "fixtures")
1415

@@ -61,7 +62,30 @@ def test_tiler_factory():
6162
@pytest.fixture
6263
def app():
6364
"""App fixture."""
64-
md = TilerFactory(router_prefix="/md", extensions=[DatasetMetadataExtension()])
65+
md = TilerFactory(
66+
router_prefix="/md",
67+
extensions=[
68+
DatasetMetadataExtension(dataset_opener=fs_open_dataset),
69+
],
70+
reader=FsReader,
71+
)
72+
assert len(md.router.routes) == 22
73+
74+
app = FastAPI()
75+
app.include_router(md.router, prefix="/md")
76+
with TestClient(app) as client:
77+
yield client
78+
79+
80+
@pytest.fixture
81+
def app_zarr():
82+
"""App fixture."""
83+
md = TilerFactory(
84+
router_prefix="/md",
85+
extensions=[
86+
DatasetMetadataExtension(),
87+
],
88+
)
6589
assert len(md.router.routes) == 22
6690

6791
app = FastAPI()
@@ -393,7 +417,7 @@ def test_zarr_group(group, app):
393417
def test_preview(filename):
394418
"""App fixture."""
395419
with pytest.warns(UserWarning):
396-
md = TilerFactory(add_preview=True)
420+
md = TilerFactory(add_preview=True, reader=FsReader)
397421

398422
app = FastAPI()
399423
app.include_router(md.router)
@@ -437,3 +461,125 @@ def test_preview(filename):
437461
with mem.open() as dst:
438462
assert dst.width == 1024
439463
assert dst.height == 1024
464+
465+
466+
@pytest.mark.parametrize(
467+
"filename",
468+
[dataset_3d_zarr],
469+
)
470+
def test_app_zarr(filename, app_zarr):
471+
"""Test endpoints with Zarr Reader."""
472+
resp = app_zarr.get("/md/dataset/keys", params={"url": filename})
473+
assert resp.status_code == 200
474+
assert resp.headers["content-type"] == "application/json"
475+
assert resp.json() == ["dataset"]
476+
477+
resp = app_zarr.get("/md/dataset/dict", params={"url": filename})
478+
assert resp.status_code == 200
479+
assert resp.headers["content-type"] == "application/json"
480+
assert resp.json()["data_vars"]["dataset"]
481+
482+
resp = app_zarr.get("/md/dataset/", params={"url": filename})
483+
assert resp.status_code == 200
484+
assert "text/html" in resp.headers["content-type"]
485+
486+
resp = app_zarr.get("/md/bounds", params={"url": filename, "variable": "dataset"})
487+
assert resp.status_code == 200
488+
assert resp.headers["content-type"] == "application/json"
489+
490+
resp = app_zarr.get("/md/info", params={"url": filename, "variable": "dataset"})
491+
assert resp.status_code == 200
492+
assert resp.headers["content-type"] == "application/json"
493+
494+
resp = app_zarr.get(
495+
"/md/tiles/WebMercatorQuad/0/0/0",
496+
params={"url": filename, "variable": "dataset", "rescale": "0,500", "bidx": 1},
497+
)
498+
assert resp.status_code == 200
499+
assert resp.headers["content-type"] == "image/png"
500+
501+
resp = app_zarr.get(
502+
"/md/WebMercatorQuad/tilejson.json",
503+
params={"url": filename, "variable": "dataset", "rescale": "0,500", "bidx": 1},
504+
)
505+
assert resp.status_code == 200
506+
assert resp.headers["content-type"] == "application/json"
507+
508+
resp = app_zarr.get(
509+
"/md/point/0,0", params={"url": filename, "variable": "dataset"}
510+
)
511+
assert resp.status_code == 200
512+
assert resp.headers["content-type"] == "application/json"
513+
514+
feat = {
515+
"type": "Feature",
516+
"properties": {},
517+
"geometry": {
518+
"type": "Polygon",
519+
"coordinates": [
520+
[
521+
(-100.0, -25.0),
522+
(40.0, -25.0),
523+
(40.0, 60.0),
524+
(-100.0, 60.0),
525+
(-100.0, -25.0),
526+
]
527+
],
528+
},
529+
}
530+
531+
resp = app_zarr.post(
532+
"/md/statistics", params={"url": filename, "variable": "dataset"}, json=feat
533+
)
534+
assert resp.status_code == 200
535+
assert resp.headers["content-type"] == "application/geo+json"
536+
537+
feat = {
538+
"type": "Feature",
539+
"properties": {},
540+
"geometry": {
541+
"type": "Polygon",
542+
"coordinates": [
543+
[
544+
(-100.0, -25.0),
545+
(40.0, -25.0),
546+
(40.0, 60.0),
547+
(-100.0, 60.0),
548+
(-100.0, -25.0),
549+
]
550+
],
551+
},
552+
}
553+
554+
resp = app_zarr.post(
555+
"/md/feature",
556+
params={"url": filename, "variable": "dataset", "rescale": "0,500", "bidx": 1},
557+
json=feat,
558+
)
559+
assert resp.status_code == 200
560+
assert resp.headers["content-type"] == "image/jpeg"
561+
562+
563+
@pytest.mark.parametrize(
564+
"group",
565+
[0, 1, 2],
566+
)
567+
def test_group_open_zarr(group, app_zarr):
568+
"""Test /tiles endpoints."""
569+
resp = app_zarr.get(
570+
f"/md/tiles/WebMercatorQuad/{group}/0/0.tif",
571+
params={"url": zarr_pyramid, "variable": "dataset", "group": str(group)},
572+
)
573+
assert resp.status_code == 200
574+
# see src/titiler/xarray/tests/fixtures/generate_fixtures.ipynb
575+
# for structure of zarr pyramid
576+
with MemoryFile(resp.content) as mem:
577+
with mem.open() as dst:
578+
arr = dst.read(1)
579+
assert arr.max() == group * 2 + 1
580+
581+
resp = app_zarr.get(
582+
"/md/point/0,0",
583+
params={"url": zarr_pyramid, "variable": "dataset", "group": str(group)},
584+
)
585+
assert resp.json()["values"] == [group * 2 + 1]

src/titiler/xarray/tests/test_io_tools.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
import xarray
1111

12-
from titiler.xarray.io import Reader, get_variable, xarray_open_dataset
12+
from titiler.xarray.io import Reader, fs_open_dataset, get_variable, open_zarr
1313

1414
prefix = os.path.join(os.path.dirname(__file__), "fixtures")
1515

@@ -220,7 +220,11 @@ def test_get_variable_datetime_tz():
220220
def test_reader(protocol, filename):
221221
"""test reader."""
222222
src_path = protocol + os.path.join(protocol, prefix, filename)
223-
with Reader(src_path, variable="dataset") as src:
223+
with Reader(src_path, variable="dataset", opener=fs_open_dataset) as src:
224+
assert src.info()
225+
assert src.tile(0, 0, 0)
226+
227+
with Reader(src_path, variable="dataset", group="/", opener=fs_open_dataset) as src:
224228
assert src.info()
225229
assert src.tile(0, 0, 0)
226230

@@ -267,7 +271,6 @@ def custom_netcdf_opener( # noqa: C901
267271

268272
fs = fsspec.filesystem(protocol)
269273
ds = xarray.open_dataset(fs.open(src_path), **xr_open_args)
270-
271274
return ds
272275

273276
with Reader(
@@ -303,14 +306,43 @@ def test_zarr_group(group):
303306

304307

305308
@pytest.mark.parametrize(
306-
"src_path",
309+
"src_path,options",
310+
[
311+
("s3://mur-sst/zarr-v1", {"anon": True}),
312+
(
313+
"https://nasa-power.s3.amazonaws.com/syn1deg/temporal/power_syn1deg_monthly_temporal_lst.zarr",
314+
{},
315+
),
316+
(os.path.join(prefix, "dataset_3d.zarr"), {}),
317+
],
318+
)
319+
def test_io_fs_open_dataset(src_path, options):
320+
"""test fs_open_dataset with cloud hosted files."""
321+
with fs_open_dataset(src_path, **options) as ds:
322+
assert list(ds.data_vars)
323+
324+
325+
@pytest.mark.parametrize(
326+
"src_path,options",
307327
[
308-
# "s3://mur-sst/zarr-v1",
309-
"https://nasa-power.s3.amazonaws.com/syn1deg/temporal/power_syn1deg_monthly_temporal_lst.zarr",
310-
os.path.join(prefix, "dataset_3d.zarr"),
328+
# Let's assume we don't have S3 Credentials
329+
("s3://mur-sst/zarr-v1", {"skip_signature": True}),
330+
("s3://mur-sst/zarr-v1", {"skip_signature": True, "region": "us-west-2"}),
331+
# HTTS url are considered public
332+
("https://mur-sst.s3.us-west-2.amazonaws.com/zarr-v1", {}),
333+
# NOTE: https://github.com/developmentseed/obstore/pull/590
334+
# (
335+
# "https://nasa-power.s3.amazonaws.com/syn1deg/temporal/power_syn1deg_monthly_temporal_lst.zarr",
336+
# {},
337+
# ),
338+
(
339+
"https://nasa-power.s3.us-west-2.amazonaws.com/syn1deg/temporal/power_syn1deg_monthly_temporal_lst.zarr",
340+
{},
341+
),
342+
(os.path.join(prefix, "dataset_3d.zarr"), {}),
311343
],
312344
)
313-
def test_io_xarray_open_dataset(src_path):
314-
"""test xarray_open_dataset with cloud hosted files."""
315-
with xarray_open_dataset(src_path) as ds:
345+
def test_io_open_zarr(src_path, options):
346+
"""test open_zarr with cloud hosted files."""
347+
with open_zarr(src_path, **options) as ds:
316348
assert list(ds.data_vars)

src/titiler/xarray/titiler/xarray/extensions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from titiler.core.resources.enums import MediaType
1414
from titiler.xarray.dependencies import XarrayIOParams
1515
from titiler.xarray.factory import TilerFactory
16-
from titiler.xarray.io import xarray_open_dataset
16+
from titiler.xarray.io import open_zarr
1717

1818

1919
@define
@@ -22,7 +22,7 @@ class VariablesExtension(FactoryExtension):
2222

2323
# Custom dependency for /variables
2424
io_dependency: Type[DefaultDependency] = XarrayIOParams
25-
dataset_opener: Callable[..., xarray.Dataset] = xarray_open_dataset
25+
dataset_opener: Callable[..., xarray.Dataset] = open_zarr
2626

2727
def __attrs_post_init__(self):
2828
"""raise deprecation warning."""
@@ -54,7 +54,7 @@ class DatasetMetadataExtension(FactoryExtension):
5454
"""Add dataset metadata endpoints to a Xarray TilerFactory."""
5555

5656
io_dependency: Type[DefaultDependency] = XarrayIOParams
57-
dataset_opener: Callable[..., xarray.Dataset] = xarray_open_dataset
57+
dataset_opener: Callable[..., xarray.Dataset] = open_zarr
5858

5959
def register(self, factory: TilerFactory):
6060
"""Register endpoint to the tiler factory."""

0 commit comments

Comments
 (0)