eodag 3.2.1__py3-none-any.whl → 3.3.0__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.
@@ -23,6 +23,7 @@ import logging
23
23
  import re
24
24
  from collections import OrderedDict
25
25
  from datetime import date, datetime, timedelta, timezone
26
+ from types import MethodType
26
27
  from typing import TYPE_CHECKING, Annotated, Any, Optional, Union
27
28
  from urllib.parse import quote_plus, unquote_plus
28
29
 
@@ -35,19 +36,22 @@ from pydantic import Field
35
36
  from pydantic.fields import FieldInfo
36
37
  from requests.auth import AuthBase
37
38
  from shapely.geometry.base import BaseGeometry
38
- from typing_extensions import get_args
39
+ from typing_extensions import get_args # noqa: F401
39
40
 
40
41
  from eodag.api.product import EOProduct
41
42
  from eodag.api.product.metadata_mapping import (
43
+ DEFAULT_GEOMETRY,
42
44
  NOT_AVAILABLE,
43
45
  OFFLINE_STATUS,
46
+ STAGING_STATUS,
44
47
  format_metadata,
48
+ mtd_cfg_as_conversion_and_querypath,
45
49
  properties_from_json,
46
50
  )
47
51
  from eodag.api.search_result import RawSearchResult
48
52
  from eodag.plugins.search import PreparedSearch
49
53
  from eodag.plugins.search.qssearch import PostJsonSearch, QueryStringSearch
50
- from eodag.types import json_field_definition_to_python
54
+ from eodag.types import json_field_definition_to_python # noqa: F401
51
55
  from eodag.types.queryables import Queryables, QueryablesDict
