eodag 3.1.0b1__py3-none-any.whl → 3.1.0b2__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 (85) hide show
  1. eodag/api/core.py +59 -52
  2. eodag/api/product/_assets.py +5 -5
  3. eodag/api/product/_product.py +27 -12
  4. eodag/api/product/drivers/__init__.py +81 -4
  5. eodag/api/product/drivers/base.py +65 -4
  6. eodag/api/product/drivers/generic.py +65 -0
  7. eodag/api/product/drivers/sentinel1.py +97 -0
  8. eodag/api/product/drivers/sentinel2.py +95 -0
  9. eodag/api/product/metadata_mapping.py +62 -74
  10. eodag/api/search_result.py +13 -23
  11. eodag/cli.py +4 -4
  12. eodag/config.py +66 -69
  13. eodag/plugins/apis/base.py +1 -1
  14. eodag/plugins/apis/ecmwf.py +10 -9
  15. eodag/plugins/apis/usgs.py +11 -10
  16. eodag/plugins/authentication/aws_auth.py +16 -13
  17. eodag/plugins/authentication/base.py +5 -3
  18. eodag/plugins/authentication/header.py +3 -3
  19. eodag/plugins/authentication/keycloak.py +4 -4
  20. eodag/plugins/authentication/oauth.py +7 -3
  21. eodag/plugins/authentication/openid_connect.py +14 -14
  22. eodag/plugins/authentication/sas_auth.py +4 -4
  23. eodag/plugins/authentication/token.py +7 -7
  24. eodag/plugins/authentication/token_exchange.py +1 -1
  25. eodag/plugins/base.py +4 -4
  26. eodag/plugins/crunch/base.py +4 -4
  27. eodag/plugins/crunch/filter_date.py +4 -4
  28. eodag/plugins/crunch/filter_latest_intersect.py +6 -6
  29. eodag/plugins/crunch/filter_latest_tpl_name.py +7 -7
  30. eodag/plugins/crunch/filter_overlap.py +4 -4
  31. eodag/plugins/crunch/filter_property.py +4 -4
  32. eodag/plugins/download/aws.py +47 -66
  33. eodag/plugins/download/base.py +8 -17
  34. eodag/plugins/download/creodias_s3.py +2 -2
  35. eodag/plugins/download/http.py +30 -32
  36. eodag/plugins/download/s3rest.py +5 -4
  37. eodag/plugins/manager.py +10 -20
  38. eodag/plugins/search/__init__.py +6 -5
  39. eodag/plugins/search/base.py +35 -40
  40. eodag/plugins/search/build_search_result.py +69 -68
  41. eodag/plugins/search/cop_marine.py +22 -12
  42. eodag/plugins/search/creodias_s3.py +8 -78
  43. eodag/plugins/search/csw.py +11 -11
  44. eodag/plugins/search/data_request_search.py +16 -15
  45. eodag/plugins/search/qssearch.py +56 -52
  46. eodag/plugins/search/stac_list_assets.py +85 -0
  47. eodag/plugins/search/static_stac_search.py +3 -3
  48. eodag/resources/ext_product_types.json +1 -1
  49. eodag/resources/product_types.yml +288 -288
  50. eodag/resources/providers.yml +146 -6
  51. eodag/resources/stac_api.yml +2 -2
  52. eodag/resources/user_conf_template.yml +11 -0
  53. eodag/rest/cache.py +2 -2
  54. eodag/rest/config.py +3 -3
  55. eodag/rest/core.py +24 -24
  56. eodag/rest/errors.py +5 -5
  57. eodag/rest/server.py +3 -11
  58. eodag/rest/stac.py +40 -38
  59. eodag/rest/types/collections_search.py +3 -3
  60. eodag/rest/types/eodag_search.py +23 -23
  61. eodag/rest/types/queryables.py +13 -13
  62. eodag/rest/types/stac_search.py +15 -25
  63. eodag/rest/utils/__init__.py +11 -21
  64. eodag/rest/utils/cql_evaluate.py +6 -6
  65. eodag/rest/utils/rfc3339.py +2 -2
  66. eodag/types/__init__.py +24 -18
  67. eodag/types/bbox.py +2 -2
  68. eodag/types/download_args.py +2 -2
  69. eodag/types/queryables.py +5 -2
  70. eodag/types/search_args.py +4 -4
  71. eodag/types/whoosh.py +1 -3
  72. eodag/utils/__init__.py +81 -40
  73. eodag/utils/exceptions.py +2 -2
  74. eodag/utils/import_system.py +2 -2
  75. eodag/utils/requests.py +2 -2
  76. eodag/utils/rest.py +2 -2
  77. eodag/utils/s3.py +208 -0
  78. eodag/utils/stac_reader.py +10 -10
  79. {eodag-3.1.0b1.dist-info → eodag-3.1.0b2.dist-info}/METADATA +5 -4
  80. eodag-3.1.0b2.dist-info/RECORD +113 -0
  81. {eodag-3.1.0b1.dist-info → eodag-3.1.0b2.dist-info}/entry_points.txt +1 -0
  82. eodag-3.1.0b1.dist-info/RECORD +0 -108
  83. {eodag-3.1.0b1.dist-info → eodag-3.1.0b2.dist-info}/LICENSE +0 -0
  84. {eodag-3.1.0b1.dist-info → eodag-3.1.0b2.dist-info}/WHEEL +0 -0
  85. {eodag-3.1.0b1.dist-info → eodag-3.1.0b2.dist-info}/top_level.txt +0 -0
