eodag 4.0.0a4__py3-none-any.whl → 4.0.0b1__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.
Files changed (42) hide show
  1. eodag/api/collection.py +65 -1
  2. eodag/api/core.py +65 -19
  3. eodag/api/product/_assets.py +1 -1
  4. eodag/api/product/_product.py +133 -18
  5. eodag/api/product/drivers/__init__.py +3 -1
  6. eodag/api/product/drivers/base.py +3 -1
  7. eodag/api/product/drivers/generic.py +9 -5
  8. eodag/api/product/drivers/sentinel1.py +14 -9
  9. eodag/api/product/drivers/sentinel2.py +14 -7
  10. eodag/api/product/metadata_mapping.py +5 -2
  11. eodag/api/provider.py +1 -0
  12. eodag/api/search_result.py +4 -1
  13. eodag/cli.py +17 -8
  14. eodag/config.py +22 -4
  15. eodag/plugins/apis/ecmwf.py +3 -24
  16. eodag/plugins/apis/usgs.py +3 -24
  17. eodag/plugins/download/aws.py +85 -44
  18. eodag/plugins/download/base.py +117 -41
  19. eodag/plugins/download/http.py +88 -65
  20. eodag/plugins/search/base.py +8 -3
  21. eodag/plugins/search/build_search_result.py +108 -120
  22. eodag/plugins/search/cop_marine.py +3 -1
  23. eodag/plugins/search/qssearch.py +7 -6
  24. eodag/resources/collections.yml +255 -0
  25. eodag/resources/ext_collections.json +1 -1
  26. eodag/resources/ext_product_types.json +1 -1
  27. eodag/resources/providers.yml +62 -25
  28. eodag/resources/user_conf_template.yml +6 -0
  29. eodag/types/__init__.py +22 -16
  30. eodag/types/download_args.py +3 -1
  31. eodag/types/queryables.py +125 -55
  32. eodag/types/stac_extensions.py +408 -0
  33. eodag/types/stac_metadata.py +312 -0
  34. eodag/utils/__init__.py +42 -4
  35. eodag/utils/dates.py +202 -2
  36. eodag/utils/s3.py +4 -4
  37. {eodag-4.0.0a4.dist-info → eodag-4.0.0b1.dist-info}/METADATA +7 -13
  38. {eodag-4.0.0a4.dist-info → eodag-4.0.0b1.dist-info}/RECORD +42 -40
  39. {eodag-4.0.0a4.dist-info → eodag-4.0.0b1.dist-info}/WHEEL +1 -1
  40. {eodag-4.0.0a4.dist-info → eodag-4.0.0b1.dist-info}/entry_points.txt +1 -1
  41. {eodag-4.0.0a4.dist-info → eodag-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
  42. {eodag-4.0.0a4.dist-info → eodag-4.0.0b1.dist-info}/top_level.txt +0 -0
@@ -21,9 +21,9 @@ import hashlib
21
21
  import logging
22
22
  import re
23
23
  from collections import OrderedDict
24
- from datetime import date, datetime, timedelta, timezone
24
+ from datetime import datetime, timedelta
25
25
  from types import MethodType
26
- from typing import TYPE_CHECKING, Annotated, Any, Optional, Union
26
+ from typing import TYPE_CHECKING, Annotated, Any, Optional
27
27
  from urllib.parse import quote_plus, unquote_plus
28
28
 
29
29
  import geojson
@@ -31,6 +31,7 @@ import orjson
31
31
  from dateutil.parser import isoparse
32
32
  from dateutil.tz import tzutc
33
33
  from dateutil.utils import today
34
+ from pydantic import AliasChoices
34
35
  from pydantic.fields import FieldInfo
35
36
  from requests.auth import AuthBase
36
37
  from typing_extensions import get_args # noqa: F401
@@ -58,10 +59,18 @@ from eodag.utils import (
58
59
  format_string,
59
60
  get_geometry_from_ecmwf_area,
60
61
  get_geometry_from_ecmwf_feature,
62
+ get_geometry_from_ecmwf_location,
61
63
  get_geometry_from_various,
62
64
  )
63
65
  from eodag.utils.cache import instance_cached_method
64
- from eodag.utils.dates import is_range_in_range
66
+ from eodag.utils.dates import (
67
+ COMPACT_DATE_RANGE_PATTERN,
68
+ DATE_RANGE_PATTERN,
69
+ format_date,
70
+ is_range_in_range,
71
+ parse_date,
72
+ parse_year_month_day,
73
+ )
65
74
  from eodag.utils.exceptions import DownloadError, NotAvailableError, ValidationError
66
75
  from eodag.utils.requests import fetch_json
67
76
 
@@ -184,6 +193,7 @@ COP_DS_KEYWORDS = {
184
193
  "region",
185
194
  "release_version",
186
195
  "satellite",
196
+ "satellite_mission",
187
197
  "sensor",
188
198
  "sensor_and_algorithm",
189
199
  "soil_level",
@@ -285,6 +295,27 @@ def _update_properties_from_element(
285
295
  if element["type"] == "DateRangeWidget":
286
296
  prop["description"] = "date formatted like yyyy-mm-dd/yyyy-mm-dd"
287
297
 
298
+ # a single geographic location
299
+ if element["type"] == "GeographicLocationWidget":
300
+ prop.update(
301
+ {
302
+ "type": "object",
303
+ "description": "Longitude and latitude of a single location",
304
+ "properties": {
305
+ "longitude": {
306
+ "type": "number",
307
+ "maximum": 180,
308
+ "minimum": -180,
309
+ },
310
+ "latitude": {
311
+ "type": "number",
312
+ "maximum": 90,
313
+ "minimum": -90,
314
+ },
315
+ },
316
+ }
317
+ )
318
+
288
319
  if description := element.get("help"):
289
320
  prop["description"] = description
290
321
 
@@ -307,93 +338,6 @@ def ecmwf_format(v: str, alias: bool = True) -> str:
307
338
  return f"{ECMWF_PREFIX[:-1]}{separator}{v}" if v in ALLOWED_KEYWORDS else v
308
339
 
309
340
 
310
- def get_min_max(
311
- value: Optional[Union[str, list[str]]] = None,
312
- ) -> tuple[Optional[str], Optional[str]]:
313
- """Returns the min and max from a list of strings or the same string if a single string is given."""
314
- if isinstance(value, list):
315
- sorted_values = sorted(value)
316
- return sorted_values[0], sorted_values[-1]
317
- return value, value
318
-
319
-
320
- def append_time(input_date: date, time: Optional[str]) -> datetime:
321
- """
322
- Parses a time string in format HHMM and appends it to a date.
323
-
324
- if the time string is in format HH:MM or HH_MM we convert it to HHMM
325
- """
326
- if not time:
327
- time = "0000"
328
- time = re.sub(":|_", "", time)
329
- if time == "2400":
330
- time = "0000"
331
- dt = datetime.combine(input_date, datetime.strptime(time, "%H%M").time())
332
- dt.replace(tzinfo=timezone.utc)
333
- return dt
334
-
335
-
336
- def parse_date(
337
- date: str, time: Optional[Union[str, list[str]]]
338
- ) -> tuple[datetime, datetime]:
339
- """Parses a date string in formats YYYY-MM-DD, YYYMMDD, solo or in start/end or start/to/end intervals."""
340
- if "to" in date:
341
- start_date_str, end_date_str = date.split("/to/")
342
- elif "/" in date:
343
- dates = date.split("/")
344
- start_date_str = dates[0]
345
- end_date_str = dates[-1]
346
- else:
347
- start_date_str = end_date_str = date
348
-
349
- # Update YYYYMMDD formatted dates
350
- if re.match(r"^\d{8}$", start_date_str):
351
- start_date_str = (
352
- f"{start_date_str[:4]}-{start_date_str[4:6]}-{start_date_str[6:]}"
353
- )
354
- if re.match(r"^\d{8}$", end_date_str):
355
- end_date_str = f"{end_date_str[:4]}-{end_date_str[4:6]}-{end_date_str[6:]}"
356
-
357
- start_date = datetime.fromisoformat(start_date_str.rstrip("Z"))
358
- end_date = datetime.fromisoformat(end_date_str.rstrip("Z"))
359
-
360
- if time:
361
- start_t, end_t = get_min_max(time)
362
- start_date = append_time(start_date.date(), start_t)
363
- end_date = append_time(end_date.date(), end_t)
364
-
365
- return start_date, end_date
366
-
367
-
368
- def parse_year_month_day(
369
- year: Union[str, list[str]],
370
- month: Optional[Union[str, list[str]]] = None,
371
- day: Optional[Union[str, list[str]]] = None,
372
- time: Optional[Union[str, list[str]]] = None,
373
- ) -> tuple[datetime, datetime]:
374
- """Extracts and returns the year, month, day, and time from the parameters."""
375
-
376
- def build_date(year, month=None, day=None, time=None) -> datetime:
377
- """Datetime from default_date with updated year, month, day and time."""
378
- updated_date = datetime(int(year), 1, 1).replace(
379
- month=int(month) if month is not None else 1,
380
- day=int(day) if day is not None else 1,
381
- )
382
- if time is not None:
383
- updated_date = append_time(updated_date.date(), time)
384
- return updated_date
385
-
386
- start_y, end_y = get_min_max(year)
387
- start_m, end_m = get_min_max(month)
388
- start_d, end_d = get_min_max(day)
389
- start_t, end_t = get_min_max(time)
390
-
391
- start_date = build_date(start_y, start_m, start_d, start_t)
392
- end_date = build_date(end_y, end_m, end_d, end_t)
393
-
394
- return start_date, end_date
395
-
396
-
397
341
  def ecmwf_temporal_to_eodag(
398
342
  params: dict[str, Any]
399
343
  ) -> tuple[Optional[str], Optional[str]]:
@@ -627,6 +571,12 @@ class ECMWFSearch(PostJsonSearch):
627
571
  if getattr(self.config, "dates_required", False):
628
572
  self._check_date_params(params, collection)
629
573
 
574
+ # read 'start_datetime' and 'end_datetime' from 'date' range
575
+ if "date" in params:
576
+ start_date, end_date = parse_date(params["date"])
577
+ params[START] = format_date(start_date)
578
+ params[END] = format_date(end_date)
579
+
630
580
  # adapt end date if it is midnight
631
581
  if END in params:
632
582
  end_date_excluded = getattr(self.config, "end_date_excluded", True)
@@ -666,6 +616,10 @@ class ECMWFSearch(PostJsonSearch):
666
616
  if "area" in params:
667
617
  params["geometry"] = get_geometry_from_ecmwf_area(params["area"])
668
618
  params.pop("area")
619
+ # single location
620
+ if "location" in params:
621
+ params["geometry"] = get_geometry_from_ecmwf_location(params["location"])
622
+ params.pop("location")
669
623
 
670
624
  return params
671
625
 
@@ -870,9 +824,7 @@ class ECMWFSearch(PostJsonSearch):
870
824
  )
871
825
  not in set(list(available_values.keys()) + [f["name"] for f in form])
872
826
  ):
873
- raise ValidationError(
874
- f"'{keyword}' is not a queryable parameter", {keyword}
875
- )
827
+ raise ValidationError("'%s' is not a queryable parameter" % keyword)
876
828
 
877
829
  # generate queryables
878
830
  if form:
@@ -890,7 +842,7 @@ class ECMWFSearch(PostJsonSearch):
890
842
  # start and end filters are supported whenever combinations of "year", "month", "day" filters exist
891
843
  queryable_prefix = f"{ECMWF_PREFIX[:-1]}_"
892
844
  if (
893
- queryables.pop(f"{queryable_prefix}date", None)
845
+ f"{queryable_prefix}date" in queryables
894
846
  or f"{queryable_prefix}year" in queryables
895
847
  or f"{queryable_prefix}hyear" in queryables
896
848
  ):
@@ -959,13 +911,21 @@ class ECMWFSearch(PostJsonSearch):
959
911
  )
