eodag 3.8.0__py3-none-any.whl → 3.9.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.
Files changed (44) hide show
  1. eodag/api/core.py +3 -2
  2. eodag/api/product/drivers/generic.py +5 -1
  3. eodag/api/product/metadata_mapping.py +110 -9
  4. eodag/cli.py +36 -4
  5. eodag/config.py +5 -2
  6. eodag/plugins/apis/ecmwf.py +3 -1
  7. eodag/plugins/apis/usgs.py +2 -1
  8. eodag/plugins/authentication/aws_auth.py +228 -37
  9. eodag/plugins/authentication/base.py +12 -2
  10. eodag/plugins/authentication/oauth.py +5 -0
  11. eodag/plugins/authentication/sas_auth.py +15 -0
  12. eodag/plugins/base.py +3 -2
  13. eodag/plugins/download/aws.py +44 -285
  14. eodag/plugins/download/base.py +3 -2
  15. eodag/plugins/download/creodias_s3.py +1 -38
  16. eodag/plugins/download/http.py +111 -103
  17. eodag/plugins/download/s3rest.py +3 -1
  18. eodag/plugins/manager.py +2 -1
  19. eodag/plugins/search/__init__.py +2 -1
  20. eodag/plugins/search/base.py +2 -1
  21. eodag/plugins/search/build_search_result.py +2 -2
  22. eodag/plugins/search/creodias_s3.py +9 -1
  23. eodag/plugins/search/qssearch.py +3 -1
  24. eodag/resources/ext_product_types.json +1 -1
  25. eodag/resources/product_types.yml +220 -30
  26. eodag/resources/providers.yml +634 -89
  27. eodag/resources/stac_provider.yml +6 -3
  28. eodag/resources/user_conf_template.yml +0 -5
  29. eodag/rest/core.py +8 -0
  30. eodag/rest/errors.py +9 -0
  31. eodag/rest/server.py +8 -0
  32. eodag/rest/stac.py +8 -0
  33. eodag/rest/utils/__init__.py +2 -4
  34. eodag/rest/utils/rfc3339.py +1 -1
  35. eodag/utils/__init__.py +69 -54
  36. eodag/utils/dates.py +204 -0
  37. eodag/utils/s3.py +187 -168
  38. {eodag-3.8.0.dist-info → eodag-3.9.0.dist-info}/METADATA +4 -3
  39. {eodag-3.8.0.dist-info → eodag-3.9.0.dist-info}/RECORD +43 -43
  40. {eodag-3.8.0.dist-info → eodag-3.9.0.dist-info}/entry_points.txt +1 -1
  41. eodag/utils/rest.py +0 -100
  42. {eodag-3.8.0.dist-info → eodag-3.9.0.dist-info}/WHEEL +0 -0
  43. {eodag-3.8.0.dist-info → eodag-3.9.0.dist-info}/licenses/LICENSE +0 -0
  44. {eodag-3.8.0.dist-info → eodag-3.9.0.dist-info}/top_level.txt +0 -0
@@ -17,29 +17,72 @@
17
17
  # limitations under the License.
18
18
  from __future__ import annotations
19
19
 
20
- from typing import TYPE_CHECKING, Optional, cast
20
+ import logging
21
+ from typing import TYPE_CHECKING, Any, Optional, cast
22
+
23
+ import boto3
24
+ from botocore.exceptions import ClientError, ProfileNotFound
25
+ from botocore.handlers import disable_signing
21
26
 
22
27
  from eodag.plugins.authentication.base import Authentication
23
28
  from eodag.types import S3SessionKwargs
29
+ from eodag.utils.exceptions import AuthenticationError
24
30
 
25
31
  if TYPE_CHECKING:
26
- from mypy_boto3_s3.client import S3Client
32
+ from mypy_boto3_s3 import S3Client, S3ServiceResource
33
+ from mypy_boto3_s3.service_resource import BucketObjectsCollection
27
34
 
28
35
  from eodag.config import PluginConfig
29
36
 
30
37
 
