1717# limitations under the License.
1818from __future__ import annotations
1919
20+ import logging
2021import re
2122from collections import UserDict
2223from 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
2429from eodag .utils .exceptions import NotAvailableError
2530from eodag .utils .repr import dict_to_html_table
2631
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
3342class 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
0 commit comments