960
912
 
961
913
  # We convert every single value to a list of string
962
- filter_v = values if isinstance(values, (list, tuple)) else [values]
914
+ filter_v = list(values) if isinstance(values, tuple) else values
915
+ filter_v = filter_v if isinstance(filter_v, list) else [filter_v]
963
916
 
964
917
  # We strip values of superfluous quotes (added by mapping converter to_geojson).
965
- # ECMWF accept values with /to/. We need to split it to an array
966
- # ECMWF accept values in format val1/val2. We need to split it to an array
967
- sep = re.compile(r"/to/|/")
968
- filter_v = [i for v in filter_v for i in sep.split(str(v))]
918
+ # ECMWF accept date ranges with /to/. We need to split it to an array
919
+ # ECMWF accept date ranges in format val1/val2. We need to split it to an array
920
+ date_regex = [
921
+ re.compile(p) for p in (DATE_RANGE_PATTERN, COMPACT_DATE_RANGE_PATTERN)
922
+ ]
923
+ is_date = any(
924
+ any(r.match(v) is not None for r in date_regex) for v in filter_v
925
+ )
926
+ if is_date:
927
+ sep = re.compile(r"/to/|/")
928
+ filter_v = [i for v in filter_v for i in sep.split(str(v))]
969
929
 
970
930
  # special handling for time 0000 converted to 0 by pre-formating with metadata_mapping
