eodag 3.1.0b2__py3-none-any.whl → 3.2.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,14 +23,14 @@ import logging
23
23
  import re
24
24
  from collections import OrderedDict
25
25
  from datetime import datetime, timedelta
26
- from typing import TYPE_CHECKING, Annotated, Any, Optional, Union, cast
26
+ from typing import TYPE_CHECKING, Annotated, Any, Optional, Union
27
27
  from urllib.parse import quote_plus, unquote_plus
28
28
 
29
29
  import geojson
30
30
  import orjson
31
31
  from dateutil.parser import isoparse
32
32
  from dateutil.tz import tzutc
33
- from jsonpath_ng import Child, Fields, Root
33
+ from dateutil.utils import today
34
34
  from pydantic import Field
35
35
  from pydantic.fields import FieldInfo
36
36
  from requests.auth import AuthBase
@@ -40,10 +40,8 @@ from typing_extensions import get_args
40
40
  from eodag.api.product import EOProduct
41
41
  from eodag.api.product.metadata_mapping import (
42
42
  NOT_AVAILABLE,
43
- NOT_MAPPED,
43
+ OFFLINE_STATUS,
44
44
  format_metadata,
45
- format_query_params,
46
- mtd_cfg_as_conversion_and_querypath,
47
45
  properties_from_json,
48
46
  )
49
47
  from eodag.api.search_result import RawSearchResult
@@ -52,6 +50,7 @@ from eodag.plugins.search.qssearch import PostJsonSearch, QueryStringSearch
52
50
  from eodag.types import json_field_definition_to_python
53
51
  from eodag.types.queryables import Queryables, QueryablesDict
