eodag 3.1.0b2__py3-none-any.whl → 3.2.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.
@@ -22,15 +22,15 @@ import hashlib
22
22
  import logging
23
23
  import re
24
24
  from collections import OrderedDict
25
- from datetime import datetime, timedelta
26
- from typing import TYPE_CHECKING, Annotated, Any, Optional, Union, cast
25
+ from datetime import date, datetime, timedelta, timezone
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"
194
199
 
200
+ START = "startTimeFromAscendingNode"
195
201
 
196
- def keywords_to_mdt(
197
- keywords: list[str], prefix: Optional[str] = None
198
- ) -> dict[str, Any]:
202
+
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,126 @@ 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
287
+
288
+
289
+ def get_min_max(
290
+ value: Optional[Union[str, list[str]]] = None,
291
+ ) -> tuple[Optional[str], Optional[str]]:
292
+ """Returns the min and max from a list of strings or the same string if a single string is given."""
293
+ if isinstance(value, list):
294
+ sorted_values = sorted(value)
295
+ return sorted_values[0], sorted_values[-1]
296
+ return value, value
297
+
298
+
299
+ def append_time(input_date: date, time: Optional[str]) -> datetime:
300
+ """
301
+ Parses a time string in format HHMM and appends it to a date.
302
+
303
+ if the time string is in format HH:MM we convert it to HHMM
304
+ """
305
+ if not time:
306
+ time = "0000"
307
+ time = time.replace(":", "")
308
+ if time == "2400":
309
+ time = "0000"
310
+ dt = datetime.combine(input_date, datetime.strptime(time, "%H%M").time())
311
+ dt.replace(tzinfo=timezone.utc)
312
+ return dt
313
+
314
+
315
+ def parse_date(
316
+ date_str: str, time: Optional[Union[str, list[str]]]
317
+ ) -> 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."""
319
+ if "to" in date_str:
320
+ start_date_str, end_date_str = date_str.split("/to/")
321
+ elif "/" in date_str:
322
+ dates = date_str.split("/")
323
+ start_date_str = dates[0]
324
+ end_date_str = dates[-1]
325
+ else:
326
+ start_date_str = end_date_str = date_str
327
+
328
+ start_date = datetime.fromisoformat(start_date_str.rstrip("Z"))
329
+ end_date = datetime.fromisoformat(end_date_str.rstrip("Z"))
330
+
331
+ if time:
332
+ start_t, end_t = get_min_max(time)
333
+ start_date = append_time(start_date.date(), start_t)
334
+ end_date = append_time(end_date.date(), end_t)
335
+
336
+ return start_date, end_date
337
+
338
+
339
+ def parse_year_month_day(
340
+ year: Union[str, list[str]],
341
+ month: Optional[Union[str, list[str]]] = None,
342
+ day: Optional[Union[str, list[str]]] = None,
343
+ time: Optional[Union[str, list[str]]] = None,
344
+ ) -> tuple[datetime, datetime]:
345
+ """Extracts and returns the year, month, day, and time from the parameters."""
346
+
347
+ def build_date(year, month=None, day=None, time=None) -> datetime:
348
+ """Datetime from default_date with updated year, month, day and time."""
349
+ updated_date = datetime(int(year), 1, 1).replace(
350
+ month=int(month) if month is not None else 1,
351
+ day=int(day) if day is not None else 1,
352
+ )
353
+ if time is not None:
354
+ updated_date = append_time(updated_date.date(), time)
355
+ return updated_date
356
+
357
+ start_y, end_y = get_min_max(year)
358
+ start_m, end_m = get_min_max(month)
359
+ start_d, end_d = get_min_max(day)
360
+ start_t, end_t = get_min_max(time)
361
+
362
+ start_date = build_date(start_y, start_m, start_d, start_t)
363
+ end_date = build_date(end_y, end_m, end_d, end_t)
364
+
365
+ return start_date, end_date
366
+
367
+
368
+ def ecmwf_temporal_to_eodag(
369
+ params: dict[str, Any]
370
+ ) -> tuple[Optional[str], Optional[str]]:
371
+ """
372
+ Converts ECMWF temporal parameters to EODAG temporal parameters.
373
+
374
+ ECMWF temporal parameters:
375
+ - **year** or **hyear**: Union[str, list[str]] — Year(s) as a string or list of strings.
376
+ - **month** or **hmonth**: Union[str, list[str]] — Month(s) as a string or list of strings.
377
+ - **day** or **hday**: Union[str, list[str]] — Day(s) as a string or list of strings.
378
+ - **time**: str — A string representing the time in the format `HHMM` (e.g., `0200`, `0800`, `1400`).
379
+ - **date**: str — A string in one of the formats:
380
+ - `YYYY-MM-DD`
381
+ - `YYYY-MM-DD/YYYY-MM-DD`
382
+ - `YYYY-MM-DD/to/YYYY-MM-DD`
383
+
384
+ :param params: Dictionary containing ECMWF temporal parameters.
385
+ :return: A tuple with:
386
+ - **start**: A string in the format `YYYY-MM-DDTHH:MM:SSZ`.
387
+ - **end**: A string in the format `YYYY-MM-DDTHH:MM:SSZ`.
388
+ """
389
+ start = end = None
390
+
391
+ if date := params.get("date"):
392
+ start, end = parse_date(date, params.get("time"))
393
+
394
+ elif year := params.get("year") or params.get("hyear"):
395
+ year = params.get("year") or params.get("hyear")
396
+ month = params.get("month") or params.get("hmonth")
397
+ day = params.get("day") or params.get("hday")
398
+ time = params.get("time")
399
+
400
+ start, end = parse_year_month_day(year, month, day, time)
401
+
402
+ if start and end:
403
+ return start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
404
+ else:
405
+ return None, None
313
406
 
314
407
 
315
408
  class ECMWFSearch(PostJsonSearch):
@@ -342,54 +435,25 @@ class ECMWFSearch(PostJsonSearch):
342
435
 
343
436
  def __init__(self, provider: str, config: PluginConfig) -> None:
344
437
  config.metadata_mapping = {
345
- **keywords_to_mdt(ECMWF_KEYWORDS + COP_DS_KEYWORDS, "ecmwf"),
438
+ **ecmwf_mtd(),
439
+ **{
440
+ "id": "$.id",
441
+ "title": "$.id",
442
+ "storageStatus": OFFLINE_STATUS,
443
+ "downloadLink": "$.null",
444
+ "geometry": ["feature", "$.geometry"],
445
+ "defaultGeometry": "POLYGON((180 -90, 180 90, -180 90, -180 -90, 180 -90))",
446
+ },
346
447
  **config.metadata_mapping,
347
448
  }
348
449
 
349
450
  super().__init__(provider, config)
350
451
 
452
+ # ECMWF providers do not feature any api_endpoint or next_page_query_obj.
453
+ # Searched is faked by EODAG.
351
454
  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
455
  self.config.pagination.setdefault("next_page_query_obj", "{{}}")
357
456
 
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
457
  def do_search(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
394
458
  """Should perform the actual search request.