971
931
  if keyword.split(":")[-1] == "time":
@@ -976,23 +936,35 @@ class ECMWFSearch(PostJsonSearch):
976
936
 
977
937
  # Filter constraints and check for missing values
978
938
  filtered_constraints = []
939
+ # True if some constraint is defined for this keyword.
940
+ # In other words: if no constraint defines a list of values
941
+ # then any value is allowed for this keyword
942
+ keyword_constrained = False
979
943
  for entry in constraints:
980
944
  # Filter based on the presence of any value in filter_v
981
945
  entry_values = entry.get(keyword, [])
946
+ if entry_values:
947
+ keyword_constrained = True
982
948
 
983
- # date constraint may be intervals. We identify intervals with a "/" in the value
984
- # we assume that if the first value is an interval, all values are intervals
949
+ # date constraint may be intervals. We identify intervals with a "/" in the value.
950
+ # date constraint can be a mixed list of single values (e.g "2023-06-27")
951
+ # and intervals (e.g. "2024-11-12/2025-11-20").
952
+ # collections with mixed values: CAMS_GAC_FORECAST, CAMS_EU_AIR_QUALITY_FORECAST
985
953
  present_values = []
986
- if keyword == "date" and "/" in entry[keyword][0]:
987
- input_range = values
988
- if isinstance(values, list):
989
- input_range = values[0]
990
- if any(is_range_in_range(x, input_range) for x in entry[keyword]):
991
- present_values = filter_v
992
- else:
993
- present_values = [
994
- value for value in filter_v if value in entry_values
995
- ]
954
+ for entry_value in entry_values:
955
+ if keyword == "date" and "/" in entry_value:
956
+ input_range = values
957
+ if isinstance(values, list):
958
+ input_range = values[0]
959
+ if "/" not in input_range:
960
+ input_range = f"{input_range}/{input_range}"
961
+ if is_range_in_range(entry_value, input_range):
962
+ present_values.extend(filter_v)
963
+ else:
964
+ new_values = [
965
+ value for value in filter_v if value == entry_value
966
+ ]
967
+ present_values.extend(new_values)
996
968
 
