Skip to content

Commit dce0e00

Browse files
tylandersongadomski
authored andcommitted
Add keywords to common metadata (stac-utils#1443)
* add keywords to common metadata * Update pystac/common_metadata.py * fix: lint --------- Co-authored-by: Pete Gadomski <[email protected]>
1 parent 778725d commit dce0e00

File tree

6 files changed

+317
-0
lines changed

6 files changed

+317
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Add netCDF to pystac.media_type ([#1386](https://github.com/stac-utils/pystac/pull/1386))
1414
- Add convenience method for accessing pystac_client ([#1365](https://github.com/stac-utils/pystac/pull/1365))
1515
- Fix field ordering when saving `Item`s ([#1423](https://github.com/stac-utils/pystac/pull/1423))
16+
- Add keywords to common metadata ([#1443](https://github.com/stac-utils/pystac/pull/1443))
1617
- Add roles to common metadata ([#1444](https://github.com/stac-utils/pystac/pull/1444/files))
1718

1819
### Changed

pystac/common_metadata.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,15 @@ def updated(self) -> datetime | None:
232232
def updated(self, v: datetime | None) -> None:
233233
self._set_field("updated", utils.map_opt(utils.datetime_to_str, v))
234234

235+
@property
236+
def keywords(self) -> list[str] | None:
237+
"""Get or set the keywords describing the STAC entity."""
238+
return self._get_field("keywords", list[str])
239+
240+
@keywords.setter
241+
def keywords(self, v: list[str] | None) -> None:
242+
self._set_field("keywords", v)
243+
235244
@property
236245
def roles(self) -> list[str] | None:
237246
"""Get or set the semantic roles of the entity."""

pystac/extensions/ext.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pystac.extensions.pointcloud import PointcloudExtension
1313
from pystac.extensions.projection import ProjectionExtension
1414
from pystac.extensions.raster import RasterExtension
15+
from pystac.extensions.render import Render, RenderExtension
1516
from pystac.extensions.sar import SarExtension
1617
from pystac.extensions.sat import SatExtension
1718
from pystac.extensions.scientific import ScientificExtension
@@ -36,6 +37,7 @@
3637
"pc",
3738
"proj",
3839
"raster",
40+
"render",
3941
"sar",
4042
"sat",
4143
"sci",
@@ -58,6 +60,7 @@
5860
PointcloudExtension.name: PointcloudExtension,
5961
ProjectionExtension.name: ProjectionExtension,
6062
RasterExtension.name: RasterExtension,
63+
RenderExtension.name: RenderExtension,
6164
SarExtension.name: SarExtension,
6265
SatExtension.name: SatExtension,
6366
ScientificExtension.name: ScientificExtension,
@@ -110,6 +113,10 @@ def cube(self) -> DatacubeExtension[Collection]:
110113
def item_assets(self) -> dict[str, AssetDefinition]:
111114
return ItemAssetsExtension.ext(self.stac_object).item_assets
112115

116+
@property
117+
def render(self) -> dict[str, Render]:
118+
return RenderExtension.ext(self.stac_object).renders
119+
113120
@property
114121
def sci(self) -> ScientificExtension[Collection]:
115122
return ScientificExtension.ext(self.stac_object)
@@ -164,6 +171,10 @@ def pc(self) -> PointcloudExtension[Item]:
164171
def proj(self) -> ProjectionExtension[Item]:
165172
return ProjectionExtension.ext(self.stac_object)
166173

174+
@property
175+
def render(self) -> RenderExtension[Item]:
176+
return RenderExtension.ext(self.stac_object)
177+
167178
@property
168179
def sar(self) -> SarExtension[Item]:
169180
return SarExtension.ext(self.stac_object)

pystac/extensions/render.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""Implements the :stac-ext:`Render Extension <render>`."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, Generic, Literal, TypeVar
6+
7+
import pystac
8+
from pystac.extensions.base import ExtensionManagementMixin, PropertiesExtension
9+
from pystac.extensions.hooks import ExtensionHooks
10+
from pystac.utils import get_required, map_opt
11+
12+
T = TypeVar("T", pystac.Collection, pystac.Item)
13+
14+
SCHEMA_URI_PATTERN: str = (
15+
"https://stac-extensions.github.io/render/v{version}/schema.json"
16+
)
17+
DEFAULT_VERSION: str = "1.0.0"
18+
19+
SUPPORTED_VERSIONS = [DEFAULT_VERSION]
20+
21+
RENDERS_PROP = "renders"
22+
23+
24+
class Render:
25+
properties: dict[str, Any]
26+
27+
def __init__(self, properties: dict[str, Any]) -> None:
28+
self.properties = properties
29+
30+
@property
31+
def assets(self) -> list[str]:
32+
return get_required(self.properties.get("assets"), self, "assets")
33+
34+
@assets.setter
35+
def assets(self, v: list[str]) -> None:
36+
self.properties["assets"] = v
37+
38+
@property
39+
def title(self) -> str | None:
40+
return self.properties.get("title")
41+
42+
@title.setter
43+
def title(self, v: str | None) -> None:
44+
if v is not None:
45+
self.properties["title"] = v
46+
else:
47+
self.properties.pop("title", None)
48+
49+
@property
50+
def rescale(self) -> list[list[float]] | None:
51+
return self.properties.get("rescale")
52+
53+
@rescale.setter
54+
def rescale(self, v: list[list[float]] | None) -> None:
55+
if v is not None:
56+
self.properties["rescale"] = v
57+
else:
58+
self.properties.pop("rescale", None)
59+
60+
@property
61+
def nodata(self) -> float | str | None:
62+
return self.properties.get("nodata")
63+
64+
@nodata.setter
65+
def nodata(self, v: float | str | None) -> None:
66+
if v is not None:
67+
self.properties["nodata"] = v
68+
else:
69+
self.properties.pop("nodata", None)
70+
71+
@property
72+
def colormap_name(self) -> str | None:
73+
return self.properties.get("colormap_name")
74+
75+
@colormap_name.setter
76+
def colormap_name(self, v: str | None) -> None:
77+
if v is not None:
78+
self.properties["colormap_name"] = v
79+
else:
80+
self.properties.pop("colormap_name", None)
81+
82+
@property
83+
def colormap(self) -> dict[str, Any] | None:
84+
return self.properties.get("colormap")
85+
86+
@colormap.setter
87+
def colormap(self, v: dict[str, Any] | None) -> None:
88+
if v is not None:
89+
self.properties["colormap"] = v
90+
else:
91+
self.properties.pop("colormap", None)
92+
93+
@property
94+
def color_formula(self) -> str | None:
95+
return self.properties.get("color_formula")
96+
97+
@color_formula.setter
98+
def color_formula(self, v: str | None) -> None:
99+
if v is not None:
100+
self.properties["color_formula"] = v
101+
else:
102+
self.properties.pop("color_formula", None)
103+
104+
@property
105+
def resampling(self) -> str | None:
106+
return self.properties.get("resampling")
107+
108+
@resampling.setter
109+
def resampling(self, v: str | None) -> None:
110+
if v is not None:
111+
self.properties["resampling"] = v
112+
else:
113+
self.properties.pop("resampling", None)
114+
115+
@property
116+
def expression(self) -> str | None:
117+
return self.properties.get("expression")
118+
119+
@expression.setter
120+
def expression(self, v: str | None) -> None:
121+
if v is not None:
122+
self.properties["expression"] = v
123+
else:
124+
self.properties.pop("expression", None)
125+
126+
@property
127+
def minmax_zoom(self) -> list[int] | None:
128+
return self.properties.get("minmax_zoom")
129+
130+
@minmax_zoom.setter
131+
def minmax_zoom(self, v: list[int] | None) -> None:
132+
if v is not None:
133+
self.properties["minmax_zoom"] = v
134+
else:
135+
self.properties.pop("minmax_zoom", None)
136+
137+
def apply(
138+
self,
139+
assets: list[str],
140+
title: str | None = None,
141+
rescale: list[list[float]] | None = None,
142+
nodata: float | str | None = None,
143+
colormap_name: str | None = None,
144+
colormap: dict[str, Any] | None = None,
145+
color_formula: str | None = None,
146+
resampling: str | None = None,
147+
expression: str | None = None,
148+
minmax_zoom: list[int] | None = None,
149+
) -> None:
150+
self.assets = assets
151+
self.title = title
152+
self.rescale = rescale
153+
self.nodata = nodata
154+
self.colormap_name = colormap_name
155+
self.colormap = colormap
156+
self.color_formula = color_formula
157+
self.resampling = resampling
158+
self.expression = expression
159+
self.minmax_zoom = minmax_zoom
160+
161+
@classmethod
162+
def create(
163+
cls,
164+
assets: list[str],
165+
title: str | None = None,
166+
rescale: list[list[float]] | None = None,
167+
nodata: float | str | None = None,
168+
colormap_name: str | None = None,
169+
colormap: dict[str, Any] | None = None,
170+
color_formula: str | None = None,
171+
resampling: str | None = None,
172+
expression: str | None = None,
173+
minmax_zoom: list[int] | None = None,
174+
) -> Render:
175+
c = cls({})
176+
c.apply(
177+
assets=assets,
178+
title=title,
179+
rescale=rescale,
180+
nodata=nodata,
181+
colormap_name=colormap_name,
182+
colormap=colormap,
183+
color_formula=color_formula,
184+
resampling=resampling,
185+
expression=expression,
186+
minmax_zoom=minmax_zoom,
187+
)
188+
return c
189+
190+
def to_dict(self) -> dict[str, Any]:
191+
return self.properties
192+
193+
def __eq__(self, other: object) -> bool:
194+
if not isinstance(other, Render):
195+
raise NotImplementedError
196+
return self.properties == other.properties
197+
198+
def __repr__(self) -> str:
199+
props = " ".join(
200+
[
201+
f"{key}={value}"
202+
for key, value in self.properties.items()
203+
if value is not None
204+
]
205+
)
206+
return f"<Render {props}"
207+
208+
209+
class RenderExtension(
210+
Generic[T],
211+
PropertiesExtension,
212+
ExtensionManagementMixin[pystac.Item | pystac.Collection],
213+
):
214+
name: Literal["render"] = "render"
215+
216+
def apply(
217+
self,
218+
renders: dict[str, Render],
219+
) -> None:
220+
self.renders = renders
221+
222+
@property
223+
def renders(self) -> dict[str, Render]:
224+
return get_required(
225+
self._get_property(RENDERS_PROP, dict[str, Render]), self, RENDERS_PROP
226+
)
227+
228+
@renders.setter
229+
def renders(self, v: dict[str, Render]) -> None:
230+
self._set_property(
231+
RENDERS_PROP,
232+
map_opt(lambda renders: {k: r.to_dict() for k, r in renders.items()}, v),
233+
pop_if_none=False,
234+
)
235+
236+
@classmethod
237+
def get_schema_uri(cls) -> str:
238+
return SCHEMA_URI_PATTERN.format(version=DEFAULT_VERSION)
239+
240+
@classmethod
241+
def ext(cls, obj: T, add_if_missing: bool = False) -> RenderExtension[T]:
242+
if isinstance(obj, pystac.Collection):
243+
cls.ensure_has_extension(obj, add_if_missing)
244+
if isinstance(obj, pystac.Item):
245+
cls.ensure_has_extension(obj, add_if_missing)
246+
return ItemRenderExtension(obj)
247+
else:
248+
raise pystac.ExtensionTypeError(
249+
f"RenderExtension does not apply to type '{type(obj).__name__}"
250+
)
251+
252+
253+
class CollectionRenderExtension(RenderExtension[pystac.Collection]):
254+
def __init__(self, collection: pystac.Collection):
255+
self.collection = collection
256+
self.properties = collection.extra_fields
257+
258+
def __repr__(self) -> str:
259+
return f"<CollectionRenderExtension Collection id={self.collection.id}>"
260+
261+
262+
class ItemRenderExtension(RenderExtension[pystac.Item]):
263+
def __init__(self, item: pystac.Item):
264+
self.item = item
265+
self.properties = item.properties
266+
267+
def __repr__(self) -> str:
268+
return f"<ItemRenderExtension Item id={self.item.id}>"
269+
270+
271+
class RenderExtensionHooks(ExtensionHooks):
272+
schema_uri: str = SCHEMA_URI_PATTERN.format(version=DEFAULT_VERSION)
273+
stac_object_types = {pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM}

tests/data-files/item/sample-item-asset-properties.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"start_datetime": "2017-05-01T13:22:30.040Z",
7575
"end_datetime": "2017-05-02T13:22:30.040Z",
7676
"license": "CC-BY-4.0",
77+
"keywords": ["keyword_a"],
7778
"roles": ["a_role"],
7879
"providers": [
7980
{

tests/test_common_metadata.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,28 @@ def test_updated(self) -> None:
541541
analytic.to_dict()["updated"], utils.datetime_to_str(set_value)
542542
)
543543

544+
def test_keywords(self) -> None:
545+
item = self.item.clone()
546+
cm = item.common_metadata
547+
analytic = item.assets["analytic"]
548+
analytic_cm = CommonMetadata(analytic)
549+
thumbnail = item.assets["thumbnail"]
550+
thumbnail_cm = CommonMetadata(thumbnail)
551+
552+
item_value = cm.keywords
553+
a2_known_value = ["keyword_a"]
554+
555+
# Get
556+
self.assertNotEqual(thumbnail_cm.keywords, item_value)
557+
self.assertEqual(thumbnail_cm.keywords, a2_known_value)
558+
559+
# Set
560+
set_value = ["keyword_b"]
561+
analytic_cm.keywords = set_value
562+
563+
self.assertEqual(analytic_cm.keywords, set_value)
564+
self.assertEqual(analytic.to_dict()["keywords"], set_value)
565+
544566
def test_roles(self) -> None:
545567
item = self.item.clone()
546568
cm = item.common_metadata

0 commit comments

Comments
 (0)