395
459
 
@@ -414,7 +478,7 @@ class ECMWFSearch(PostJsonSearch):
414
478
  product_type = prep.product_type
415
479
  if not product_type:
416
480
  product_type = kwargs.get("productType", None)
417
- self._preprocess_search_params(kwargs, product_type)
481
+ kwargs = self._preprocess_search_params(kwargs, product_type)
418
482
  result, num_items = super().query(prep, **kwargs)
419
483
  if prep.count and not num_items:
420
484
  num_items = 1
@@ -426,34 +490,31 @@ class ECMWFSearch(PostJsonSearch):
426
490
  super().clear()
427
491
 
428
492
  def build_query_string(
429
- self, product_type: str, **kwargs: Any
493
+ self, product_type: str, query_dict: dict[str, Any]
430
494
  ) -> tuple[dict[str, Any], str]:
431
495
  """Build The query string using the search parameters
432
496
 
433
497
  :param product_type: product type id
434
- :param kwargs: keyword arguments to be used in the query string
498
+ :param query_dict: keyword arguments to be used in the query string
435
499
  :return: formatted query params and encode query string
436
500
  """
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
- }
501
+ query_dict["_date"] = f"{query_dict.get(START)}/{query_dict.get(END)}"
502
+
503
+ # Reorder kwargs to make sure year/month/day/time if set overwrite default datetime.
504
+ priority_keys = [
505
+ START,
506
+ END,
507
+ ]
508
+ ordered_kwargs = {k: query_dict[k] for k in priority_keys if k in query_dict}
509
+ ordered_kwargs.update(query_dict)
448
510
 
