Skip to content

Commit b9b0b11

Browse files
feat: alignment with STAC 1.1.0
1 parent d88f651 commit b9b0b11

File tree

6 files changed

+145
-6
lines changed

6 files changed

+145
-6
lines changed

eodag/api/product/_assets.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,14 @@ def __init__(self, product: EOProduct, *args: Any, **kwargs: Any) -> None:
6060
self.product = product
6161
super(AssetsDict, self).__init__(*args, **kwargs)
6262

63+
def update(self, data: dict[str, Any]) -> None: # type: ignore
64+
"""Update assets"""
65+
super().update(data)
66+
self.product.normalize()
67+
6368
def __setitem__(self, key: str, value: dict[str, Any]) -> None:
6469
super().__setitem__(key, Asset(self.product, key, value))
70+
self.product.normalize()
6571

6672
def as_dict(self) -> dict[str, Any]:
6773
"""Builds a representation of AssetsDict to enable its serialization

eodag/api/product/_product.py

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,19 @@
3131
from requests.auth import AuthBase
3232
from shapely import geometry
3333
from shapely.errors import ShapelyError
34+
from typing_extensions import Self
3435

3536
from eodag.types.queryables import CommonStacMetadata
3637
from eodag.types.stac_metadata import create_stac_metadata_model
3738

3839
try:
3940
# import from eodag-cube if installed
4041
from eodag_cube.api.product import ( # pyright: ignore[reportMissingImports]
42+
Asset,
4143
AssetsDict,
4244
)
4345
except ImportError:
44-
from eodag.api.product._assets import AssetsDict
46+
from ._assets import AssetsDict, Asset
4547

4648
from eodag.api.product.drivers import DRIVERS
4749
from eodag.api.product.drivers.generic import GenericDriver
@@ -130,6 +132,8 @@ class EOProduct:
130132
next_try: datetime
131133
#: Stream for requests
132134
_stream: requests.Response
135+
#: For normalize
136+
_normalizing: bool = False
133137

134138
def __init__(
135139
self, provider: str, properties: dict[str, Any], **kwargs: Any
@@ -207,6 +211,7 @@ def __init__(
207211
self.driver = self.get_driver()
208212
self.downloader: Optional[Union[Api, Download]] = None
209213
self.downloader_auth: Optional[Authentication] = None
214+
self.normalize()
210215

211216
def as_dict(self) -> dict[str, Any]:
212217
"""Builds a representation of EOProduct as a dictionary to enable its geojson
@@ -310,8 +315,132 @@ def from_geojson(cls, feature: dict[str, Any]) -> EOProduct:
310315
obj = cls(provider, properties, collection=collection)
311316
obj.search_intersection = geometry.shape(search_intersection)
312317
obj.assets = AssetsDict(obj, feature.get("assets", {}))
318+
obj.normalize()
313319
return obj
314320

