|
31 | 31 | from requests.auth import AuthBase |
32 | 32 | from shapely import geometry |
33 | 33 | from shapely.errors import ShapelyError |
| 34 | +from typing_extensions import Self |
34 | 35 |
|
35 | 36 | from eodag.types.queryables import CommonStacMetadata |
36 | 37 | from eodag.types.stac_metadata import create_stac_metadata_model |
37 | 38 |
|
38 | 39 | try: |
39 | 40 | # import from eodag-cube if installed |
40 | 41 | from eodag_cube.api.product import ( # pyright: ignore[reportMissingImports] |
| 42 | + Asset, |
41 | 43 | AssetsDict, |
42 | 44 | ) |
43 | 45 | except ImportError: |
44 | | - from eodag.api.product._assets import AssetsDict |
| 46 | + from ._assets import AssetsDict, Asset |
45 | 47 |
|
46 | 48 | from eodag.api.product.drivers import DRIVERS |
47 | 49 | from eodag.api.product.drivers.generic import GenericDriver |
@@ -130,6 +132,8 @@ class EOProduct: |
130 | 132 | next_try: datetime |
131 | 133 | #: Stream for requests |
132 | 134 | _stream: requests.Response |
| 135 | + #: For normalize |
| 136 | + _normalizing: bool = False |
133 | 137 |
|
134 | 138 | def __init__( |
135 | 139 | self, provider: str, properties: dict[str, Any], **kwargs: Any |
@@ -207,6 +211,7 @@ def __init__( |
207 | 211 | self.driver = self.get_driver() |
208 | 212 | self.downloader: Optional[Union[Api, Download]] = None |
209 | 213 | self.downloader_auth: Optional[Authentication] = None |
| 214 | + self.normalize() |
210 | 215 |
|
211 | 216 | def as_dict(self) -> dict[str, Any]: |
212 | 217 | """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: |
310 | 315 | obj = cls(provider, properties, collection=collection) |
311 | 316 | obj.search_intersection = geometry.shape(search_intersection) |
312 | 317 | obj.assets = AssetsDict(obj, feature.get("assets", {})) |
| 318 | + obj.normalize() |
313 | 319 | return obj |
314 | 320 |
|
| 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 | + |
315 | 444 | # Implementation of geo-interface protocol (See |
316 | 445 | # https://gist.github.com/sgillies/2217756) |
317 | 446 | __geo_interface__ = property(as_dict) |
|
0 commit comments