449
- # build and return the query
450
511
  return super().build_query_string(
451
- product_type=product_type, **available_properties
512
+ product_type=product_type, query_dict=ordered_kwargs
452
513
  )
453
514
 
454
515
  def _preprocess_search_params(
455
516
  self, params: dict[str, Any], product_type: Optional[str]
456
- ) -> None:
517
+ ) -> dict[str, Any]:
457
518
  """Preprocess search parameters before making a request to the CDS API.
458
519
 
459
520
  This method is responsible for checking and updating the provided search parameters
@@ -469,28 +530,20 @@ class ECMWFSearch(PostJsonSearch):
469
530
  # if available, update search params using datacube query-string
470
531
  _dc_qp = geojson.loads(unquote_plus(unquote_plus(_dc_qs)))
471
532
  if "/to/" in _dc_qp.get("date", ""):
472
- (
473
- params["startTimeFromAscendingNode"],
474
- params["completionTimeFromAscendingNode"],
475
- ) = _dc_qp["date"].split("/to/")
533
+ params[START], params[END] = _dc_qp["date"].split("/to/")
476
534
  elif "/" in _dc_qp.get("date", ""):
477
- (
478
- params["startTimeFromAscendingNode"],
479
- params["completionTimeFromAscendingNode"],
480
- ) = _dc_qp["date"].split("/")
535
+ (params[START], params[END],) = _dc_qp[
536
+ "date"
537
+ ].split("/")
481
538
  elif _dc_qp.get("date", None):
482
- params["startTimeFromAscendingNode"] = params[
483
- "completionTimeFromAscendingNode"
484
- ] = _dc_qp["date"]
539
+ params[START] = params[END] = _dc_qp["date"]
485
540
 
486
541
  if "/" in _dc_qp.get("area", ""):
487
542
  params["geometry"] = _dc_qp["area"].split("/")
488
543
 
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)
544
+ params = {
545
+ k.removeprefix(ECMWF_PREFIX): v for k, v in params.items() if v is not None
546
+ }
494
547
 
495
548
  # dates
496
549
  # check if default dates have to be added
@@ -498,25 +551,23 @@ class ECMWFSearch(PostJsonSearch):
498
551
  self._check_date_params(params, product_type)
499
552
 
500
553
  # adapt end date if it is midnight
501
- if "completionTimeFromAscendingNode" in params:
554
+ if END in params:
502
555
  end_date_excluded = getattr(self.config, "end_date_excluded", True)
503
556
  is_datetime = True
504
557
  try:
505
- end_date = datetime.strptime(
506
- params["completionTimeFromAscendingNode"], "%Y-%m-%dT%H:%M:%SZ"
507
- )
558
+ end_date = datetime.strptime(params[END], "%Y-%m-%dT%H:%M:%SZ")
508
559
  end_date = end_date.replace(tzinfo=tzutc())
509
560
  except ValueError:
510
561
  try:
511
562
  end_date = datetime.strptime(
512
- params["completionTimeFromAscendingNode"],
563
+ params[END],
513
564
  "%Y-%m-%dT%H:%M:%S.%fZ",
514
565
  )
515
566
  end_date = end_date.replace(tzinfo=tzutc())
516
567
  except ValueError:
517
- end_date = isoparse(params["completionTimeFromAscendingNode"])
568
+ end_date = isoparse(params[END])
518
569
  is_datetime = False
519
- start_date = isoparse(params["startTimeFromAscendingNode"])
570
+ start_date = isoparse(params[START])
520
571
  if (
521
572
  not end_date_excluded
522
573
  and is_datetime
@@ -525,12 +576,72 @@ class ECMWFSearch(PostJsonSearch):
525
576
  == end_date.replace(hour=0, minute=0, second=0, microsecond=0)
526
577
  ):
527
578
  end_date += timedelta(days=-1)
528
- params["completionTimeFromAscendingNode"] = end_date.isoformat()
579
+ params[END] = end_date.isoformat()
529
580
 
530
581
  # geometry
531
582
  if "geometry" in params:
532
583
  params["geometry"] = get_geometry_from_various(geometry=params["geometry"])
533
584
 
585
+ return params
586
+
587
+ def _check_date_params(
588
+ self, keywords: dict[str, Any], product_type: Optional[str]
589
+ ) -> None:
590
+ """checks if start and end date are present in the keywords and adds them if not"""
591
+
592
+ if START and END in keywords:
593
+ return
594
+
595
+ product_type_conf = getattr(self.config, "metadata_mapping", {})
596
+ if (
597
+ product_type
598
+ and product_type in self.config.products
599
+ and "metadata_mapping" in self.config.products[product_type]
600
+ ):
601
+ product_type_conf = self.config.products[product_type]["metadata_mapping"]
602
+
603
+ # start time given, end time missing
604
+ if START in keywords:
605
+ keywords[END] = (
606
+ keywords[START]
607
+ if END in product_type_conf
608
+ else self.get_product_type_cfg_value(
609
+ "missionEndDate", today().isoformat()
610
+ )
611
+ )
612
+ return
613
+
614
+ if END in product_type_conf:
615
+ mapping = product_type_conf[START]
616
+ if not isinstance(mapping, list):
617
+ mapping = product_type_conf[END]
618
+ 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
+ # if startTime is not given but other time params (e.g. year/month/(day)) are given,
628
+ # no default date is required
629
+ start, end = ecmwf_temporal_to_eodag(keywords)
630
+ if start is None:
631
+ keywords[START] = self.get_product_type_cfg_value(
632
+ "missionStartDate", DEFAULT_MISSION_START_DATE
633
+ )
634
+ keywords[END] = (
635
+ keywords[START]
636
+ if END in product_type_conf
637
+ else self.get_product_type_cfg_value(
638
+ "missionEndDate", today().isoformat()
639
+ )
640
+ )
641
+ else:
642
+ keywords[START] = start
643
+ keywords[END] = end
644
+
534
645
  def _get_product_type_queryables(
535
646
  self, product_type: Optional[str], alias: Optional[str], filters: dict[str, Any]
536
647
  ) -> QueryablesDict:
@@ -555,61 +666,60 @@ class ECMWFSearch(PostJsonSearch):
555
666
  :returns: fetched queryable parameters dict
556
667
  """
