From f8f0244eeb2b79764d653c6445cf4418517ec12a Mon Sep 17 00:00:00 2001 From: hrodmn Date: Thu, 17 Sep 2026 15:33:02 -0500 Subject: [PATCH 1/2] feat: add xarray dataset coordinate endpoint resolves #1503 --- docs/src/advanced/Extensions.md | 2 +- src/titiler/xarray/tests/test_factory.py | 39 +++++++++++++++++-- .../xarray/titiler/xarray/extensions.py | 22 ++++++++++- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/src/advanced/Extensions.md b/docs/src/advanced/Extensions.md index 317ad5fa4..3428ecfa3 100644 --- a/docs/src/advanced/Extensions.md +++ b/docs/src/advanced/Extensions.md @@ -65,7 +65,7 @@ class FactoryExtension(metaclass=abc.ABCMeta): #### titiler.xarray.DatasetMetadataExtension -- Goal: adds `/dataset/`, `/dataset/keys` and `/datasets/dict` endpoints which return metadata about a multidimensional Dataset (not a DataArray) +- Goal: adds `/dataset/`, `/dataset/keys`, `/dataset/dict` and `/dataset/coordinates/{name}` endpoints which return metadata about a multidimensional Dataset (not a DataArray) #### titiler.mosaic.extensions.wmts.wmtsExtension diff --git a/src/titiler/xarray/tests/test_factory.py b/src/titiler/xarray/tests/test_factory.py index 6c0448d97..991f885f5 100644 --- a/src/titiler/xarray/tests/test_factory.py +++ b/src/titiler/xarray/tests/test_factory.py @@ -43,10 +43,10 @@ def test_tiler_factory(): md = TilerFactory( router_prefix="/md", - # /dataset, /dataset/dict, /dataset/keys + # /dataset, /dataset/dict, /dataset/keys, /dataset/coordinates/{name} extensions=[DatasetMetadataExtension()], ) - assert len(md.router.routes) == 18 + assert len(md.router.routes) == 19 app = FastAPI() app.include_router(md.router, prefix="/md") @@ -69,7 +69,7 @@ def app(): ], reader=FsReader, ) - assert len(md.router.routes) == 18 + assert len(md.router.routes) == 19 app = FastAPI() app.include_router(md.router, prefix="/md") @@ -86,7 +86,7 @@ def app_zarr(): DatasetMetadataExtension(), ], ) - assert len(md.router.routes) == 18 + assert len(md.router.routes) == 19 app = FastAPI() app.include_router(md.router, prefix="/md") @@ -115,6 +115,37 @@ def test_dataset_extension(filename, app): assert "text/html" in resp.headers["content-type"] +def test_dataset_coordinate(app): + """Test coordinate metadata and values.""" + resp = app.get( + "/md/dataset/coordinates/x", + params={"url": dataset_2d_nc}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + coordinate = resp.json() + assert coordinate["name"] == "x" + assert coordinate["dims"] == ["x"] + assert coordinate["data"][:2] == [-170.0, -169.83] + + resp = app.get( + "/md/dataset/coordinates/time", + params={"url": dataset_3d_nc}, + ) + assert resp.status_code == 200 + assert resp.json()["data"] == [ + "2022-01-01T00:00:00", + "2023-01-01T00:00:00", + ] + + resp = app.get( + "/md/dataset/coordinates/missing", + params={"url": dataset_2d_nc}, + ) + assert resp.status_code == 404 + assert resp.json() == {"detail": "Coordinate 'missing' not found"} + + @pytest.mark.parametrize( "filename", [dataset_2d_nc, dataset_3d_nc, dataset_3d_zarr], diff --git a/src/titiler/xarray/titiler/xarray/extensions.py b/src/titiler/xarray/titiler/xarray/extensions.py index 27dfa8a83..b3de58935 100644 --- a/src/titiler/xarray/titiler/xarray/extensions.py +++ b/src/titiler/xarray/titiler/xarray/extensions.py @@ -7,7 +7,7 @@ import xarray from attrs import define -from fastapi import Depends, Query +from fastapi import Depends, HTTPException, Query from rio_tiler.constants import WGS84_CRS from starlette.responses import HTMLResponse @@ -102,6 +102,26 @@ def dataset_metadata_dict( with self.dataset_opener(src_path, **io_params.as_dict()) as ds: return ds.to_dict(data=False) + @factory.router.get( + "/dataset/coordinates/{name}", + responses={ + 200: {"description": "Returns a Dataset coordinate and its values."} + }, + ) + def dataset_coordinate( + name: str, + src_path=Depends(factory.path_dependency), + io_params=Depends(self.io_dependency), + ): + """Returns a Dataset coordinate and its values.""" + with self.dataset_opener(src_path, **io_params.as_dict()) as ds: + try: + return ds.coords[name].to_dict(data=True) + except KeyError as e: + raise HTTPException( + status_code=404, detail=f"Coordinate '{name}' not found" + ) from e + @factory.router.get( "/dataset/keys", response_model=list[str], From b1303e1f586f3f3860e794907b3c41d07da524a3 Mon Sep 17 00:00:00 2001 From: hrodmn Date: Fri, 18 Sep 2026 09:03:07 -0500 Subject: [PATCH 2/2] feat: add /dataset/dimensions endpoint to show dimension names and sizes --- docs/src/advanced/Extensions.md | 2 +- src/titiler/xarray/tests/test_factory.py | 17 ++- .../xarray/titiler/xarray/extensions.py | 13 +++ uv.lock | 104 +++++++++--------- 4 files changed, 79 insertions(+), 57 deletions(-) diff --git a/docs/src/advanced/Extensions.md b/docs/src/advanced/Extensions.md index 3428ecfa3..01856183e 100644 --- a/docs/src/advanced/Extensions.md +++ b/docs/src/advanced/Extensions.md @@ -65,7 +65,7 @@ class FactoryExtension(metaclass=abc.ABCMeta): #### titiler.xarray.DatasetMetadataExtension -- Goal: adds `/dataset/`, `/dataset/keys`, `/dataset/dict` and `/dataset/coordinates/{name}` endpoints which return metadata about a multidimensional Dataset (not a DataArray) +- Goal: adds `/dataset/`, `/dataset/keys`, `/dataset/dict`, `/dataset/dimensions` and `/dataset/coordinates/{name}` endpoints which return metadata about a multidimensional Dataset (not a DataArray) #### titiler.mosaic.extensions.wmts.wmtsExtension diff --git a/src/titiler/xarray/tests/test_factory.py b/src/titiler/xarray/tests/test_factory.py index 991f885f5..ff4d21823 100644 --- a/src/titiler/xarray/tests/test_factory.py +++ b/src/titiler/xarray/tests/test_factory.py @@ -43,10 +43,11 @@ def test_tiler_factory(): md = TilerFactory( router_prefix="/md", - # /dataset, /dataset/dict, /dataset/keys, /dataset/coordinates/{name} + # /dataset, /dataset/dict, /dataset/keys, /dataset/dimensions, + # /dataset/coordinates/{name} extensions=[DatasetMetadataExtension()], ) - assert len(md.router.routes) == 19 + assert len(md.router.routes) == 20 app = FastAPI() app.include_router(md.router, prefix="/md") @@ -69,7 +70,7 @@ def app(): ], reader=FsReader, ) - assert len(md.router.routes) == 19 + assert len(md.router.routes) == 20 app = FastAPI() app.include_router(md.router, prefix="/md") @@ -86,7 +87,7 @@ def app_zarr(): DatasetMetadataExtension(), ], ) - assert len(md.router.routes) == 19 + assert len(md.router.routes) == 20 app = FastAPI() app.include_router(md.router, prefix="/md") @@ -115,6 +116,14 @@ def test_dataset_extension(filename, app): assert "text/html" in resp.headers["content-type"] +def test_dataset_dimensions(app): + """Test dimension names and sizes.""" + resp = app.get("/md/dataset/dimensions", params={"url": dataset_2d_nc}) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + assert resp.json() == {"y": 1000, "x": 2000} + + def test_dataset_coordinate(app): """Test coordinate metadata and values.""" resp = app.get( diff --git a/src/titiler/xarray/titiler/xarray/extensions.py b/src/titiler/xarray/titiler/xarray/extensions.py index b3de58935..950089341 100644 --- a/src/titiler/xarray/titiler/xarray/extensions.py +++ b/src/titiler/xarray/titiler/xarray/extensions.py @@ -102,6 +102,19 @@ def dataset_metadata_dict( with self.dataset_opener(src_path, **io_params.as_dict()) as ds: return ds.to_dict(data=False) + @factory.router.get( + "/dataset/dimensions", + response_model=dict[str, int], + responses={200: {"description": "Returns the Dataset dimension sizes."}}, + ) + def dataset_dimensions( + src_path=Depends(factory.path_dependency), + io_params=Depends(self.io_dependency), + ): + """Returns the Dataset dimension sizes.""" + with self.dataset_opener(src_path, **io_params.as_dict()) as ds: + return dict(ds.sizes) + @factory.router.get( "/dataset/coordinates/{name}", responses={ diff --git a/uv.lock b/uv.lock index c40659baa..bc635a5ee 100644 --- a/uv.lock +++ b/uv.lock @@ -946,7 +946,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1036,7 +1036,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ @@ -2119,8 +2119,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -4076,10 +4076,10 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "lxml" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "requests" }, + { name = "lxml", marker = "python_full_version < '3.12'" }, + { name = "python-dateutil", marker = "python_full_version < '3.12'" }, + { name = "pyyaml", marker = "python_full_version < '3.12'" }, + { name = "requests", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bf/03/5df0b995a3a6eefc24cc2320e3fe830d95e76ee6b0b1c6c9d0f1d4040bbe/owslib-0.35.0.tar.gz", hash = "sha256:0182f377bb30d25b78284bbaf82a12dece97902ed844cee88791ff38665b9b00", size = 194282, upload-time = "2025-10-28T15:13:42.594Z" } wheels = [ @@ -4099,10 +4099,10 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten'", ] dependencies = [ - { name = "lxml" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "requests" }, + { name = "lxml", marker = "python_full_version >= '3.12'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.12'" }, + { name = "pyyaml", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/5d/f2b14a61454bc290c48786c1521f7c9e60996cb0d207b544feb722c82dee/owslib-0.36.0.tar.gz", hash = "sha256:b1ea352ee84f37a1a8ae44122b13cbd3b5b7bf9af0f74d84fe9ed7d9613b9543", size = 196090, upload-time = "2026-06-04T07:05:57.616Z" } wheels = [ @@ -4214,7 +4214,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "(python_full_version >= '3.12' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -4747,7 +4747,7 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "certifi" }, + { name = "certifi", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } wheels = [ @@ -4820,7 +4820,7 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten'", ] dependencies = [ - { name = "certifi" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c8/29/6598570c90cbfc84ddefc3ccac4aa412bf51a527d72c74cc4fe64a5e6f24/pyproj-3.8.0.tar.gz", hash = "sha256:efa59725bba68bf97fa808b61302df32934acdceb6a5c92a8dd0e71dc266a876", size = 242018, upload-time = "2026-09-05T20:05:09.353Z" } wheels = [ @@ -5480,14 +5480,14 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "affine" }, - { name = "attrs" }, - { name = "certifi" }, - { name = "click" }, - { name = "click-plugins" }, - { name = "cligj" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "pyparsing" }, + { name = "affine", marker = "python_full_version < '3.12'" }, + { name = "attrs", marker = "python_full_version < '3.12'" }, + { name = "certifi", marker = "python_full_version < '3.12'" }, + { name = "click", marker = "python_full_version < '3.12'" }, + { name = "click-plugins", marker = "python_full_version < '3.12'" }, + { name = "cligj", marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pyparsing", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/fa/fce8dc9f09e5bc6520b6fc1b4ecfa510af9ca06eb42ad7bdff9c9b8989d0/rasterio-1.4.4.tar.gz", hash = "sha256:c95424e2c7f009b8f7df1095d645c52895cd332c0c2e1b4c2e073ea28b930320", size = 445004, upload-time = "2025-12-12T18:01:08.971Z" } wheels = [ @@ -5542,12 +5542,12 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten'", ] dependencies = [ - { name = "affine" }, - { name = "attrs" }, - { name = "certifi" }, - { name = "click" }, - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, - { name = "pyparsing" }, + { name = "affine", marker = "python_full_version >= '3.12'" }, + { name = "attrs", marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyparsing", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/1a/ee73b447f1623a6bb6490af08d4bbed3fb6e38b0adc54553a0d244d4103a/rasterio-1.5.1.tar.gz", hash = "sha256:c1b6ae15f4ccad704f1fe8417da5c2250145c7bcdb91acb53833bf5aefdd9e48", size = 457868, upload-time = "2026-08-08T02:45:15.844Z" } wheels = [ @@ -5726,11 +5726,11 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" } }, - { name = "rasterio", version = "1.4.4", source = { registry = "https://pypi.org/simple" } }, - { name = "xarray" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "rasterio", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "xarray", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/8e/fe4e87460f8c62d8d5c683e09f19fbde5d9cfcfd0342d02df1f452999b5d/rioxarray-0.19.0.tar.gz", hash = "sha256:7819a0036fd874c8c8e280447cbbe43d8dc72fc4a14ac7852a665b1bdb7d4b04", size = 54600, upload-time = "2025-04-21T17:46:54.183Z" } wheels = [ @@ -5750,11 +5750,11 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten'", ] dependencies = [ - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "pyproj", version = "3.8.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rasterio", version = "1.5.1", source = { registry = "https://pypi.org/simple" } }, - { name = "xarray" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pyproj", version = "3.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "rasterio", version = "1.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "xarray", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/8d/9e5ab7525d2e81f9963a9192637ba8808e214f1c93a5ed000048447d626f/rioxarray-0.23.0.tar.gz", hash = "sha256:800fc3ae2405ff535edf657f3f0a6bda5a70e72282883ce18c7daecd8e0bfc58", size = 61638, upload-time = "2026-07-27T19:33:29.281Z" } wheels = [ @@ -6143,7 +6143,7 @@ wheels = [ [[package]] name = "titiler" -version = "2.2.2" +version = "2.3.0" source = { virtual = "." } dependencies = [ { name = "titiler-application" }, @@ -7268,12 +7268,12 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version < '3.12'" }, + { name = "google-crc32c", marker = "python_full_version < '3.12'" }, + { name = "numcodecs", marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } wheels = [ @@ -7293,12 +7293,12 @@ resolution-markers = [ "python_full_version == '3.12.*' and sys_platform != 'emscripten'", ] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version >= '3.12'" }, + { name = "google-crc32c", marker = "python_full_version >= '3.12'" }, + { name = "numcodecs", marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/15/436cb1d3bbe86173bd44ce7a34ecb210d0c0416946e337858149a905ef5a/zarr-3.3.0.tar.gz", hash = "sha256:cd0c8cf738b4bb4807815bc1255acad5bdf1a7b7264b606c5a1bc0d0392a306b", size = 943626, upload-time = "2026-07-30T16:35:10.491Z" } wheels = [