321+
def normalize(self) -> Self:
322+
"""Method used to normalize product"""
323+
# Restrict panic recursion
324+
# set/update during "normalize" must not trigger new "normalize"
325+
# to avoid recursion loop by accessors
326+
if not self._normalizing:
327+
self._normalizing = True
328+
329+
# Bands
330+
if hasattr(self, "properties"):
331+
self.properties = EOProduct.stac_normalize_bands(self.properties)
332+
if hasattr(self, "assets"):
333+
for key in self.assets:
334+
self.assets[key] = EOProduct.stac_normalize_bands(self.assets[key])
335+
336+
self._normalizing = False
337+
338+
return self
339+
340+
@staticmethod
341+
def stac_normalize_bands(data: Union[dict, Asset]) -> dict:
342+
"""Normalize bands in product.properties or product.assets from STAC 1.0 to STAC 1.1"""
343+
344+
UNPREFIX_BAND_FIELDNAME = [
345+
"name",
346+
"description",
347+
"data_type",
348+
"nodata",
349+
"unit",
350+
"statistics",
351+
]
352+
EXCLUDE_MOVE_TO_PARENT_BAND_FIELDNAME = ["name", "eo:common_name"]
353+
354+
# https://github.com/radiantearth/stac-spec/blob/v1.1.0/best-practices.md#bands
355+
# Migrate band STAC 1.0 to 1.1
356+
if isinstance(data, dict) or isinstance(data, Asset):
357+
358+
# Gather eo:band et raster:bands
359+
bands: dict[str, Any] = {"eo:bands": [], "raster:bands": []}
360+
hasData = False
361+
for fieldname in bands:
362+
if fieldname in data:
363+
if isinstance(data[fieldname], list):
364+
bands[fieldname] = data[fieldname]
365+
else:
366+
bands[fieldname] = [data[fieldname]]
367+
hasData = True
368+
del data[fieldname]
369+
370+
if hasData:
371+
processed_bands = []
372+
373+
# migrate eo:bands > bands
374+
if len(bands["eo:bands"]) > 0:
375+
for item in bands["eo:bands"]:
376+
band = {}
377+
for key in item:
378+
if key in UNPREFIX_BAND_FIELDNAME:
379+
band[key] = item[key]
380+
else:
381+
band["eo:{}".format(key)] = item[key]
382+
processed_bands.append(band)
383+
384+
# migrate raster:bands > bands
385+
if len(bands["raster:bands"]) > 0:
386+
index = 0
387+
for item in bands["raster:bands"]:
388+
band = {}
389+
for key in item:
390+
if key in UNPREFIX_BAND_FIELDNAME:
391+
band[key] = item[key]
392+
else:
393+
band["raster:{}".format(key)] = item[key]
394+
if index < len(processed_bands):
395+
processed_bands[index] = band
396+
else:
397+
processed_bands.append(band)
398+
index += 1
399+
400+
# When a property has same value for each band, have to be moved into parent scope
401+
if len(processed_bands) > 0:
402+
field_values: dict[str, Any] = {}
403+
404+
# Lists each distinct value for a field of the same name on each band
405+
for band in processed_bands:
406+
for key in band:
407+
if key not in field_values:
408+
field_values[key] = []
409+
if band[key] not in field_values[key]:
410+
field_values[key].append(band[key])
411+
412+
# Move band fields from asset to parent if all fields shared same value
413+
# (distincs values == 1)
414+
remove_band_fields = []
415+
for key in field_values:
416+
if (
417+
key not in EXCLUDE_MOVE_TO_PARENT_BAND_FIELDNAME
418+
and len(field_values[key]) == 1
419+
):
420+
# All band have same value
421+
data[key] = field_values[key][0]
422+
# Tag field "to remove" from assets
423+
remove_band_fields.append(key)
424+
del field_values
425+
426+
# Remove from assets field moved to parent
427+
cleaned_bands = []
428+
for band in processed_bands:
429+
cleaned_band = {}
430+
for key in band:
431+
if key not in remove_band_fields:
432+
cleaned_band[key] = band[key]
433+
if len(list(cleaned_band.keys())) > 0:
434+
cleaned_bands.append(cleaned_band)
435+
processed_bands = cleaned_bands
436+
del cleaned_bands
437+
438+
# Remap éband" field if contains at least one value
439+
if len(processed_bands) > 0:
440+
data["bands"] = processed_bands
441+
442+
return data
443+
315444
# Implementation of geo-interface protocol (See
316445
# https://gist.github.com/sgillies/2217756)
317446
__geo_interface__ = property(as_dict)

eodag/plugins/search/cop_marine.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ def _create_product(
292292
assets.update(additional_assets)
293293
product = EOProduct(self.provider, properties, collection=collection)
294294
product.assets = AssetsDict(product, assets)
295+
product.normalize()
295296
return product
296297

297298
def query(

eodag/plugins/search/qssearch.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1296,9 +1296,9 @@ def normalize_results(
12961296
product,
12971297
)
12981298
if norm_key:
1299-
product.assets[norm_key] = asset
13001299
# Normalize title with key
1301-
product.assets[norm_key]["title"] = norm_key
1300+
asset["title"] = norm_key
1301+
product.assets.update({norm_key: asset})
13021302
# sort assets
13031303
product.assets.data = dict(sorted(product.assets.data.items()))
13041304
products.append(product)

eodag/resources/providers.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2965,6 +2965,8 @@
29652965
start_datetime: properties.datetime
29662966
platform: properties.platform
29672967
metadata_mapping:
2968+
created: '$.properties.created'
2969+
description: '$.properties.description'
29682970
grid:code:
29692971
- '{{"query":{{"s2:mgrs_tile":{{"eq":"{grid:code#replace_str("MGRS-","")}"}}}}}}'
29702972
- '{$.properties."s2:mgrs_tile"#replace_str(r"^T?(.*)$",r"MGRS-\1")}'

eodag/utils/s3.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,20 +557,21 @@ def update_assets_from_s3(
557557
).get("Contents", [])
558558
]
559559

560+
assets = {}
560561
for asset_url in assets_urls:
561562
out_of_zip_url = asset_url.split("!")[-1]
562563
key, roles = product.driver.guess_asset_key_and_roles(
563564
out_of_zip_url, product
564565
)
565-
566566
if key and key not in product.assets:
567-
product.assets[key] = {
567+
assets[key] = {
568568
"title": key, # Normalize title with key
569569
"roles": roles,
570570
"href": asset_url,
571571
}
572572
if mime_type := guess_file_type(asset_url):
573-
product.assets[key]["type"] = mime_type
573+
assets[key]["type"] = mime_type
574+
product.assets.update(assets)
574575

575576
# sort assets
576577
product.assets.data = dict(sorted(product.assets.data.items()))

0 commit comments

Comments
 (0)