557
668
  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")
669
+
670
+ pt_config = self.get_product_type_def_params(product_type)
671
+
672
+ default_values = deepcopy(pt_config)
673
+ default_values.pop("metadata_mapping", None)
674
+ filters = {**default_values, **kwargs}
675
+
676
+ if "start" in filters:
677
+ filters[START] = filters.pop("start")
678
+ if "end" in filters:
679
+ filters[END] = filters.pop("end")
567
680
 
568
681
  # extract default datetime
569
- processed_kwargs = deepcopy(kwargs)
570
- self._preprocess_search_params(processed_kwargs, product_type)
682
+ processed_filters = self._preprocess_search_params(
683
+ deepcopy(filters), product_type
684
+ )
571
685
 
572
686
  constraints_url = format_metadata(
573
687
  getattr(self.config, "discover_queryables", {}).get("constraints_url", ""),
574
- **kwargs,
688
+ **filters,
575
689
  )
576
690
  constraints: list[dict[str, Any]] = self._fetch_data(constraints_url)
577
691
 
578
692
  form_url = format_metadata(
579
693
  getattr(self.config, "discover_queryables", {}).get("form_url", ""),
580
- **kwargs,
694
+ **filters,
581
695
  )
582
696
  form: list[dict[str, Any]] = self._fetch_data(form_url)
583
697
 
584
- formated_kwargs = self.format_as_provider_keyword(
585
- product_type, processed_kwargs
698
+ formated_filters = self.format_as_provider_keyword(
699
+ product_type, processed_filters
586
700
  )
587
701
  # 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",
702
+ for k, v in {**default_values, **kwargs}.items():
703
+ key = k.removeprefix(ECMWF_PREFIX)
704
+
705
+ if key not in ALLOWED_KEYWORDS | {
706
+ START,
707
+ END,
594
708
  "geom",
595
- ):
596
- formated_kwargs[key] = kwargs[key]
597
- else:
709
+ "geometry",
710
+ }:
598
711
  raise ValidationError(
599
712
  f"{key} is not a queryable parameter for {self.provider}"
600
713
  )
