eodag 3.2.1__py3-none-any.whl → 3.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eodag/api/core.py CHANGED
@@ -1599,6 +1599,7 @@ class EODataAccessGateway:
1599
1599
  if kwargs.get("raise_errors"):
1600
1600
  raise
1601
1601
  logger.warning(e)
1602
+ results.errors.append((plugin.provider, e))
1602
1603
  continue
1603
1604
 
1604
1605
  # try using crunch to get unique result
@@ -1622,7 +1623,7 @@ class EODataAccessGateway:
1622
1623
  "Several products found for this id (%s). You may try searching using more selective criteria.",
1623
1624
  results,
1624
1625
  )
1625
- return SearchResult([], 0)
1626
+ return SearchResult([], 0, results.errors)
1626
1627
 
1627
1628
  def _fetch_external_product_type(self, provider: str, product_type: str):
1628
1629
  plugins = self._plugins_manager.get_search_plugins(provider=provider)
@@ -36,7 +36,7 @@ if TYPE_CHECKING:
36
36
  from eodag.plugins.crunch.base import Crunch
37
37
 
38
38
 
39
- class SearchResult(UserList):
39
+ class SearchResult(UserList[EOProduct]):
40
40
  """An object representing a collection of :class:`~eodag.api.product._product.EOProduct` resulting from a search.
41
41
 
42
42
  :param products: A list of products resulting from a search
@@ -46,8 +46,6 @@ class SearchResult(UserList):
46
46
  :ivar number_matched: Estimated total number of matching results
47
47
  """
48
48
 
49
- data: list[EOProduct]
50
-
51
49
  errors: Annotated[
52
50
  list[tuple[str, Exception]], Doc("Tuple of provider name, exception")
53
51
  ]
@@ -56,11 +54,11 @@ class SearchResult(UserList):
56
54
  self,
57
55
  products: list[EOProduct],
58
56
  number_matched: Optional[int] = None,
59
- errors: list[tuple[str, Exception]] = [],
57
+ errors: Optional[list[tuple[str, Exception]]] = None,
60
58
  ) -> None:
61
59
  super().__init__(products)
62
60
  self.number_matched = number_matched
63
- self.errors = errors
61
+ self.errors = errors if errors is not None else []
64
62
 
65
63
  def crunch(self, cruncher: Crunch, **search_params: Any) -> SearchResult:
66
64
  """Do some crunching with the underlying EO products.
@@ -193,7 +191,7 @@ class SearchResult(UserList):
193
191
  <details><summary style='color: grey; font-family: monospace;'>
194
192
  {i}&ensp;
195
193
  {type(p).__name__}(id=<span style='color: black;'>{
196
- p.properties['id']
194
+ p.properties["id"]
197
195
  }</span>, provider={p.provider})
198
196
  </summary>
199
197
  {p._repr_html_()}
@@ -214,13 +212,12 @@ class SearchResult(UserList):
214
212
  return super().extend(other)
215
213
 
216
214
 
217
- class RawSearchResult(UserList):
215
+ class RawSearchResult(UserList[dict[str, Any]]):
218
216
  """An object representing a collection of raw/unparsed search results obtained from a provider.
219
217
 
220
218
  :param results: A list of raw/unparsed search results