eodag/utils/__init__.py CHANGED
@@ -54,14 +54,10 @@ from typing import (
54
54
  TYPE_CHECKING,
55
55
  Any,
56
56
  Callable,
57
- Dict,
58
57
  Iterable,
59
58
  Iterator,
60
- List,
61
59
  Mapping,
62
60
  Optional,
63
- Tuple,
64
- Type,
65
61
  Union,
66
62
  cast,
67
63
  )
@@ -119,6 +115,7 @@ eodag_version = metadata("eodag")["Version"]
119
115
  USER_AGENT = {"User-Agent": f"eodag/{eodag_version}"}
120
116
 
121
117
  HTTP_REQ_TIMEOUT = 5 # in seconds
118
+ DEFAULT_SEARCH_TIMEOUT = 20 # in seconds
122
119
  DEFAULT_STREAM_REQUESTS_TIMEOUT = 60 # in seconds
123
120
 
124
121
  REQ_RETRY_TOTAL = 3
@@ -322,7 +319,7 @@ def path_to_uri(path: str) -> str:
322
319
  return Path(path).as_uri()
323
320
 
324
321
 
325
- def mutate_dict_in_place(func: Callable[[Any], Any], mapping: Dict[Any, Any]) -> None:
322
+ def mutate_dict_in_place(func: Callable[[Any], Any], mapping: dict[Any, Any]) -> None:
326
323
  """Apply func to values of mapping.
327
324
 
328
325
  The mapping object's values are modified in-place. The function is recursive,
@@ -340,7 +337,7 @@ def mutate_dict_in_place(func: Callable[[Any], Any], mapping: Dict[Any, Any]) ->
340
337
  mapping[key] = func(value)
341
338
 
342
339
 
343
- def merge_mappings(mapping1: Dict[Any, Any], mapping2: Dict[Any, Any]) -> None:
340
+ def merge_mappings(mapping1: dict[Any, Any], mapping2: dict[Any, Any]) -> None:
344
341
  """Merge two mappings with string keys, values from ``mapping2`` overriding values
345
342
  from ``mapping1``.
346
343
 
@@ -605,9 +602,49 @@ def rename_subfolder(dirpath: str, name: str) -> None:
605
602
  )
606
603
 
607
604
 
605
+ def rename_with_version(file_path: str, suffix: str = "old") -> str:
606
+ """
607
+ Renames a file by appending and incrementing a version number if a conflict exists.
608
+
609
+ :param file_path: full path of the file to rename
610
+ :param suffix: suffix preceding version number in case of name conflict
611
+ :returns: new file path with the version appended or incremented
612
+
613
+ Example:
614
+
615
+ >>> import tempfile
616
+ >>> from pathlib import Path
617
+ >>> with tempfile.TemporaryDirectory() as tmpdir:
618
+ ... file_path = (Path(tmpdir) / "foo.txt")
619
+ ... file_path.touch()
620
+ ... (Path(tmpdir) / "foo_old1.txt").touch()
621
+ ... expected = str(Path(tmpdir) / "foo_old2.txt")
622
+ ... assert expected == rename_with_version(str(file_path))
623
+
624
+ """
625
+ if not os.path.isfile(file_path):
626
+ raise FileNotFoundError(f"The file '{file_path}' does not exist.")
627
+
628
+ dir_path, file_name = os.path.split(file_path)
629
+ file_base, file_ext = os.path.splitext(file_name)
630
+
631
+ new_file_path = file_path
632
+
633
+ # loop and iterate on conflicting existing files
634
+ version = 0
635
+ while os.path.exists(new_file_path):
636
+ version += 1
637
+ new_file_name = f"{file_base}_{suffix}{version}{file_ext}"
638
+ new_file_path = os.path.join(dir_path, new_file_name)
639
+
640
+ # Rename the file
641
+ os.rename(file_path, new_file_path)
642
+ return new_file_path
643
+
644
+
608
645
  def format_dict_items(
609
- config_dict: Dict[str, Any], **format_variables: Any
610
- ) -> Dict[Any, Any]:
646
+ config_dict: dict[str, Any], **format_variables: Any
647
+ ) -> dict[Any, Any]:
611
648
  r"""Recursively apply :meth:`str.format` to ``**format_variables`` on ``config_dict`` values
612
649
 
613
650
  >>> format_dict_items(
@@ -624,8 +661,8 @@ def format_dict_items(
624
661
 
625
662
 
626
663
  def jsonpath_parse_dict_items(
627
- jsonpath_dict: Dict[str, Any], values_dict: Dict[str, Any]
628
- ) -> Dict[Any, Any]:
664
+ jsonpath_dict: dict[str, Any], values_dict: dict[str, Any]
665
+ ) -> dict[Any, Any]:
629
666
  """Recursively parse :class:`jsonpath_ng.JSONPath` elements in dict