601
714
 
602
- # we use non empty kwargs as default to integrate user inputs
715
+ formated_filters[key] = v
716
+
717
+ # we use non empty filters as default to integrate user inputs
603
718
  # it is needed because pydantic json schema does not represent "value"
604
719
  # but only "default"
605
720
  non_empty_formated: dict[str, Any] = {
606
721
  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()
722
+ for k, v in formated_filters.items()
613
723
  if v and (not isinstance(v, list) or all(v))
614
724
  }
615
725
 
@@ -627,15 +737,16 @@ class ECMWFSearch(PostJsonSearch):
627
737
  # Pre-compute the required keywords (present in all constraint dicts)
628
738
  # when form, required keywords are extracted directly from form
629
739
  if not form:
630
- required_keywords = set(constraints[0].keys())
631
- for constraint in constraints[1:]:
632
- required_keywords.intersection_update(constraint.keys())
740
+ required_keywords = set.intersection(
741
+ *(map(lambda d: set(d.keys()), constraints))
742
+ )
743
+
633
744
  else:
634
745
  values_url = getattr(self.config, "available_values_url", "")
635
746
  if not values_url:
636
747
  return self.queryables_from_metadata_mapping(product_type)
637
748
  if "{" in values_url:
638
- values_url = values_url.format(productType=provider_product_type)
749
+ values_url = values_url.format(**filters)
639
750
  data = self._fetch_data(values_url)
640
751
  available_values = data["constraints"]
641
752
  required_keywords = data.get("required", [])
@@ -643,18 +754,18 @@ class ECMWFSearch(PostJsonSearch):
643
754
  # To check if all keywords are queryable parameters, we check if they are in the
644
755
  # available values or the product type config (available values calculated from the
645
756
  # constraints might not include all queryables)
646
- for keyword in kwargs:
757
+ for keyword in filters:
647
758
  if (
648
759
  keyword
649
760
  not in available_values.keys()
650
- | product_type_config.keys()
761
+ | pt_config.keys()
651
762
  | {
652
- "startTimeFromAscendingNode",
653
- "completionTimeFromAscendingNode",
763
+ START,
764
+ END,
654
765
  "geom",
655
766
  }
656
767
  and keyword not in [f["name"] for f in form]
657
- and keyword.replace("ecmwf:", "")
768
+ and keyword.removeprefix(ECMWF_PREFIX)
658
769
  not in set(list(available_values.keys()) + [f["name"] for f in form])
659
770
  ):
660
771
  raise ValidationError(f"{keyword} is not a queryable parameter")
@@ -668,24 +779,24 @@ class ECMWFSearch(PostJsonSearch):
668
779
  )
669
780
  else:
670
781
  queryables = self.queryables_by_values(
671
- available_values, list(required_keywords), non_empty_kwargs
782
+ available_values, list(required_keywords), non_empty_formated
672
783
  )
673
784
 
674
785
  # ecmwf:date is replaced by start and end.
675
786
  # start and end filters are supported whenever combinations of "year", "month", "day" filters exist
676
787
  if (
677
- queryables.pop("ecmwf:date", None)
678
- or "ecmwf:year" in queryables
679
- or "ecmwf:hyear" in queryables
788
+ queryables.pop(f"{ECMWF_PREFIX}date", None)
789
+ or f"{ECMWF_PREFIX}year" in queryables
790
+ or f"{ECMWF_PREFIX}hyear" in queryables
680
791
  ):
681
792
  queryables.update(
682
793
  {
683
794
  "start": Queryables.get_with_default(
684
- "start", non_empty_kwargs.get("startTimeFromAscendingNode")
795
+ "start", processed_filters.get(START)
685
796
  ),
686
797
  "end": Queryables.get_with_default(
687
798
  "end",
688
- non_empty_kwargs.get("completionTimeFromAscendingNode"),
799
+ processed_filters.get(END),
689
800
  ),
690
801
  }
691
802
  )