221
219
  """
222
220
 
223
- data: list[Any]
224
221
  query_params: dict[str, Any]
225
222
  product_type_def_params: dict[str, Any]
226
223
 
eodag/config.py CHANGED
@@ -615,6 +615,12 @@ class PluginConfig(yaml.YAMLObject):
615
615
  #: :class:`~eodag.plugins.authentication.token.TokenAuth`
616
616
  #: type of the token
617
617
  token_type: str
618
+ #: :class:`~eodag.plugins.authentication.token.TokenAuth`
619
+ #: key to get the expiration time of the token
620
+ token_expiration_key: str
621
+ #: :class:`~eodag.plugins.authentication.token.TokenAuth`
622
+ #: HTTP method to use
623
+ request_method: str
618
624
  #: :class:`~eodag.plugins.authentication.token_exchange.OIDCTokenExchangeAuth`
619
625
  #: The full :class:`~eodag.plugins.authentication.openid_connect.OIDCAuthorizationCodeFlowAuth` plugin configuration
620
626
  #: used to retrieve subject token
@@ -18,6 +18,8 @@
18
18
  from __future__ import annotations
19
19
 
20
20
  import logging
21
+ from datetime import datetime, timedelta
22
+ from threading import Lock
21
23
  from typing import TYPE_CHECKING, Any, Optional
22
24
  from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
23
25
 
@@ -72,8 +74,11 @@ class TokenAuth(Authentication):
72
74
  key to get the access token in the response to the token request
73
75
  * :attr:`~eodag.config.PluginConfig.refresh_token_key` (``str``): key to get the refresh
74
76
  token in the response to the token request
77
+ * :attr:`~eodag.config.PluginConfig.token_expiration_key` (``str``): key to get expiration time of
78
+ the token (given in s)
75
79
  * :attr:`~eodag.config.PluginConfig.ssl_verify` (``bool``): if the ssl certificates
76
80
  should be verified in the requests; default: ``True``
81
+ * :attr:`~eodag.config.PluginConfig.request_method` (``str``): HTTP method to use; default: ``POST``
77
82
  * :attr:`~eodag.config.PluginConfig.auth_error_code` (``int``): which error code is
78
83
  returned in case of an authentication error
79
84
  * :attr:`~eodag.config.PluginConfig.req_data` (``dict[str, Any]``): if the credentials
@@ -91,6 +96,20 @@ class TokenAuth(Authentication):
91
96
  super(TokenAuth, self).__init__(provider, config)
92
97
  self.token = ""
93
98
  self.refresh_token = ""
99
+ self.token_expiration = datetime.now()
100
+ self.auth_lock = Lock()
101
+
102
+ def __getstate__(self):
103
+ """Exclude attributes that can't be pickled from serialization."""
104
+ state = dict(self.__dict__)
105
+ del state["auth_lock"]
106
+ return state
107
+
108
+ def __setstate__(self, state):
109
+ """Exclude attributes that can't be pickled from deserialization."""
110
+ self.__dict__.update(state)
111
+ # Init them manually
112
+ self.auth_lock = Lock()
94
113
 
95
114
  def validate_config_credentials(self) -> None:
96
115
  """Validate configured credentials"""
@@ -130,55 +149,72 @@ class TokenAuth(Authentication):
130
149
 
131
150
  def authenticate(self) -> AuthBase:
132
151
  """Authenticate"""
133
- self.validate_config_credentials()
134
152
 
135
- s = requests.Session()
136
- try:
137
- # First get the token
138
- response = self._token_request(session=s)
139
- response.raise_for_status()
140
- except requests.exceptions.Timeout as exc:
141
- raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
142
- except RequestException as e:
143
- response_text = getattr(e.response, "text", "").strip()
144
- # check if error is identified as auth_error in provider conf
145
- auth_errors = getattr(self.config, "auth_error_code", [None])
146
- if not isinstance(auth_errors, list):
147
- auth_errors = [auth_errors]
148
- if (
149
- e.response is not None
150
- and getattr(e.response, "status_code", None)
151
- and e.response.status_code in auth_errors
152
- ):
153
- raise AuthenticationError(
154
- f"Please check your credentials for {self.provider}.",
155
- f"HTTP Error {e.response.status_code} returned.",
156
- response_text,
157
- ) from e
158
- # other error
159
- else:
160
- raise AuthenticationError(
161
- "Could no get authentication token", str(e), response_text
162
- ) from e
163
- else:
164
- if getattr(self.config, "token_type", "text") == "json":
165
- token = response.json()[self.config.token_key]
153
+ # Use a thread lock to avoid several threads requesting the token at the same time
154
+ with self.auth_lock:
155
+
156
+ self.validate_config_credentials()
157
+ if self.token and self.token_expiration > datetime.now():
158
+ logger.debug("using existing access token")
159
+ return RequestsTokenAuth(
160
+ self.token, "header", headers=getattr(self.config, "headers", {})
161
+ )
162
+ s = requests.Session()
163
+ try:
164
+ # First get the token
165
+ response = self._token_request(session=s)
166
+ response.raise_for_status()
167
+ except requests.exceptions.Timeout as exc:
168
+ raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
169
+ except RequestException as e:
170
+ response_text = getattr(e.response, "text", "").strip()
171
+ # check if error is identified as auth_error in provider conf
172
+ auth_errors = getattr(self.config, "auth_error_code", [None])
173
+ if not isinstance(auth_errors, list):
174
+ auth_errors = [auth_errors]
175
+ if (
176
+ e.response is not None
177
+ and getattr(e.response, "status_code", None)
178
+ and e.response.status_code in auth_errors
179
+ ):
180
+ raise AuthenticationError(
181
+ f"Please check your credentials for {self.provider}.",
182
+ f"HTTP Error {e.response.status_code} returned.",
183
+ response_text,
184
+ ) from e
185
+ # other error
186
+ else:
187
+ raise AuthenticationError(
188
+ "Could no get authentication token", str(e), response_text
189
+ ) from e
166
190
  else:
