Skip to content

Commit d37f5c3

Browse files
committed
Merge branch 'main' into release/v1.12.2
2 parents 5668f1f + 6d0bf65 commit d37f5c3

File tree

12 files changed

+1133
-989
lines changed

12 files changed

+1133
-989
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
## [v1.12.2]
610

711
### Fixed

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_catalog.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import os
55
import posixpath
66
import tempfile
7-
import unittest
87
from collections import defaultdict
98
from collections.abc import Iterator
109
from copy import deepcopy
@@ -46,7 +45,7 @@
4645
)
4746

4847

49-
class CatalogTypeTest(unittest.TestCase):
48+
class TestCatalogType:
5049
def test_determine_type_for_absolute_published(self) -> None:
5150
cat = TestCases.case_1()
5251
with tempfile.TemporaryDirectory() as tmp_dir:
@@ -56,7 +55,7 @@ def test_determine_type_for_absolute_published(self) -> None:
5655
)
5756

5857
catalog_type = CatalogType.determine_type(cat_json)
59-
self.assertEqual(catalog_type, CatalogType.ABSOLUTE_PUBLISHED)
58+
assert catalog_type == CatalogType.ABSOLUTE_PUBLISHED
6059

6160
def test_determine_type_for_relative_published(self) -> None:
6261
cat = TestCases.case_2()
@@ -67,14 +66,14 @@ def test_determine_type_for_relative_published(self) -> None:
6766
)
6867

6968
catalog_type = CatalogType.determine_type(cat_json)
70-
self.assertEqual(catalog_type, CatalogType.RELATIVE_PUBLISHED)
69+
assert catalog_type == CatalogType.RELATIVE_PUBLISHED
7170

7271
def test_determine_type_for_self_contained(self) -> None:
7372
cat_json = pystac.StacIO.default().read_json(
7473
TestCases.get_path("data-files/catalogs/test-case-1/catalog.json")
7574
)
7675
catalog_type = CatalogType.determine_type(cat_json)
77-
self.assertEqual(catalog_type, CatalogType.SELF_CONTAINED)
76+
assert catalog_type == CatalogType.SELF_CONTAINED
7877

7978
def test_determine_type_for_unknown(self) -> None:
8079
catalog = Catalog(id="test", description="test desc")
@@ -83,7 +82,7 @@ def test_determine_type_for_unknown(self) -> None:
8382
catalog.normalize_hrefs("http://example.com")
8483
d = catalog.to_dict(include_self_link=False)
8584

86-
self.assertIsNone(CatalogType.determine_type(d))
85+
assert CatalogType.determine_type(d) is None
8786

8887

8988
class TestCatalog:
@@ -1423,7 +1422,7 @@ def test_to_dict_no_self_href(self) -> None:
14231422
Catalog.from_dict(d)
14241423

14251424

1426-
class FullCopyTest(unittest.TestCase):
1425+
class TestFullCopy:
14271426
def check_link(self, link: pystac.Link, tag: str) -> None:
14281427
if link.is_resolved():
14291428
target_href: str = cast(pystac.STACObject, link.target).self_href
@@ -1438,7 +1437,7 @@ def check_item(self, item: Item, tag: str) -> None:
14381437
self.check_link(link, tag)
14391438

14401439
def check_catalog(self, c: Catalog, tag: str) -> None:
1441-
self.assertEqual(len(c.get_links("root")), 1, msg=f"{c}")
1440+
assert len(c.get_links("root")) == 1, f"Failure for catalog: {c}"
14421441

14431442
for link in c.links:
14441443
self.check_link(link, tag)
@@ -1540,7 +1539,7 @@ def test_full_copy_4(self) -> None:
15401539
assert os.path.exists(href)
15411540

15421541

1543-
class CatalogSubClassTest(unittest.TestCase):
1542+
class TestCatalogSubClass:
15441543
"""This tests cases related to creating classes inheriting from pystac.Catalog to
15451544
ensure that inheritance, class methods, etc. function as expected."""
15461545

@@ -1553,25 +1552,20 @@ def get_items(self) -> Iterator[Item]: # type: ignore
15531552
# backwards compatibility of inherited classes
15541553
return super().get_items()
15551554

1556-
def setUp(self) -> None:
1557-
self.stac_io = pystac.StacIO.default()
1558-
15591555
def test_from_dict_returns_subclass(self) -> None:
1556+
self.stac_io = pystac.StacIO.default()
15601557
catalog_dict = self.stac_io.read_json(self.case_1)
15611558
custom_catalog = self.BasicCustomCatalog.from_dict(catalog_dict)
1562-
1563-
self.assertIsInstance(custom_catalog, self.BasicCustomCatalog)
1559+
assert isinstance(custom_catalog, self.BasicCustomCatalog)
15641560

15651561
def test_from_file_returns_subclass(self) -> None:
15661562
custom_catalog = self.BasicCustomCatalog.from_file(self.case_1)
1567-
1568-
self.assertIsInstance(custom_catalog, self.BasicCustomCatalog)
1563+
assert isinstance(custom_catalog, self.BasicCustomCatalog)
15691564

15701565
def test_clone(self) -> None:
15711566
custom_catalog = self.BasicCustomCatalog.from_file(self.case_1)
15721567
cloned_catalog = custom_catalog.clone()
1573-
1574-
self.assertIsInstance(cloned_catalog, self.BasicCustomCatalog)
1568+
assert isinstance(cloned_catalog, self.BasicCustomCatalog)
15751569

15761570
def test_get_all_items_works(self) -> None:
15771571
custom_catalog = self.BasicCustomCatalog.from_file(self.case_1)

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)