@@ -746,14 +857,15 @@ class ECMWFSearch(PostJsonSearch):
746
857
  raise ValidationError(
747
858
  f"Parameter value as object is not supported: {keyword}={values}"
748
859
  )
749
- filter_v = values if isinstance(values, (list, tuple)) else [values]
750
860
 
751
861
  # We convert every single value to a list of string
862
+ filter_v = values if isinstance(values, (list, tuple)) else [values]
863
+
752
864
  # We strip values of superfluous quotes (added by mapping converter to_geojson).
753
865
  # ECMWF accept values with /to/. We need to split it to an array
754
866
  # ECMWF accept values in format val1/val2. We need to split it to an array
755
867
  sep = re.compile(r"/to/|/")
756
- filter_v = [i for v in filter_v for i in sep.split(strip_quotes(v))]
868
+ filter_v = [i for v in filter_v for i in sep.split(str(v))]
757
869
 
758
870
  # special handling for time 0000 converted to 0 by pre-formating with metadata_mapping
759
871
  if keyword.split(":")[-1] == "time":
@@ -772,7 +884,10 @@ class ECMWFSearch(PostJsonSearch):
772
884
  # we assume that if the first value is an interval, all values are intervals
773
885
  present_values = []
774
886
  if keyword == "date" and "/" in entry[keyword][0]:
775
- if any(is_range_in_range(x, values[0]) for x in entry[keyword]):
887
+ input_range = values
888
+ if isinstance(values, list):
889
+ input_range = values[0]
890
+ if any(is_range_in_range(x, input_range) for x in entry[keyword]):
776
891
  present_values = filter_v
777
892
  else:
778
893
  present_values = [
@@ -792,12 +907,12 @@ class ECMWFSearch(PostJsonSearch):
792
907
  {value for c in constraints for value in c.get(keyword, [])}
793
908
  )
794
909
  # restore ecmwf: prefix before raising error
795
- keyword = f"ecmwf:{keyword}"
910
+ keyword = ECMWF_PREFIX + keyword
796
911
 
797
912
  all_keywords_str = ""
798
913
  if len(parsed_keywords) > 1:
799
914
  keywords = [
800
- f"ecmwf:{k}={pk}"
915
+ f"{ECMWF_PREFIX + k}={pk}"
801
916
  for k in parsed_keywords
802
917
  if (pk := input_keywords.get(k))
803
918
  ]
@@ -846,6 +961,8 @@ class ECMWFSearch(PostJsonSearch):
846
961
  if name in ("area_group", "global", "warning", "licences"):
847
962
  continue
848
963
  if "type" not in element or element["type"] == "FreeEditionWidget":
964
+ # FreeEditionWidget used to select the whole available region
965
+ # and to provide comments for the dataset
849
966
  continue
850
967
 
851
968
  # ordering done by id -> set id to high value if not present -> element will be last
@@ -876,12 +993,8 @@ class ECMWFSearch(PostJsonSearch):
876
993
  if name == "area" and isinstance(default, dict):
877
994
  default = list(default.values())
878
995
 
879
- if default:
880
- # We strip values of superfluous quotes (addded by mapping converter to_geojson).
881
- default = strip_quotes(default)
882
-
883
996
  # sometimes form returns default as array instead of string
884
- if default and prop["type"] == "string" and isinstance(default, list):
997
+ if default and prop.get("type") == "string" and isinstance(default, list):
885
998
  default = ",".join(default)
886
999
 
887
1000
  is_required = bool(element.get("required"))
@@ -925,13 +1038,11 @@ class ECMWFSearch(PostJsonSearch):
925
1038
  # Needed to map constraints like "xxxx" to eodag parameter "ecmwf:xxxx"
926
1039
  key = ecmwf_format(name)
927
1040
 