38
+ logger = logging.getLogger("eodag.download.aws_auth")
39
+
40
+ AWS_AUTH_ERROR_MESSAGES = [
41
+ "AccessDenied",
42
+ "InvalidAccessKeyId",
43
+ "SignatureDoesNotMatch",
44
+ "InvalidRequest",
45
+ ]
46
+
47
+
48
+ def raise_if_auth_error(exception: ClientError, provider: str) -> None:
49
+ """Raises an error if given exception is an authentication error"""
50
+ err = cast(dict[str, str], exception.response["Error"])
51
+ if err["Code"] in AWS_AUTH_ERROR_MESSAGES and "key" in err["Message"].lower():
52
+ raise AuthenticationError(
53
+ f"Please check your credentials for {provider}.",
54
+ f"HTTP Error {exception.response['ResponseMetadata']['HTTPStatusCode']} returned.",
55
+ err["Code"] + ": " + err["Message"],
56
+ )
57
+
58
+
59
+ def create_s3_session(**kwargs) -> boto3.Session:
60
+ """create s3 session based on available credentials
61
+
62
+ :param kwargs: keyword arguments containing credentials
63
+ :returns: boto3 Session
64
+ """
65
+ try:
66
+ s3_session = boto3.Session(**kwargs)
67
+ except ProfileNotFound:
68
+ raise AuthenticationError(
69
+ f"AWS profile {kwargs['profile_name']} not found, please check your credentials configuration"
70
+ )
71
+ return s3_session
72
+
73
+
31
74
  class AwsAuth(Authentication):
32
75
  """AWS authentication plugin
33
76
 
34
- Authentication will use the first valid method within the following ones depending on which
35
- parameters are available in the configuration:
77
+ The authentication method will be chosen depending on which parameters are available in the configuration:
36
78
 
37
- * auth anonymously using no-sign-request
38
- * auth using ``aws_profile``
39
- * auth using ``aws_access_key_id`` and ``aws_secret_access_key``
40
- (optionally ``aws_session_token``)
41
- * auth using current environment (AWS environment variables and/or ``~/aws/*``),
42
- will be skipped if AWS credentials are filled in eodag conf
79
+ * auth using ``profile_name`` (if credentials are given and contain ``aws_profile``)
80
+ * auth using ``aws_access_key_id``, ``aws_secret_access_key`` and optionally ``aws_session_token``
81
+ (if credentials are given but no ``aws_profile``)
82
+ * auth using current environment - AWS environment variables and/or ``~/.aws/*``
83
+ (if no credentials are given in config)
84
+ * auth anonymously using no-sign-request if no credentials are given in config and
85
+ auth using current environment failed
43
86
 
44
87
  :param provider: provider name
45
88
  :param config: Authentication plugin configuration:
@@ -47,41 +90,189 @@ class AwsAuth(Authentication):
47
90
  * :attr:`~eodag.config.PluginConfig.type` (``str``) (**mandatory**): AwsAuth
48
91
  * :attr:`~eodag.config.PluginConfig.auth_error_code` (``int``) (mandatory for ``creodias_s3``):
49
92
  which error code is returned in case of an authentication error
93
+ * :attr:`~eodag.config.PluginConfig.s3_endpoint` (``str``): s3 endpoint url
94
+ * :attr:`~eodag.config.PluginConfig.requester_pays` (``bool``): whether download is done
95
+ from a requester-pays bucket or not; default: ``False``
50
96
 
51
97
  """
52
98
 
53
- s3_client: S3Client
54
-
55
99
  def __init__(self, provider: str, config: PluginConfig) -> None:
56
100
  super(AwsAuth, self).__init__(provider, config)