630
667
 
631
668
  >>> import jsonpath_ng.ext as jsonpath
@@ -643,12 +680,12 @@ def jsonpath_parse_dict_items(
643
680
 
644
681
 
645
682
  def update_nested_dict(
646
- old_dict: Dict[Any, Any],
647
- new_dict: Dict[Any, Any],
683
+ old_dict: dict[Any, Any],
684
+ new_dict: dict[Any, Any],
648
685
  extend_list_values: bool = False,
649
686
  allow_empty_values: bool = False,
650
687
  allow_extend_duplicates: bool = True,
651
- ) -> Dict[Any, Any]:
688
+ ) -> dict[Any, Any]:
652
689
  """Update recursively ``old_dict`` items with ``new_dict`` ones
653
690
 
654
691
  >>> update_nested_dict(
@@ -728,10 +765,10 @@ def update_nested_dict(
728
765
 
729
766
 
730
767
  def items_recursive_apply(
731
- input_obj: Union[Dict[Any, Any], List[Any]],
768
+ input_obj: Union[dict[Any, Any], list[Any]],
732
769
  apply_method: Callable[..., Any],
733
770
  **apply_method_parameters: Any,
734
- ) -> Union[Dict[Any, Any], List[Any]]:
771
+ ) -> Union[dict[Any, Any], list[Any]]:
735
772
  """Recursive apply method to items contained in input object (dict or list)
736
773
 