167
- token = response.text
168
- self.token = token
169
- if getattr(self.config, "refresh_token_key", None):
170
- self.refresh_token = response.json()[self.config.refresh_token_key]
171
- if not hasattr(self.config, "headers"):
172
- raise MisconfiguredError(f"Missing headers configuration for {self}")
173
- # Return auth class set with obtained token
174
- return RequestsTokenAuth(
175
- token, "header", headers=getattr(self.config, "headers", {})
176
- )
191
+ if getattr(self.config, "token_type", "text") == "json":
192
+ token = response.json()[self.config.token_key]
193
+ else:
194
+ token = response.text
195
+ self.token = token
196
+ if getattr(self.config, "refresh_token_key", None):
197
+ self.refresh_token = response.json()[self.config.refresh_token_key]
198
+ if getattr(self.config, "token_expiration_key", None):
199
+ expiration_time = response.json()[self.config.token_expiration_key]
200
+ self.token_expiration = datetime.now() + timedelta(
201
+ seconds=expiration_time
202
+ )
203
+
204
+ if not hasattr(self.config, "headers"):
205
+ raise MisconfiguredError(
206
+ f"Missing headers configuration for {self}"
207
+ )
208
+ # Return auth class set with obtained token
209
+ return RequestsTokenAuth(
210
+ token, "header", headers=getattr(self.config, "headers", {})
211
+ )
177
212
 
178
213
  def _token_request(
179
214
  self,
180
215
  session: requests.Session,
181
216
  ) -> requests.Response:
217
+
182
218
  retry_total = getattr(self.config, "retry_total", REQ_RETRY_TOTAL)