57
- self.aws_access_key_id: Optional[str] = None
58
- self.aws_secret_access_key: Optional[str] = None
59
- self.aws_session_token: Optional[str] = None
60
- self.profile_name: Optional[str] = None
101
+ self.s3_session: Optional[boto3.Session] = None
102
+ self.s3_resource: Optional[S3ServiceResource] = None
103
+ # set default for requester_pays if not given
104
+ self.config.__dict__.setdefault("requester_pays", False)
105
+
106
+ def _create_s3_session_from_credentials(self) -> boto3.Session:
107
+ credentials = getattr(self.config, "credentials", {}) or {}
108
+ if "aws_profile" in credentials:
109
+ return create_s3_session(profile_name=credentials["aws_profile"])
110
+ # auth using aws keys
111
+ elif credentials:
112
+ s3_session_kwargs: S3SessionKwargs = {
113
+ "aws_access_key_id": credentials["aws_access_key_id"],
114
+ "aws_secret_access_key": credentials["aws_secret_access_key"],
115
+ }
116
+ if credentials.get("aws_session_token"):
117
+ s3_session_kwargs["aws_session_token"] = credentials[
118
+ "aws_session_token"
119
+ ]
120
+ return create_s3_session(**s3_session_kwargs)
121
+ else:
122
+ # auth using env variables or ~/.aws
123
+ return create_s3_session()
124
+
125
+ def _create_s3_resource(self) -> S3ServiceResource:
126
+ """create s3 resource based on s3 session"""
127
+ if not self.s3_session:
128
+ self.s3_session = self._create_s3_session_from_credentials()
129
+ endpoint_url = getattr(self.config, "s3_endpoint", None)
130
+ if self.s3_session.get_credentials():
131
+ return self.s3_session.resource(
132
+ service_name="s3",
133
+ endpoint_url=endpoint_url,
134
+ )
135
+ # could not auth using credentials: use no-sign-request strategy
136
+ s3_resource = boto3.resource(service_name="s3", endpoint_url=endpoint_url)
137
+ s3_resource.meta.client.meta.events.register(
138
+ "choose-signer.s3.*", disable_signing
139
+ )
140
+ return s3_resource
141
+
142
+ def get_s3_client(self) -> S3Client:
143
+ """Get S3 client from S3 resource
61
144
 
62
- def authenticate(self) -> S3SessionKwargs:
145
+ :returns: boto3 client
146
+ """
147
+ if not self.s3_resource:
148
+ self.s3_resource = self._create_s3_resource()
149
+ return self.s3_resource.meta.client
150
+
151
+ def authenticate(self) -> S3ServiceResource:
63
152
  """Authenticate
64
153
 
65
- :returns: dict containing AWS/boto3 non-empty credentials
154
+ :returns: S3 Resource created based on an S3 session
66
155
  """