54
52
  from eodag.utils import (
53
+ DEFAULT_MISSION_START_DATE,
55
54
  DEFAULT_SEARCH_TIMEOUT,
56
55
  deepcopy,
57
56
  dict_items_recursive_sort,
@@ -66,9 +65,11 @@ if TYPE_CHECKING:
66
65
 
67
66
  logger = logging.getLogger("eodag.search.build_search_result")
68
67
 
68
+ ECMWF_PREFIX = "ecmwf:"
69
+
69
70
  # keywords from ECMWF keyword database + "dataset" (not part of database but exists)
70
71
  # database: https://confluence.ecmwf.int/display/UDOC/Keywords+in+MARS+and+Dissemination+requests
71
- ECMWF_KEYWORDS = [
72
+ ECMWF_KEYWORDS = {
72
73
  "dataset",
73
74
  "accuracy",
74
75
  "activity",
@@ -132,10 +133,10 @@ ECMWF_KEYWORDS = [
132
133
  "truncation",
133
134
  "type",
134
135
  "use",
135
- ]
136
+ }
136
137
 
137
138
  # additional keywords from copernicus services
138
- COP_DS_KEYWORDS = [
139
+ COP_DS_KEYWORDS = {
139
140
  "aerosol_type",
140
141
  "altitude",
141
142
  "product_type",
@@ -190,55 +191,28 @@ COP_DS_KEYWORDS = [
190
191
  "variable_type",
191
192
  "version",
192
193
  "year",
193
- ]
194
+ }
195
+
196
+ ALLOWED_KEYWORDS = ECMWF_KEYWORDS | COP_DS_KEYWORDS
197
+
198
+ END = "completionTimeFromAscendingNode"
199
+
200
+ START = "startTimeFromAscendingNode"
194
201
 
195
202
 
196
- def keywords_to_mdt(
197
- keywords: list[str], prefix: Optional[str] = None
198
- ) -> dict[str, Any]:
203
+ def ecmwf_mtd() -> dict[str, Any]:
199
204
  """
200
- Make metadata mapping dict from a list of keywords
205
+ Make metadata mapping dict from a list of defined ECMWF Keywords
201
206
 
202
- prefix:keyword:
203
- - keyword
204
- - $."prefix:keyword"
207
+ We automatically add the #to_geojson convert to prevent modification of entries by eval() in the metadata mapping.
205
208
 
206
- >>> keywords_to_mdt(["month", "year"])
207
- {'month': ['month', '$."month"'], 'year': ['year', '$."year"']}
208
- >>> keywords_to_mdt(["month", "year"], "ecmwf")
209
- {'ecmwf:month': ['month', '$."ecmwf:month"'], 'ecmwf:year': ['year', '$."ecmwf:year"']}
209
+ keyword:
210
+ - keyword
211
+ - $."keyword"#to_geojson
210
212
 
211
- :param keywords: List of keywords to be converted
212
- :param prefix: prefix to be added to the parameter in the mapping
213
213
  :return: metadata mapping dict
214
214
  """
215
- mdt: dict[str, Any] = {}
216
- for keyword in keywords:
217
- key = f"{prefix}:{keyword}" if prefix else keyword
218
- mdt[key] = [keyword, f'$."{key}"']
219
- return mdt
220
-
221
-
222
- def strip_quotes(value: Any) -> Any:
223
- """Strip superfluous quotes from elements (added by mapping converter to_geojson).
224
-
225
- >>> strip_quotes("'abc'")
226
- 'abc'
227
- >>> strip_quotes(["'abc'", '"def'])
228
- ['abc', 'def']
229
- >>> strip_quotes({"'abc'": 'def"'})
230
- {'abc': 'def'}
231
-
232
- :param value: value from which quotes should be removed (should be either str or list)
233
- :return: value without quotes
234
- :raises: NotImplementedError
235
- """
236
- if isinstance(value, (list, tuple)):
237
- return [strip_quotes(v) for v in value]
238
- elif isinstance(value, dict):
239
- return {strip_quotes(k): strip_quotes(v) for k, v in value.items()}
240
- else:
241
- return str(value).strip("'\"")
215
+ return {k: [k, f'{{$."{k}"#to_geojson}}'] for k in ALLOWED_KEYWORDS}
242
216
 
243
217
 
244
218
  def _update_properties_from_element(
@@ -309,7 +283,7 @@ def _update_properties_from_element(
309
283
 
310
284
  def ecmwf_format(v: str) -> str:
311
285
  """Add ECMWF prefix to value v if v is a ECMWF keyword."""
312
- return "ecmwf:" + v if v in ECMWF_KEYWORDS + COP_DS_KEYWORDS else v
286
+ return ECMWF_PREFIX + v if v in ALLOWED_KEYWORDS else v
313
287
 
314
288
 
315
289
  class ECMWFSearch(PostJsonSearch):
@@ -342,54 +316,25 @@ class ECMWFSearch(PostJsonSearch):
342
316
 
343
317
  def __init__(self, provider: str, config: PluginConfig) -> None:
344
318
  config.metadata_mapping = {
345
- **keywords_to_mdt(ECMWF_KEYWORDS + COP_DS_KEYWORDS, "ecmwf"),
319
+ **ecmwf_mtd(),
320
+ **{
321
+ "id": "$.id",
322
+ "title": "$.id",
323
+ "storageStatus": OFFLINE_STATUS,
324
+ "downloadLink": "$.null",
325
+ "geometry": ["feature", "$.geometry"],
326
+ "defaultGeometry": "POLYGON((180 -90, 180 90, -180 90, -180 -90, 180 -90))",
327
+ },
346
328
  **config.metadata_mapping,
347
329
  }
348
330
 
349
331
  super().__init__(provider, config)
350
332
 
333
+ # ECMWF providers do not feature any api_endpoint or next_page_query_obj.
334
+ # Searched is faked by EODAG.
351
335
  self.config.__dict__.setdefault("api_endpoint", "")
352
-
353
- # needed by QueryStringSearch.build_query_string / format_free_text_search
354
- self.config.__dict__.setdefault("free_text_search_operations", {})
355
- # needed for compatibility
356
336
  self.config.pagination.setdefault("next_page_query_obj", "{{}}")
357
337
 
358
- # parse jsonpath on init: product type specific metadata-mapping
359
- for product_type in self.config.products.keys():
360
- if "metadata_mapping" in self.config.products[product_type].keys():
361
- self.config.products[product_type][
362
- "metadata_mapping"
363
- ] = mtd_cfg_as_conversion_and_querypath(
364
- self.config.products[product_type]["metadata_mapping"]
365
- )
366
- # Complete and ready to use product type specific metadata-mapping
367
- product_type_metadata_mapping = deepcopy(self.config.metadata_mapping)
368
-
369
- # update config using provider product type definition metadata_mapping
370
- # from another product
371
- other_product_for_mapping = cast(
372
- str,
373
- self.config.products[product_type].get(
374
- "metadata_mapping_from_product", ""
375
- ),
376
- )
377
- if other_product_for_mapping:
378
- other_product_type_def_params = self.get_product_type_def_params(
379
- other_product_for_mapping,
380
- )
381
- product_type_metadata_mapping.update(
382
- other_product_type_def_params.get("metadata_mapping", {})
383
- )
384
- # from current product
385
- product_type_metadata_mapping.update(
386
- self.config.products[product_type]["metadata_mapping"]
387
- )
388
-
389
- self.config.products[product_type][
390
- "metadata_mapping"
391
- ] = product_type_metadata_mapping
392
-
393
338
  def do_search(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
394
339
  """Should perform the actual search request.
395
340
 
@@ -414,7 +359,7 @@ class ECMWFSearch(PostJsonSearch):
414
359
  product_type = prep.product_type
415
360
  if not product_type:
416
361
  product_type = kwargs.get("productType", None)
417
- self._preprocess_search_params(kwargs, product_type)
362
+ kwargs = self._preprocess_search_params(kwargs, product_type)
418
363
  result, num_items = super().query(prep, **kwargs)
419
364
  if prep.count and not num_items:
420
365
  num_items = 1
@@ -426,34 +371,31 @@ class ECMWFSearch(PostJsonSearch):
426
371
  super().clear()
427
372
 
428
373
  def build_query_string(
429
- self, product_type: str, **kwargs: Any
374
+ self, product_type: str, query_dict: dict[str, Any]
430
375
  ) -> tuple[dict[str, Any], str]:
431
376
  """Build The query string using the search parameters
432
377
 
433
378
  :param product_type: product type id
434
- :param kwargs: keyword arguments to be used in the query string
379
+ :param query_dict: keyword arguments to be used in the query string
435
380
  :return: formatted query params and encode query string
436
381
  """
437
- # parse kwargs as properties as they might be needed to build the query
438
- parsed_properties = properties_from_json(
439
- kwargs,
440
- self.config.metadata_mapping,
441
- )
442
- available_properties = {
443
- # We strip values of superfluous quotes (added by mapping converter to_geojson).
444
- k: strip_quotes(v)
445
- for k, v in parsed_properties.items()
446
- if v not in [NOT_AVAILABLE, NOT_MAPPED]
447
- }
382
+ query_dict["_date"] = f"{query_dict.get(START)}/{query_dict.get(END)}"
383
+
384
+ # Reorder kwargs to make sure year/month/day/time if set overwrite default datetime.
385
+ priority_keys = [
386
+ START,
387
+ END,
388
+ ]
389
+ ordered_kwargs = {k: query_dict[k] for k in priority_keys if k in query_dict}
390
+ ordered_kwargs.update(query_dict)
448
391
 
449
- # build and return the query
450
392
  return super().build_query_string(
451
- product_type=product_type, **available_properties
393
+ product_type=product_type, query_dict=ordered_kwargs
452
394
  )
453
395
 
454
396
  def _preprocess_search_params(
455
397
  self, params: dict[str, Any], product_type: Optional[str]
456
- ) -> None:
398
+ ) -> dict[str, Any]:
457
399
  """Preprocess search parameters before making a request to the CDS API.
458
400
 
459
401
  This method is responsible for checking and updating the provided search parameters
@@ -469,28 +411,20 @@ class ECMWFSearch(PostJsonSearch):
469
411
  # if available, update search params using datacube query-string
470
412
  _dc_qp = geojson.loads(unquote_plus(unquote_plus(_dc_qs)))
471
413
  if "/to/" in _dc_qp.get("date", ""):
472
- (
473
- params["startTimeFromAscendingNode"],
474
- params["completionTimeFromAscendingNode"],
475
- ) = _dc_qp["date"].split("/to/")
414
+ params[START], params[END] = _dc_qp["date"].split("/to/")
476
415
  elif "/" in _dc_qp.get("date", ""):
477
- (
478
- params["startTimeFromAscendingNode"],
479
- params["completionTimeFromAscendingNode"],
480
- ) = _dc_qp["date"].split("/")
416
+ (params[START], params[END],) = _dc_qp[
417
+ "date"
418
+ ].split("/")
481
419
  elif _dc_qp.get("date", None):
482
- params["startTimeFromAscendingNode"] = params[
483
- "completionTimeFromAscendingNode"
484
- ] = _dc_qp["date"]
420
+ params[START] = params[END] = _dc_qp["date"]
485
421
 
486
422
  if "/" in _dc_qp.get("area", ""):
487
423
  params["geometry"] = _dc_qp["area"].split("/")
488
424
 
489
- non_none_params = {k: v for k, v in params.items() if v}
490
-
491
- # productType
492
- dataset = params.get("ecmwf:dataset", None)
493
- params["productType"] = non_none_params.get("productType", dataset)
425
+ params = {
426
+ k.removeprefix(ECMWF_PREFIX): v for k, v in params.items() if v is not None
427
+ }
494
428
 
495
429
  # dates
496
430
  # check if default dates have to be added
@@ -498,25 +432,23 @@ class ECMWFSearch(PostJsonSearch):
498
432
  self._check_date_params(params, product_type)
499
433
 
500
434
  # adapt end date if it is midnight
501
- if "completionTimeFromAscendingNode" in params:
435
+ if END in params:
502
436
  end_date_excluded = getattr(self.config, "end_date_excluded", True)
503
437
  is_datetime = True
504
438
  try:
505
- end_date = datetime.strptime(
506
- params["completionTimeFromAscendingNode"], "%Y-%m-%dT%H:%M:%SZ"
507
- )
439
+ end_date = datetime.strptime(params[END], "%Y-%m-%dT%H:%M:%SZ")
508
440
  end_date = end_date.replace(tzinfo=tzutc())
509
441
  except ValueError:
510
442
  try:
511
443
  end_date = datetime.strptime(
512
- params["completionTimeFromAscendingNode"],
444
+ params[END],
513
445
  "%Y-%m-%dT%H:%M:%S.%fZ",
514
446
  )
515
447
  end_date = end_date.replace(tzinfo=tzutc())
516
448
  except ValueError:
517
- end_date = isoparse(params["completionTimeFromAscendingNode"])
449
+ end_date = isoparse(params[END])
518
450
  is_datetime = False
519
- start_date = isoparse(params["startTimeFromAscendingNode"])
451
+ start_date = isoparse(params[START])
520
452
  if (
521
453
  not end_date_excluded
522
454
  and is_datetime
@@ -525,12 +457,73 @@ class ECMWFSearch(PostJsonSearch):
525
457
  == end_date.replace(hour=0, minute=0, second=0, microsecond=0)
526
458
  ):
527
459
  end_date += timedelta(days=-1)
528
- params["completionTimeFromAscendingNode"] = end_date.isoformat()
460
+ params[END] = end_date.isoformat()
529
461
 
530
462
  # geometry
531
463
  if "geometry" in params:
532
464
  params["geometry"] = get_geometry_from_various(geometry=params["geometry"])
533
465
 
466
+ return params
467
+
468
+ def _check_date_params(
469
+ self, keywords: dict[str, Any], product_type: Optional[str]
470
+ ) -> None:
471
+ """checks if start and end date are present in the keywords and adds them if not"""
472
+
473
+ if START and END in keywords:
474
+ return
475
+
476
+ product_type_conf = getattr(self.config, "metadata_mapping", {})
477
+ if (
478
+ product_type
479
+ and product_type in self.config.products
480
+ and "metadata_mapping" in self.config.products[product_type]
481
+ ):
482
+ product_type_conf = self.config.products[product_type]["metadata_mapping"]
483
+
484
+ # start time given, end time missing
485
+ if START in keywords:
486
+ keywords[END] = (
487
+ keywords[START]
488
+ if END in product_type_conf
489
+ else self.get_product_type_cfg_value(
490
+ "missionEndDate", today().isoformat()
491
+ )
492
+ )
493
+ return
494
+
495
+ if END in product_type_conf:
496
+ mapping = product_type_conf[START]
497
+ if not isinstance(mapping, list):
498
+ mapping = product_type_conf[END]
499
+ if isinstance(mapping, list):
500
+ # get time parameters (date, year, month, ...) from metadata mapping
501
+ input_mapping = mapping[0].replace("{{", "").replace("}}", "")
502
+ time_params = [
503
+ values.split(":")[0].strip() for values in input_mapping.split(",")
504
+ ]
505
+ time_params = [
506
+ tp.replace('"', "").replace("'", "") for tp in time_params
507
+ ]
508
+ # if startTime is not given but other time params (e.g. year/month/(day)) are given,
509
+ # no default date is required
510
+ in_keywords = True
511
+ for tp in time_params:
512
+ if tp not in keywords:
513
+ in_keywords = False
514
+ break
515
+ if not in_keywords:
516
+ keywords[START] = self.get_product_type_cfg_value(
517
+ "missionStartDate", DEFAULT_MISSION_START_DATE
518
+ )
519
+ keywords[END] = (
520
+ keywords[START]
521
+ if END in product_type_conf
522
+ else self.get_product_type_cfg_value(
523
+ "missionEndDate", today().isoformat()
524
+ )
525
+ )
526
+
534
527
  def _get_product_type_queryables(
535
528
  self, product_type: Optional[str], alias: Optional[str], filters: dict[str, Any]
536
529
  ) -> QueryablesDict:
@@ -555,61 +548,60 @@ class ECMWFSearch(PostJsonSearch):
555
548
  :returns: fetched queryable parameters dict
556
549
  """
557
550
  product_type = kwargs.pop("productType")
558
- product_type_config = self.config.products.get(product_type, {})
559
- provider_product_type = (
560
- product_type_config.get("ecmwf:dataset", None)
561
- or product_type_config["productType"]
562
- )
563
- if "start" in kwargs:
564
- kwargs["startTimeFromAscendingNode"] = kwargs.pop("start")
565
- if "end" in kwargs:
566
- kwargs["completionTimeFromAscendingNode"] = kwargs.pop("end")
551
+
552
+ pt_config = self.get_product_type_def_params(product_type)
553
+
554
+ default_values = deepcopy(pt_config)
555
+ default_values.pop("metadata_mapping", None)
556
+ filters = {**default_values, **kwargs}
557
+
558
+ if "start" in filters:
559
+ filters[START] = filters.pop("start")
560
+ if "end" in filters:
561
+ filters[END] = filters.pop("end")
567
562
 
568
563
  # extract default datetime
569
- processed_kwargs = deepcopy(kwargs)
570
- self._preprocess_search_params(processed_kwargs, product_type)
564
+ processed_filters = self._preprocess_search_params(
565
+ deepcopy(filters), product_type
566
+ )
571
567
 
572
568
  constraints_url = format_metadata(
573
569
  getattr(self.config, "discover_queryables", {}).get("constraints_url", ""),
574
- **kwargs,
570
+ **filters,
575
571
  )
576
572
  constraints: list[dict[str, Any]] = self._fetch_data(constraints_url)
577
573
 
578
574
  form_url = format_metadata(
579
575
  getattr(self.config, "discover_queryables", {}).get("form_url", ""),
580
- **kwargs,
576
+ **filters,
581
577
  )
582
578
  form: list[dict[str, Any]] = self._fetch_data(form_url)
583
579
 
584
- formated_kwargs = self.format_as_provider_keyword(
585
- product_type, processed_kwargs
580
+ formated_filters = self.format_as_provider_keyword(
581
+ product_type, processed_filters
586
582
  )
587
583
  # we re-apply kwargs input to consider override of year, month, day and time.
588
- for key in kwargs:
589
- if key.startswith("ecmwf:"):
590
- formated_kwargs[key.replace("ecmwf:", "")] = kwargs[key]
591
- elif key in (
592
- "startTimeFromAscendingNode",
593
- "completionTimeFromAscendingNode",
584
+ for k, v in {**default_values, **kwargs}.items():
585
+ key = k.removeprefix(ECMWF_PREFIX)
586
+
587
+ if key not in ALLOWED_KEYWORDS | {
588
+ START,
589
+ END,
594
590
  "geom",
595
- ):
596
- formated_kwargs[key] = kwargs[key]
597
- else:
591
+ "geometry",
592
+ }:
598
593
  raise ValidationError(
599
594
  f"{key} is not a queryable parameter for {self.provider}"
600
595
  )
601
596
 
602
- # we use non empty kwargs as default to integrate user inputs
597
+ formated_filters[key] = v
598
+
599
+ # we use non empty filters as default to integrate user inputs
603
600
  # it is needed because pydantic json schema does not represent "value"
604
601
  # but only "default"
605
602
  non_empty_formated: dict[str, Any] = {
606
603
  k: v
607
- for k, v in formated_kwargs.items()
608
- if v and (not isinstance(v, list) or all(v))
609
- }
610
- non_empty_kwargs: dict[str, Any] = {
611
- k: v
612
- for k, v in processed_kwargs.items()
604
+ for k, v in formated_filters.items()
613
605
  if v and (not isinstance(v, list) or all(v))
614
606
  }
615
607
 
@@ -627,15 +619,16 @@ class ECMWFSearch(PostJsonSearch):
627
619
  # Pre-compute the required keywords (present in all constraint dicts)
628
620
  # when form, required keywords are extracted directly from form
629
621
  if not form:
630
- required_keywords = set(constraints[0].keys())
631
- for constraint in constraints[1:]:
632
- required_keywords.intersection_update(constraint.keys())
622
+ required_keywords = set.intersection(
623
+ *(map(lambda d: set(d.keys()), constraints))
624
+ )
625
+
633
626
  else:
634
627
  values_url = getattr(self.config, "available_values_url", "")
635
628
  if not values_url:
636
629
  return self.queryables_from_metadata_mapping(product_type)
637
630
  if "{" in values_url:
638
- values_url = values_url.format(productType=provider_product_type)
631
+ values_url = values_url.format(**filters)
639
632
  data = self._fetch_data(values_url)
640
633
  available_values = data["constraints"]
641
634
  required_keywords = data.get("required", [])
@@ -643,18 +636,18 @@ class ECMWFSearch(PostJsonSearch):
643
636
  # To check if all keywords are queryable parameters, we check if they are in the
644
637
  # available values or the product type config (available values calculated from the
645
638
  # constraints might not include all queryables)
646
- for keyword in kwargs:
639
+ for keyword in filters:
647
640
  if (
648
641
  keyword
649
642
  not in available_values.keys()
650
- | product_type_config.keys()
643
+ | pt_config.keys()
651
644
  | {
652
- "startTimeFromAscendingNode",
653
- "completionTimeFromAscendingNode",
645
+ START,
646
+ END,
654
647
  "geom",
655
648
  }
656
649
  and keyword not in [f["name"] for f in form]
657
- and keyword.replace("ecmwf:", "")
650
+ and keyword.removeprefix(ECMWF_PREFIX)
658
651
  not in set(list(available_values.keys()) + [f["name"] for f in form])
659
652
  ):
660
653
  raise ValidationError(f"{keyword} is not a queryable parameter")
@@ -668,24 +661,24 @@ class ECMWFSearch(PostJsonSearch):
668
661
  )
669
662
  else:
670
663
  queryables = self.queryables_by_values(
671
- available_values, list(required_keywords), non_empty_kwargs
664
+ available_values, list(required_keywords), non_empty_formated
672
665
  )
673
666
 
674
667
  # ecmwf:date is replaced by start and end.
675
668
  # start and end filters are supported whenever combinations of "year", "month", "day" filters exist
676
669
  if (
677
- queryables.pop("ecmwf:date", None)
678
- or "ecmwf:year" in queryables
679
- or "ecmwf:hyear" in queryables
670
+ queryables.pop(f"{ECMWF_PREFIX}date", None)
671
+ or f"{ECMWF_PREFIX}year" in queryables
672
+ or f"{ECMWF_PREFIX}hyear" in queryables
680
673
  ):
681
674
  queryables.update(
682
675
  {
683
676
  "start": Queryables.get_with_default(
684
- "start", non_empty_kwargs.get("startTimeFromAscendingNode")
677
+ "start", processed_filters.get(START)
685
678
  ),
686
679
  "end": Queryables.get_with_default(
687
680
  "end",
688
- non_empty_kwargs.get("completionTimeFromAscendingNode"),
681
+ processed_filters.get(END),
689
682
  ),
690
683
  }
691
684
  )
@@ -746,14 +739,15 @@ class ECMWFSearch(PostJsonSearch):
746
739
  raise ValidationError(
747
740
  f"Parameter value as object is not supported: {keyword}={values}"
748
741
  )
749
- filter_v = values if isinstance(values, (list, tuple)) else [values]
750
742
 
751
743
  # We convert every single value to a list of string
744
+ filter_v = values if isinstance(values, (list, tuple)) else [values]
745
+
752
746
  # We strip values of superfluous quotes (added by mapping converter to_geojson).
753
747
  # ECMWF accept values with /to/. We need to split it to an array
754
748
  # ECMWF accept values in format val1/val2. We need to split it to an array
755
749
  sep = re.compile(r"/to/|/")
756
- filter_v = [i for v in filter_v for i in sep.split(strip_quotes(v))]
750
+ filter_v = [i for v in filter_v for i in sep.split(str(v))]
757
751
 
758
752
  # special handling for time 0000 converted to 0 by pre-formating with metadata_mapping
759
753
  if keyword.split(":")[-1] == "time":
@@ -772,7 +766,10 @@ class ECMWFSearch(PostJsonSearch):
772
766
  # we assume that if the first value is an interval, all values are intervals
773
767
  present_values = []
774
768
  if keyword == "date" and "/" in entry[keyword][0]:
775
- if any(is_range_in_range(x, values[0]) for x in entry[keyword]):
769
+ input_range = values
770
+ if isinstance(values, list):
771
+ input_range = values[0]
772
+ if any(is_range_in_range(x, input_range) for x in entry[keyword]):
776
773
  present_values = filter_v
777
774
  else:
778
775
  present_values = [
@@ -792,12 +789,12 @@ class ECMWFSearch(PostJsonSearch):
792
789
  {value for c in constraints for value in c.get(keyword, [])}
793
790
  )
794
791
  # restore ecmwf: prefix before raising error
795
- keyword = f"ecmwf:{keyword}"
792
+ keyword = ECMWF_PREFIX + keyword
796
793
 
797
794
  all_keywords_str = ""
798
795
  if len(parsed_keywords) > 1:
799
796
  keywords = [
800
- f"ecmwf:{k}={pk}"
797
+ f"{ECMWF_PREFIX + k}={pk}"
801
798
  for k in parsed_keywords
802
799
  if (pk := input_keywords.get(k))
803
800
  ]
@@ -846,6 +843,8 @@ class ECMWFSearch(PostJsonSearch):
846
843
  if name in ("area_group", "global", "warning", "licences"):
847
844
  continue
848
845
  if "type" not in element or element["type"] == "FreeEditionWidget":
846
+ # FreeEditionWidget used to select the whole available region
847
+ # and to provide comments for the dataset
849
848
  continue
850
849
 
851
850
  # ordering done by id -> set id to high value if not present -> element will be last
@@ -876,12 +875,8 @@ class ECMWFSearch(PostJsonSearch):
876
875
  if name == "area" and isinstance(default, dict):
877
876
  default = list(default.values())
878
877
 
879
- if default:
880
- # We strip values of superfluous quotes (addded by mapping converter to_geojson).
881
- default = strip_quotes(default)
882
-
883
878
  # sometimes form returns default as array instead of string
884
- if default and prop["type"] == "string" and isinstance(default, list):
879
+ if default and prop.get("type") == "string" and isinstance(default, list):
885
880
  default = ",".join(default)
886
881
 
887
882
  is_required = bool(element.get("required"))
@@ -925,13 +920,11 @@ class ECMWFSearch(PostJsonSearch):
925
920
  # Needed to map constraints like "xxxx" to eodag parameter "ecmwf:xxxx"
926
921
  key = ecmwf_format(name)
927
922
 
928
- default = defaults.get(key)
929
-
930
923
  queryables[key] = Annotated[
931
924
  get_args(
932
925
  json_field_definition_to_python(
933
926
  {"type": "string", "title": name, "enum": values},
934
- default_value=strip_quotes(default) if default else None,
927
+ default_value=defaults.get(name),
935
928
  required=bool(key in required),
936
929
  )
937
930
  )
@@ -948,16 +941,26 @@ class ECMWFSearch(PostJsonSearch):
948
941
  :param properties: dict of properties to be formatted
949
942
  :return: dict of formatted properties
950
943
  """
951
- parsed_properties = properties_from_json(
952
- properties,
953
- self.config.metadata_mapping,
944
+ properties["productType"] = product_type
945
+
946
+ # provider product type specific conf
947
+ product_type_def_params = self.get_product_type_def_params(
948
+ product_type, format_variables=properties
954
949
  )
955
- available_properties = {
956
- k: v
957
- for k, v in parsed_properties.items()
958
- if v not in [NOT_AVAILABLE, NOT_MAPPED]
959
- }
960
- return format_query_params(product_type, self.config, available_properties)
950
+
951
+ # Add to the query, the queryable parameters set in the provider product type definition
952
+ properties.update(
953
+ {
954
+ k: v
955
+ for k, v in product_type_def_params.items()
956
+ if k not in properties.keys()
957
+ and k in self.config.metadata_mapping.keys()
958
+ and isinstance(self.config.metadata_mapping[k], list)
959
+ }
960
+ )
961
+ qp, _ = self.build_query_string(product_type, properties)
962
+
963
+ return qp
961
964
 
962
965
  def _fetch_data(self, url: str) -> Any:
963
966
  """
@@ -1045,67 +1048,37 @@ class ECMWFSearch(PostJsonSearch):
1045
1048
  discovery_config=getattr(self.config, "discover_metadata", {}),
1046
1049
  )
1047
1050
 
1048
- if not product_type:
1049
- product_type = parsed_properties.get("productType", None)
1051
+ properties = {
1052
+ # use product_type_config as default properties
1053
+ **getattr(self.config, "product_type_config", {}),
1054
+ **{ecmwf_format(k): v for k, v in parsed_properties.items()},
1055
+ }
1056
+
1057
+ def slugify(date_str: str) -> str:
1058
+ return date_str.split("T")[0].replace("-", "")
1050
1059
 
1051
1060
  # build product id
1052
- id_prefix = (product_type or self.provider).upper()
1053
- if (
1054
- "startTimeFromAscendingNode" in parsed_properties
1055
- and parsed_properties["startTimeFromAscendingNode"] != "Not Available"
1056
- and "completionTimeFromAscendingNode" in parsed_properties
1057
- and parsed_properties["completionTimeFromAscendingNode"] != "Not Available"
1058
- ):
1059
- product_id = "%s_%s_%s_%s" % (
1060
- id_prefix,
1061
- parsed_properties["startTimeFromAscendingNode"]
1062
- .split("T")[0]
1063
- .replace("-", ""),
1064
- parsed_properties["completionTimeFromAscendingNode"]
1065
- .split("T")[0]
1066
- .replace("-", ""),
1067
- query_hash,
1068
- )
1069
- elif (
1070
- "startTimeFromAscendingNode" in parsed_properties
1071
- and parsed_properties["startTimeFromAscendingNode"] != "Not Available"
1072
- ):
1073
- product_id = "%s_%s_%s" % (
1074
- id_prefix,
1075
- parsed_properties["startTimeFromAscendingNode"]
1076
- .split("T")[0]
1077
- .replace("-", ""),
1078
- query_hash,
1079
- )
1080
- else:
1081
- product_id = f"{id_prefix}_{query_hash}"
1082
-
1083
- parsed_properties["id"] = parsed_properties["title"] = product_id
1084
-
1085
- # update downloadLink and orderLink
1086
- parsed_properties["_dc_qs"] = quote_plus(qs)
1087
- if parsed_properties["downloadLink"] != "Not Available":
1088
- parsed_properties["downloadLink"] += f"?{qs}"
1089
-
1090
- # parse metadata needing downloadLink
1091
- dl_path = Fields("downloadLink")
1092
- dl_path_from_root = Child(Root(), dl_path)
1093
- for param, mapping in self.config.metadata_mapping.items():
1094
- if dl_path in mapping or dl_path_from_root in mapping:
1095
- parsed_properties.update(
1096
- properties_from_json(parsed_properties, {param: mapping})
1097
- )
1061
+ product_id = (product_type or kwargs.get("dataset") or self.provider).upper()
1098
1062
 
1099
- # use product_type_config as default properties
1100
- parsed_properties = dict(
1101
- getattr(self.config, "product_type_config", {}),
1102
- **parsed_properties,
1103
- )
1063
+ start = properties.get(START, NOT_AVAILABLE)
1064
+ end = properties.get(END, NOT_AVAILABLE)
1065
+
1066
+ if start != NOT_AVAILABLE:
1067
+ product_id += f"_{slugify(start)}"
1068
+ if end != NOT_AVAILABLE:
1069
+ product_id += f"_{slugify(end)}"
1070
+
1071
+ product_id += f"_{query_hash}"
1072
+
1073
+ properties["id"] = properties["title"] = product_id
1074
+
1075
+ # used by server mode to generate downloadlink href
1076
+ properties["_dc_qs"] = quote_plus(qs)
1104
1077
 
1105
1078
  product = EOProduct(
1106
1079
  provider=self.provider,
1107
1080
  productType=product_type,
1108
- properties=parsed_properties,
1081
+ properties=properties,
1109
1082
  )
1110
1083
 
1111
1084
  return [
@@ -1182,17 +1155,15 @@ class MeteoblueSearch(ECMWFSearch):
1182
1155
  return [response.json()]
1183
1156
 
1184
1157
  def build_query_string(
1185
- self, product_type: str, **kwargs: Any
1158
+ self, product_type: str, query_dict: dict[str, Any]
1186
1159
  ) -> tuple[dict[str, Any], str]:
1187
1160
  """Build The query string using the search parameters
1188
1161
 
1189
1162
  :param product_type: product type id
1190
- :param kwargs: keyword arguments to be used in the query string
1163
+ :param query_dict: keyword arguments to be used in the query string
1191
1164
  :return: formatted query params and encode query string
1192
1165
  """
1193
- return QueryStringSearch.build_query_string(
1194
- self, product_type=product_type, **kwargs
1195
- )
1166
+ return QueryStringSearch.build_query_string(self, product_type, query_dict)
1196
1167
 
1197
1168
 
1198
1169
  class WekeoECMWFSearch(ECMWFSearch):
@@ -1256,25 +1227,3 @@ class WekeoECMWFSearch(ECMWFSearch):
1256
1227
  :return: list containing the results from the provider in json format
1257
1228
  """
1258
1229
  return QueryStringSearch.do_search(self, *args, **kwargs)
1259
-
1260
- def build_query_string(
1261
- self, product_type: str, **kwargs: Any
1262
- ) -> tuple[dict[str, Any], str]:
1263
- """Build The query string using the search parameters
1264
-
1265
- :param product_type: product type id
1266
- :param kwargs: keyword arguments to be used in the query string
1267
- :return: formatted query params and encode query string
1268
- """
1269
- # Reorder kwargs to make sure year/month/day/time if set overwrite default datetime.
1270
- # strip_quotes to remove duplicated quotes like "'1_1'" produced by convertors like to_geojson.
1271
- priority_keys = [
1272
- "startTimeFromAscendingNode",
1273
- "completionTimeFromAscendingNode",
1274
- ]
1275
- ordered_kwargs = {k: kwargs[k] for k in priority_keys if k in kwargs}
1276
- ordered_kwargs.update({k: strip_quotes(v) for k, v in kwargs.items()})
1277
-
1278
- return QueryStringSearch.build_query_string(
1279
- self, product_type=product_type, **ordered_kwargs
1280
- )