997
969
  # Remove present values from the missing_values set
998
970
  missing_values -= set(present_values)
@@ -1002,7 +974,7 @@ class ECMWFSearch(PostJsonSearch):
1002
974
 
1003
975
  # raise an error as no constraint entry matched the input keywords
1004
976
  # raise an error if one value from input is not allowed
1005
- if not filtered_constraints or missing_values:
977
+ if keyword_constrained and (not filtered_constraints or missing_values):
1006
978
  allowed_values = list(
1007
979
  {value for c in constraints for value in c.get(keyword, [])}
1008
980
  )
@@ -1028,7 +1000,9 @@ class ECMWFSearch(PostJsonSearch):
1028
1000
  )
1029
1001
 
1030
1002
  parsed_keywords.append(keyword)
1031
- constraints = filtered_constraints
1003
+ # if the keyword is not constrained then any value is allowed
1004
+ if keyword_constrained:
1005
+ constraints = filtered_constraints
1032
1006
 
1033
1007
  available_values: dict[str, Any] = {k: set() for k in ordered_keywords}
1034
1008
 
@@ -1100,9 +1074,21 @@ class ECMWFSearch(PostJsonSearch):
1100
1074
  if default and prop.get("type") == "string" and isinstance(default, list):
1101
1075
  default = ",".join(default)
