Skip to content

Commit 2510e03

Browse files
feat(core): move download_link property to an asset (#2091)
Co-authored-by: Sylvain Brunato <sylvain.brunato@c-s.fr>
1 parent 2e6ac19 commit 2510e03

44 files changed

Lines changed: 1129 additions & 417 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

eodag/api/product/_assets.py

Lines changed: 176 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@
1717
# limitations under the License.
1818
from __future__ import annotations
1919

20+
import logging
2021
import re
2122
from collections import UserDict
2223
from typing import TYPE_CHECKING, Any, Optional
2324

25+
from typing_extensions import override
26+
27+
from eodag.api.product.metadata_mapping import NOT_AVAILABLE, OFFLINE_STATUS
28+
from eodag.utils import guess_file_type
2429
from eodag.utils.exceptions import NotAvailableError
2530
from eodag.utils.repr import dict_to_html_table
2631

@@ -29,6 +34,10 @@
2934
from eodag.types.download_args import DownloadConf
3035
from eodag.utils import StreamResponse, Unpack
3136

37+
logger = logging.getLogger("eodag.api.product.assets")
38+
39+
TECHNICAL_ASSET_KEYS: tuple[str, ...] = ("download_link", "quicklook", "thumbnail")
40+
3241

3342
class AssetsDict(UserDict):
3443
"""A UserDict object which values are :class:`~eodag.api.product._assets.Asset`
@@ -47,11 +56,12 @@ class AssetsDict(UserDict):
4756
... provider="foo",
4857
... properties={"id": "bar", "geometry": "POINT (0 0)"}
4958
... )
50-
>>> type(product.assets)
51-
<class 'eodag.api.product._assets.AssetsDict'>
59+
>>> from eodag.api.product._assets import AssetsDict
60+
>>> isinstance(product.assets, AssetsDict)
61+
True
5262
>>> product.assets.update({"foo": {"href": "http://somewhere/something"}})
5363
>>> product.assets
54-
{'foo': {'href': 'http://somewhere/something'}}
64+
{'foo': {'href': 'http://somewhere/something', 'title': 'foo', 'type': 'application/octet-stream'}}
5565
"""
5666

5767
product: EOProduct
@@ -60,12 +70,97 @@ def __init__(self, product: EOProduct, *args: Any, **kwargs: Any) -> None:
6070
self.product = product
6171
super(AssetsDict, self).__init__(*args, **kwargs)
6272

63-
def update(self, data: dict[str, Any]) -> None: # type: ignore
64-
"""Update assets"""
65-
super().update(data)
66-
6773
def __setitem__(self, key: str, value: dict[str, Any]) -> None:
74+
if not self._check(key, value):
75+
return
6876
super().__setitem__(key, Asset(self.product, key, value))
77+
self.sort()
78+
self._update_product_location()
79+
80+
@override
81+
def update(self, *args: Any, **kwargs: Any) -> None:
82+
"""Used to self update with external value"""
83+
incoming = {k: v for k, v in dict(*args, **kwargs).items() if self._check(k, v)}
84+
85+
if not incoming:
86+
return
87+
88+
super().update(incoming)
89+
self.sort()
90+
self._update_product_location()
91+
92+
def _update_product_location(self) -> None:
93+
"""Backward-compat: propagate ``download_link`` asset to product location.
94+
95+
``EOProduct.location`` / ``EOProduct.remote_location`` are still expected to
96+
carry the download URL by downstream code (download plugins, server-mode, ...).
97+
Technical assets are NOT written back to ``EOProduct.properties``.
98+
"""
99+
dl = self.data.get("download_link")
100+
if dl is None:
101+
return
102+
href = dl.get("href") or ""
103+
if href:
104+
if not self.product.location:
105+
self.product.location = href
106+
if not self.product.remote_location:
107+
self.product.remote_location = href
108+
109+
def _check(self, asset_key: str, asset_value: dict[str, Any]) -> bool:
110+
"""Validate an asset before insertion.
111+
112+
Checks that the asset has a valid ``href`` or ``order_link`` and that its
113+
URL is not already used by another (non-technical) asset. Mutates
114+
*asset_value* in place by stripping empty/unavailable href/order_link entries.
115+
116+
:param asset_key: Key under which the asset will be stored
117+
:param asset_value: Mutable asset dictionary (modified in place)
118+
:returns: ``True`` if the asset is valid and can be inserted, ``False`` otherwise
119+
"""
120+
# Asset must have href or order_link
121+
href = asset_value.pop("href", None)
122+
if href not in [None, "", NOT_AVAILABLE]:
123+
asset_value["href"] = href
124+
order_link = asset_value.pop("order_link", None)
125+
if order_link not in [None, "", NOT_AVAILABLE]:
126+
asset_value["order_link"] = order_link
127+
128+
if "href" not in asset_value and "order_link" not in asset_value:
129+
logger.warning(
130+
"asset '{}' skipped ignored because neither href nor order_link is available".format(
131+
asset_key
132+
),
133+
)
134+
return False
135+
136+
def target_url(asset: dict[str, Any]) -> Optional[str]:
137+
return asset.get("href") or asset.get("order_link")
138+
139+
assets = self.as_dict()
140+
used_urls = [
141+
target_url(asset) for key, asset in assets.items() if key != asset_key
142+
]
143+
144+
# Prevent asset key / asset target url replication (out from technical ones)
145+
# thumbnail and quicklook can share same url
146+
url = target_url(asset_value)
147+
if asset_key not in TECHNICAL_ASSET_KEYS and (url in used_urls):
148+
# Duplicated url
149+
return False
150+
151+
return True
152+
153+
def sort(self):
154+
"""Used to self sort"""
155+
sorted_assets = {}
156+
# Keep technical assets first
157+
for key in TECHNICAL_ASSET_KEYS:
158+
if key in self.data:
159+
sorted_assets[key] = self.data.pop(key)
160+
# Sort and add others
161+
for key in sorted(self.data):
162+
sorted_assets[key] = self.data[key]
163+
self.data = sorted_assets
69164

70165
def as_dict(self) -> dict[str, Any]:
71166
"""Builds a representation of AssetsDict to enable its serialization
@@ -83,29 +178,31 @@ def get_values(self, asset_filter: str = "", regex=True) -> list[Asset]:
83178
:param regex: Uses regex to match the asset key or simply compare strings
84179
:return: list of assets
85180
"""
86-
if asset_filter:
87-
if regex:
88-
filter_regex = re.compile(asset_filter)
89-
assets_keys = list(self.keys())
90-
assets_keys = list(filter(filter_regex.fullmatch, assets_keys))
91-
else:
92-
assets_keys = [a for a in self.keys() if a == asset_filter]
93-
filtered_assets = {}
94-
if len(assets_keys) > 0:
95-
filtered_assets = {a_key: self.get(a_key) for a_key in assets_keys}
96-
assets_values = [a for a in filtered_assets.values() if a and "href" in a]
97-
if not assets_values and regex:
98-
# retry without regex
99-
return self.get_values(asset_filter, regex=False)
100-
elif not assets_values:
101-
raise NotAvailableError(
102-
rf"No asset key matching re.fullmatch(r'{asset_filter}') was found in {self.product}"
103-
)
104-
else:
105-
return assets_values
106-
else:
181+
if not asset_filter:
107182
return [a for a in self.values() if "href" in a]
108183

184+
if regex:
185+
filter_regex = re.compile(asset_filter)
186+
assets_keys = [k for k in self.keys() if filter_regex.fullmatch(k)]
187+
else:
188+
assets_keys = [k for k in self.keys() if k == asset_filter]
189+
190+
assets_values = [
191+
a
192+
for k in assets_keys
193+
for a in [self.get(k)]
194+
if isinstance(a, Asset) and "href" in a
195+
]
196+
197+
if assets_values:
198+
return assets_values
199+
if regex:
200+
# retry without regex
201+
return self.get_values(asset_filter, regex=False)
202+
raise NotAvailableError(
203+
rf"No asset key matching re.fullmatch(r'{asset_filter}') was found in {self.product}"
204+
)
205+
109206
def _repr_html_(self, embeded=False):
110207
thead = (
111208
f"""<thead><tr><td style='text-align: left; color: grey;'>
@@ -162,21 +259,69 @@ class Asset(UserDict):
162259
... properties={"id": "bar", "geometry": "POINT (0 0)"}
163260
... )
164261
>>> product.assets.update({"foo": {"href": "http://somewhere/something"}})
165-
>>> type(product.assets["foo"])
166-
<class 'eodag.api.product._assets.Asset'>
262+
>>> from eodag.api.product._assets import Asset
263+
>>> isinstance(product.assets["foo"], Asset)
264+
True
167265
>>> product.assets["foo"]
168-
{'href': 'http://somewhere/something'}
266+
{'href': 'http://somewhere/something', 'title': 'foo', 'type': 'application/octet-stream'}
169267
"""
170268

171269
product: EOProduct
270+
271+
# Location
272+
location: Optional[str]
273+
remote_location: Optional[str]
274+
275+
# File
172276
size: int
173277
filename: Optional[str]
174278
rel_path: str
175279

176280
def __init__(self, product: EOProduct, key: str, *args: Any, **kwargs: Any) -> None:
177281
self.product = product
178282
self.key = key
283+
self.location = None
284+
self.remote_location = None
179285
super(Asset, self).__init__(*args, **kwargs)
286+
self._update()
287+
288+
def __setitem__(self, key, item):
289+
super().__setitem__(key, item)
290+
self._update()
291+
292+
def _update(self):
293+
"""Normalize asset fields and sync ``location``/``remote_location`` from ``href``.
294+
295+
Fills in default ``title``, ``href``, ``order:status`` and ``type`` entries
296+
when missing, and mirrors a non-empty ``href`` into the asset's
297+
``location`` / ``remote_location`` attributes.
298+
"""
299+
300+
title = self.get("title")
301+
if title is None:
302+
super().__setitem__("title", self.key)
303+
304+
# Order link behaviour require order:status state
305+
orderlink = self.get("order_link")
306+
orderstatus = self.get("order:status")
307+
if orderlink is not None and orderstatus is None:
308+
super().__setitem__("order:status", OFFLINE_STATUS)
309+
310+
href = self.get("href")
311+
if href is None:
312+
super().__setitem__("href", "")
313+
href = ""
314+
315+
if href != "":
316+
# Provide location and remote_location when undefined and href updated
317+
if self.location is None:
318+
self.location = href
319+
if self.remote_location is None:
320+
self.remote_location = href
321+
# With order behaviour, href can be fill later
322+
content_type = self.get("type")
323+
if content_type is None:
324+
super().__setitem__("type", guess_file_type(href))
180325

181326
def as_dict(self) -> dict[str, Any]:
182327
"""Builds a representation of Asset to enable its serialization

eodag/api/product/metadata_mapping.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import re
2525
from string import Formatter
2626
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Iterator, Optional, Union, cast
27+
from urllib.parse import unquote
2728

2829
import geojson
2930
import orjson
@@ -39,7 +40,6 @@
3940
from shapely.geometry import LineString, MultiPolygon, Point, Polygon
4041
from shapely.ops import transform
4142

42-
from eodag.api.product._assets import Asset
4343
from eodag.types.queryables import Queryables
4444
from eodag.utils import (
4545
DEFAULT_PROJ,
@@ -63,6 +63,7 @@
6363

6464
from shapely.geometry.base import BaseGeometry
6565

66+
from eodag.api.product._assets import Asset
6667
from eodag.config import PluginConfig
6768

6869
logger = logging.getLogger("eodag.product.metadata_mapping")
@@ -211,6 +212,8 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
211212
- ``to_rounded_wkt``: simplify the WKT of a geometry
212213
- ``to_title``: Convert a string to title case
213214
- ``to_upper``: Convert a string to uppercase
215+
- ``url_decode``: Convert a string url_encoded to decoded ones
216+
- ``round``: Convert a string number to another one without decimal part
214217
215218
:param search_param: The string to be formatted
216219
:param args: (optional) Additional arguments to use in the formatting process
@@ -832,6 +835,28 @@ def convert_to_upper(string: str) -> str:
832835
"""Convert a string to uppercase."""
833836
return string.upper()
834837

838+
@staticmethod
839+
def convert_url_decode(string: str):
840+
return unquote(string)
841+
842+
@staticmethod
843+
def convert_round(string: Any) -> str:
844+
"""Convert a number string to integer string"""
845+
if isinstance(string, float):
846+
return str(int(string))
847+
elif isinstance(string, int):
848+
return str(string)
849+
elif isinstance(string, str):
850+
formatted = ""
851+
for i in range(0, len(string)):
852+
char = string[i]
853+
if char == ".":
854+
break
855+
if "0123456789".find(char) >= 0:
856+
formatted += char
857+
return formatted
858+
return string
859+
835860
@staticmethod
836861
def convert_to_title(string: str) -> str:
837862
"""Convert a string to title case."""
@@ -1607,10 +1632,18 @@ def format_query_params(
16071632

16081633
if COMPLEX_QS_REGEX.match(provider_search_param):
16091634
parts = provider_search_param.split("=")
1635+
16101636
if len(parts) == 1:
1611-
formatted_query_param = format_metadata(
1612-
provider_search_param, collection, **query_dict
1613-
)
1637+
1638+
# If part contains something to interprete (nested braces or a converter)
1639+
inner = parts[0].strip("{}")
1640+
if inner.find("{") >= 0 or "#" in inner:
1641+
formatted_query_param = format_metadata(
1642+
provider_search_param, collection, **query_dict
1643+
)
1644+
else:
1645+
formatted_query_param = "{" + inner + "}"
1646+
16141647
formatted_query_param = formatted_query_param.replace("'", '"')
16151648
if "{{" in provider_search_param:
16161649
# retrieve values from hashes where keys are given in the param
@@ -2049,6 +2082,7 @@ def normalize_bands(data: Union[dict, Asset]) -> Union[dict, Asset]:
20492082
"statistics",
20502083
]
20512084
EXCLUDE_MOVE_TO_PARENT_BAND_FIELDNAME = ["name", "eo:common_name"]
2085+
from eodag.api.product._assets import Asset
20522086

20532087
# https://github.com/radiantearth/stac-spec/blob/v1.1.0/best-practices.md#bands
20542088
# Migrate band STAC 1.0 to 1.1

eodag/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ class MetadataPreMapping(TypedDict, total=False):
586586

587587
def __or__(self, other: Union[Self, dict[str, Any]]) -> Self:
588588
"""Return a new PluginConfig with merged values."""
589-
new_config = self.__class__.from_mapping(self.__dict__)
589+
new_config: Self = self.__class__.from_mapping(self.__dict__)
590590
new_config.update(other)
591591
return new_config
592592

eodag/plugins/apis/ecmwf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ def download(
242242
raise DownloadError(e)
243243

244244
with open(record_filename, "w") as fh:
245-
fh.write(product.properties["eodag:download_link"])
245+
fh.write(product.remote_location)
246246
logger.debug("Download recorded in %s", record_filename)
247247

248248
# do not try to extract a directory

0 commit comments

Comments
 (0)