Skip to content

Commit b3dfa27

Browse files
authored
Add Collection.from_items (#1522)
* Add Collection.from_items * Fix datetime for python 3.10 * More tests, fix docs * Use `Collection` rather than TypeVar * Update changelog * objections -> objects
1 parent 542b9fb commit b3dfa27

File tree

6 files changed

+200
-3
lines changed

6 files changed

+200
-3
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- `Collection.from_items` for creating a `pystac.Collection` from an `ItemCollection` ([#1522](https://github.com/stac-utils/pystac/pull/1522))
8+
59
### Fixed
610

711
- Make sure that `VersionRange` has `VersionID`s rather than strings ([#1512](https://github.com/stac-utils/pystac/pull/1512))

docs/api/pystac.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ ItemCollection
141141
.. autoclass:: pystac.ItemCollection
142142
:members:
143143
:inherited-members:
144+
:undoc-members:
144145

145146
Link
146147
----

pystac/catalog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ class Catalog(STACObject):
131131
catalog_type : Optional catalog type for this catalog. Must
132132
be one of the values in :class:`~pystac.CatalogType`.
133133
strategy : The layout strategy to use for setting the
134-
HREFs of the catalog child objections and items.
134+
HREFs of the catalog child objects and items.
135135
If not provided, it will default to the strategy of the root and fallback to
136136
:class:`~pystac.layout.BestPracticesLayoutStrategy`.
137137
"""

pystac/collection.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ class Collection(Catalog, Assets):
474474
:class:`~pystac.Asset` values in the dictionary will have their
475475
:attr:`~pystac.Asset.owner` attribute set to the created Collection.
476476
strategy : The layout strategy to use for setting the
477-
HREFs of the catalog child objections and items.
477+
HREFs of the catalog child objects and items.
478478
If not provided, it will default to strategy of the parent and fallback to
479479
:class:`~pystac.layout.BestPracticesLayoutStrategy`.
480480
"""
@@ -710,6 +710,77 @@ def from_dict(
710710

711711
return collection
712712

713+
@classmethod
714+
def from_items(
715+
cls: type[Collection],
716+
items: Iterable[Item] | pystac.ItemCollection,
717+
*,
718+
id: str | None = None,
719+
strategy: HrefLayoutStrategy | None = None,
720+
) -> Collection:
721+
"""Create a :class:`Collection` from iterable of items or an
722+
:class:`~pystac.ItemCollection`.
723+
724+
Will try to pull collection attributes from
725+
:attr:`~pystac.ItemCollection.extra_fields` and items when possible.
726+
727+
Args:
728+
items : Iterable of :class:`~pystac.Item` instances to include in the
729+
:class:`Collection`. This can be a :class:`~pystac.ItemCollection`.
730+
id : Identifier for the collection. If not set, must be available on the
731+
items and they must all match.
732+
strategy : The layout strategy to use for setting the
733+
HREFs of the catalog child objects and items.
734+
If not provided, it will default to strategy of the parent and fallback
735+
to :class:`~pystac.layout.BestPracticesLayoutStrategy`.
736+
"""
737+
738+
def extract(attr: str) -> Any:
739+
"""Extract attrs from items or item.properties as long as they all match"""
740+
value = None
741+
values = {getattr(item, attr, None) for item in items}
742+
if len(values) == 1:
743+
value = next(iter(values))
744+
if value is None:
745+
values = {item.properties.get(attr, None) for item in items}
746+
if len(values) == 1:
747+
value = next(iter(values))
748+
return value
749+
750+
if isinstance(items, pystac.ItemCollection):
751+
extra_fields = deepcopy(items.extra_fields)
752+
links = extra_fields.pop("links", {})
753+
providers = extra_fields.pop("providers", None)
754+
if providers is not None:
755+
providers = [pystac.Provider.from_dict(p) for p in providers]
756+
else:
757+
extra_fields = {}
758+
links = {}
759+
providers = []
760+
761+
id = id or extract("collection_id")
762+
if id is None:
763+
raise ValueError(
764+
"Collection id must be defined. Either by specifying collection_id "
765+
"on every item, or as a keyword argument to this function."
766+
)
767+
768+
collection = cls(
769+
id=id,
770+
description=extract("description"),
771+
extent=Extent.from_items(items),
772+
title=extract("title"),
773+
providers=providers,
774+
extra_fields=extra_fields,
775+
strategy=strategy,
776+
)
777+
collection.add_items(items)
778+
779+
for link in links:
780+
collection.add_link(Link.from_dict(link))
781+
782+
return collection
783+
713784
def get_item(self, id: str, recursive: bool = False) -> Item | None:
714785
"""Returns an item with a given ID.
715786

tests/conftest.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import pytest
1111

12-
from pystac import Asset, Catalog, Collection, Item, Link
12+
from pystac import Asset, Catalog, Collection, Item, ItemCollection, Link
1313

1414
from .utils import ARBITRARY_BBOX, ARBITRARY_EXTENT, ARBITRARY_GEOM, TestCases
1515

@@ -76,6 +76,18 @@ def sample_item() -> Item:
7676
return Item.from_file(TestCases.get_path("data-files/item/sample-item.json"))
7777

7878

79+
@pytest.fixture
80+
def sample_item_collection() -> ItemCollection:
81+
return ItemCollection.from_file(
82+
TestCases.get_path("data-files/item-collection/sample-item-collection.json")
83+
)
84+
85+
86+
@pytest.fixture
87+
def sample_items(sample_item_collection: ItemCollection) -> list[Item]:
88+
return list(sample_item_collection)
89+
90+
7991
@pytest.fixture(scope="function")
8092
def tmp_asset(tmp_path: Path) -> Asset:
8193
"""Copy the entirety of test-case-2 to tmp and"""

tests/test_collection.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Collection,
2020
Extent,
2121
Item,
22+
ItemCollection,
2223
Provider,
2324
SpatialExtent,
2425
TemporalExtent,
@@ -711,3 +712,111 @@ def test_permissive_temporal_extent_deserialization(collection: Collection) -> N
711712
]["interval"][0]
712713
with pytest.warns(UserWarning):
713714
Collection.from_dict(collection_dict)
715+
716+
717+
@pytest.mark.parametrize("fixture_name", ("sample_item_collection", "sample_items"))
718+
def test_from_items(fixture_name: str, request: pytest.FixtureRequest) -> None:
719+
items = request.getfixturevalue(fixture_name)
720+
collection = Collection.from_items(items)
721+
722+
for item in items:
723+
assert collection.id == item.collection_id
724+
assert collection.extent.spatial.bboxes[0][0] <= item.bbox[0]
725+
assert collection.extent.spatial.bboxes[0][1] <= item.bbox[1]
726+
assert collection.extent.spatial.bboxes[0][2] >= item.bbox[2]
727+
assert collection.extent.spatial.bboxes[0][3] >= item.bbox[3]
728+
729+
start = collection.extent.temporal.intervals[0][0]
730+
end = collection.extent.temporal.intervals[0][1]
731+
assert start and start <= str_to_datetime(item.properties["start_datetime"])
732+
assert end and end >= str_to_datetime(item.properties["end_datetime"])
733+
734+
if isinstance(items, ItemCollection):
735+
expected = {(link["rel"], link["href"]) for link in items.extra_fields["links"]}
736+
actual = {(link.rel, link.href) for link in collection.links}
737+
assert expected.issubset(actual)
738+
739+
740+
def test_from_items_pulls_from_properties() -> None:
741+
item1 = Item(
742+
id="test-item-1",
743+
geometry=ARBITRARY_GEOM,
744+
bbox=[-10, -20, 0, -10],
745+
datetime=datetime(2000, 2, 1, 12, 0, 0, 0, tzinfo=tz.UTC),
746+
collection="test-collection-1",
747+
properties={"title": "Test Item", "description": "Extra words describing"},
748+
)
749+
collection = Collection.from_items([item1])
750+
assert collection.id == item1.collection_id
751+
assert collection.title == item1.properties["title"]
752+
assert collection.description == item1.properties["description"]
753+
754+
755+
def test_from_items_without_collection_id() -> None:
756+
item1 = Item(
757+
id="test-item-1",
758+
geometry=ARBITRARY_GEOM,
759+
bbox=[-10, -20, 0, -10],
760+
datetime=datetime(2000, 2, 1, 12, 0, 0, 0, tzinfo=tz.UTC),
761+
properties={},
762+
)
763+
with pytest.raises(ValueError, match="Collection id must be defined."):
764+
Collection.from_items([item1])
765+
766+
collection = Collection.from_items([item1], id="test-collection")
767+
assert collection.id == "test-collection"
768+
769+
770+
def test_from_items_with_collection_ids() -> None:
771+
item1 = Item(
772+
id="test-item-1",
773+
geometry=ARBITRARY_GEOM,
774+
bbox=[-10, -20, 0, -10],
775+
datetime=datetime(2000, 2, 1, 12, 0, 0, 0, tzinfo=tz.UTC),
776+
collection="test-collection-1",
777+
properties={},
778+
)
779+
item2 = Item(
780+
id="test-item-2",
781+
geometry=ARBITRARY_GEOM,
782+
bbox=[-15, -20, 0, -10],
783+
datetime=datetime(2000, 2, 1, 13, 0, 0, 0, tzinfo=tz.UTC),
784+
collection="test-collection-2",
785+
properties={},
786+
)
787+
788+
with pytest.raises(ValueError, match="Collection id must be defined."):
789+
Collection.from_items([item1, item2])
790+
791+
collection = Collection.from_items([item1, item2], id="test-collection")
792+
assert collection.id == "test-collection"
793+
794+
795+
def test_from_items_with_different_values() -> None:
796+
item1 = Item(
797+
id="test-item-1",
798+
geometry=ARBITRARY_GEOM,
799+
bbox=[-10, -20, 0, -10],
800+
datetime=datetime(2000, 2, 1, 12, 0, 0, 0, tzinfo=tz.UTC),
801+
properties={"title": "Test Item 1"},
802+
)
803+
item2 = Item(
804+
id="test-item-2",
805+
geometry=ARBITRARY_GEOM,
806+
bbox=[-15, -20, 0, -10],
807+
datetime=datetime(2000, 2, 1, 13, 0, 0, 0, tzinfo=tz.UTC),
808+
properties={"title": "Test Item 2"},
809+
)
810+
811+
collection = Collection.from_items([item1, item2], id="test_collection")
812+
assert collection.title is None
813+
814+
815+
def test_from_items_with_providers(sample_item_collection: ItemCollection) -> None:
816+
sample_item_collection.extra_fields["providers"] = [{"name": "pystac"}]
817+
818+
collection = Collection.from_items(sample_item_collection)
819+
assert collection.providers and len(collection.providers) == 1
820+
821+
provider = collection.providers[0]
822+
assert provider and provider.name == "pystac"

0 commit comments

Comments
 (0)