1102
1076
 
1103
- is_required = bool(element.get("required")) and bool(
1104
- available_values.get(name)
1105
- )
1077
+ is_required: bool
1078
+ if available_values.get(name):
1079
+ # required by the filtered constraints (available_values[name] is a not empty list)
1080
+ is_required = True
1081
+ elif bool(element.get("required")):
1082
+ if name in available_values and not available_values[name]:
1083
+ # not required by the filtered constraints (available_values[name] is an empty list)
1084
+ is_required = False
1085
+ else:
1086
+ # required only by form
1087
+ is_required = True
1088
+ else:
1089
+ # not required by form
1090
+ is_required = False
1091
+
1106
1092
  if is_required:
1107
1093
  required_list.append(name)
1108
1094
 
@@ -1114,7 +1100,8 @@ class ECMWFSearch(PostJsonSearch):
1114
1100
  prop,
1115
1101
  default_value=default,
1116
1102
  required=is_required,
1117
- alias=formatted_alias,
1103
+ validation_alias=AliasChoices(formatted_alias, name),
1104
+ serialization_alias=formatted_alias,
1118
1105
  )
1119
1106
  )
1120
1107
  ]
@@ -1153,7 +1140,8 @@ class ECMWFSearch(PostJsonSearch):
1153
1140
  {"type": "string", "title": name, "enum": values},
1154
1141
  default_value=defaults.get(name),
1155
1142
  required=bool(formatted_alias in required),
1156
- alias=formatted_alias,
1143
+ validation_alias=AliasChoices(formatted_alias, name),
1144
+ serialization_alias=formatted_alias,
1157
1145
  )
1158
1146
  )
1159
1147
  ]
@@ -1279,7 +1267,7 @@ class ECMWFSearch(PostJsonSearch):
1279
1267
 
1280
1268
  # collection alias (required by opentelemetry-instrumentation-eodag)
1281
1269
  if alias := getattr(self.config, "collection_config", {}).get("alias"):
1282
- properties["eodag:alias"] = alias
1270
+ kwargs["collection"] = alias
1283
1271
 
1284
1272
  qs = geojson.dumps(sorted_unpaginated_qp)
1285
1273
 
@@ -1521,7 +1509,7 @@ class MeteoblueSearch(ECMWFSearch):
1521
1509
  properties = {ecmwf_format(k): v for k, v in parsed_properties.items()}
1522
1510
  # collection alias (required by opentelemetry-instrumentation-eodag)
1523
1511
  if alias := getattr(self.config, "collection_config", {}).get("alias"):