52
56
  from eodag.utils import (
53
57
  DEFAULT_MISSION_START_DATE,
@@ -57,7 +61,7 @@ from eodag.utils import (
57
61
  get_geometry_from_various,
58
62
  is_range_in_range,
59
63
  )
60
- from eodag.utils.exceptions import ValidationError
64
+ from eodag.utils.exceptions import DownloadError, NotAvailableError, ValidationError
61
65
  from eodag.utils.requests import fetch_json
62
66
 
63
67
  if TYPE_CHECKING:
@@ -315,7 +319,7 @@ def append_time(input_date: date, time: Optional[str]) -> datetime:
315
319
  def parse_date(
316
320
  date_str: str, time: Optional[Union[str, list[str]]]
317
321
  ) -> tuple[datetime, datetime]:
318
- """Parses a date string in format YYYY-MM-DD or YYYY-MM-DD/YYYY-MM-DD or YYYY-MM-DD/to/YYYY-MM-DD."""
322
+ """Parses a date string in formats YYYY-MM-DD, YYYMMDD, solo or in start/end or start/to/end intervals."""
319
323
  if "to" in date_str:
320
324
  start_date_str, end_date_str = date_str.split("/to/")
321
325
  elif "/" in date_str:
@@ -325,6 +329,14 @@ def parse_date(
325
329
  else:
326
330
  start_date_str = end_date_str = date_str
327
331
 
332
+ # Update YYYYMMDD formatted dates
333
+ if re.match(r"^\d{8}$", start_date_str):
334
+ start_date_str = (
335
+ f"{start_date_str[:4]}-{start_date_str[4:6]}-{start_date_str[6:]}"
336
+ )
337
+ if re.match(r"^\d{8}$", end_date_str):
338
+ end_date_str = f"{end_date_str[:4]}-{end_date_str[4:6]}-{end_date_str[6:]}"
339
+
328
340
  start_date = datetime.fromisoformat(start_date_str.rstrip("Z"))
329
341
  end_date = datetime.fromisoformat(end_date_str.rstrip("Z"))
330
342
 
@@ -454,6 +466,16 @@ class ECMWFSearch(PostJsonSearch):
454
466
  self.config.__dict__.setdefault("api_endpoint", "")
455
467
  self.config.pagination.setdefault("next_page_query_obj", "{{}}")
456
468
 
469
+ # defaut conf for accepting custom query params
470
+ self.config.__dict__.setdefault(
471
+ "discover_metadata",
472
+ {
473
+ "auto_discovery": True,
474
+ "search_param": "{metadata}",
475
+ "metadata_pattern": "^[a-zA-Z0-9][a-zA-Z0-9_]*$",
476
+ },
477
+ )
478
+
457
479
  def do_search(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
458
480
  """Should perform the actual search request.
459
481
 
@@ -616,14 +638,6 @@ class ECMWFSearch(PostJsonSearch):
616
638
  if not isinstance(mapping, list):
617
639
  mapping = product_type_conf[END]
618
640
  if isinstance(mapping, list):
619
- # get time parameters (date, year, month, ...) from metadata mapping
620
- input_mapping = mapping[0].replace("{{", "").replace("}}", "")
621
- time_params = [
622
- values.split(":")[0].strip() for values in input_mapping.split(",")
623
- ]
624
- time_params = [
625
- tp.replace('"', "").replace("'", "") for tp in time_params
626
- ]
627
641
  # if startTime is not given but other time params (e.g. year/month/(day)) are given,
628
642
  # no default date is required
629
643
  start, end = ecmwf_temporal_to_eodag(keywords)
@@ -638,9 +652,6 @@ class ECMWFSearch(PostJsonSearch):
638
652
  "missionEndDate", today().isoformat()
639
653
  )
640
654
  )
641
- else:
642
- keywords[START] = start
643
- keywords[END] = end
644
655
 
645
656
  def _get_product_type_queryables(
646
657
  self, product_type: Optional[str], alias: Optional[str], filters: dict[str, Any]
@@ -1030,7 +1041,7 @@ class ECMWFSearch(PostJsonSearch):
1030
1041
  """
1031
1042
  # Rename keywords from form with metadata mapping.
1032
1043
  # Needed to map constraints like "xxxx" to eodag parameter "ecmwf:xxxx"
1033
- required = [ecmwf_format(k) for k in required_keywords]
1044
+ required = [ecmwf_format(k) for k in required_keywords] # noqa: F841
1034
1045
 
1035
1046
  queryables: dict[str, Annotated[Any, FieldInfo]] = {}
1036
1047
  for name, values in available_values.items():
@@ -1116,92 +1127,74 @@ class ECMWFSearch(PostJsonSearch):
1116
1127
  _dc_qs = kwargs.pop("_dc_qs", None)
1117
1128
  if _dc_qs is not None:
1118
1129
  qs = unquote_plus(unquote_plus(_dc_qs))
1119
- sorted_unpaginated_query_params = geojson.loads(qs)
1130
+ sorted_unpaginated_qp = geojson.loads(qs)
1120
1131
  else:
1121
- # update result with query parameters without pagination (or search-only params)
1122
- if isinstance(
1123
- self.config.pagination["next_page_query_obj"], str
1124
- ) and hasattr(results, "query_params_unpaginated"):
1125
- unpaginated_query_params = results.query_params_unpaginated
1126
- elif isinstance(self.config.pagination["next_page_query_obj"], str):
1127
- next_page_query_obj = orjson.loads(
1128
- self.config.pagination["next_page_query_obj"].format()
1129
- )
1130
- unpaginated_query_params = {
1131
- k: v
1132
- for k, v in results.query_params.items()
1133
- if (k, v) not in next_page_query_obj.items()
1134
- }
1135
- else:
1136
- unpaginated_query_params = self.query_params
1137
- # query hash, will be used to build a product id
1138
- sorted_unpaginated_query_params = dict_items_recursive_sort(
1139
- unpaginated_query_params
1140
- )
1141
-
1142
- # use all available query_params to parse properties
1143
- result = dict(
1144
- result,
1145
- **sorted_unpaginated_query_params,
1146
- qs=sorted_unpaginated_query_params,
1147
- )
1132
+ sorted_unpaginated_qp = dict_items_recursive_sort(results.query_params)
1148
1133
 
1149
1134
  # remove unwanted query params
1150
1135
  for param in getattr(self.config, "remove_from_query", []):
1151
- sorted_unpaginated_query_params.pop(param, None)
1136
+ sorted_unpaginated_qp.pop(param, None)
1152
1137
 
1153
- qs = geojson.dumps(sorted_unpaginated_query_params)
1138
+ if result:
1139
+ properties = result
1140
+ properties.update(result.pop("request_params", None) or {})
1154
1141
 
1155
- query_hash = hashlib.sha1(str(qs).encode("UTF-8")).hexdigest()
1156
-
1157
- # update result with product_type_def_params and search args if not None (and not auth)
1158
- kwargs.pop("auth", None)
1159
- result.update(results.product_type_def_params)
1160
- result = dict(result, **{k: v for k, v in kwargs.items() if v is not None})
1142
+ properties = {k: v for k, v in properties.items() if not k.startswith("__")}
1161
1143
 
1162
- # parse properties
1163
- parsed_properties = properties_from_json(
1164
- result,
1165
- self.config.metadata_mapping,
1166
- discovery_config=getattr(self.config, "discover_metadata", {}),
1167
- )
1144
+ properties["geometry"] = properties.get("area") or DEFAULT_GEOMETRY
1168
1145
 
1169
- properties = {
1170
- # use product_type_config as default properties
1171
- **getattr(self.config, "product_type_config", {}),
1172
- **{ecmwf_format(k): v for k, v in parsed_properties.items()},
1173
- }
1146
+ start, end = ecmwf_temporal_to_eodag(properties)
1147
+ properties["startTimeFromAscendingNode"] = start
1148
+ properties["completionTimeFromAscendingNode"] = end
1174
1149
 
1175
- def slugify(date_str: str) -> str:
1176
- return date_str.split("T")[0].replace("-", "")
1150
+ else:
1151
+ # use all available query_params to parse properties
1152
+ result_data: dict[str, Any] = {
1153
+ **results.product_type_def_params,
1154
+ **sorted_unpaginated_qp,
1155
+ **{"qs": sorted_unpaginated_qp},
1156
+ }
1177
1157
 
1178
- # build product id
1179
- product_id = (product_type or kwargs.get("dataset") or self.provider).upper()
1158
+ # update result with product_type_def_params and search args if not None (and not auth)
1159
+ kwargs.pop("auth", None)
1160
+ result_data.update(results.product_type_def_params)
1161
+ result_data = {
1162
+ **result_data,
1163
+ **{k: v for k, v in kwargs.items() if v is not None},
1164
+ }
1180
1165
 
1181
- start = properties.get(START, NOT_AVAILABLE)
1182
- end = properties.get(END, NOT_AVAILABLE)
1166
+ properties = properties_from_json(
1167
+ result_data,
1168
+ self.config.metadata_mapping,
1169
+ discovery_config=getattr(self.config, "discover_metadata", {}),
1170
+ )
1183
1171
 
1184
- if start != NOT_AVAILABLE:
1185
- product_id += f"_{slugify(start)}"
1186
- if end != NOT_AVAILABLE:
1187
- product_id += f"_{slugify(end)}"
1172
+ query_hash = hashlib.sha1(str(result_data).encode("UTF-8")).hexdigest()
1188
1173
 
1189
- product_id += f"_{query_hash}"
1174
+ properties["title"] = properties["id"] = (
1175
+ (product_type or kwargs.get("dataset", self.provider)).upper()
1176
+ + "_ORDERABLE_"
1177
+ + query_hash
1178
+ )
1190
1179
 
1191
- properties["id"] = properties["title"] = product_id
1180
+ qs = geojson.dumps(sorted_unpaginated_qp)
1192
1181
 
1193
1182
  # used by server mode to generate downloadlink href
1183
+ # TODO: to remove once the legacy server is removed
1194
1184
  properties["_dc_qs"] = quote_plus(qs)
1195
1185
 
1196
1186
  product = EOProduct(
1197
1187
  provider=self.provider,
1198
- productType=product_type,
1199
- properties=properties,
1188
+ properties={ecmwf_format(k): v for k, v in properties.items()},
1189
+ **kwargs,
1200
1190
  )
1201
1191
 
1202
- return [
1203
- product,
1204
- ]
1192
+ # backup original register_downloader to register_downloader_only
1193
+ product.register_downloader_only = product.register_downloader
1194
+ # patched register_downloader that will also update properties
1195
+ product.register_downloader = MethodType(patched_register_downloader, product)
1196
+
1197
+ return [product]
1205
1198
 
1206
1199
  def count_hits(
1207
1200
  self, count_url: Optional[str] = None, result_type: Optional[str] = None
@@ -1215,6 +1208,83 @@ class ECMWFSearch(PostJsonSearch):
1215
1208
  return 1
1216
1209
 
1217
1210
 
1211
+ def _check_id(product: EOProduct) -> EOProduct:
1212
+ """Check if the id is the one of an existing job.
1213
+
1214
+ If the job exists, poll it, otherwise, raise an error.
1215
+
1216
+ :param product: The product to check the id for
1217
+ :raises: :class:`~eodag.utils.exceptions.ValidationError`
1218
+ """
1219
+ if not (product_id := product.search_kwargs.get("id")):
1220
+ return product
1221
+
1222
+ on_response_mm = getattr(product.downloader.config, "order_on_response", {}).get(
1223
+ "metadata_mapping", {}
1224
+ )
1225
+ if not on_response_mm:
1226
+ return product
1227
+
1228
+ logger.debug(f"Update product properties using given orderId {product_id}")
1229
+ on_response_mm_jsonpath = mtd_cfg_as_conversion_and_querypath(
1230
+ on_response_mm,
1231
+ )
1232
+ properties_update = properties_from_json(
1233
+ {}, {**on_response_mm_jsonpath, **{"orderId": (None, product_id)}}
1234
+ )
1235
+ product.properties.update(
1236
+ {k: v for k, v in properties_update.items() if v != NOT_AVAILABLE}
1237
+ )
1238
+
1239
+ auth = product.downloader_auth.authenticate() if product.downloader_auth else None
1240
+
1241
+ # try to poll the job corresponding to the given id
1242
+ try:
1243
+ product.downloader._order_status(product=product, auth=auth) # type: ignore
1244
+ # when a NotAvailableError is catched, it means the product is not ready and still needs to be polled
1245
+ except NotAvailableError:
1246
+ product.properties["storageStatus"] = STAGING_STATUS
1247
+ except Exception as e:
1248
+ if (
1249
+ isinstance(e, DownloadError) or isinstance(e, ValidationError)
1250
+ ) and "order status could not be checked" in e.args[0]:
1251
+ raise ValidationError(
1252
+ f"Item {product_id} does not exist with {product.provider}."
1253
+ ) from e
1254
+ raise ValidationError(e.args[0]) from e
1255
+
1256
+ # update product id
1257
+ product.properties["id"] = product_id
1258
+ # update product type if needed
1259
+ if product.product_type is None:
1260
+ product.product_type = product.properties.get("ecmwf:dataset")
1261
+ # update product title
1262
+ product.properties["title"] = (
1263
+ (product.product_type or product.provider).upper() + "_" + product_id
1264
+ )
1265
+ # use NOT_AVAILABLE as fallback product_type to avoid using guess_product_type
1266
+ if product.product_type is None:
1267
+ product.product_type = NOT_AVAILABLE
1268
+
1269
+ return product
1270
+
1271
+
1272
+ def patched_register_downloader(self, downloader, authenticator):
1273
+ """Register product donwloader and update properties if searched by id.
1274
+
1275
+ :param self: product to which information should be added
1276
+ :param downloader: The download method that it can use
1277
+ :class:`~eodag.plugins.download.base.Download` or
1278
+ :class:`~eodag.plugins.api.base.Api`
1279
+ :param authenticator: The authentication method needed to perform the download
1280
+ :class:`~eodag.plugins.authentication.base.Authentication`
1281
+ """
1282
+ # register downloader
1283
+ self.register_downloader_only(downloader, authenticator)
1284
+ # and also update properties
1285
+ _check_id(self)
1286
+
1287
+
1218
1288
  class MeteoblueSearch(ECMWFSearch):
1219
1289
  """MeteoblueSearch search plugin.
1220
1290
 
@@ -1283,6 +1353,97 @@ class MeteoblueSearch(ECMWFSearch):
1283
1353
  """
1284
1354
  return QueryStringSearch.build_query_string(self, product_type, query_dict)
1285
1355
 
1356
+ def normalize_results(self, results, **kwargs):
1357
+ """Build :class:`~eodag.api.product._product.EOProduct` from provider result
1358
+
1359
+ :param results: Raw provider result as single dict in list
1360
+ :param kwargs: Search arguments
1361
+ :returns: list of single :class:`~eodag.api.product._product.EOProduct`
1362
+ """
1363
+
1364
+ product_type = kwargs.get("productType")
1365
+
1366
+ result = results[0]
1367
+
1368
+ # datacube query string got from previous search
1369
+ _dc_qs = kwargs.pop("_dc_qs", None)
1370
+ if _dc_qs is not None:
1371
+ qs = unquote_plus(unquote_plus(_dc_qs))
1372
+ sorted_unpaginated_query_params = geojson.loads(qs)
1373
+ else:
1374
+ next_page_query_obj = orjson.loads(
1375
+ self.config.pagination["next_page_query_obj"].format()
1376
+ )
1377
+ unpaginated_query_params = {
1378
+ k: v
1379
+ for k, v in results.query_params.items()
1380
+ if (k, v) not in next_page_query_obj.items()
1381
+ }
1382
+ # query hash, will be used to build a product id
1383
+ sorted_unpaginated_query_params = dict_items_recursive_sort(
1384
+ unpaginated_query_params
1385
+ )
1386
+
1387
+ # use all available query_params to parse properties
1388
+ result = dict(
1389
+ result,
1390
+ **sorted_unpaginated_query_params,
1391
+ qs=sorted_unpaginated_query_params,
1392
+ )
1393
+
1394
+ qs = geojson.dumps(sorted_unpaginated_query_params)
1395
+
1396
+ query_hash = hashlib.sha1(str(qs).encode("UTF-8")).hexdigest()
1397
+
1398
+ # update result with product_type_def_params and search args if not None (and not auth)
1399
+ kwargs.pop("auth", None)
1400
+ result.update(results.product_type_def_params)
1401
+ result = dict(result, **{k: v for k, v in kwargs.items() if v is not None})
1402
+
1403
+ # parse properties
1404
+ parsed_properties = properties_from_json(
1405
+ result,
1406
+ self.config.metadata_mapping,
1407
+ discovery_config=getattr(self.config, "discover_metadata", {}),
1408
+ )
1409
+
1410
+ properties = {
1411
+ # use product_type_config as default properties
1412
+ **getattr(self.config, "product_type_config", {}),
1413
+ **{ecmwf_format(k): v for k, v in parsed_properties.items()},
1414
+ }
1415
+
1416
+ def slugify(date_str: str) -> str:
1417
+ return date_str.split("T")[0].replace("-", "")
1418
+
1419
+ # build product id
1420
+ product_id = (product_type or self.provider).upper()
1421
+
1422
+ start = properties.get(START, NOT_AVAILABLE)
1423
+ end = properties.get(END, NOT_AVAILABLE)
1424
+
1425
+ if start != NOT_AVAILABLE:
1426
+ product_id += f"_{slugify(start)}"
1427
+ if end != NOT_AVAILABLE:
1428
+ product_id += f"_{slugify(end)}"
1429
+
1430
+ product_id += f"_{query_hash}"
1431
+
1432
+ properties["id"] = properties["title"] = product_id
1433
+
1434
+ # used by server mode to generate downloadlink href
1435
+ properties["_dc_qs"] = quote_plus(qs)
1436
+
1437
+ product = EOProduct(
1438
+ provider=self.provider,
1439
+ productType=product_type,
1440
+ properties=properties,
1441
+ )
1442
+
1443
+ return [
1444
+ product,
1445
+ ]
1446
+
1286
1447
 
1287
1448
  class WekeoECMWFSearch(ECMWFSearch):
1288
1449
  """
@@ -1319,6 +1480,10 @@ class WekeoECMWFSearch(ECMWFSearch):
1319
1480
  :returns: list of single :class:`~eodag.api.product._product.EOProduct`
1320
1481
  """
1321
1482
 
1483
+ if kwargs.get("id") and "ORDERABLE" not in kwargs["id"]:
1484
+ # id is order id (only letters and numbers) -> use parent normalize results
1485
+ return super().normalize_results(results, **kwargs)
1486
+
1322
1487
  # formating of orderLink requires access to the productType value.
1323
1488
  results.data = [
1324
1489
  {**result, **results.product_type_def_params} for result in results
@@ -1329,12 +1494,28 @@ class WekeoECMWFSearch(ECMWFSearch):
1329
1494
  if not normalized:
1330
1495
  return normalized
1331
1496
 
1332
- query_params_encoded = quote_plus(orjson.dumps(results.query_params))
1497
+ # remove unwanted query params
1498
+ excluded_query_params = getattr(self.config, "remove_from_query", [])
1499
+ filtered_query_params = {
1500
+ k: v
1501
+ for k, v in results.query_params.items()
1502
+ if k not in excluded_query_params
1503
+ }
1333
1504
  for product in normalized:
1334
1505
  properties = {**product.properties, **results.query_params}
1335
- properties["_dc_qs"] = query_params_encoded
1506
+ properties["_dc_qs"] = quote_plus(orjson.dumps(filtered_query_params))
1336
1507
  product.properties = {ecmwf_format(k): v for k, v in properties.items()}
1337
1508
 
1509
+ # update product and title the same way as in parent class
1510
+ splitted_id = product.properties.get("title", "").split("-")
1511
+ dataset = "_".join(splitted_id[:-1])
1512
+ query_hash = splitted_id[-1]
1513
+ product.properties["title"] = product.properties["id"] = (
1514
+ (product.product_type or dataset or self.provider).upper()
1515
+ + "_ORDERABLE_"
1516
+ + query_hash
1517
+ )
1518
+
1338
1519
  return normalized
1339
1520
 
1340
1521
  def do_search(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
@@ -1344,4 +1525,9 @@ class WekeoECMWFSearch(ECMWFSearch):
1344
1525
  :param kwargs: keyword arguments to be used in the search
1345
1526
  :return: list containing the results from the provider in json format
1346
1527
  """
1347
- return QueryStringSearch.do_search(self, *args, **kwargs)
1528
+ if "id" in kwargs and "ORDERABLE" not in kwargs["id"]:
1529
+ # id is order id (only letters and numbers) -> use parent normalize results.
1530
+ # No real search. We fake it all, then check order status using given id
1531
+ return [{}]
1532
+ else:
1533
+ return QueryStringSearch.do_search(self, *args, **kwargs)