From d33d76b35697a00df6034b00ea2d6424c353e2df Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Jun 2026 00:00:58 +0200 Subject: [PATCH 01/10] initial draft of the alternate assets extension code --- xpystac/extensions.py | 68 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 xpystac/extensions.py diff --git a/xpystac/extensions.py b/xpystac/extensions.py new file mode 100644 index 0000000..9c75d6b --- /dev/null +++ b/xpystac/extensions.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None + +if TYPE_CHECKING: + import pystac + + +@dataclass +class AssetInfo: + href: str + additional_data: dict[str, JSON] + storage_refs: list[str] | None + auth_refs: list[str] | None + + def resolve_storage(self, schemes: dict[str, JSON]) -> dict[str, JSON] | None: + refs = self.storage_refs + if refs is None: + return None + + return {ref: schemes.get(ref) for ref in refs} + + def resolve_auth(self, schemes: dict[str, JSON]) -> dict[str, JSON] | None: + refs = self.auth_refs + if refs is None: + return None + + return {ref: schemes.get(ref) for ref in refs} + + +def _extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> AssetInfo: + excludes = {"href", "storage:refs", "auth:refs", "alternate:name"} + + asset_name = asset.extra_fields.get("alternate:name") + if alternate is None or alternate == asset_name: + href = asset.href + storage_refs = asset.extra_fields.get("storage:refs") + auth_refs = asset.extra_fields.get("auth:refs") + additional_data = { + k: v for k, v in asset.extra_fields.items() if k not in excludes + } + else: + alternate_assets = asset.extra_fields.get("alternate") + if alternate_assets is None: + raise ValueError( + "Alternate asset name given but no alternate assets found." + ) + + alternate_asset = alternate_assets.get(alternate) + if alternate_asset is None: + raise ValueError( + f"alternate asset {alternate} not found." + " Choose one of {', '.join(sorted(alternate_assets))}" + ) + + href = alternate_asset["href"] + storage_refs = alternate_asset.get("storage:refs") + auth_refs = alternate_asset.get("auth:refs") + additional_data = { + k: v + for k, v in (asset.extra_fields | alternate_asset).items() + if k not in excludes + } + + return AssetInfo(href, additional_data, storage_refs, auth_refs) From 2a8b3c9b8e628d77b45ea3f18d2ed9436e5a0da1 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Jun 2026 11:31:58 +0200 Subject: [PATCH 02/10] don't interact with other extensions for now --- xpystac/extensions.py | 45 +++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/xpystac/extensions.py b/xpystac/extensions.py index 9c75d6b..a381849 100644 --- a/xpystac/extensions.py +++ b/xpystac/extensions.py @@ -12,36 +12,16 @@ @dataclass class AssetInfo: href: str - additional_data: dict[str, JSON] - storage_refs: list[str] | None - auth_refs: list[str] | None - - def resolve_storage(self, schemes: dict[str, JSON]) -> dict[str, JSON] | None: - refs = self.storage_refs - if refs is None: - return None - - return {ref: schemes.get(ref) for ref in refs} - - def resolve_auth(self, schemes: dict[str, JSON]) -> dict[str, JSON] | None: - refs = self.auth_refs - if refs is None: - return None - - return {ref: schemes.get(ref) for ref in refs} + properties: dict[str, JSON] def _extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> AssetInfo: - excludes = {"href", "storage:refs", "auth:refs", "alternate:name"} + excludes = {"href", "alternate:name"} asset_name = asset.extra_fields.get("alternate:name") if alternate is None or alternate == asset_name: href = asset.href - storage_refs = asset.extra_fields.get("storage:refs") - auth_refs = asset.extra_fields.get("auth:refs") - additional_data = { - k: v for k, v in asset.extra_fields.items() if k not in excludes - } + properties = {k: v for k, v in asset.extra_fields.items() if k not in excludes} else: alternate_assets = asset.extra_fields.get("alternate") if alternate_assets is None: @@ -57,12 +37,23 @@ def _extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> Asse ) href = alternate_asset["href"] - storage_refs = alternate_asset.get("storage:refs") - auth_refs = alternate_asset.get("auth:refs") - additional_data = { + properties = { k: v for k, v in (asset.extra_fields | alternate_asset).items() if k not in excludes } - return AssetInfo(href, additional_data, storage_refs, auth_refs) + return AssetInfo(href, properties) + + +def _resolve_scheme( + refs: list[str] | None, schemes: dict[str, dict[str, JSON]] +) -> list[dict[str, JSON]] | None: + if refs is None: + return None + + missing_refs = [ref for ref in refs if ref not in schemes] + if missing_refs: + raise ValueError("selected unknown refs: {', '.join(missing_refs)}") + + return [schemes[ref] for ref in refs] From 9c6c805942c14486bd241593993ff666b29a1096 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Jun 2026 12:04:13 +0200 Subject: [PATCH 03/10] tests for the alternate assets extraction --- tests/data/alternate_asset.json | 99 +++++++++++++++++++++++++++++++++ tests/test_extensions.py | 48 ++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/data/alternate_asset.json create mode 100644 tests/test_extensions.py diff --git a/tests/data/alternate_asset.json b/tests/data/alternate_asset.json new file mode 100644 index 0000000..9ce1852 --- /dev/null +++ b/tests/data/alternate_asset.json @@ -0,0 +1,99 @@ +{ + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/storage/v2.0.0/schema.json", + "https://stac-extensions.github.io/version/v1.2.0/schema.json", + "https://stac-extensions.github.io/alternate-assets/v1.1.0/schema.json" + ], + "id": "m_3009743_sw_14_1_20160928_20161129", + "bbox": [ + -97.75, + 30.25, + -97.6875, + 30.312499999999996 + ], + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -97.75, + 30.312499999999996 + ], + [ + -97.75, + 30.25 + ], + [ + -97.6875, + 30.25 + ], + [ + -97.6875, + 30.312499999999996 + ], + [ + -97.75, + 30.312499999999996 + ] + ] + ] + }, + "properties": { + "datetime": "2016-09-28T00:00:00+00:00", + "mission": "NAIP", + "gsd": 1, + "storage:schemes": { + "az-wus2-ar": { + "type": "ms-azure", + "platform": "https://{account}.blob.core.windows.net", + "account": "jon-doe-123", + "region": "westus2" + }, + "aws-std": { + "type": "aws-s3", + "platform": "https://{bucket}.s3.{region}.amazonaws.com", + "bucket": "naip-visualization", + "region": "us-west-2", + "requester_pays": true + }, + "minio": { + "type": "custom-s3", + "platform": "https://play.min.io:9000" + } + } + }, + "assets": { + "CO_GEOTIFF_RGB": { + "href": "s3://naip-visualization/tx/2016/100cm/rgb/30097/m_3009743_sw_14_1_20160928.tif", + "type": "image/tiff; application=geotiff; profile=cloud-optimized", + "storage:refs": [ + "aws-std" + ], + "alternate:name": "Amazon S3", + "alternate": { + "minio": { + "href": "s3://mybucket/tx/2016/100cm/rgb/30097/m_3009743_sw_14_1_20160928.tif", + "alternate:name": "MinIO", + "storage:refs": [ + "minio" + ] + } + } + }, + "GEOTIFF_AZURE_RGBIR": { + "href": "https://naip-nsl.blob.core.windows.net/tx/2016/100cm/rgb/30097/m_3009743_sw_14_1_20160928.tif", + "type": "image/tiff; application=geotiff", + "storage:refs": [ + "az-wus2-ar" + ] + } + }, + "links": [ + { + "href": "https://example.com/examples/item-naip.json", + "rel": "self" + } + ] +} diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 0000000..9d93bf0 --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,48 @@ +import json +import pathlib + +import pystac +import pytest + +from xpystac import extensions +from xpystac.extensions import JSON, AssetInfo + + +@pytest.fixture +def alternate_asset_item(request): + root = pathlib.Path(__file__).parent / "data" + path = root / "alternate_asset.json" + data = json.loads(path.read_text()) + + return pystac.Item.from_dict(data) + + +@pytest.mark.parametrize( + ["alternate", "expected_href", "expected_properties"], + ( + pytest.param( + None, + "s3://naip-visualization/tx/2016/100cm/rgb/30097/m_3009743_sw_14_1_20160928.tif", + {"alternate:name": "Amazon S3", "storage:refs": ["aws-std"]}, + id="default", + ), + pytest.param( + "minio", + "s3://mybucket/tx/2016/100cm/rgb/30097/m_3009743_sw_14_1_20160928.tif", + {"alternate:name": "MinIO", "storage:refs": ["minio"]}, + id="minio", + ), + ), +) +def test_extract_alternate_asset( + alternate_asset_item: pystac.Item, + alternate: str | None, + expected_href: str, + expected_properties: dict[str, JSON], +) -> None: + asset = alternate_asset_item.assets["CO_GEOTIFF_RGB"] + + actual: AssetInfo = extensions._extract_alternate_asset(asset, alternate) + + assert actual.href == expected_href + assert actual.properties == expected_properties From 7fae4cafc5dff7e572bf8898967372f982e23424 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Jun 2026 12:04:36 +0200 Subject: [PATCH 04/10] refactor and don't use `alternate:name` for the main asset --- xpystac/extensions.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/xpystac/extensions.py b/xpystac/extensions.py index a381849..3a679e1 100644 --- a/xpystac/extensions.py +++ b/xpystac/extensions.py @@ -16,12 +16,12 @@ class AssetInfo: def _extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> AssetInfo: - excludes = {"href", "alternate:name"} + excludes = {"href", "alternate"} - asset_name = asset.extra_fields.get("alternate:name") - if alternate is None or alternate == asset_name: + properties = {k: v for k, v in asset.extra_fields.items() if k not in excludes} + if alternate is None: href = asset.href - properties = {k: v for k, v in asset.extra_fields.items() if k not in excludes} + additional_properties = {} else: alternate_assets = asset.extra_fields.get("alternate") if alternate_assets is None: @@ -37,13 +37,11 @@ def _extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> Asse ) href = alternate_asset["href"] - properties = { - k: v - for k, v in (asset.extra_fields | alternate_asset).items() - if k not in excludes + additional_properties = { + k: v for k, v in alternate_asset.items() if k not in excludes } - return AssetInfo(href, properties) + return AssetInfo(href, properties | additional_properties) def _resolve_scheme( From 233d1414b0245e965a58cb83d84318e2e467b069 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Jun 2026 12:11:30 +0200 Subject: [PATCH 05/10] tests for resolving refs --- tests/test_extensions.py | 26 +++++++++++++++++++++++++- xpystac/extensions.py | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9d93bf0..4b5f83e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -8,7 +8,7 @@ from xpystac.extensions import JSON, AssetInfo -@pytest.fixture +@pytest.fixture(scope="module") def alternate_asset_item(request): root = pathlib.Path(__file__).parent / "data" path = root / "alternate_asset.json" @@ -46,3 +46,27 @@ def test_extract_alternate_asset( assert actual.href == expected_href assert actual.properties == expected_properties + + +@pytest.mark.parametrize( + ["refs", "expected"], + ( + pytest.param(None, None, id="none"), + pytest.param(["minio"], [{"type": "custom-s3"}], id="one"), + pytest.param( + ["minio", "aws-us-west-2"], + [{"type": "custom-s3"}, {"type": "aws-s3"}], + id="two", + ), + ), +) +def test_resolve_refs( + refs: list[str] | None, expected: list[dict[str, JSON]] | None +) -> None: + schemes: dict[str, dict[str, JSON]] = { + "minio": {"type": "custom-s3"}, + "aws-us-west-2": {"type": "aws-s3"}, + } + + actual = extensions._resolve_refs(refs, schemes) + assert actual == expected diff --git a/xpystac/extensions.py b/xpystac/extensions.py index 3a679e1..e9ddfea 100644 --- a/xpystac/extensions.py +++ b/xpystac/extensions.py @@ -44,7 +44,7 @@ def _extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> Asse return AssetInfo(href, properties | additional_properties) -def _resolve_scheme( +def _resolve_refs( refs: list[str] | None, schemes: dict[str, dict[str, JSON]] ) -> list[dict[str, JSON]] | None: if refs is None: From 52c171172c63a752f46e09483276ae93bffcee14 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Jun 2026 18:37:09 +0200 Subject: [PATCH 06/10] function to extract a attribute from the owner of the asset --- xpystac/extensions.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/xpystac/extensions.py b/xpystac/extensions.py index e9ddfea..59cb125 100644 --- a/xpystac/extensions.py +++ b/xpystac/extensions.py @@ -44,6 +44,17 @@ def _extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> Asse return AssetInfo(href, properties | additional_properties) +def _extract_parent_attribute( + obj: pystac.Asset, attr: str +) -> dict[str, dict[str, JSON]] | None: + if isinstance(obj.owner, pystac.Item): + fields = obj.owner.properties + else: + fields = obj.owner.extra_fields + + return fields.get(attr) + + def _resolve_refs( refs: list[str] | None, schemes: dict[str, dict[str, JSON]] ) -> list[dict[str, JSON]] | None: From e9ad1f398535c3843e617420323c86682f3145a6 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Jun 2026 18:54:46 +0200 Subject: [PATCH 07/10] first draft for integrating into the main API --- xpystac/core.py | 44 ++++++++++++++++++++++++++++++++++++------- xpystac/extensions.py | 11 ++--------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/xpystac/core.py b/xpystac/core.py index 6dc2f49..1163d8a 100644 --- a/xpystac/core.py +++ b/xpystac/core.py @@ -1,10 +1,13 @@ import functools from collections.abc import Callable +from typing import cast import pystac import xarray +from xpystac import extensions from xpystac._xstac_kerchunk import _stac_to_kerchunk +from xpystac.extensions import JSON from xpystac.utils import _import_optional_dependency @@ -83,11 +86,42 @@ def _( obj: pystac.Asset, patch_url: None | Callable[[str], str] = None, allow_kerchunk: bool = True, + *, + alternate: str | None = None, **kwargs, ) -> xarray.Dataset: - open_kwargs = obj.extra_fields.get("xarray:open_kwargs", {}) + info = extensions._extract_alternate_asset(obj, alternate) + href = info.href + if patch_url is not None: + href = patch_url(href) - storage_options = obj.extra_fields.get("xarray:storage_options", None) + storage_refs = cast(list[str] | None, info.properties.get("storage:refs")) + if storage_refs is not None: + storage_schemes = cast( + dict[str, JSON], + extensions._extract_parent_attribute(obj, "storage:schemes"), + ) + if storage_schemes is None: + raise ValueError( + "storage:refs found but no storage:schemes on the parent object" + ) + + selected_storage: list[JSON] = extensions._resolve_refs( + storage_refs, storage_schemes + ) + if len(storage_refs) != 1: + raise NotImplementedError( + "Only one storage:ref per asset is currently supported" + ) + + [storage_scheme] = selected_storage + # TODO: figure out how to best pass along the storage scheme + + open_kwargs = cast(dict[str, JSON], info.properties.get("xarray:open_kwargs", {})) + + storage_options = cast( + dict[str, JSON] | None, info.properties.get("xarray:storage_options", None) + ) if storage_options: open_kwargs["storage_options"] = storage_options @@ -97,7 +131,7 @@ def _( and {"index", "references"}.intersection(set(obj.roles) if obj.roles else set()) ): requests = _import_optional_dependency("requests") - r = requests.get(obj.href) + r = requests.get(href) r.raise_for_status() refs = r.json() @@ -125,9 +159,5 @@ def _( return read_icechunk(obj) - href = obj.href - if patch_url is not None: - href = patch_url(href) - ds = xarray.open_dataset(href, **{**default_kwargs, **open_kwargs, **kwargs}) return ds diff --git a/xpystac/extensions.py b/xpystac/extensions.py index 59cb125..199485f 100644 --- a/xpystac/extensions.py +++ b/xpystac/extensions.py @@ -44,9 +44,7 @@ def _extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> Asse return AssetInfo(href, properties | additional_properties) -def _extract_parent_attribute( - obj: pystac.Asset, attr: str -) -> dict[str, dict[str, JSON]] | None: +def _extract_parent_attribute(obj: pystac.Asset, attr: str) -> JSON: if isinstance(obj.owner, pystac.Item): fields = obj.owner.properties else: @@ -55,12 +53,7 @@ def _extract_parent_attribute( return fields.get(attr) -def _resolve_refs( - refs: list[str] | None, schemes: dict[str, dict[str, JSON]] -) -> list[dict[str, JSON]] | None: - if refs is None: - return None - +def _resolve_refs(refs: list[str], schemes: dict[str, JSON]) -> list[JSON]: missing_refs = [ref for ref in refs if ref not in schemes] if missing_refs: raise ValueError("selected unknown refs: {', '.join(missing_refs)}") From 3f399fa98ba1bfc2b9c424d4b76004c918619a4c Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Jun 2026 20:48:27 +0200 Subject: [PATCH 08/10] refactor again --- xpystac/core.py | 32 ++++++++------------------------ xpystac/extensions.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/xpystac/core.py b/xpystac/core.py index 1163d8a..d6dc75c 100644 --- a/xpystac/core.py +++ b/xpystac/core.py @@ -90,38 +90,22 @@ def _( alternate: str | None = None, **kwargs, ) -> xarray.Dataset: + # extract extensions info = extensions._extract_alternate_asset(obj, alternate) href = info.href - if patch_url is not None: - href = patch_url(href) - storage_refs = cast(list[str] | None, info.properties.get("storage:refs")) - if storage_refs is not None: - storage_schemes = cast( - dict[str, JSON], - extensions._extract_parent_attribute(obj, "storage:schemes"), - ) - if storage_schemes is None: - raise ValueError( - "storage:refs found but no storage:schemes on the parent object" - ) - - selected_storage: list[JSON] = extensions._resolve_refs( - storage_refs, storage_schemes - ) - if len(storage_refs) != 1: - raise NotImplementedError( - "Only one storage:ref per asset is currently supported" - ) - - [storage_scheme] = selected_storage - # TODO: figure out how to best pass along the storage scheme + storage = extensions.extract_scheme(info, kind="storage") + auth = extensions.extract_scheme(info, kind="auth") open_kwargs = cast(dict[str, JSON], info.properties.get("xarray:open_kwargs", {})) - storage_options = cast( dict[str, JSON] | None, info.properties.get("xarray:storage_options", None) ) + + # use extension data + # FIXME: these should be replaced by the authentication, zarr and storage extensions + if patch_url is not None: + href = patch_url(href) if storage_options: open_kwargs["storage_options"] = storage_options diff --git a/xpystac/extensions.py b/xpystac/extensions.py index 199485f..53e69d4 100644 --- a/xpystac/extensions.py +++ b/xpystac/extensions.py @@ -1,11 +1,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None if TYPE_CHECKING: + from typing import Literal + import pystac @@ -53,9 +55,35 @@ def _extract_parent_attribute(obj: pystac.Asset, attr: str) -> JSON: return fields.get(attr) -def _resolve_refs(refs: list[str], schemes: dict[str, JSON]) -> list[JSON]: +def _resolve_refs( + refs: list[str], schemes: dict[str, dict[str, JSON]] +) -> list[dict[str, JSON]]: missing_refs = [ref for ref in refs if ref not in schemes] if missing_refs: raise ValueError("selected unknown refs: {', '.join(missing_refs)}") return [schemes[ref] for ref in refs] + + +def extract_scheme( + info: AssetInfo, kind: Literal["storage", "auth"] +) -> dict[str, JSON] | None: + refs = cast(list[str], info.properties.get(f"{kind}:refs", [])) + if not refs: + return None + + schemes = cast( + dict[str, dict[str, JSON]], _extract_parent_attribute(info, f"{kind}:schemes") + ) + if schemes is None: + raise ValueError( + f"{kind}:refs found but no {kind}:schemes on the parent object" + ) + + if len(refs) != 1: + raise NotImplementedError( + f"Only one {kind}:ref per asset is currently supported" + ) + [scheme] = _resolve_refs(refs, schemes) + + return scheme From 38f3c2bc7c0b4ce2a26cf1c67bb93cae5c99c014 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 22 Sep 2026 15:52:14 +0200 Subject: [PATCH 09/10] typing --- xpystac/typing.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 xpystac/typing.py diff --git a/xpystac/typing.py b/xpystac/typing.py new file mode 100644 index 0000000..9e4f7e2 --- /dev/null +++ b/xpystac/typing.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None From 729635788093798b1c32933ad5eb2eb45f9d5f32 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 22 Sep 2026 15:53:36 +0200 Subject: [PATCH 10/10] refactor into separate modules --- tests/test_extensions.py | 12 ++++----- xpystac/extensions/__init__.py | 4 +++ xpystac/extensions/alternate.py | 32 ++++++++++++++++++++++ xpystac/extensions/common.py | 9 +++++++ xpystac/extensions/schemes.py | 48 +++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 xpystac/extensions/__init__.py create mode 100644 xpystac/extensions/alternate.py create mode 100644 xpystac/extensions/common.py create mode 100644 xpystac/extensions/schemes.py diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 4b5f83e..a9973d8 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -5,7 +5,7 @@ import pytest from xpystac import extensions -from xpystac.extensions import JSON, AssetInfo +from xpystac.extensions.common import JSON, AssetInfo @pytest.fixture(scope="module") @@ -42,7 +42,7 @@ def test_extract_alternate_asset( ) -> None: asset = alternate_asset_item.assets["CO_GEOTIFF_RGB"] - actual: AssetInfo = extensions._extract_alternate_asset(asset, alternate) + actual: AssetInfo = extensions.extract_alternate_asset(asset, alternate) assert actual.href == expected_href assert actual.properties == expected_properties @@ -51,7 +51,7 @@ def test_extract_alternate_asset( @pytest.mark.parametrize( ["refs", "expected"], ( - pytest.param(None, None, id="none"), + pytest.param([], [], id="none"), pytest.param(["minio"], [{"type": "custom-s3"}], id="one"), pytest.param( ["minio", "aws-us-west-2"], @@ -60,13 +60,11 @@ def test_extract_alternate_asset( ), ), ) -def test_resolve_refs( - refs: list[str] | None, expected: list[dict[str, JSON]] | None -) -> None: +def test_resolve_refs(refs: list[str], expected: list[dict[str, JSON]]) -> None: schemes: dict[str, dict[str, JSON]] = { "minio": {"type": "custom-s3"}, "aws-us-west-2": {"type": "aws-s3"}, } - actual = extensions._resolve_refs(refs, schemes) + actual = extensions.schemes._resolve_refs(refs, schemes) assert actual == expected diff --git a/xpystac/extensions/__init__.py b/xpystac/extensions/__init__.py new file mode 100644 index 0000000..d431d33 --- /dev/null +++ b/xpystac/extensions/__init__.py @@ -0,0 +1,4 @@ +from xpystac.extensions.alternate import extract_alternate_asset +from xpystac.extensions.schemes import extract_scheme + +__all__ = ["extract_alternate_asset", "extract_scheme"] diff --git a/xpystac/extensions/alternate.py b/xpystac/extensions/alternate.py new file mode 100644 index 0000000..1cdbe9a --- /dev/null +++ b/xpystac/extensions/alternate.py @@ -0,0 +1,32 @@ +import pystac + +from xpystac.extensions.common import AssetInfo + + +def extract_alternate_asset(asset: pystac.Asset, alternate: str | None) -> AssetInfo: + excludes = {"href", "alternate"} + + properties = {k: v for k, v in asset.extra_fields.items() if k not in excludes} + if alternate is None: + href = asset.href + additional_properties = {} + else: + alternate_assets = asset.extra_fields.get("alternate") + if alternate_assets is None: + raise ValueError( + "Alternate asset name given but no alternate assets found." + ) + + alternate_asset = alternate_assets.get(alternate) + if alternate_asset is None: + raise ValueError( + f"alternate asset {alternate} not found." + " Choose one of {', '.join(sorted(alternate_assets))}" + ) + + href = alternate_asset["href"] + additional_properties = { + k: v for k, v in alternate_asset.items() if k not in excludes + } + + return AssetInfo(href, properties | additional_properties) diff --git a/xpystac/extensions/common.py b/xpystac/extensions/common.py new file mode 100644 index 0000000..97d4969 --- /dev/null +++ b/xpystac/extensions/common.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +from xpystac.typing import JSON + + +@dataclass +class AssetInfo: + href: str + properties: dict[str, JSON] diff --git a/xpystac/extensions/schemes.py b/xpystac/extensions/schemes.py new file mode 100644 index 0000000..9f385a9 --- /dev/null +++ b/xpystac/extensions/schemes.py @@ -0,0 +1,48 @@ +from typing import Literal, cast + +import pystac + +from xpystac.typing import JSON + + +def _resolve_refs( + refs: list[str], schemes: dict[str, dict[str, JSON]] +) -> list[dict[str, JSON]]: + missing_refs = [ref for ref in refs if ref not in schemes] + if missing_refs: + raise ValueError("selected unknown refs: {', '.join(missing_refs)}") + + return [schemes[ref] for ref in refs] + + +def _extract_parent_attribute(obj: pystac.Asset, attr: str) -> JSON: + if isinstance(obj.owner, pystac.Item): + fields = obj.owner.properties + else: + fields = obj.owner.extra_fields + + return fields.get(attr) + + +def extract_scheme( + asset: pystac.Asset, kind: Literal["storage", "auth"] +) -> dict[str, JSON] | None: + refs = cast(list[str], asset.properties.get(f"{kind}:refs", [])) + if not refs: + return None + + schemes = cast( + dict[str, dict[str, JSON]], _extract_parent_attribute(asset, f"{kind}:schemes") + ) + if schemes is None: + raise ValueError( + f"{kind}:refs found but no {kind}:schemes on the parent object" + ) + + if len(refs) != 1: + raise NotImplementedError( + f"Only one {kind}:ref per asset is currently supported" + ) + [scheme] = _resolve_refs(refs, schemes) + + return scheme