1524
- properties["eodag:alias"] = alias
1512
+ collection = alias
1525
1513
 
1526
1514
  def slugify(date_str: str) -> str:
1527
1515
  return date_str.split("T")[0].replace("-", "")
@@ -512,9 +512,11 @@ class CopMarineSearch(StaticStacSearch):
512
512
  else {}
513
513
  )
514
514
 
515
+ number_matched = num_total if prep.count else None
516
+
515
517
  formated_result = SearchResult(
516
518
  products,
517
- num_total,
519
+ number_matched,
518
520
  search_params=search_params,
519
521
  next_page_token=str(start_index + 1),
520
522
  )
@@ -28,7 +28,6 @@ from typing import (
28
28
  Callable,
29
29
  Optional,
30
30
  Sequence,
31
- TypedDict,
32
31
  cast,
33
32
  get_args,
34
33
  )
@@ -58,6 +57,7 @@ from pydantic.fields import FieldInfo
58
57
  from requests import Response
59
58
  from requests.adapters import HTTPAdapter
60
59
  from requests.auth import AuthBase
60
+ from typing_extensions import TypedDict
61
61
  from urllib3 import Retry
62
62
 
63
63
  from eodag.api.product import EOProduct
@@ -1283,7 +1283,7 @@ class QueryStringSearch(Search):
1283
1283
  )
1284
1284
  # collection alias (required by opentelemetry-instrumentation-eodag)
1285
1285
  if alias := getattr(self.config, "collection_config", {}).get("alias"):
1286
- properties["eodag:alias"] = alias
1286
+ kwargs["collection"] = alias
1287
1287
  product = EOProduct(self.provider, properties, **kwargs)
1288
1288
 
1289
1289
  additional_assets = self.get_assets_from_mapping(result)
@@ -2166,17 +2166,18 @@ class StacSearch(PostJsonSearch):
2166
2166
  # convert json results to pydantic model fields
2167
2167
  field_definitions: dict[str, Any] = dict()
2168
2168
  eodag_queryables_and_defaults: list[tuple[str, Any]] = []
2169
+ StacQueryables = Queryables.from_stac_models()
2169
2170
  for json_param, json_mtd in json_queryables.items():
2170
2171
  param = get_queryable_from_provider(
2171
2172
  json_param, self.get_metadata_mapping(collection)
2172
- ) or Queryables.get_queryable_from_alias(json_param)
2173
+ ) or StacQueryables.get_queryable_from_alias(json_param)
2173
2174
  # do not expose internal parameters, neither datetime
2174
2175
  if param == "datetime" or param.startswith("_"):
2175
2176
  continue
2176
2177
 
2177
2178
  default = kwargs.get(param, json_mtd.get("default"))
2178
2179
 
2179
- if param in Queryables.model_fields:
2180
+ if param in StacQueryables.model_fields:
2180
2181
  # use eodag queryable as default
2181
2182
  eodag_queryables_and_defaults += [(param, default)]
2182
2183
  continue
@@ -2195,12 +2196,12 @@ class StacSearch(PostJsonSearch):
2195
2196
 
2196
2197
  # append eodag queryables
2197
2198
  for param, default in eodag_queryables_and_defaults:
2198
- queryables_dict[param] = Queryables.get_with_default(param, default)
2199
+ queryables_dict[param] = StacQueryables.get_with_default(param, default)
2199
2200
 
2200
2201
  # append "datetime" as "start" & "end" if needed
2201
2202
  if "datetime" in json_queryables:
2202
2203
  eodag_queryables = copy_deepcopy(
2203
- model_fields_to_annotated(Queryables.model_fields)
2204
+ model_fields_to_annotated(StacQueryables.model_fields)
2204
2205
  )
2205
2206
  queryables_dict.setdefault("start", eodag_queryables["start"])
2206
2207
  queryables_dict.setdefault("end", eodag_queryables["end"])