183
219
  retry_backoff_factor = getattr(
184
220
  self.config, "retry_backoff_factor", REQ_RETRY_BACKOFF_FACTOR
@@ -202,14 +238,36 @@ class TokenAuth(Authentication):
202
238
  # append headers to req if some are specified in config
203
239
  req_kwargs: dict[str, Any] = {"headers": dict(headers, **USER_AGENT)}
204
240
  ssl_verify = getattr(self.config, "ssl_verify", True)
241
+ method = getattr(self.config, "request_method", "POST")
242
+
243
+ def set_request_data(call_refresh: bool) -> None:
244
+ """Set the request data contents for POST requests"""
245
+ if method != "POST":
246
+ return
247
+
248
+ # append req_data to credentials if specified in config
249
+ data = dict(getattr(self.config, "req_data", {}), **self.config.credentials)
250
+
251
+ # when refreshing the token, we pass only the client_id/secret if present,
252
+ # not other parameters (username/password, scope, ...)
253
+ if call_refresh:
254
+ data = {
255
+ k: v for k, v in data.items() if k in ["client_id", "client_secret"]
256
+ }
257
+ # the grant type is always refresh_token
258
+ data["grant_type"] = "refresh_token"
259
+ # and we add the old refresh token value to the request
260
+ data["refresh_token"] = self.refresh_token
261
+
262
+ req_kwargs["data"] = data
205
263
 
206
264
  if self.refresh_token:
207
265
  logger.debug("fetching access token with refresh token")
208
266
  session.mount(self.config.refresh_uri, HTTPAdapter(max_retries=retries))
267
+ set_request_data(call_refresh=True)
209
268
  try:
210
269
  response = session.post(
211
270
  self.config.refresh_uri,
212
- data={"refresh_token": self.refresh_token},
213
271
  timeout=HTTP_REQ_TIMEOUT,
214
272
  verify=ssl_verify,
215
273
  **req_kwargs,
@@ -222,14 +280,8 @@ class TokenAuth(Authentication):
222
280
  logger.debug("fetching access token from %s", self.config.auth_uri)
223
281
  # append headers to req if some are specified in config
224
282
  session.mount(self.config.auth_uri, HTTPAdapter(max_retries=retries))
225
- method = getattr(self.config, "request_method", "POST")
226
283
 
227
- # send credentials also as data in POST requests
228
- if method == "POST":
229
- # append req_data to credentials if specified in config
230
- req_kwargs["data"] = dict(
231
- getattr(self.config, "req_data", {}), **self.config.credentials
232
- )
284
+ set_request_data(call_refresh=False)
233
285
 
234
286
  # credentials as auth tuple if possible
235
287
  req_kwargs["auth"] = (
@@ -216,7 +216,7 @@ class HTTPDownload(Download):
216
216
  product.properties["storageStatus"] = STAGING_STATUS
217
217
  except RequestException as e:
218
218
  self._check_auth_exception(e)
219
- msg = f'{product.properties["title"]} could not be ordered'
219
+ msg = f"{product.properties['title']} could not be ordered"
220
220
  if e.response is not None and e.response.status_code == 400:
221
221
  raise ValidationError.from_error(e, msg) from e
222
222
  else:
@@ -255,6 +255,16 @@ class HTTPDownload(Download):
255
255
  product.properties.update(
256
256
  {k: v for k, v in properties_update.items() if v != NOT_AVAILABLE}
257
257
  )
258
+ # the job id becomes the product id for EcmwfSearch products
259
+ if "ORDERABLE" in product.properties.get("id", ""):
260
+ product.properties["id"] = product.properties.get(
261
+ "orderId", product.properties["id"]
262
+ )
263
+ product.properties["title"] = (
264
+ (product.product_type or product.provider).upper()
265
+ + "_"
266
+ + product.properties["id"]
267
+ )
258
268
  if "downloadLink" in product.properties:
259
269
  product.remote_location = product.location = product.properties[
260
270
  "downloadLink"
@@ -390,7 +400,10 @@ class HTTPDownload(Download):
390
400
  # success and no need to get status response content
391
401
  skip_parsing_status_response = True
392
402
  except RequestException as e:
393
- msg = f'{product.properties["title"]} order status could not be checked'
403
+ msg = (
404
+ f"{product.properties.get('title') or product.properties.get('id') or product} "
405
+ "order status could not be checked"
406
+ )
394
407
  if e.response is not None and e.response.status_code == 400:
395
408
  raise ValidationError.from_error(e, msg) from e
396
409
  else:
@@ -426,9 +439,14 @@ class HTTPDownload(Download):
426
439
  f"{product.properties['title']} order status: {status_percent}"
427
440
  )
428
441
 
429
- status_message = status_dict.get("message")
442
+ product.properties.update(
443
+ {k: v for k, v in status_dict.items() if v != NOT_AVAILABLE}
444
+ )
445
+
430
446
  product.properties["orderStatus"] = status_dict.get("status")
431
447
 
448
+ status_message = status_dict.get("message")
449
+
432
450
  # handle status error
433
451
  errors: dict[str, Any] = status_config.get("error", {})
434
452
  if errors and errors.items() <= status_dict.items():
@@ -436,13 +454,14 @@ class HTTPDownload(Download):
436
454
  f"Provider {product.provider} returned: {status_dict.get('error_message', status_message)}"
437
455
  )
438
456
 
457
+ product.properties["storageStatus"] = STAGING_STATUS
458
+
439
459
  success_status: dict[str, Any] = status_config.get("success", {}).get("status")
440
460
  # if not success
441
461
  if (success_status and success_status != status_dict.get("status")) or (
442
462
  success_code and success_code != response.status_code
443
463
  ):
444
- error = NotAvailableError(status_message)
445
- raise error
464
+ return None
446
465
 
447
466
  product.properties["storageStatus"] = ONLINE_STATUS
448
467
 
@@ -461,7 +480,11 @@ class HTTPDownload(Download):
461
480
  product.properties["title"],
462
481
  e,
463
482
  )
464
- return None
483
+ msg = f"{product.properties['title']} order status could not be checked"
484
+ if e.response is not None and e.response.status_code == 400:
485
+ raise ValidationError.from_error(e, msg) from e
486
+ else:
487
+ raise DownloadError.from_error(e, msg) from e
465
488
 
466
489
  result_type = config_on_success.get("result_type", "json")
467
490
  result_entry = config_on_success.get("results_entry")
@@ -626,6 +649,8 @@ class HTTPDownload(Download):
626
649
  if fs_path is not None:
627
650
  ext = Path(product.filename).suffix
628
651
  path = Path(fs_path).with_suffix(ext)
652
+ if "ORDERABLE" in path.stem and product.properties.get("title"):
653
+ path = path.with_stem(sanitize(product.properties["title"]))
629
654
 
630
655
  with open(path, "wb") as fhandle:
631
656
  for chunk in chunk_iterator:
@@ -961,17 +986,21 @@ class HTTPDownload(Download):
961
986
  auth = None
962
987
 
963
988
  s = requests.Session()
964
- self.stream = s.request(
965
- req_method,
966
- req_url,
967
- stream=True,
968
- auth=auth,
969
- params=params,
970
- headers=USER_AGENT,
971
- timeout=DEFAULT_STREAM_REQUESTS_TIMEOUT,
972
- verify=ssl_verify,
973
- **req_kwargs,
974
- )
989
+ try:
990
+ self.stream = s.request(
991
+ req_method,
992
+ req_url,
993
+ stream=True,
994
+ auth=auth,
995
+ params=params,
996
+ headers=USER_AGENT,
997
+ timeout=DEFAULT_STREAM_REQUESTS_TIMEOUT,
998
+ verify=ssl_verify,
999
+ **req_kwargs,
1000
+ )
1001
+ except requests.exceptions.MissingSchema:
1002
+ # location is not a valid url -> product is not available yet
1003
+ raise NotAvailableError("Product is not available yet")
975
1004
  try:
976
1005
  self.stream.raise_for_status()
977
1006
  except requests.exceptions.Timeout as exc:
@@ -337,7 +337,9 @@ class Search(PluginTopic):
337
337
  try:
338
338
  filters["productType"] = product_type
339
339
  queryables = self.discover_queryables(**{**default_values, **filters}) or {}
340
- except NotImplementedError:
340
+ except NotImplementedError as e:
341
+ if str(e):
342
+ logger.debug(str(e))
341
343
  queryables = self.queryables_from_metadata_mapping(product_type, alias)
342
344
 
343
345
  return QueryablesDict(**queryables)
@@ -381,12 +383,12 @@ class Search(PluginTopic):
381
383
  for pt in available_product_types:
382
384
  self.config.product_type_config = product_type_configs[pt]
383
385
  pt_queryables = self._get_product_type_queryables(pt, None, filters)
384
- # only use key and type because values and defaults will vary between product types
385
- pt_queryables_neutral = {
386
- k: Annotated[v.__args__[0], Field(default=None)]
387
- for k, v in pt_queryables.items()
388
- }
389
- all_queryables.update(pt_queryables_neutral)
386
+ all_queryables.update(pt_queryables)
387
+ # reset defaults because they may vary between product types
388
+ for k, v in all_queryables.items():
389
+ v.__metadata__[0].default = getattr(
390
+ Queryables.model_fields.get(k, Field(None)), "default", None
391
+ )
390
392
  return QueryablesDict(
391
393
  additional_properties=True,
392
394
  additional_information=additional_info,