737
774
  >>> items_recursive_apply(
@@ -769,10 +806,10 @@ def items_recursive_apply(
769
806
 
770
807
 
771
808
  def dict_items_recursive_apply(
772
- config_dict: Dict[Any, Any],
809
+ config_dict: dict[Any, Any],
773
810
  apply_method: Callable[..., Any],
774
811
  **apply_method_parameters: Any,
775
- ) -> Dict[Any, Any]:
812
+ ) -> dict[Any, Any]:
776
813
  """Recursive apply method to dict elements
777
814
 
778
815
  >>> dict_items_recursive_apply(
@@ -786,7 +823,7 @@ def dict_items_recursive_apply(
786
823
  :param apply_method_parameters: Optional parameters passed to the method
787
824
  :returns: Updated dict
788
825
  """
789
- result_dict: Dict[Any, Any] = deepcopy(config_dict)
826
+ result_dict: dict[Any, Any] = deepcopy(config_dict)
790
827
  for dict_k, dict_v in result_dict.items():
791
828
  if isinstance(dict_v, dict):
792
829
  result_dict[dict_k] = dict_items_recursive_apply(
@@ -794,7 +831,7 @@ def dict_items_recursive_apply(
794
831
  )
795
832
  elif any(isinstance(dict_v, t) for t in (list, tuple)):
796
833
  result_dict[dict_k] = list_items_recursive_apply(
797
- dict_v, apply_method, **apply_method_parameters
834
+ list(dict_v), apply_method, **apply_method_parameters
798
835
  )
799
836
  else:
800
837
  result_dict[dict_k] = apply_method(
@@ -805,10 +842,10 @@ def dict_items_recursive_apply(
805
842
 
806
843
 
807
844
  def list_items_recursive_apply(
808
- config_list: List[Any],
845
+ config_list: list[Any],
809
846
  apply_method: Callable[..., Any],
810
847
  **apply_method_parameters: Any,
811
- ) -> List[Any]:
848
+ ) -> list[Any]:
812
849
  """Recursive apply method to list elements
813
850
 
814
851
  >>> list_items_recursive_apply(
@@ -841,8 +878,8 @@ def list_items_recursive_apply(
841
878
 
842
879
 
843
880
  def items_recursive_sort(
844
- input_obj: Union[List[Any], Dict[Any, Any]],
845
- ) -> Union[List[Any], Dict[Any, Any]]:
881
+ input_obj: Union[list[Any], dict[Any, Any]],
882
+ ) -> Union[list[Any], dict[Any, Any]]:
846
883
  """Recursive sort dict items contained in input object (dict or list)
847
884
 
848
885
  >>> items_recursive_sort(
@@ -866,7 +903,7 @@ def items_recursive_sort(
866
903
  return input_obj
867
904
 
868
905
 
869
- def dict_items_recursive_sort(config_dict: Dict[Any, Any]) -> Dict[Any, Any]:
906
+ def dict_items_recursive_sort(config_dict: dict[Any, Any]) -> dict[Any, Any]:
870
907
  """Recursive sort dict elements
871
908
 
872
909
  >>> dict_items_recursive_sort(
@@ -877,7 +914,7 @@ def dict_items_recursive_sort(config_dict: Dict[Any, Any]) -> Dict[Any, Any]:
877
914
  :param config_dict: Input nested dictionary
878
915
  :returns: Updated dict
879
916
  """
880
- result_dict: Dict[Any, Any] = deepcopy(config_dict)
917
+ result_dict: dict[Any, Any] = deepcopy(config_dict)
881
918
  for dict_k, dict_v in result_dict.items():
882
919
  if isinstance(dict_v, dict):
883
920
  result_dict[dict_k] = dict_items_recursive_sort(dict_v)
@@ -889,7 +926,7 @@ def dict_items_recursive_sort(config_dict: Dict[Any, Any]) -> Dict[Any, Any]:
889
926
  return dict(sorted(result_dict.items()))
890
927
 
891
928
 
892
- def list_items_recursive_sort(config_list: List[Any]) -> List[Any]:
929
+ def list_items_recursive_sort(config_list: list[Any]) -> list[Any]:
893
930
  """Recursive sort dicts in list elements
894
931
 
895
932
  >>> list_items_recursive_sort(["b", {2: 0, 0: 1, 1: 2}])
@@ -898,7 +935,7 @@ def list_items_recursive_sort(config_list: List[Any]) -> List[Any]:
898
935
  :param config_list: Input list containing nested lists/dicts
899
936
  :returns: Updated list
900
937
  """
901
- result_list: List[Any] = deepcopy(config_list)
938
+ result_list: list[Any] = deepcopy(config_list)
902
939
  for list_idx, list_v in enumerate(result_list):
903
940
  if isinstance(list_v, dict):
904
941
  result_list[list_idx] = dict_items_recursive_sort(list_v)
@@ -1024,7 +1061,7 @@ def format_string(key: str, str_to_format: Any, **format_variables: Any) -> Any:
1024
1061
 
1025
1062
 
1026
1063
  def parse_jsonpath(
1027
- key: str, jsonpath_obj: Union[str, jsonpath.Child], **values_dict: Dict[str, Any]
1064
+ key: str, jsonpath_obj: Union[str, jsonpath.Child], **values_dict: dict[str, Any]
1028
1065
  ) -> Optional[str]:
1029
1066
  """Parse jsonpah in ``jsonpath_obj`` using ``values_dict``
1030
1067
 
@@ -1044,7 +1081,7 @@ def parse_jsonpath(
1044
1081
  return jsonpath_obj
1045
1082
 
1046
1083
 
1047
- def nested_pairs2dict(pairs: Union[List[Any], Any]) -> Union[Any, Dict[Any, Any]]:
1084
+ def nested_pairs2dict(pairs: Union[list[Any], Any]) -> Union[Any, dict[Any, Any]]:
1048
1085
  """Create a dict using nested pairs
1049
1086
 
1050
1087
  >>> nested_pairs2dict([["foo", [["bar", "baz"]]]])
@@ -1066,7 +1103,7 @@ def nested_pairs2dict(pairs: Union[List[Any], Any]) -> Union[Any, Dict[Any, Any]
1066
1103
 
1067
1104
 
1068
1105
  def get_geometry_from_various(
1069
- locations_config: List[Dict[str, Any]] = [], **query_args: Any
1106
+ locations_config: list[dict[str, Any]] = [], **query_args: Any
1070
1107
  ) -> BaseGeometry:
1071
1108
  """Creates a ``shapely.geometry`` using given query kwargs arguments
1072
1109
 
@@ -1242,7 +1279,7 @@ def _mutable_cached_yaml_load(config_path: str) -> Any:
1242
1279
  return yaml.load(fh, Loader=yaml.SafeLoader)
1243
1280
 
1244
1281
 
1245
- def cached_yaml_load(config_path: str) -> Dict[str, Any]:
1282
+ def cached_yaml_load(config_path: str) -> dict[str, Any]:
1246
1283
  """Cached :func:`yaml.load`
1247
1284
 
1248
1285
  :param config_path: path to the yaml configuration file
@@ -1252,12 +1289,12 @@ def cached_yaml_load(config_path: str) -> Dict[str, Any]:
1252
1289
 
1253
1290
 
1254
1291
  @functools.lru_cache()
1255
- def _mutable_cached_yaml_load_all(config_path: str) -> List[Any]:
1292
+ def _mutable_cached_yaml_load_all(config_path: str) -> list[Any]:
1256
1293
  with open(config_path, "r") as fh:
1257
1294
  return list(yaml.load_all(fh, Loader=yaml.Loader))
1258
1295
 
1259
1296
 
1260
- def cached_yaml_load_all(config_path: str) -> List[Any]:
1297
+ def cached_yaml_load_all(config_path: str) -> list[Any]:
1261
1298
  """Cached :func:`yaml.load_all`
1262
1299
 
1263
1300
  Load all configurations stored in the configuration file as separated yaml documents
@@ -1270,7 +1307,7 @@ def cached_yaml_load_all(config_path: str) -> List[Any]:
1270
1307
 
1271
1308
  def get_bucket_name_and_prefix(
1272
1309
  url: str, bucket_path_level: Optional[int] = None
1273
- ) -> Tuple[Optional[str], Optional[str]]:
1310
+ ) -> tuple[Optional[str], Optional[str]]:
1274
1311
  """Extract bucket name and prefix from URL
1275
1312
 
1276
1313
  :param url: (optional) URL to use as product.location
@@ -1283,7 +1320,9 @@ def get_bucket_name_and_prefix(
1283
1320
  subdomain = netloc.split(".")[0]
1284
1321
  path = path.strip("/")
1285
1322
 
1286
- if scheme and bucket_path_level is None:
1323
+ if "/" in path and scheme and subdomain == "s3" and bucket_path_level is None:
1324
+ bucket, prefix = path.split("/", 1)
1325
+ elif scheme and bucket_path_level is None:
1287
1326
  bucket = subdomain
1288
1327
  prefix = path
1289
1328
  elif not scheme and bucket_path_level is None:
@@ -1329,10 +1368,10 @@ def deepcopy(sth: Any) -> Any:
1329
1368
  :param sth: Object to copy
1330
1369
  :returns: Copied object
1331
1370
  """
1332
- _dispatcher: Dict[Type[Any], Callable[..., Any]] = {}
1371
+ _dispatcher: dict[type[Any], Callable[..., Any]] = {}
1333
1372
 
1334
1373
  def _copy_list(
1335
- input_list: List[Any], dispatch: Dict[Type[Any], Callable[..., Any]]
1374
+ input_list: list[Any], dispatch: dict[type[Any], Callable[..., Any]]
1336
1375
  ):
1337
1376
  ret = input_list.copy()
1338
1377
  for idx, item in enumerate(ret):
@@ -1342,7 +1381,7 @@ def deepcopy(sth: Any) -> Any:
1342
1381
  return ret
1343
1382
 
1344
1383
  def _copy_dict(
1345
- input_dict: Dict[Any, Any], dispatch: Dict[Type[Any], Callable[..., Any]]
1384
+ input_dict: dict[Any, Any], dispatch: dict[type[Any], Callable[..., Any]]
1346
1385
  ):
1347
1386
  ret = input_dict.copy()
1348
1387
  for key, value in ret.items():
@@ -1431,6 +1470,8 @@ def guess_file_type(file: str) -> Optional[str]:
1431
1470
  :returns: guessed mime type
1432
1471
  """
1433
1472
  mime_type, _ = mimetypes.guess_type(file, False)
1473
+ if mime_type == "text/xml":
1474
+ return "application/xml"
1434
1475
  return mime_type
1435
1476
 
1436
1477
 
@@ -1465,7 +1506,7 @@ def get_ssl_context(ssl_verify: bool) -> ssl.SSLContext:
1465
1506
  return ctx
1466
1507
 
1467
1508
 
1468
- def sort_dict(input_dict: Dict[str, Any]) -> Dict[str, Any]:
1509
+ def sort_dict(input_dict: dict[str, Any]) -> dict[str, Any]:
1469
1510
  """
1470
1511
  Recursively sorts a dict by keys.
1471
1512
 
@@ -1481,7 +1522,7 @@ def sort_dict(input_dict: Dict[str, Any]) -> Dict[str, Any]:
1481
1522
  }
1482
1523
 
1483
1524
 
1484
- def dict_md5sum(input_dict: Dict[str, Any]) -> str:
1525
+ def dict_md5sum(input_dict: dict[str, Any]) -> str:
1485
1526
  """
1486
1527
  Hash nested dictionary
1487
1528
 
eodag/utils/exceptions.py CHANGED
@@ -20,7 +20,7 @@ from __future__ import annotations
20
20
  from typing import TYPE_CHECKING, Annotated
21
21
 
22
22
  if TYPE_CHECKING:
23
- from typing import Optional, Set
23
+ from typing import Optional
24
24
 
25
25
  from typing_extensions import Doc, Self
26
26
 
@@ -108,7 +108,7 @@ class RequestError(EodagError):
108
108
  class ValidationError(RequestError):
109
109
  """Error validating data"""
110
110
 
111
- def __init__(self, message: str, parameters: Set[str] = set()) -> None:
111
+ def __init__(self, message: str, parameters: set[str] = set()) -> None:
112
112
  self.message = message
113
113
  self.parameters = parameters
114
114
 
@@ -21,14 +21,14 @@ import importlib
21
21
  import pkgutil
22
22
  from contextlib import contextmanager
23
23
  from functools import partial
24
- from typing import TYPE_CHECKING, Any, Generator, Tuple
24
+ from typing import TYPE_CHECKING, Any, Generator
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from types import ModuleType
28
28
 
29
29
 
30
30
  def import_all_modules(
31
- base_package: ModuleType, depth: int = 1, exclude: Tuple[str, ...] = ()
31
+ base_package: ModuleType, depth: int = 1, exclude: tuple[str, ...] = ()
32
32
  ) -> None:
33
33
  """Import all modules in base_package, including modules in the sub-packages up to `depth` and excluding modules in
34
34
  `exclude`.
eodag/utils/requests.py CHANGED
@@ -19,7 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  import logging
21
21
  import os
22
- from typing import Any, Optional, Tuple
22
+ from typing import Any, Optional
23
23
 
24
24
  import requests
25
25
 
@@ -75,7 +75,7 @@ class LocalFileAdapter(requests.adapters.BaseAdapter):
75
75
  """
76
76
 
77
77
  @staticmethod
78
- def _chkpath(method: str, path: str) -> Tuple[int, str]:
78
+ def _chkpath(method: str, path: str) -> tuple[int, str]:
79
79
  """Return an HTTP status for the given filesystem path.
80
80
 
81
81
  :param method: method of the request
eodag/utils/rest.py CHANGED
@@ -21,7 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import datetime
23
23
  import re
24
- from typing import Any, Dict, Optional, Tuple
24
+ from typing import Any, Optional
25
25
 
26
26
  import dateutil.parser
27
27
  from dateutil import tz
@@ -35,7 +35,7 @@ RFC3339_PATTERN = (
35
35
  )
36
36
 
37
37
 
38
- def get_datetime(arguments: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
38
+ def get_datetime(arguments: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
39
39
  """Get start and end dates from a dict containing `/` separated dates in `datetime` item
40
40
 
41
41
  :param arguments: dict containing a single date or `/` separated dates in `datetime` item
eodag/utils/s3.py ADDED
@@ -0,0 +1,208 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2024, CS GROUP - France, https://www.csgroup.eu/
3
+ #
4
+ # This file is part of EODAG project
5
+ # https://www.github.com/CS-SI/EODAG
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import logging
22
+ import os
23
+ import zipfile
24
+ from typing import TYPE_CHECKING, List, Optional
25
+ from urllib.parse import urlparse
26
+
27
+ import boto3
28
+ import botocore
29
+
30
+ from eodag.plugins.authentication.aws_auth import AwsAuth
31
+ from eodag.utils import get_bucket_name_and_prefix, guess_file_type
32
+ from eodag.utils.exceptions import (
33
+ AuthenticationError,
34
+ MisconfiguredError,
35
+ NotAvailableError,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from zipfile import ZipInfo
40
+
41
+ from mypy_boto3_s3.client import S3Client
42
+
43
+ from eodag.api.product import EOProduct # type: ignore
44
+
45
+ logger = logging.getLogger("eodag.utils.s3")
46
+
47
+
48
+ def fetch(
49
+ bucket_name: str, key_name: str, start: int, len: int, client_s3: S3Client
50
+ ) -> bytes:
51
+ """
52
+ Range-fetches a S3 key.
53
+
54
+ :param bucket_name: Bucket name of the object to fetch
55
+ :param key_name: Key name of the object to fetch
56
+ :param start: Bucket name to fetch
57
+ :param len: Bucket name to fetch
58
+ :param client_s3: s3 client used to fetch the object
59
+ :returns: Object bytes
60
+ """
61
+ end = start + len - 1
62
+ s3_object = client_s3.get_object(
63
+ Bucket=bucket_name, Key=key_name, Range="bytes=%d-%d" % (start, end)
64
+ )
65
+ return s3_object["Body"].read()
66
+
67
+
68
+ def parse_int(bytes: bytes) -> int:
69
+ """
70
+ Parses 2 or 4 little-endian bits into their corresponding integer value.
71
+
72
+ :param bytes: bytes to parse
73
+ :returns: parsed int
74
+ """
75
+ val = (bytes[0]) + ((bytes[1]) << 8)
76
+ if len(bytes) > 3:
77
+ val += ((bytes[2]) << 16) + ((bytes[3]) << 24)
78
+ return val
79
+
80
+
81
+ def list_files_in_s3_zipped_object(
82
+ bucket_name: str, key_name: str, client_s3: S3Client
83
+ ) -> List[ZipInfo]:
84
+ """
85
+ List files in s3 zipped object, without downloading it.
86
+
87
+ See https://stackoverflow.com/questions/41789176/how-to-count-files-inside-zip-in-aws-s3-without-downloading-it;
88
+ Based on https://stackoverflow.com/questions/51351000/read-zip-files-from-s3-without-downloading-the-entire-file
89
+
90
+ :param bucket_name: Bucket name of the object to fetch
91
+ :param key_name: Key name of the object to fetch
92
+ :param client_s3: s3 client used to fetch the object
93
+ :returns: List of files in zip
94
+ """
95
+ response = client_s3.head_object(Bucket=bucket_name, Key=key_name)
96
+ size = response["ContentLength"]
97
+
98
+ # End Of Central Directory bytes
99
+ eocd = fetch(bucket_name, key_name, size - 22, 22, client_s3)
100
+
101
+ # start offset and size of the central directory
102
+ cd_start = parse_int(eocd[16:20])
103
+ cd_size = parse_int(eocd[12:16])
104
+
105
+ # fetch central directory, append EOCD, and open as zipfile
106
+ cd = fetch(bucket_name, key_name, cd_start, cd_size, client_s3)
107
+ zip = zipfile.ZipFile(io.BytesIO(cd + eocd))
108
+
109
+ logger.debug("Found %s files in %s" % (len(zip.filelist), key_name))
110
+
111
+ return zip.filelist
112
+
113
+
114
+ def update_assets_from_s3(
115
+ product: EOProduct,
116
+ auth: AwsAuth,
117
+ s3_endpoint: Optional[str] = None,
118
+ content_url: Optional[str] = None,
119
+ ) -> None:
120
+ """Update ``EOProduct.assets`` using content listed in its ``remote_location`` or given
121
+ ``content_url``.
122
+
123
+ If url points to a zipped archive, its content will also be be listed.
124
+
125
+ :param product: product to update
126
+ :param auth: Authentication plugin
127
+ :param s3_endpoint: s3 endpoint if not hosted on AWS
128
+ :param content_url: s3 URL pointing to the content that must be listed (defaults to
129
+ ``product.remote_location`` if empty)
130
+ """
131
+ required_creds = ["aws_access_key_id", "aws_secret_access_key"]
132
+
133
+ if content_url is None:
134
+ content_url = product.remote_location
135
+
136
+ bucket, prefix = get_bucket_name_and_prefix(content_url)
137
+
138
+ if bucket is None or prefix is None:
139
+ logger.debug(f"No s3 prefix could guessed from {content_url}")
140
+ return None
141
+
142
+ try:
143
+ auth_dict = auth.authenticate()
144
+
145
+ if not all(x in auth_dict for x in required_creds):
146
+ raise MisconfiguredError(
147
+ f"Incomplete credentials for {product.provider}, missing "
148
+ f"{[x for x in required_creds if x not in auth_dict]}"
149
+ )
150
+ if not getattr(auth, "s3_client", None):
151
+ auth.s3_client = boto3.client(
152
+ service_name="s3",
153
+ endpoint_url=s3_endpoint,
154
+ aws_access_key_id=auth_dict.get("aws_access_key_id"),
155
+ aws_secret_access_key=auth_dict.get("aws_secret_access_key"),
156
+ aws_session_token=auth_dict.get("aws_session_token"),
157
+ )
158
+
159
+ logger.debug("Listing assets in %s", prefix)
160
+
161
+ if prefix.endswith(".zip"):
162
+ # List prefix zip content
163
+ assets_urls = [
164
+ f"zip+s3://{bucket}/{prefix}!{f.filename}"
165
+ for f in list_files_in_s3_zipped_object(bucket, prefix, auth.s3_client)
166
+ ]
167
+ else:
168
+ # List files in prefix
169
+ assets_urls = [
170
+ f"s3://{bucket}/{obj['Key']}"
171
+ for obj in auth.s3_client.list_objects(
172
+ Bucket=bucket, Prefix=prefix, MaxKeys=300
173
+ ).get("Contents", [])
174
+ ]
175
+
176
+ for asset_url in assets_urls:
177
+ out_of_zip_url = asset_url.split("!")[-1]
178
+ key, roles = product.driver.guess_asset_key_and_roles(
179
+ out_of_zip_url, product
180
+ )
181
+ parsed_url = urlparse(out_of_zip_url)
182
+ title = os.path.basename(parsed_url.path)
183
+
184
+ if key and key not in product.assets:
185
+ product.assets[key] = {
186
+ "title": title,
187
+ "roles": roles,
188
+ "href": asset_url,
189
+ }
190
+ if mime_type := guess_file_type(asset_url):
191
+ product.assets[key]["type"] = mime_type
192
+
193
+ # sort assets
194
+ product.assets.data = dict(sorted(product.assets.data.items()))
195
+
196
+ # update driver
197
+ product.driver = product.get_driver()
198
+
199
+ except botocore.exceptions.ClientError as e:
200
+ if hasattr(auth.config, "auth_error_code") and str(
201
+ auth.config.auth_error_code
202
+ ) in str(e):
203
+ raise AuthenticationError(
204
+ f"Authentication failed on {s3_endpoint} s3"
205
+ ) from e
206
+ raise NotAvailableError(
207
+ f"assets for product {prefix} could not be found"
208
+ ) from e
@@ -20,7 +20,7 @@ from __future__ import annotations
20
20
  import logging
21
21
  import re
22
22
  import socket
23
- from typing import Any, Callable, Dict, List, Optional, Union
23
+ from typing import Any, Callable, Optional, Union
24
24
  from urllib.error import URLError
25
25
  from urllib.request import urlopen
26
26
 
@@ -108,7 +108,7 @@ def fetch_stac_items(
108
108
  max_connections: int = 100,
109
109
  timeout: int = HTTP_REQ_TIMEOUT,
110
110
  ssl_verify: bool = True,
111
- ) -> List[Dict[str, Any]]:
111
+ ) -> list[dict[str, Any]]:
112
112
  """Fetch STAC item from a single item file or items from a catalog.
113
113
 
114
114
  :param stac_path: A STAC object filepath
@@ -142,13 +142,13 @@ def _fetch_stac_items_from_catalog(
142
142
  recursive: bool,
143
143
  max_connections: int,
144
144
  _text_opener: Callable[[str, bool], Any],
145
- ) -> List[Any]:
145
+ ) -> list[Any]:
146
146
  """Fetch items from a STAC catalog"""
147
- items: List[Dict[Any, Any]] = []
147
+ items: list[dict[Any, Any]] = []
148
148
 
149
149
  # pystac cannot yet return links from a single file catalog, see:
150
150
  # https://github.com/stac-utils/pystac/issues/256
151
- extensions: Optional[Union[List[str], str]] = getattr(cat, "stac_extensions", None)
151
+ extensions: Optional[Union[list[str], str]] = getattr(cat, "stac_extensions", None)
152
152
  if extensions:
153
153
  extensions = extensions if isinstance(extensions, list) else [extensions]
154
154
  if "single-file-stac" in extensions:
@@ -157,7 +157,7 @@ def _fetch_stac_items_from_catalog(
157
157
 
158
158
  # Making the links absolutes allow for both relative and absolute links to be handled.
159
159
  if not recursive:
160
- hrefs: List[Optional[str]] = [
160
+ hrefs: list[Optional[str]] = [
161
161
  link.get_absolute_href() for link in cat.get_item_links()
162
162
  ]
163
163
  else:
@@ -188,7 +188,7 @@ def fetch_stac_collections(
188
188
  max_connections: int = 100,
189
189
  timeout: int = HTTP_REQ_TIMEOUT,
190
190
  ssl_verify: bool = True,
191
- ) -> List[Dict[str, Any]]:
191
+ ) -> list[dict[str, Any]]:
192
192
  """Fetch STAC collection(s) from a catalog.
193
193
 
194
194
  :param stac_path: A STAC object filepath
@@ -217,12 +217,12 @@ def _fetch_stac_collections_from_catalog(
217
217
  collection: Optional[str],
218
218
  max_connections: int,
219
219
  _text_opener: Callable[[str, bool], Any],
220
- ) -> List[Any]:
220
+ ) -> list[Any]:
221
221
  """Fetch collections from a STAC catalog"""
222
- collections: List[Dict[Any, Any]] = []
222
+ collections: list[dict[Any, Any]] = []
223
223
 
224
224
  # Making the links absolutes allow for both relative and absolute links to be handled.
225
- hrefs: List[Optional[str]] = [
225
+ hrefs: list[Optional[str]] = [
226
226
  link.get_absolute_href()
227
227
  for link in cat.get_child_links()
228
228
  if collection is not None and link.title == collection