Skip to content

Commit 46fbaa7

Browse files
author
Jon Duckworth
authored
Merge pull request #430 from duckontheweb/add/371-item-collection
Add basic ItemCollection implementation
2 parents e5316fa + 47017df commit 46fbaa7

20 files changed

+1977
-149
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
- Links to Issues, Discussions, and documentation sites ([#409](https://github.com/stac-utils/pystac/pull/409))
99
- Python minimum version set to `>=3.6` ([#409](https://github.com/stac-utils/pystac/pull/409))
1010
- Code of Conduct ([#399](https://github.com/stac-utils/pystac/pull/399))
11+
- `ItemCollection` class for working with GeoJSON FeatureCollections containing only
12+
STAC Items ([#430](https://github.com/stac-utils/pystac/pull/430))
1113

1214
### Changed
1315

docs/api.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ CommonMetadata
127127
:members:
128128
:undoc-members:
129129

130+
ItemCollection
131+
--------------
132+
Represents a GeoJSON FeatureCollection in which all Features are STAC Items
133+
134+
.. autoclass:: pystac.ItemCollection
135+
:members:
136+
:show-inheritance:
137+
130138
Links
131139
-----
132140

docs/conf.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
extensions = [
5353
'sphinx.ext.autodoc',
5454
'sphinx.ext.viewcode',
55+
'sphinx.ext.intersphinx',
5556
'sphinx.ext.napoleon',
5657
'sphinx.ext.githubpages',
5758
'sphinx.ext.extlinks',
@@ -207,3 +208,7 @@
207208

208209

209210
# -- Extension configuration -------------------------------------------------
211+
212+
intersphinx_mapping = {
213+
'python': ('https://docs.python.org/3', None),
214+
}

pystac/__init__.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from pystac.summaries import RangeSummary
3636
from pystac.item import Item, Asset, CommonMetadata
37+
from pystac.item_collection import ItemCollection
3738

3839
import pystac.validation
3940

@@ -85,12 +86,20 @@ def read_file(href: str) -> STACObject:
8586
Returns:
8687
The specific STACObject implementation class that is represented
8788
by the JSON read from the file located at HREF.
89+
90+
Raises:
91+
STACTypeError : If the file at ``href`` does not represent a valid
92+
:class:`~pystac.STACObject`. Note that an :class:`~pystac.ItemCollection` is not
93+
a :class:`~pystac.STACObject` and must be read using
94+
:meth:`ItemCollection.from_file <pystac.ItemCollection.from_file>`
8895
"""
8996
return STACObject.from_file(href)
9097

9198

9299
def write_file(
93-
obj: STACObject, include_self_link: bool = True, dest_href: Optional[str] = None
100+
obj: STACObject,
101+
include_self_link: bool = True,
102+
dest_href: Optional[str] = None,
94103
) -> None:
95104
"""Writes a STACObject to a file.
96105
@@ -106,10 +115,10 @@ def write_file(
106115
107116
Args:
108117
obj : The STACObject to save.
109-
include_self_link : If this is true, include the 'self' link with this object.
118+
include_self_link : If ``True``, include the ``"self"`` link with this object.
110119
Otherwise, leave out the self link.
111-
dest_href : Optional HREF to save the file to. If None, the object will be saved
112-
to the object's self href.
120+
dest_href : Optional HREF to save the file to. If ``None``, the object will be
121+
saved to the object's ``"self"`` href.
113122
"""
114123
obj.save_object(include_self_link=include_self_link, dest_href=dest_href)
115124

@@ -120,13 +129,14 @@ def read_dict(
120129
root: Optional[Catalog] = None,
121130
stac_io: Optional[StacIO] = None,
122131
) -> STACObject:
123-
"""Reads a STAC object from a dict representing the serialized JSON version of the
124-
STAC object.
132+
"""Reads a :class:`~STACObject` or :class:`~ItemCollection` from a JSON-like dict
133+
representing a serialized STAC object.
125134
126-
This method will return either a Catalog, a Collection, or an Item based on what the
127-
dict contains.
135+
This method will return either a :class:`~Catalog`, :class:`~Collection`,
136+
or :class`~Item` based on the contents of the dict.
128137
129-
This is a convenience method for :meth:`pystac.serialization.stac_object_from_dict`
138+
This is a convenience method for either
139+
:meth:`stac_io.stac_object_from_dict <stac_io.stac_object_from_dict>`.
130140
131141
Args:
132142
d : The dict to parse.
@@ -135,8 +145,14 @@ def read_dict(
135145
root : Optional root of the catalog for this object.
136146
If provided, the root's resolved object cache can be used to search for
137147
previously resolved instances of the STAC object.
138-
stac_io: Optional StacIO instance to use for reading. If None, the
139-
default instance will be used.
148+
stac_io: Optional :class:`~StacIO` instance to use for reading. If ``None``,
149+
the default instance will be used.
150+
151+
Raises:
152+
STACTypeError : If the ``d`` dictionary does not represent a valid
153+
:class:`~pystac.STACObject`. Note that an :class:`~pystac.ItemCollection` is not
154+
a :class:`~pystac.STACObject` and must be read using
155+
:meth:`ItemCollection.from_dict <pystac.ItemCollection.from_dict>`
140156
"""
141157
if stac_io is None:
142158
stac_io = StacIO.default()

pystac/item_collection.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
from copy import deepcopy
2+
from pystac.errors import STACTypeError
3+
from typing import Any, Dict, Iterator, List, Optional, Collection, Iterable, Union
4+
5+
import pystac
6+
from pystac.utils import make_absolute_href, is_absolute_href
7+
from pystac.serialization.identify import identify_stac_object_type
8+
9+
10+
ItemLike = Union[pystac.Item, Dict[str, Any]]
11+
12+
13+
class ItemCollection(Collection[pystac.Item]):
14+
"""Implementation of a GeoJSON FeatureCollection whose features are all STAC
15+
Items.
16+
17+
All :class:`~pystac.Item` instances passed to the :class:`~ItemCollection` instance
18+
during instantiation are cloned and have their ``"root"`` URL cleared. Instances of
19+
this class implement the abstract methods of :class:`typing.Collection` and can also
20+
be added together (see below for examples using these methods).
21+
22+
Any additional top-level fields in the FeatureCollection are retained in
23+
:attr:`~ItemCollection.extra_fields` by the :meth:`~ItemCollection.from_dict` and
24+
:meth:`~ItemCollection.from_file` methods and will be present in the serialized file
25+
from :meth:`~ItemCollection.save_object`.
26+
27+
Arguments:
28+
29+
items : List of :class:`~pystac.Item` instances to include in the
30+
:class:`~ItemCollection`.
31+
extra_fields : Dictionary of additional top-level fields included in the
32+
:class:`~ItemCollection`.
33+
clone_items : Optional flag indicating whether :class:`~pystac.Item` instances
34+
should be cloned before storing in the :class:`~ItemCollection`. Setting to
35+
``True`` ensures that changes made to :class:`~pystac.Item` instances in
36+
the :class:`~ItemCollection` will not mutate the original ``Item``, but
37+
will result in slower instantiation. Defaults to ``False``.
38+
39+
Examples:
40+
41+
Loop over all items in the :class`~ItemCollection`
42+
43+
>>> item_collection: ItemCollection = ...
44+
>>> for item in item_collection:
45+
... ...
46+
47+
Get the number of :class:`~pytac.Item` instances in the
48+
:class:`~ItemCollection`
49+
50+
>>> length: int = len(item_collection)
51+
52+
Check if an :class:`~pystac.Item` is in the :class:`~ItemCollection`. Note
53+
that the ``clone_items`` argument must be ``False`` for this to return
54+
``True``, since equality of PySTAC objects is currently evaluated using default
55+
object equality (i.e. ``item_1 is item_2``).
56+
57+
>>> item: Item = ...
58+
>>> item_collection = ItemCollection(items=[item])
59+
>>> assert item in item_collection
60+
61+
Combine :class:`~ItemCollection` instances
62+
63+
>>> item_1: Item = ...
64+
>>> item_2: Item = ...
65+
>>> item_3: Item = ...
66+
>>> item_collection_1 = ItemCollection(items=[item_1, item_2])
67+
>>> item_collection_2 = ItemCollection(items=[item_2, item_3])
68+
>>> combined = item_collection_1 + item_collection_2
69+
>>> assert len(combined) == 3
70+
# If an item is present in both ItemCollections it will only be added once
71+
"""
72+
73+
items: List[pystac.Item]
74+
"""List of :class:`pystac.Item` instances contained in this ``ItemCollection``."""
75+
76+
extra_fields: Dict[str, Any]
77+
"""Dictionary of additional top-level fields for the GeoJSON
78+
FeatureCollection."""
79+
80+
def __init__(
81+
self,
82+
items: Iterable[ItemLike],
83+
extra_fields: Optional[Dict[str, Any]] = None,
84+
clone_items: bool = False,
85+
):
86+
def map_item(item_or_dict: ItemLike) -> pystac.Item:
87+
# Converts dicts to pystac.Items and clones if necessary
88+
if isinstance(item_or_dict, pystac.Item):
89+
return item_or_dict.clone() if clone_items else item_or_dict
90+
else:
91+
return pystac.Item.from_dict(item_or_dict)
92+
93+
self.items = list(map(map_item, items))
94+
self.extra_fields = extra_fields or {}
95+
96+
def __getitem__(self, idx: int) -> pystac.Item:
97+
return self.items[idx]
98+
99+
def __iter__(self) -> Iterator[pystac.Item]:
100+
return iter(self.items)
101+
102+
def __len__(self) -> int:
103+
return len(self.items)
104+
105+
def __contains__(self, __x: object) -> bool:
106+
return __x in self.items
107+
108+
def __add__(self, other: object) -> "ItemCollection":
109+
if not isinstance(other, ItemCollection):
110+
return NotImplemented
111+
112+
combined = []
113+
for item in self.items + other.items:
114+
if item not in combined:
115+
combined.append(item)
116+
117+
return ItemCollection(items=combined, clone_items=False)
118+
119+
def to_dict(self) -> Dict[str, Any]:
120+
"""Serializes an :class:`ItemCollection` instance to a JSON-like dictionary."""
121+
return {
122+
"type": "FeatureCollection",
123+
"features": [item.to_dict() for item in self.items],
124+
**self.extra_fields,
125+
}
126+
127+
def clone(self) -> "ItemCollection":
128+
"""Creates a clone of this instance. This clone is a deep copy; all
129+
:class:`~pystac.Item` instances are cloned and all additional top-level fields
130+
are deep copied."""
131+
return self.__class__(
132+
items=[item.clone() for item in self.items],
133+
extra_fields=deepcopy(self.extra_fields),
134+
)
135+
136+
@classmethod
137+
def from_dict(cls, d: Dict[str, Any]) -> "ItemCollection":
138+
"""Creates a :class:`ItemCollection` instance from a dictionary.
139+
140+
Arguments:
141+
d : The dictionary from which the :class:`~ItemCollection` will be created
142+
"""
143+
if not cls.is_item_collection(d):
144+
raise STACTypeError("Dict is not a valid ItemCollection")
145+
146+
items = [pystac.Item.from_dict(item) for item in d.get("features", [])]
147+
extra_fields = {k: v for k, v in d.items() if k not in ("features", "type")}
148+
149+
return cls(items=items, extra_fields=extra_fields)
150+
151+
@classmethod
152+
def from_file(
153+
cls, href: str, stac_io: Optional[pystac.StacIO] = None
154+
) -> "ItemCollection":
155+
"""Reads a :class:`ItemCollection` from a JSON file.
156+
157+
Arguments:
158+
href : Path to the file.
159+
stac_io : A :class:`~pystac.StacIO` instance to use for file I/O
160+
"""
161+
if stac_io is None:
162+
stac_io = pystac.StacIO.default()
163+
164+
if not is_absolute_href(href):
165+
href = make_absolute_href(href)
166+
167+
d = stac_io.read_json(href)
168+
169+
return cls.from_dict(d)
170+
171+
def save_object(
172+
self,
173+
dest_href: str,
174+
stac_io: Optional[pystac.StacIO] = None,
175+
) -> None:
176+
"""Saves this instance to the ``dest_href`` location.
177+
178+
Args:
179+
dest_href : Location to which the file will be saved.
180+
stac_io: Optional :class:`~pystac.StacIO` instance to use. If not provided,
181+
will use the default instance.
182+
"""
183+
if stac_io is None:
184+
stac_io = pystac.StacIO.default()
185+
186+
stac_io.save_json(dest_href, self.to_dict())
187+
188+
@staticmethod
189+
def is_item_collection(d: Dict[str, Any]) -> bool:
190+
"""Checks if the given dictionary represents a valid :class:`ItemCollection`.
191+
192+
Args:
193+
d : Dictionary to check
194+
"""
195+
typ = d.get("type")
196+
197+
# All ItemCollections are GeoJSON FeatureCollections
198+
if typ != "FeatureCollection":
199+
return False
200+
201+
# If it is a FeatureCollection and has a "stac_version" field, then it is an
202+
# ItemCollection. This will cover ItemCollections from STAC 0.9 to
203+
# <1.0.0-beta.1, when ItemCollections were removed from the core STAC Spec
204+
if "stac_version" in d:
205+
return True
206+
207+
# Prior to STAC 0.9 ItemCollections did not have a stac_version field and could
208+
# only be identified by the fact that all of their 'features' are STAC Items.
209+
return all(
210+
identify_stac_object_type(feature) == pystac.STACObjectType.ITEM
211+
for feature in d.get("features", [])
212+
)

pystac/serialization/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,4 @@ def stac_object_from_dict(
5151
if info.object_type == pystac.STACObjectType.ITEM:
5252
return pystac.Item.from_dict(d, href=href, root=root, migrate=False)
5353

54-
raise ValueError(f"Unknown STAC object type {info.object_type}")
54+
raise pystac.STACTypeError(f"Unknown STAC object type {info.object_type}")

0 commit comments

Comments
 (0)