eodag 2.12.1__py3-none-any.whl → 3.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.
- eodag/api/core.py +434 -319
- eodag/api/product/__init__.py +5 -1
- eodag/api/product/_assets.py +7 -2
- eodag/api/product/_product.py +46 -68
- eodag/api/product/metadata_mapping.py +181 -66
- eodag/api/search_result.py +21 -1
- eodag/cli.py +20 -6
- eodag/config.py +95 -6
- eodag/plugins/apis/base.py +8 -165
- eodag/plugins/apis/ecmwf.py +36 -24
- eodag/plugins/apis/usgs.py +40 -24
- eodag/plugins/authentication/aws_auth.py +2 -2
- eodag/plugins/authentication/header.py +31 -6
- eodag/plugins/authentication/keycloak.py +13 -84
- eodag/plugins/authentication/oauth.py +3 -3
- eodag/plugins/authentication/openid_connect.py +256 -46
- eodag/plugins/authentication/qsauth.py +3 -0
- eodag/plugins/authentication/sas_auth.py +8 -1
- eodag/plugins/authentication/token.py +92 -46
- eodag/plugins/authentication/token_exchange.py +120 -0
- eodag/plugins/download/aws.py +86 -91
- eodag/plugins/download/base.py +72 -40
- eodag/plugins/download/http.py +607 -264
- eodag/plugins/download/s3rest.py +28 -15
- eodag/plugins/manager.py +73 -57
- eodag/plugins/search/__init__.py +36 -0
- eodag/plugins/search/base.py +225 -18
- eodag/plugins/search/build_search_result.py +389 -32
- eodag/plugins/search/cop_marine.py +378 -0
- eodag/plugins/search/creodias_s3.py +15 -14
- eodag/plugins/search/csw.py +5 -7
- eodag/plugins/search/data_request_search.py +44 -20
- eodag/plugins/search/qssearch.py +508 -203
- eodag/plugins/search/static_stac_search.py +99 -36
- eodag/resources/constraints/climate-dt.json +13 -0
- eodag/resources/constraints/extremes-dt.json +8 -0
- eodag/resources/ext_product_types.json +1 -1
- eodag/resources/product_types.yml +1897 -34
- eodag/resources/providers.yml +3539 -3277
- eodag/resources/stac.yml +48 -54
- eodag/resources/stac_api.yml +71 -25
- eodag/resources/stac_provider.yml +5 -0
- eodag/resources/user_conf_template.yml +51 -3
- eodag/rest/__init__.py +6 -0
- eodag/rest/cache.py +70 -0
- eodag/rest/config.py +68 -0
- eodag/rest/constants.py +27 -0
- eodag/rest/core.py +757 -0
- eodag/rest/server.py +397 -258
- eodag/rest/stac.py +438 -307
- eodag/rest/types/collections_search.py +44 -0
- eodag/rest/types/eodag_search.py +232 -43
- eodag/rest/types/{stac_queryables.py → queryables.py} +81 -43
- eodag/rest/types/stac_search.py +277 -0
- eodag/rest/utils/__init__.py +216 -0
- eodag/rest/utils/cql_evaluate.py +119 -0
- eodag/rest/utils/rfc3339.py +65 -0
- eodag/types/__init__.py +99 -9
- eodag/types/bbox.py +15 -14
- eodag/types/download_args.py +31 -0
- eodag/types/search_args.py +58 -7
- eodag/types/whoosh.py +81 -0
- eodag/utils/__init__.py +72 -9
- eodag/utils/constraints.py +37 -37
- eodag/utils/exceptions.py +23 -17
- eodag/utils/requests.py +138 -0
- eodag/utils/rest.py +104 -0
- eodag/utils/stac_reader.py +100 -16
- {eodag-2.12.1.dist-info → eodag-3.0.0b1.dist-info}/METADATA +64 -44
- eodag-3.0.0b1.dist-info/RECORD +109 -0
- {eodag-2.12.1.dist-info → eodag-3.0.0b1.dist-info}/WHEEL +1 -1
- {eodag-2.12.1.dist-info → eodag-3.0.0b1.dist-info}/entry_points.txt +6 -5
- eodag/plugins/apis/cds.py +0 -540
- eodag/rest/utils.py +0 -1133
- eodag-2.12.1.dist-info/RECORD +0 -94
- {eodag-2.12.1.dist-info → eodag-3.0.0b1.dist-info}/LICENSE +0 -0
- {eodag-2.12.1.dist-info → eodag-3.0.0b1.dist-info}/top_level.txt +0 -0
eodag/utils/constraints.py
CHANGED
|
@@ -19,13 +19,14 @@ import copy
|
|
|
19
19
|
import logging
|
|
20
20
|
from typing import Any, Dict, List, Set, Union
|
|
21
21
|
|
|
22
|
-
import
|
|
22
|
+
from requests.auth import AuthBase
|
|
23
23
|
|
|
24
24
|
from eodag.api.product.metadata_mapping import get_provider_queryable_key
|
|
25
25
|
from eodag.plugins.apis.base import Api
|
|
26
26
|
from eodag.plugins.search.base import Search
|
|
27
|
-
from eodag.utils import
|
|
28
|
-
from eodag.utils.exceptions import
|
|
27
|
+
from eodag.utils import deepcopy
|
|
28
|
+
from eodag.utils.exceptions import RequestError, ValidationError
|
|
29
|
+
from eodag.utils.requests import fetch_json
|
|
29
30
|
|
|
30
31
|
logger = logging.getLogger("eodag.constraints")
|
|
31
32
|
|
|
@@ -73,8 +74,20 @@ def get_constraint_queryables_with_additional_params(
|
|
|
73
74
|
if provider_key and provider_key in constraint:
|
|
74
75
|
eodag_provider_key_mapping[provider_key] = param
|
|
75
76
|
params_available[param] = True
|
|
76
|
-
if
|
|
77
|
+
if (
|
|
78
|
+
isinstance(value, list)
|
|
79
|
+
and all([v in constraint[provider_key] for v in value])
|
|
80
|
+
or not isinstance(value, list)
|
|
81
|
+
and value in constraint[provider_key]
|
|
82
|
+
):
|
|
77
83
|
params_matched[param] = True
|
|
84
|
+
elif isinstance(value, str):
|
|
85
|
+
# for Copernicus providers, values can be multiple and represented with a string
|
|
86
|
+
# separated by slashes (example: time = "0000/0100/0200")
|
|
87
|
+
values = value.split("/")
|
|
88
|
+
params_matched[param] = all(
|
|
89
|
+
[v in constraint[provider_key] for v in values]
|
|
90
|
+
)
|
|
78
91
|
values_available[param].update(constraint[provider_key])
|
|
79
92
|
# match with default values of params
|
|
80
93
|
for default_param, default_value in defaults.items():
|
|
@@ -167,42 +180,29 @@ def fetch_constraints(
|
|
|
167
180
|
:returns: list of constraints fetched from the provider
|
|
168
181
|
:rtype: List[Dict[Any, Any]]
|
|
169
182
|
"""
|
|
183
|
+
auth = (
|
|
184
|
+
plugin.auth
|
|
185
|
+
if hasattr(plugin, "auth") and isinstance(plugin.auth, AuthBase)
|
|
186
|
+
else None
|
|
187
|
+
)
|
|
170
188
|
try:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
res.raise_for_status()
|
|
185
|
-
except requests.exceptions.Timeout as exc:
|
|
186
|
-
raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
|
|
187
|
-
except requests.exceptions.HTTPError as err:
|
|
188
|
-
logger.error(
|
|
189
|
-
"constraints could not be fetched from %s, error: %s",
|
|
190
|
-
constraints_url,
|
|
191
|
-
str(err),
|
|
192
|
-
)
|
|
189
|
+
constraints_data = fetch_json(constraints_url, auth=auth)
|
|
190
|
+
except RequestError as err:
|
|
191
|
+
logger.error(str(err))
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
config = plugin.config.__dict__
|
|
195
|
+
if (
|
|
196
|
+
"constraints_entry" in config
|
|
197
|
+
and config["constraints_entry"]
|
|
198
|
+
and config["constraints_entry"] in constraints_data
|
|
199
|
+
):
|
|
200
|
+
constraints = constraints_data[config["constraints_entry"]]
|
|
201
|
+
elif config.get("stop_without_constraints_entry_key", False):
|
|
193
202
|
return []
|
|
194
203
|
else:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
"constraints_entry" in config
|
|
199
|
-
and config["constraints_entry"]
|
|
200
|
-
and config["constraints_entry"] in constraints_data
|
|
201
|
-
):
|
|
202
|
-
constraints = constraints_data[config["constraints_entry"]]
|
|
203
|
-
else:
|
|
204
|
-
constraints = constraints_data
|
|
205
|
-
return constraints
|
|
204
|
+
constraints = constraints_data
|
|
205
|
+
return constraints
|
|
206
206
|
|
|
207
207
|
|
|
208
208
|
def _get_other_possible_values_for_values_with_defaults(
|
eodag/utils/exceptions.py
CHANGED
|
@@ -23,77 +23,83 @@ if TYPE_CHECKING:
|
|
|
23
23
|
from typing import Optional, Set, Tuple
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
class
|
|
26
|
+
class EodagError(Exception):
|
|
27
|
+
"""General EODAG error"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ValidationError(EodagError):
|
|
27
31
|
"""Error validating data"""
|
|
28
32
|
|
|
29
|
-
def __init__(self, message: str) -> None:
|
|
33
|
+
def __init__(self, message: str, parameters: Set[str] = set()) -> None:
|
|
30
34
|
self.message = message
|
|
35
|
+
self.parameters = parameters
|
|
31
36
|
|
|
32
37
|
|
|
33
|
-
class PluginNotFoundError(
|
|
38
|
+
class PluginNotFoundError(EodagError):
|
|
34
39
|
"""Error when looking for a plugin class that was not defined"""
|
|
35
40
|
|
|
36
41
|
|
|
37
|
-
class PluginImplementationError(
|
|
42
|
+
class PluginImplementationError(EodagError):
|
|
38
43
|
"""Error when a plugin does not behave as expected"""
|
|
39
44
|
|
|
40
45
|
|
|
41
|
-
class MisconfiguredError(
|
|
46
|
+
class MisconfiguredError(EodagError):
|
|
42
47
|
"""An error indicating a Search Plugin that is not well configured"""
|
|
43
48
|
|
|
44
49
|
|
|
45
|
-
class AddressNotFound(
|
|
50
|
+
class AddressNotFound(EodagError):
|
|
46
51
|
"""An error indicating the address of a subdataset was not found"""
|
|
47
52
|
|
|
48
53
|
|
|
49
|
-
class UnsupportedProvider(
|
|
54
|
+
class UnsupportedProvider(EodagError):
|
|
50
55
|
"""An error indicating that eodag does not support a provider"""
|
|
51
56
|
|
|
52
57
|
|
|
53
|
-
class UnsupportedProductType(
|
|
58
|
+
class UnsupportedProductType(EodagError):
|
|
54
59
|
"""An error indicating that eodag does not support a product type"""
|
|
55
60
|
|
|
56
61
|
def __init__(self, product_type: str) -> None:
|
|
57
62
|
self.product_type = product_type
|
|
58
63
|
|
|
59
64
|
|
|
60
|
-
class UnsupportedDatasetAddressScheme(
|
|
65
|
+
class UnsupportedDatasetAddressScheme(EodagError):
|
|
61
66
|
"""An error indicating that eodag does not yet support an address scheme for
|
|
62
67
|
accessing raster subdatasets"""
|
|
63
68
|
|
|
64
69
|
|
|
65
|
-
class AuthenticationError(
|
|
70
|
+
class AuthenticationError(EodagError):
|
|
66
71
|
"""An error indicating that an authentication plugin did not succeeded
|
|
67
72
|
authenticating a user"""
|
|
68
73
|
|
|
69
74
|
|
|
70
|
-
class DownloadError(
|
|
75
|
+
class DownloadError(EodagError):
|
|
71
76
|
"""An error indicating something wrong with the download process"""
|
|
72
77
|
|
|
73
78
|
|
|
74
|
-
class NotAvailableError(
|
|
79
|
+
class NotAvailableError(EodagError):
|
|
75
80
|
"""An error indicating that the product is not available for download"""
|
|
76
81
|
|
|
77
82
|
|
|
78
|
-
class RequestError(
|
|
83
|
+
class RequestError(EodagError):
|
|
79
84
|
"""An error indicating that a request has failed. Usually eodag functions
|
|
80
85
|
and methods should catch and skip this"""
|
|
81
86
|
|
|
82
|
-
history: Set[Tuple[
|
|
87
|
+
history: Set[Tuple[str, Exception]] = set()
|
|
88
|
+
parameters: Set[str] = set()
|
|
83
89
|
|
|
84
90
|
def __str__(self):
|
|
85
91
|
repr = super().__str__()
|
|
86
92
|
for err_tuple in self.history:
|
|
87
|
-
repr += f"
|
|
93
|
+
repr += f"- {str(err_tuple)}"
|
|
88
94
|
return repr
|
|
89
95
|
|
|
90
96
|
|
|
91
|
-
class NoMatchingProductType(
|
|
97
|
+
class NoMatchingProductType(EodagError):
|
|
92
98
|
"""An error indicating that eodag was unable to derive a product type from a set
|
|
93
99
|
of search parameters"""
|
|
94
100
|
|
|
95
101
|
|
|
96
|
-
class STACOpenerError(
|
|
102
|
+
class STACOpenerError(EodagError):
|
|
97
103
|
"""An error indicating that a STAC file could not be opened"""
|
|
98
104
|
|
|
99
105
|
|
eodag/utils/requests.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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 logging
|
|
21
|
+
import os
|
|
22
|
+
from typing import Any, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from eodag.utils import HTTP_REQ_TIMEOUT, USER_AGENT, path_to_uri, uri_to_path
|
|
27
|
+
from eodag.utils.exceptions import RequestError, TimeOutError
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("eodag.utils.requests")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fetch_json(
|
|
33
|
+
file_url: str,
|
|
34
|
+
req_session: Optional[requests.Session] = None,
|
|
35
|
+
auth: Optional[requests.AuthBase] = None,
|
|
36
|
+
timeout: float = HTTP_REQ_TIMEOUT,
|
|
37
|
+
) -> Any:
|
|
38
|
+
"""
|
|
39
|
+
Fetches http/distant or local json file
|
|
40
|
+
|
|
41
|
+
:param file_url: url from which the file can be fetched
|
|
42
|
+
:type file_url: str
|
|
43
|
+
:param req_session: (optional) requests session
|
|
44
|
+
:type req_session: requests.Session
|
|
45
|
+
:param auth: (optional) authenticated object if request needs authentication
|
|
46
|
+
:type auth: Optional[requests.AuthBase]
|
|
47
|
+
:param timeout: (optional) authenticated object
|
|
48
|
+
:type timeout: float
|
|
49
|
+
:returns: json file content
|
|
50
|
+
:rtype: Any
|
|
51
|
+
"""
|
|
52
|
+
if req_session is None:
|
|
53
|
+
req_session = requests.Session()
|
|
54
|
+
try:
|
|
55
|
+
if not file_url.lower().startswith("http"):
|
|
56
|
+
file_url = path_to_uri(os.path.abspath(file_url))
|
|
57
|
+
req_session.mount("file://", LocalFileAdapter())
|
|
58
|
+
|
|
59
|
+
headers = USER_AGENT
|
|
60
|
+
logger.debug(f"fetching {file_url}")
|
|
61
|
+
res = req_session.get(
|
|
62
|
+
file_url,
|
|
63
|
+
headers=headers,
|
|
64
|
+
auth=auth,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
res.raise_for_status()
|
|
68
|
+
except requests.exceptions.Timeout as exc:
|
|
69
|
+
raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
|
|
70
|
+
except requests.exceptions.RequestException as exc:
|
|
71
|
+
raise RequestError(
|
|
72
|
+
f"Unable to fetch {file_url}: {str(exc)}",
|
|
73
|
+
) from exc
|
|
74
|
+
else:
|
|
75
|
+
return res.json()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LocalFileAdapter(requests.adapters.BaseAdapter):
|
|
79
|
+
"""Protocol Adapter to allow Requests to GET file:// URLs inspired
|
|
80
|
+
by https://stackoverflow.com/questions/10123929/fetch-a-file-from-a-local-url-with-python-requests/27786580
|
|
81
|
+
`LocalFileAdapter` class available for the moment (on the 2024-04-22)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _chkpath(method: str, path: str) -> Tuple[int, str]:
|
|
86
|
+
"""Return an HTTP status for the given filesystem path.
|
|
87
|
+
|
|
88
|
+
:param method: method of the request
|
|
89
|
+
:type method: str
|
|
90
|
+
:param path: path of the given file
|
|
91
|
+
:type path: str
|
|
92
|
+
:returns: HTTP status and its associated message
|
|
93
|
+
:rtype: Tuple[int, str]
|
|
94
|
+
"""
|
|
95
|
+
if method.lower() in ("put", "delete"):
|
|
96
|
+
return 501, "Not Implemented" # TODO
|
|
97
|
+
elif method.lower() not in ("get", "head"):
|
|
98
|
+
return 405, "Method Not Allowed"
|
|
99
|
+
elif os.path.isdir(path):
|
|
100
|
+
return 400, "Path Not A File"
|
|
101
|
+
elif not os.path.isfile(path):
|
|
102
|
+
return 404, "File Not Found"
|
|
103
|
+
elif not os.access(path, os.R_OK):
|
|
104
|
+
return 403, "Access Denied"
|
|
105
|
+
else:
|
|
106
|
+
return 200, "OK"
|
|
107
|
+
|
|
108
|
+
def send(self, req: requests.PreparedRequest, **kwargs: Any) -> requests.Response:
|
|
109
|
+
"""Wraps a file, described in request, in a Response object.
|
|
110
|
+
|
|
111
|
+
:param req: The PreparedRequest being "sent".
|
|
112
|
+
:type req: :class:`~requests.PreparedRequest`
|
|
113
|
+
:param kwargs: (not used) additionnal arguments of the request
|
|
114
|
+
:type kwargs: Any
|
|
115
|
+
:returns: a Response object containing the file
|
|
116
|
+
:rtype: :class:`~requests.Response`
|
|
117
|
+
"""
|
|
118
|
+
response = requests.Response()
|
|
119
|
+
|
|
120
|
+
path_url = uri_to_path(req.url)
|
|
121
|
+
|
|
122
|
+
if req.method is None or req.url is None:
|
|
123
|
+
raise RequestError("Method or url of the request is missing")
|
|
124
|
+
response.status_code, response.reason = self._chkpath(req.method, path_url)
|
|
125
|
+
if response.status_code == 200 and req.method.lower() != "head":
|
|
126
|
+
try:
|
|
127
|
+
response.raw = open(path_url, "rb")
|
|
128
|
+
except (OSError, IOError) as err:
|
|
129
|
+
response.status_code = 500
|
|
130
|
+
response.reason = str(err)
|
|
131
|
+
response.url = req.url
|
|
132
|
+
response.request = req
|
|
133
|
+
|
|
134
|
+
return response
|
|
135
|
+
|
|
136
|
+
def close(self):
|
|
137
|
+
"""Closes without cleaning up adapter specific items."""
|
|
138
|
+
pass
|
eodag/utils/rest.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Copyright 2018, 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
|
+
"""eodag.rest.utils methods that must be importable without eodag[server] installed"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any, Dict, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
import dateutil.parser
|
|
27
|
+
from dateutil import tz
|
|
28
|
+
|
|
29
|
+
from eodag.utils.exceptions import ValidationError
|
|
30
|
+
|
|
31
|
+
RFC3339_PATTERN = (
|
|
32
|
+
r"^(\d{4})-(\d{2})-(\d{2})"
|
|
33
|
+
r"(?:T(\d{2}):(\d{2}):(\d{2})(\.\d+)?"
|
|
34
|
+
r"(Z|([+-])(\d{2}):(\d{2}))?)?$"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_datetime(arguments: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
|
39
|
+
"""Get start and end dates from a dict containing `/` separated dates in `datetime` item
|
|
40
|
+
|
|
41
|
+
:param arguments: dict containing a single date or `/` separated dates in `datetime` item
|
|
42
|
+
:type arguments: dict
|
|
43
|
+
:returns: Start date and end date from datetime string (duplicate value if only one date as input)
|
|
44
|
+
:rtype: Tuple[Optional[str], Optional[str]]
|
|
45
|
+
"""
|
|
46
|
+
datetime_str = arguments.pop("datetime", None)
|
|
47
|
+
|
|
48
|
+
if datetime_str:
|
|
49
|
+
datetime_split = datetime_str.split("/")
|
|
50
|
+
if len(datetime_split) > 1:
|
|
51
|
+
dtstart = datetime_split[0] if datetime_split[0] != ".." else None
|
|
52
|
+
dtend = datetime_split[1] if datetime_split[1] != ".." else None
|
|
53
|
+
elif len(datetime_split) == 1:
|
|
54
|
+
# same time for start & end if only one is given
|
|
55
|
+
dtstart, dtend = datetime_split[0:1] * 2
|
|
56
|
+
else:
|
|
57
|
+
return None, None
|
|
58
|
+
|
|
59
|
+
return get_date(dtstart), get_date(dtend)
|
|
60
|
+
|
|
61
|
+
else:
|
|
62
|
+
# return already set (dtstart, dtend) or None
|
|
63
|
+
dtstart = get_date(arguments.pop("dtstart", None))
|
|
64
|
+
dtend = get_date(arguments.pop("dtend", None))
|
|
65
|
+
return get_date(dtstart), get_date(dtend)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_date(date: Optional[str]) -> Optional[str]:
|
|
69
|
+
"""Check if the input date can be parsed as a date"""
|
|
70
|
+
|
|
71
|
+
if not date:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return (
|
|
75
|
+
dateutil.parser.parse(date)
|
|
76
|
+
.replace(tzinfo=tz.UTC)
|
|
77
|
+
.isoformat()
|
|
78
|
+
.replace("+00:00", "")
|
|
79
|
+
)
|
|
80
|
+
except ValueError as e:
|
|
81
|
+
exc = ValidationError("invalid input date: %s" % e)
|
|
82
|
+
raise exc
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def rfc3339_str_to_datetime(s: str) -> datetime.datetime:
|
|
86
|
+
"""Convert a string conforming to RFC 3339 to a :class:`datetime.datetime`.
|
|
87
|
+
|
|
88
|
+
:param s: The string to convert to :class:`datetime.datetime`
|
|
89
|
+
:type s: str
|
|
90
|
+
|
|
91
|
+
:returns: The datetime represented by the ISO8601 (RFC 3339) formatted string
|
|
92
|
+
:rtype: :class:`datetime.datetime`
|
|
93
|
+
|
|
94
|
+
raises: :class:`ValidationError`
|
|
95
|
+
"""
|
|
96
|
+
# Uppercase the string
|
|
97
|
+
s = s.upper()
|
|
98
|
+
|
|
99
|
+
# Match against RFC3339 regex.
|
|
100
|
+
result = re.match(RFC3339_PATTERN, s)
|
|
101
|
+
if not result:
|
|
102
|
+
raise ValidationError("Invalid RFC3339 datetime.")
|
|
103
|
+
|
|
104
|
+
return dateutil.parser.isoparse(s).replace(tzinfo=datetime.timezone.utc)
|
eodag/utils/stac_reader.py
CHANGED
|
@@ -27,8 +27,9 @@ from urllib.request import urlopen
|
|
|
27
27
|
import concurrent.futures
|
|
28
28
|
import orjson
|
|
29
29
|
import pystac
|
|
30
|
+
from pystac.stac_object import STACObjectType
|
|
30
31
|
|
|
31
|
-
from eodag.utils import HTTP_REQ_TIMEOUT
|
|
32
|
+
from eodag.utils import HTTP_REQ_TIMEOUT, get_ssl_context
|
|
32
33
|
from eodag.utils.exceptions import STACOpenerError
|
|
33
34
|
|
|
34
35
|
logger = logging.getLogger("eodag.utils.stac_reader")
|
|
@@ -38,10 +39,11 @@ class _TextOpener:
|
|
|
38
39
|
"""Exhaust read methods for pystac.StacIO in the order defined
|
|
39
40
|
in the openers list"""
|
|
40
41
|
|
|
41
|
-
def __init__(self, timeout: int) -> None:
|
|
42
|
+
def __init__(self, timeout: int, ssl_verify: bool) -> None:
|
|
42
43
|
self.openers = [self.read_local_json, self.read_http_remote_json]
|
|
43
44
|
# Only used by read_http_remote_json
|
|
44
45
|
self.timeout = timeout
|
|
46
|
+
self.ssl_verify = ssl_verify
|
|
45
47
|
|
|
46
48
|
@staticmethod
|
|
47
49
|
def read_local_json(url: str, as_json: bool = False) -> Any:
|
|
@@ -54,13 +56,14 @@ class _TextOpener:
|
|
|
54
56
|
with open(url) as f:
|
|
55
57
|
return f.read()
|
|
56
58
|
except OSError:
|
|
57
|
-
|
|
58
|
-
raise STACOpenerError
|
|
59
|
+
raise STACOpenerError("read_local_json is not the right STAC opener")
|
|
59
60
|
|
|
60
61
|
def read_http_remote_json(self, url: str, as_json: bool = False) -> Any:
|
|
61
62
|
"""Read JSON remote HTTP file"""
|
|
63
|
+
ssl_ctx = get_ssl_context(self.ssl_verify)
|
|
64
|
+
|
|
62
65
|
try:
|
|
63
|
-
res = urlopen(url, timeout=self.timeout)
|
|
66
|
+
res = urlopen(url, timeout=self.timeout, context=ssl_ctx)
|
|
64
67
|
content_type = res.getheader("Content-Type")
|
|
65
68
|
if content_type is None:
|
|
66
69
|
encoding = "utf-8"
|
|
@@ -79,17 +82,19 @@ class _TextOpener:
|
|
|
79
82
|
f"{url} with a timeout of {self.timeout} seconds"
|
|
80
83
|
) from None
|
|
81
84
|
else:
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
raise STACOpenerError(
|
|
86
|
+
"read_http_remote_json is not the right STAC opener"
|
|
87
|
+
)
|
|
84
88
|
|
|
85
89
|
def __call__(self, url: str, as_json: bool = False) -> Any:
|
|
90
|
+
openers = self.openers[:]
|
|
86
91
|
res = None
|
|
87
|
-
while
|
|
92
|
+
while openers:
|
|
88
93
|
try:
|
|
89
|
-
res =
|
|
94
|
+
res = openers[0](url, as_json)
|
|
90
95
|
except STACOpenerError:
|
|
91
96
|
# Remove the opener that just failed
|
|
92
|
-
|
|
97
|
+
openers.pop(0)
|
|
93
98
|
if res is not None:
|
|
94
99
|
break
|
|
95
100
|
if res is None:
|
|
@@ -102,6 +107,7 @@ def fetch_stac_items(
|
|
|
102
107
|
recursive: bool = False,
|
|
103
108
|
max_connections: int = 100,
|
|
104
109
|
timeout: int = HTTP_REQ_TIMEOUT,
|
|
110
|
+
ssl_verify: bool = True,
|
|
105
111
|
) -> List[Dict[str, Any]]:
|
|
106
112
|
"""Fetch STAC item from a single item file or items from a catalog.
|
|
107
113
|
|
|
@@ -112,14 +118,16 @@ def fetch_stac_items(
|
|
|
112
118
|
:param max_connections: (optional) Maximum number of connections for HTTP requests
|
|
113
119
|
:type max_connections: int
|
|
114
120
|
:param timeout: (optional) Timeout in seconds for each internal HTTP request
|
|
115
|
-
:type timeout:
|
|
121
|
+
:type timeout: int
|
|
122
|
+
:param ssl_verify: (optional) SSL Verification for HTTP request
|
|
123
|
+
:type ssl_verify: bool
|
|
116
124
|
:returns: The items found in `stac_path`
|
|
117
125
|
:rtype: :class:`list`
|
|
118
126
|
"""
|
|
119
127
|
|
|
120
128
|
# URI opener used by PySTAC internally, instantiated here
|
|
121
129
|
# to retrieve the timeout.
|
|
122
|
-
_text_opener = _TextOpener(timeout)
|
|
130
|
+
_text_opener = _TextOpener(timeout, ssl_verify)
|
|
123
131
|
pystac.StacIO.read_text = _text_opener
|
|
124
132
|
|
|
125
133
|
stac_obj = pystac.read_file(stac_path)
|
|
@@ -142,6 +150,8 @@ def _fetch_stac_items_from_catalog(
|
|
|
142
150
|
_text_opener: Callable[[str, bool], Any],
|
|
143
151
|
) -> List[Any]:
|
|
144
152
|
"""Fetch items from a STAC catalog"""
|
|
153
|
+
items: List[Dict[Any, Any]] = []
|
|
154
|
+
|
|
145
155
|
# pystac cannot yet return links from a single file catalog, see:
|
|
146
156
|
# https://github.com/stac-utils/pystac/issues/256
|
|
147
157
|
extensions: Optional[Union[List[str], str]] = getattr(cat, "stac_extensions", None)
|
|
@@ -151,8 +161,7 @@ def _fetch_stac_items_from_catalog(
|
|
|
151
161
|
items = [feature for feature in cat.to_dict()["features"]]
|
|
152
162
|
return items
|
|
153
163
|
|
|
154
|
-
# Making the links absolutes allow for both relative and absolute links
|
|
155
|
-
# to be handled.
|
|
164
|
+
# Making the links absolutes allow for both relative and absolute links to be handled.
|
|
156
165
|
if not recursive:
|
|
157
166
|
hrefs: List[Optional[str]] = [
|
|
158
167
|
link.get_absolute_href() for link in cat.get_item_links()
|
|
@@ -164,7 +173,6 @@ def _fetch_stac_items_from_catalog(
|
|
|
164
173
|
link.get_absolute_href() for link in parent_catalog.get_item_links()
|
|
165
174
|
]
|
|
166
175
|
|
|
167
|
-
items: List[Dict[Any, Any]] = []
|
|
168
176
|
if hrefs:
|
|
169
177
|
logger.debug("Fetching %s items", len(hrefs))
|
|
170
178
|
with concurrent.futures.ThreadPoolExecutor(
|
|
@@ -176,5 +184,81 @@ def _fetch_stac_items_from_catalog(
|
|
|
176
184
|
for future in concurrent.futures.as_completed(future_to_href):
|
|
177
185
|
item = future.result()
|
|
178
186
|
if item:
|
|
179
|
-
items.append(
|
|
187
|
+
items.append(item)
|
|
180
188
|
return items
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def fetch_stac_collections(
|
|
192
|
+
stac_path: str,
|
|
193
|
+
collection: Optional[str] = None,
|
|
194
|
+
max_connections: int = 100,
|
|
195
|
+
timeout: int = HTTP_REQ_TIMEOUT,
|
|
196
|
+
ssl_verify: bool = True,
|
|
197
|
+
) -> List[Dict[str, Any]]:
|
|
198
|
+
"""Fetch STAC collection(s) from a catalog.
|
|
199
|
+
|
|
200
|
+
:param stac_path: A STAC object filepath
|
|
201
|
+
:type stac_path: str
|
|
202
|
+
:param collection: the collection to fetch
|
|
203
|
+
:type collection: Optional[str]
|
|
204
|
+
:param max_connections: (optional) Maximum number of connections for HTTP requests
|
|
205
|
+
:type max_connections: int
|
|
206
|
+
:param timeout: (optional) Timeout in seconds for each internal HTTP request
|
|
207
|
+
:type timeout: int
|
|
208
|
+
:param ssl_verify: (optional) SSL Verification for HTTP request
|
|
209
|
+
:type ssl_verify: bool
|
|
210
|
+
:returns: The collection(s) found in `stac_path`
|
|
211
|
+
:rtype: :class:`list`
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
# URI opener used by PySTAC internally, instantiated here to retrieve the timeout.
|
|
215
|
+
_text_opener = _TextOpener(timeout, ssl_verify)
|
|
216
|
+
pystac.StacIO.read_text = _text_opener
|
|
217
|
+
|
|
218
|
+
stac_obj = pystac.read_file(stac_path)
|
|
219
|
+
if isinstance(stac_obj, pystac.Catalog):
|
|
220
|
+
return _fetch_stac_collections_from_catalog(
|
|
221
|
+
stac_obj, collection, max_connections, _text_opener
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
raise STACOpenerError(f"{stac_path} must be a STAC catalog")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fetch_stac_collections_from_catalog(
|
|
228
|
+
cat: pystac.Catalog,
|
|
229
|
+
collection: Optional[str],
|
|
230
|
+
max_connections: int,
|
|
231
|
+
_text_opener: Callable[[str, bool], Any],
|
|
232
|
+
) -> List[Any]:
|
|
233
|
+
"""Fetch collections from a STAC catalog"""
|
|
234
|
+
collections: List[Dict[Any, Any]] = []
|
|
235
|
+
|
|
236
|
+
# Making the links absolutes allow for both relative and absolute links to be handled.
|
|
237
|
+
hrefs: List[Optional[str]] = [
|
|
238
|
+
link.get_absolute_href()
|
|
239
|
+
for link in cat.get_child_links()
|
|
240
|
+
if collection is not None and link.title == collection
|
|
241
|
+
]
|
|
242
|
+
if len(hrefs) == 0:
|
|
243
|
+
hrefs = [link.get_absolute_href() for link in cat.get_child_links()]
|
|
244
|
+
|
|
245
|
+
if hrefs:
|
|
246
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
247
|
+
max_workers=max_connections
|
|
248
|
+
) as executor:
|
|
249
|
+
future_to_href = (
|
|
250
|
+
executor.submit(_text_opener, str(href), True) for href in hrefs
|
|
251
|
+
)
|
|
252
|
+
for future in concurrent.futures.as_completed(future_to_href):
|
|
253
|
+
fetched_collection = future.result()
|
|
254
|
+
if (
|
|
255
|
+
fetched_collection
|
|
256
|
+
and fetched_collection["type"] == STACObjectType.COLLECTION
|
|
257
|
+
and (
|
|
258
|
+
collection is None
|
|
259
|
+
or collection is not None
|
|
260
|
+
and fetched_collection.get("id") == collection
|
|
261
|
+
)
|
|
262
|
+
):
|
|
263
|
+
collections.append(fetched_collection)
|
|
264
|
+
return collections
|