67
- credentials = getattr(self.config, "credentials", {}) or {}
68
- self.aws_access_key_id = credentials.get(
69
- "aws_access_key_id", self.aws_access_key_id
70
- )
71
- self.aws_secret_access_key = credentials.get(
72
- "aws_secret_access_key", self.aws_secret_access_key
73
- )
74
- self.aws_session_token = credentials.get(
75
- "aws_session_token", self.aws_session_token
156
+ self.s3_resource = self._create_s3_resource()
157
+ return self.s3_resource
158
+
159
+ def _get_authenticated_objects(
160
+ self, bucket_name: str, prefix: str
161
+ ) -> BucketObjectsCollection:
162
+ """Get boto3 authenticated objects for the given bucket
163
+
164
+ :param bucket_name: Bucket containg objects
165
+ :param prefix: Prefix used to filter objects
166
+ :returns: The boto3 authenticated objects
167
+ """
168
+ if not self.s3_resource:
169
+ self.s3_resource = self._create_s3_resource()
170
+ try:
171
+ if self.config.requester_pays:
172
+ objects = self.s3_resource.Bucket(bucket_name).objects.filter(
173
+ RequestPayer="requester"
174
+ )
175
+ else:
176
+ objects = self.s3_resource.Bucket(bucket_name).objects
177
+ list(objects.filter(Prefix=prefix).limit(1))
178
+ if objects:
179
+ logger.debug(
180
+ "Authentication for bucket %s succeeded; returning available objects",
181
+ bucket_name,
182
+ )
183
+ return objects
184
+ except ClientError as e:
185
+ if e.response.get("Error", {}).get("Code", {}) in AWS_AUTH_ERROR_MESSAGES:
186
+ pass
187
+ else:
188
+ raise e
189
+ logger.debug(
190
+ "Authentication for bucket %s failed, please check the credentials",
191
+ bucket_name,
76
192
  )
77
- self.profile_name = credentials.get("aws_profile", self.profile_name)
78
-
79
- auth_dict = cast(
80
- S3SessionKwargs,
81
- {
82
- k: getattr(self, k)
83
- for k in S3SessionKwargs.__annotations__
84
- if getattr(self, k, None)
85
- },
193
+
194
+ raise AuthenticationError(
195
+ "Unable do authenticate on s3://%s using credendials configuration"
196
+ % bucket_name
86
197
  )
87
- return auth_dict
198
+
199
+ def authenticate_objects(
200
+ self,
201
+ bucket_names_and_prefixes: list[tuple[str, Optional[str]]],
202
+ ) -> dict[str, BucketObjectsCollection]:
203
+ """
204
+ Authenticates with s3 and retrieves the available objects
205
+
206
+ :param bucket_names_and_prefixes: list of bucket names and corresponding path prefixes
207
+ :raises AuthenticationError: authentication is not possible
208
+ :return: authenticated objects per bucket
209
+ """
210
+
211
+ authenticated_objects: dict[str, Any] = {}
212
+ auth_error_messages: set[str] = set()
213
+ for _, pack in enumerate(bucket_names_and_prefixes):
214
+
215
+ bucket_name, prefix = pack
216
+ if not prefix:
217
+ continue
218
+ if bucket_name not in authenticated_objects:
219
+ # get Prefixes longest common base path
220
+ common_prefix = ""
221
+ prefix_split = prefix.split("/")
222
+ prefixes_in_bucket = len(
223
+ [p for b, p in bucket_names_and_prefixes if b == bucket_name]
224
+ )
225
+ for i in range(1, len(prefix_split)):
226
+ common_prefix = "/".join(prefix_split[0:i])
227
+ if (
228
+ len(
229
+ [
230
+ p
231
+ for b, p in bucket_names_and_prefixes
232
+ if p and b == bucket_name and common_prefix in p
233
+ ]
234
+ )
235
+ < prefixes_in_bucket
236
+ ):
237
+ common_prefix = "/".join(prefix_split[0 : i - 1])
238
+ break
239
+ try:
240
+ # connect to aws s3 and get bucket auhenticated objects
241
+ authenticated_objects[
242
+ bucket_name
243
+ ] = self._get_authenticated_objects(bucket_name, common_prefix)
244
+
245
+ except AuthenticationError as e:
246
+ logger.warning("Unexpected error: %s" % e)
247
+ logger.warning("Skipping %s/%s" % (bucket_name, prefix))
248
+ auth_error_messages.add(str(e))
249
+ except ClientError as e:
250
+ raise_if_auth_error(e, self.provider)
251
+ logger.warning("Unexpected error: %s" % e)
252
+ logger.warning("Skipping %s/%s" % (bucket_name, prefix))
253
+ auth_error_messages.add(str(e))
254
+
255
+ # could not auth on any bucket
256
+ if not authenticated_objects:
257
+ raise AuthenticationError(", ".join(auth_error_messages))
258
+ return authenticated_objects
259
+
260
+ def get_rio_env(self) -> dict[str, Any]:
261
+ """Get rasterio environment variables needed for data access authentication.
262
+
263
+ :returns: The rasterio environement variables
264
+ """
265
+ rio_env_kwargs = {}
266
+ if endpoint_url := getattr(self.config, "s3_endpoint", None):
267
+ rio_env_kwargs["endpoint_url"] = endpoint_url.split("://")[-1]
268
+
269
+ if self.s3_session is None:
270
+ self.authenticate()
271
+
272
+ if self.config.requester_pays:
273
+ rio_env_kwargs["requester_pays"] = True
274
+
275
+ return {
276
+ "session": self.s3_session,
277
+ **rio_env_kwargs,
278
+ }
@@ -17,12 +17,13 @@
17
17
  # limitations under the License.
18
18
  from __future__ import annotations
19
19
 
20
- from typing import TYPE_CHECKING, Union
20
+ from typing import TYPE_CHECKING, Any, Optional, Union
21
21
 
22
22
  from eodag.plugins.base import PluginTopic
23
23
  from eodag.utils.exceptions import MisconfiguredError
24
24
 
25
25
  if TYPE_CHECKING:
26
+ from mypy_boto3_s3 import S3ServiceResource
26
27
  from requests.auth import AuthBase
27
28
 
28
29
  from eodag.types import S3SessionKwargs
@@ -40,7 +41,7 @@ class Authentication(PluginTopic):
40
41
  configuration that needs authentication and helps identifying it
41
42
  """
42
43
 
43
- def authenticate(self) -> Union[AuthBase, S3SessionKwargs]:
44
+ def authenticate(self) -> Union[AuthBase, S3SessionKwargs, S3ServiceResource]:
44
45
  """Authenticate"""
45
46
  raise NotImplementedError
46
47
 
@@ -70,3 +71,12 @@ class Authentication(PluginTopic):
70
71
  self.provider, ", ".join(missing_credentials)
71
72
  )
72
73
  )
74
+
75
+ def authenticate_objects(
76
+ self,
77
+ bucket_names_and_prefixes: list[tuple[str, Optional[str]]],
78
+ ) -> dict[str, Any]:
79
+ """
80
+ Authenticates with s3 and retrieves the available objects
81
+ """
82
+ raise NotImplementedError
@@ -20,12 +20,17 @@ from __future__ import annotations
20
20
  from typing import TYPE_CHECKING, Optional
21
21
 
22
22
  from eodag.plugins.authentication.base import Authentication
23
+ from eodag.utils import _deprecated
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from eodag.config import PluginConfig
26
27
  from eodag.types import S3SessionKwargs
27
28
 
28
29
 
30
+ @_deprecated(
31
+ reason="Plugin was used to authenticate using S3 credentials, use AwsAuth instead",
32
+ version="3.8.1",
33
+ )
29
34
  class OAuth(Authentication):
30
35
  """OAuth authentication plugin
31
36
 
@@ -18,6 +18,7 @@
18
18
  from __future__ import annotations
19
19
 
20
20
  import logging
21
+ import re
21
22
  from json import JSONDecodeError
22
23
  from typing import TYPE_CHECKING, Optional
23
24
 
@@ -29,6 +30,8 @@ from eodag.utils import HTTP_REQ_TIMEOUT, USER_AGENT, deepcopy, format_dict_item
29
30
  from eodag.utils.exceptions import AuthenticationError, TimeOutError
30
31
 
31
32
  if TYPE_CHECKING:
33
+ from typing import Pattern
34
+
32
35
  from requests import PreparedRequest
33
36
 
34
37
 
@@ -44,15 +47,24 @@ class RequestsSASAuth(AuthBase):
44
47
  signed_url_key: str,
45
48
  headers: Optional[dict[str, str]] = None,
46
49
  ssl_verify: bool = True,
50
+ matching_url: Optional[Pattern[str]] = None,
47
51
  ) -> None:
48
52
  self.auth_uri = auth_uri
49
53
  self.signed_url_key = signed_url_key
50
54
  self.headers = headers
51
55
  self.signed_urls: dict[str, str] = {}
52
56
  self.ssl_verify = ssl_verify
57
+ self.matching_url = matching_url
53
58
 
54
59
  def __call__(self, request: PreparedRequest) -> PreparedRequest:
55
60
  """Perform the actual authentication"""
61
+ # if matching_url is set, check if request.url matches
62
+ if (
63
+ self.matching_url
64
+ and request.url
65
+ and not self.matching_url.match(request.url)
66
+ ):
67
+ return request
56
68
 
57
69
  # update headers
58
70
  if self.headers and isinstance(self.headers, dict):
@@ -118,6 +130,8 @@ class SASAuth(Authentication):
118
130
  # update headers with subscription key if exists
119
131
  apikey = getattr(self.config, "credentials", {}).get("apikey")
120
132
  ssl_verify = getattr(self.config, "ssl_verify", True)
133
+ if matching_url := getattr(self.config, "matching_url", None):
134
+ matching_url = re.compile(matching_url)
121
135
  if apikey:
122
136
  headers_update = format_dict_items(self.config.headers, apikey=apikey)
123
137
  headers.update(headers_update)
@@ -127,4 +141,5 @@ class SASAuth(Authentication):
127
141
  signed_url_key=self.config.signed_url_key,
128
142
  headers=headers,
129
143
  ssl_verify=ssl_verify,
144
+ matching_url=matching_url,
130
145
  )
eodag/plugins/base.py CHANGED
@@ -65,9 +65,10 @@ class PluginTopic(metaclass=EODAGPluginMount):
65
65
  self.provider = provider
66
66
 
67
67
  def __repr__(self) -> str:
68
+ config = getattr(self, "config", None)
68
69
  return "{}(provider={}, priority={}, topic={})".format(
69
70
  self.__class__.__name__,
70
- self.provider,
71
- self.config.priority,
71
+ getattr(self, "provider", ""),
72
+ config.priority if config else "",
72
73
  self.__class__.mro()[-3].__name__,
73
74
  )