928
- default = defaults.get(key)
929
-
930
1041
  queryables[key] = Annotated[
931
1042
  get_args(
932
1043
  json_field_definition_to_python(
933
1044
  {"type": "string", "title": name, "enum": values},
934
- default_value=strip_quotes(default) if default else None,
1045
+ default_value=defaults.get(name),
935
1046
  required=bool(key in required),
936
1047
  )
937
1048
  )
@@ -948,16 +1059,26 @@ class ECMWFSearch(PostJsonSearch):
948
1059
  :param properties: dict of properties to be formatted
949
1060
  :return: dict of formatted properties
950
1061
  """
951
- parsed_properties = properties_from_json(
952
- properties,
953
- self.config.metadata_mapping,
1062
+ properties["productType"] = product_type
1063
+
1064
+ # provider product type specific conf
1065
+ product_type_def_params = self.get_product_type_def_params(
1066
+ product_type, format_variables=properties
954
1067
  )
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)
1068
+
1069
+ # Add to the query, the queryable parameters set in the provider product type definition
1070
+ properties.update(
1071
+ {
1072
+ k: v
1073
+ for k, v in product_type_def_params.items()
1074
+ if k not in properties.keys()
1075
+ and k in self.config.metadata_mapping.keys()
1076
+ and isinstance(self.config.metadata_mapping[k], list)
1077
+ }
1078
+ )
1079
+ qp, _ = self.build_query_string(product_type, properties)
1080
+
1081
+ return qp
961
1082
 
962
1083
  def _fetch_data(self, url: str) -> Any:
963
1084
  """
@@ -1045,67 +1166,37 @@ class ECMWFSearch(PostJsonSearch):
1045
1166
  discovery_config=getattr(self.config, "discover_metadata", {}),
1046
1167
  )
1047
1168
 
1048
- if not product_type:
1049
- product_type = parsed_properties.get("productType", None)
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
+ }
1174
+
1175
+ def slugify(date_str: str) -> str:
1176
+ return date_str.split("T")[0].replace("-", "")
1050
1177
 
1051
1178
  # 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
- )
1179
+ product_id = (product_type or kwargs.get("dataset") or self.provider).upper()
1098
1180
 
1099
- # use product_type_config as default properties
1100
- parsed_properties = dict(
1101
- getattr(self.config, "product_type_config", {}),
1102
- **parsed_properties,
1103
- )
1181
+ start = properties.get(START, NOT_AVAILABLE)
1182
+ end = properties.get(END, NOT_AVAILABLE)
1183
+
1184
+ if start != NOT_AVAILABLE:
1185
+ product_id += f"_{slugify(start)}"
1186
+ if end != NOT_AVAILABLE:
1187
+ product_id += f"_{slugify(end)}"
1188
+
1189
+ product_id += f"_{query_hash}"
1190
+
1191
+ properties["id"] = properties["title"] = product_id
1192
+
1193
+ # used by server mode to generate downloadlink href
1194
+ properties["_dc_qs"] = quote_plus(qs)
1104
1195
 
1105
1196
  product = EOProduct(
1106
1197
  provider=self.provider,
1107
1198
  productType=product_type,
1108
- properties=parsed_properties,
1199
+ properties=properties,
1109
1200
  )
1110
1201
 
1111
1202
  return [
@@ -1182,17 +1273,15 @@ class MeteoblueSearch(ECMWFSearch):
1182
1273
  return [response.json()]
1183
1274
 
1184
1275
  def build_query_string(
1185
- self, product_type: str, **kwargs: Any
1276
+ self, product_type: str, query_dict: dict[str, Any]
1186
1277
  ) -> tuple[dict[str, Any], str]:
1187
1278
  """Build The query string using the search parameters
1188
1279
 
1189
1280
  :param product_type: product type id
1190
- :param kwargs: keyword arguments to be used in the query string
1281
+ :param query_dict: keyword arguments to be used in the query string
1191
1282
  :return: formatted query params and encode query string
1192
1283
  """
1193
- return QueryStringSearch.build_query_string(
1194
- self, product_type=product_type, **kwargs
1195
- )
1284
+ return QueryStringSearch.build_query_string(self, product_type, query_dict)
1196
1285
 
1197
1286
 
1198
1287
  class WekeoECMWFSearch(ECMWFSearch):
@@ -1256,25 +1345,3 @@ class WekeoECMWFSearch(ECMWFSearch):
1256
1345
  :return: list containing the results from the provider in json format
1257
1346
  """
1258
1347
  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
- )