Skip to content
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ uv sync --all-extras
This repo is set to use `pre-commit` to run *isort*, *flake8*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code.

```bash
uv pre-commit install
uv run pre-commit install
```

## Testing
Expand Down
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ classifiers = [
"Topic :: Scientific/Engineering :: GIS",
]
dependencies = [
"titiler.core>=0.23.1,<0.24",
"titiler.mosaic>=0.23.1,<0.24",
"titiler.xarray>=0.23.1,<0.24",
# ~0.24.3 will be the release of opener_options
# and approximately equivalent to the range >=0.24.3,<0.25.0
"titiler.core~=0.24.2",
"titiler.mosaic~=0.24.2",
"titiler.xarray@git+https://github.com/developmentseed/titiler.git@main#subdirectory=src/titiler/xarray",
"aiobotocore>=2.24.0",
"boto3>=1.39.0",
"cftime~=1.6.4",
Expand Down
116 changes: 115 additions & 1 deletion tests/test_backend.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Test backend functions"""

import pytest

from titiler.cmr.backend import Access, CMRBackend


Expand All @@ -28,3 +27,118 @@ def test_get_assets(access: Access, expectation: str) -> None:
assert asset_url
assert isinstance(asset_url, dict)
assert asset_url[band].startswith(expectation)


@pytest.fixture
def reader_options():
"""Fixture for reader options"""
from titiler.cmr.reader import xarray_open_dataset

return {"variable": "foo", "opener": xarray_open_dataset}


@pytest.fixture
def expected_opener_options():
"""Expected format for opener_options after transformation"""
return {
"s3_credentials": {
"key": "test_key",
"secret": "test_secret",
"token": "test_token",
}
}


def setup_backend_with_mock(
mocker, reader_options, mock_method_name, mock_return_value
):
"""
Helper to setup backend with mocked Reader.__init__ and asset retrieval method

Args:
mocker: pytest-mock mocker fixture
reader_options: Configuration for the reader
mock_method_name: Name of the method to mock (e.g., 'assets_for_tile')
mock_return_value: List of assets to return from the mocked method

Returns:
tuple: (backend, mock_init)
"""
from titiler.xarray.io import Reader

mock_init = mocker.patch.object(Reader, "__init__", return_value=None)
backend = CMRBackend(reader=Reader, reader_options=reader_options)
setattr(backend, mock_method_name, lambda *args, **kwargs: mock_return_value)

aws_s3_credentials = {
"accessKeyId": "test_key",
"secretAccessKey": "test_secret",
"sessionToken": "test_token",
}

# Mock _get_s3_credentials to return test credentials
mocker.patch.object(backend, "_get_s3_credentials", return_value=aws_s3_credentials)

return backend, mock_init


def assert_opener_options_passed(mock_init, expected_opener_options):
"""Assert that opener_options were passed correctly to Reader.__init__"""
mock_init.assert_called()
call_args, call_kwargs = mock_init.call_args
assert call_kwargs["opener_options"] == expected_opener_options


@pytest.mark.parametrize(
"method_name,method_call",
[
(
"tile",
lambda backend: backend.tile(
tile_x=0, tile_y=0, tile_z=0, cmr_query={}, bands_regex=""
),
),
(
"part",
lambda backend: backend.part(
bbox=(0, 0, 1, 1), cmr_query={}, bands_regex=""
),
),
(
"feature",
lambda backend: backend.feature(
shape={"type": "Point", "coordinates": [0, 0]},
cmr_query={},
bands_regex="",
),
),
],
)
def test_opener_options_passed_to_reader(
mocker,
reader_options,
expected_opener_options,
method_name,
method_call,
):
"""Test that opener_options are passed to the reader in tile/part/feature methods"""
# Map method names to their corresponding asset retrieval methods
asset_method_map = {
"tile": "assets_for_tile",
"part": "assets_for_bbox",
"feature": "get_assets",
}

mock_assets = [{"url": "s3://test-bucket/test.zarr", "provider": "TEST_PROVIDER"}]
backend, mock_init = setup_backend_with_mock(
mocker, reader_options, asset_method_map[method_name], mock_assets
)

# Call the method - we expect it to fail after Reader.__init__, which is fine
try:
method_call(backend)
except (AttributeError, Exception):
pass # Expected - we only care about __init__ call

# Verify opener_options were passed correctly
assert_opener_options_passed(mock_init, expected_opener_options)
135 changes: 51 additions & 84 deletions titiler/cmr/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,24 +252,10 @@ def tile(
)

def _reader(asset: Asset, x: int, y: int, z: int, **kwargs: Any) -> ImageData:
if (
s3_auth_config.strategy == "environment"
and s3_auth_config.access == "direct"
and self.auth
):
s3_credentials = aws_s3_credential(self.auth, asset["provider"])
s3_credentials = self._get_s3_credentials(asset)

else:
s3_credentials = None

if isinstance(self.reader, Reader):
aws_session = None
if s3_credentials:
aws_session = rasterio.session.AWSSession(
aws_access_key_id=s3_credentials["accessKeyId"],
aws_secret_access_key=s3_credentials["secretAccessKey"],
aws_session_token=s3_credentials["sessionToken"],
)
if isinstance(self.reader, type) and self.reader == Reader:
aws_session = self._create_aws_session(s3_credentials)

with rasterio.Env(aws_session):
with self.reader(
Expand All @@ -279,17 +265,7 @@ def _reader(asset: Asset, x: int, y: int, z: int, **kwargs: Any) -> ImageData:
) as src_dst:
return src_dst.tile(x, y, z, **kwargs)

if s3_credentials:
options = {
**self.reader_options,
"s3_credentials": {
"key": s3_credentials["accessKeyId"],
"secret": s3_credentials["secretAccessKey"],
"token": s3_credentials["sessionToken"],
},
}
else:
options = self.reader_options
options = self._build_reader_options(s3_credentials)

with self.reader(
asset["url"],
Expand Down Expand Up @@ -340,24 +316,10 @@ def part(
raise NoAssetFoundError("No assets found for bbox input")

def _reader(asset: Asset, bbox: BBox, **kwargs: Any) -> ImageData:
if (
s3_auth_config.strategy == "environment"
and s3_auth_config.access == "direct"
and self.auth
):
s3_credentials = aws_s3_credential(self.auth, asset["provider"])
s3_credentials = self._get_s3_credentials(asset)

else:
s3_credentials = None

if isinstance(self.reader, Reader):
aws_session = None
if s3_credentials:
aws_session = rasterio.session.AWSSession(
aws_access_key_id=s3_credentials["accessKeyId"],
aws_secret_access_key=s3_credentials["secretAccessKey"],
aws_session_token=s3_credentials["sessionToken"],
)
if isinstance(self.reader, type) and self.reader == Reader:
aws_session = self._create_aws_session(s3_credentials)

with rasterio.Env(aws_session):
with self.reader(
Expand All @@ -366,17 +328,7 @@ def _reader(asset: Asset, bbox: BBox, **kwargs: Any) -> ImageData:
) as src_dst:
return src_dst.part(bbox, **kwargs)

if s3_credentials:
options = {
**self.reader_options,
"s3_credentials": {
"key": s3_credentials["accessKeyId"],
"secret": s3_credentials["secretAccessKey"],
"token": s3_credentials["sessionToken"],
},
}
else:
options = self.reader_options
options = self._build_reader_options(s3_credentials)

with self.reader(
asset["url"],
Expand All @@ -393,6 +345,45 @@ def _reader(asset: Asset, bbox: BBox, **kwargs: Any) -> ImageData:
**kwargs,
)

def _get_s3_credentials(self, asset: Asset) -> Optional[Dict]:
"""Get s3 credentials from kwargs or via auth."""
if (
s3_auth_config.strategy == "environment"
and s3_auth_config.access == "direct"
and self.auth
):
return aws_s3_credential(self.auth, asset["provider"])
else:
return None

def _build_reader_options(self, s3_credentials: Optional[Dict]) -> Dict:
"""Build reader options with opener_options if s3_credentials provided."""
if s3_credentials:
return {
**self.reader_options,
"opener_options": {
"s3_credentials": {
"key": s3_credentials["accessKeyId"],
"secret": s3_credentials["secretAccessKey"],
"token": s3_credentials["sessionToken"],
}
},
}
else:
return self.reader_options

def _create_aws_session(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for making these convenience functions - it makes the tile/part/etc functions easier to read!

self, s3_credentials: Optional[Dict]
) -> Optional[rasterio.session.AWSSession]:
"""Create rasterio AWSSession from s3 credentials."""
if s3_credentials:
return rasterio.session.AWSSession(
aws_access_key_id=s3_credentials["accessKeyId"],
aws_secret_access_key=s3_credentials["secretAccessKey"],
aws_session_token=s3_credentials["sessionToken"],
)
return None

def feature(
self,
shape: Dict,
Expand Down Expand Up @@ -424,24 +415,10 @@ def feature(
raise NoAssetFoundError("No assets found for Geometry")

def _reader(asset: Asset, shape: Dict, **kwargs: Any) -> ImageData:
if (
s3_auth_config.strategy == "environment"
and s3_auth_config.access == "direct"
and self.auth
):
s3_credentials = aws_s3_credential(self.auth, asset["provider"])
s3_credentials = self._get_s3_credentials(asset)

else:
s3_credentials = None

if isinstance(self.reader, Reader):
aws_session = None
if s3_credentials:
aws_session = rasterio.session.AWSSession(
aws_access_key_id=s3_credentials["accessKeyId"],
aws_secret_access_key=s3_credentials["secretAccessKey"],
aws_session_token=s3_credentials["sessionToken"],
)
if isinstance(self.reader, type) and self.reader == Reader:
aws_session = self._create_aws_session(s3_credentials)

with rasterio.Env(aws_session):
with self.reader(
Expand All @@ -450,17 +427,7 @@ def _reader(asset: Asset, shape: Dict, **kwargs: Any) -> ImageData:
) as src_dst:
return src_dst.feature(shape, **kwargs)

if s3_credentials:
options = {
**self.reader_options,
"s3_credentials": {
"key": s3_credentials["accessKeyId"],
"secret": s3_credentials["secretAccessKey"],
"token": s3_credentials["sessionToken"],
},
}
else:
options = self.reader_options
options = self._build_reader_options(s3_credentials)

with self.reader(
asset["url"],
Expand Down
Loading
Loading