diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b761b7ba..bc93d256b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Optional `strategy` parameter to `catalog.add_items()` ([#967](https://github.com/stac-utils/pystac/pull/967)) - `start_datetime` and `end_datetime` arguments to the `Item` constructor ([#918](https://github.com/stac-utils/pystac/pull/918)) - `RetryStacIO` ([#986](https://github.com/stac-utils/pystac/pull/986)) +- `STACObject.remove_hierarchical_links` and `Link.is_hierarchical` ([#999](https://github.com/stac-utils/pystac/pull/999)) ### Removed diff --git a/pystac/link.py b/pystac/link.py index 51c5859d7..add3c3fbf 100644 --- a/pystac/link.py +++ b/pystac/link.py @@ -24,6 +24,7 @@ HREF = Union[str, os.PathLike] +#: Hierarchical links provide structure to STAC catalogs. HIERARCHICAL_LINKS = [ pystac.RelType.ROOT, pystac.RelType.CHILD, @@ -345,6 +346,18 @@ def is_resolved(self) -> bool: """ return self._target_object is not None + def is_hierarchical(self) -> bool: + """Returns true if this link's rel type is hierarchical. + + Hierarchical links are used to build relationships in STAC, e.g. + "parent", "child", "item", etc. For a complete list of hierarchical + relation types, see :py:const:`HIERARCHICAL_LINKS`. + + Returns: + bool: True if the link's rel type is hierarchical. + """ + return self.rel in HIERARCHICAL_LINKS + def to_dict(self, transform_href: bool = True) -> Dict[str, Any]: """Generate a dictionary representing the JSON of this serialized Link. diff --git a/pystac/stac_object.py b/pystac/stac_object.py index 155c2f1df..1c706e43b 100644 --- a/pystac/stac_object.py +++ b/pystac/stac_object.py @@ -96,6 +96,35 @@ def remove_links(self, rel: Union[str, pystac.RelType]) -> None: self.links = [link for link in self.links if link.rel != rel] + def remove_hierarchical_links(self, add_canonical: bool = False) -> List[Link]: + """Removes all hierarchical links from this object. + + See :py:const:`pystac.link.HIERARCHICAL_LINKS` for a list of all + hierarchical links. If the object has a ``self`` href and + ``add_canonical`` is True, a link with ``rel="canonical"`` is added. + + Args: + add_canonical : If true, and this item has a ``self`` href, that + href is used to build a ``canonical`` link. + + Returns: + List[Link]: All removed links + """ + keep = list() + self_href = self.get_self_href() + if add_canonical and self_href is not None: + keep.append( + Link("canonical", self_href, media_type=pystac.MediaType.GEOJSON) + ) + remove = list() + for link in self.links: + if link.is_hierarchical(): + remove.append(link) + else: + keep.append(link) + self.links = keep + return remove + def get_single_link( self, rel: Optional[Union[str, pystac.RelType]] = None, diff --git a/tests/conftest.py b/tests/conftest.py index d38bb96b5..fa525042a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,22 +1,29 @@ +# TODO move all test case code to this file + from datetime import datetime import pytest from pystac import Catalog, Collection, Item -from .utils import ARBITRARY_BBOX, ARBITRARY_EXTENT, ARBITRARY_GEOM +from .utils import ARBITRARY_BBOX, ARBITRARY_EXTENT, ARBITRARY_GEOM, TestCases @pytest.fixture -def test_catalog() -> Catalog: +def catalog() -> Catalog: return Catalog("test-catalog", "A test catalog") @pytest.fixture -def test_collection() -> Catalog: +def collection() -> Catalog: return Collection("test-collection", "A test collection", ARBITRARY_EXTENT) @pytest.fixture -def test_item() -> Item: +def item() -> Item: return Item("test-item", ARBITRARY_GEOM, ARBITRARY_BBOX, datetime.now(), {}) + + +@pytest.fixture +def label_catalog() -> Catalog: + return TestCases.case_1() diff --git a/tests/test_catalog.py b/tests/test_catalog.py index d88950cd3..6bf42d972 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -1444,7 +1444,7 @@ def test_clone(self) -> None: self.assertIsInstance(cloned_catalog, self.BasicCustomCatalog) -def test_custom_catalog_from_dict(test_catalog: Catalog) -> None: +def test_custom_catalog_from_dict(catalog: Catalog) -> None: # https://github.com/stac-utils/pystac/issues/862 class CustomCatalog(Catalog): @classmethod @@ -1458,4 +1458,12 @@ def from_dict( ) -> CustomCatalog: return super().from_dict(d) - _ = CustomCatalog.from_dict(test_catalog.to_dict()) + _ = CustomCatalog.from_dict(catalog.to_dict()) + + +@pytest.mark.parametrize("add_canonical", (True, False)) +def test_remove_hierarchical_links(label_catalog: Catalog, add_canonical: bool) -> None: + label_catalog.remove_hierarchical_links(add_canonical=add_canonical) + for link in label_catalog.links: + assert not link.is_hierarchical() + assert bool(label_catalog.get_single_link("canonical")) == add_canonical diff --git a/tests/test_collection.py b/tests/test_collection.py index f92c502f2..8751bcb29 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -8,6 +8,7 @@ from datetime import datetime from typing import Any, Dict, Optional +import pytest from dateutil import tz import pystac @@ -514,7 +515,7 @@ def test_clone(self) -> None: self.assertIsInstance(cloned_collection, self.BasicCustomCollection) -def test_custom_collection_from_dict(test_collection: Collection) -> None: +def test_custom_collection_from_dict(collection: Collection) -> None: # https://github.com/stac-utils/pystac/issues/862 class CustomCollection(Collection): @classmethod @@ -528,4 +529,13 @@ def from_dict( ) -> CustomCollection: return super().from_dict(d) - _ = CustomCollection.from_dict(test_collection.to_dict()) + _ = CustomCollection.from_dict(collection.to_dict()) + + +@pytest.mark.parametrize("add_canonical", (True, False)) +def test_remove_hierarchical_links(label_catalog: Catalog, add_canonical: bool) -> None: + collection = list(label_catalog.get_all_collections())[0] + collection.remove_hierarchical_links(add_canonical=add_canonical) + for link in collection.links: + assert not link.is_hierarchical() + assert bool(collection.get_single_link("canonical")) == add_canonical diff --git a/tests/test_item.py b/tests/test_item.py index 3568f5612..2ac7a8d2c 100644 --- a/tests/test_item.py +++ b/tests/test_item.py @@ -422,7 +422,7 @@ def test_clone(self) -> None: self.assertIsInstance(cloned_asset, self.CustomAsset) -def test_custom_item_from_dict(test_item: Item) -> None: +def test_custom_item_from_dict(item: Item) -> None: # https://github.com/stac-utils/pystac/issues/862 class CustomItem(Item): @classmethod @@ -436,7 +436,7 @@ def from_dict( ) -> CustomItem: return super().from_dict(d) - _ = CustomItem.from_dict(test_item.to_dict()) + _ = CustomItem.from_dict(item.to_dict()) def test_item_from_dict_raises_useful_error() -> None: @@ -455,3 +455,12 @@ def test_item_from_dict_with_missing_type_raises_useful_error() -> None: item_dict = {"stac_version": "0.8.0", "id": "lalalalala"} with pytest.raises(pystac.STACTypeError, match="'type' is missing"): Item.from_dict(item_dict) + + +@pytest.mark.parametrize("add_canonical", (True, False)) +def test_remove_hierarchical_links(label_catalog: Catalog, add_canonical: bool) -> None: + item = list(label_catalog.get_all_items())[0] + item.remove_hierarchical_links(add_canonical=add_canonical) + for link in item.links: + assert not link.is_hierarchical() + assert bool(item.get_single_link("canonical")) == add_canonical diff --git a/tests/test_link.py b/tests/test_link.py index cd2a46a7d..9a5dcc9f3 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -6,8 +6,11 @@ from tempfile import TemporaryDirectory from typing import Any, Dict, List +import pytest + import pystac from pystac import Collection, Item, Link +from pystac.link import HIERARCHICAL_LINKS from tests.utils.test_cases import ARBITRARY_EXTENT TEST_DATETIME: datetime = datetime(2020, 3, 14, 16, 32) @@ -330,3 +333,15 @@ def test_relative_self_link(tmp_path: Path) -> None: asset_href = read_item.assets["data"].get_absolute_href() assert asset_href assert Path(asset_href).exists() + + +@pytest.mark.parametrize("rel", HIERARCHICAL_LINKS) +def test_is_hierarchical(rel: str) -> None: + assert Link(rel, "a-target").is_hierarchical() + + +@pytest.mark.parametrize( + "rel", ["canonical", "derived_from", "alternate", "via", "prev", "next", "preview"] +) +def test_is_not_hierarchical(rel: str) -> None: + assert not Link(rel, "a-target").is_hierarchical()