eodag 2.12.0__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.
Files changed (77) hide show
  1. eodag/api/core.py +434 -319
  2. eodag/api/product/__init__.py +5 -1
  3. eodag/api/product/_assets.py +7 -2
  4. eodag/api/product/_product.py +46 -68
  5. eodag/api/product/metadata_mapping.py +181 -66
  6. eodag/api/search_result.py +21 -1
  7. eodag/cli.py +20 -6
  8. eodag/config.py +95 -6
  9. eodag/plugins/apis/base.py +8 -162
  10. eodag/plugins/apis/ecmwf.py +36 -24
  11. eodag/plugins/apis/usgs.py +40 -24
  12. eodag/plugins/authentication/aws_auth.py +2 -2
  13. eodag/plugins/authentication/header.py +31 -6
  14. eodag/plugins/authentication/keycloak.py +13 -84
  15. eodag/plugins/authentication/oauth.py +3 -3
  16. eodag/plugins/authentication/openid_connect.py +256 -46
  17. eodag/plugins/authentication/qsauth.py +3 -0
  18. eodag/plugins/authentication/sas_auth.py +8 -1
  19. eodag/plugins/authentication/token.py +92 -46
  20. eodag/plugins/authentication/token_exchange.py +120 -0
  21. eodag/plugins/download/aws.py +86 -91
  22. eodag/plugins/download/base.py +72 -40
  23. eodag/plugins/download/http.py +607 -264
  24. eodag/plugins/download/s3rest.py +28 -15
  25. eodag/plugins/manager.py +73 -57
  26. eodag/plugins/search/__init__.py +36 -0
  27. eodag/plugins/search/base.py +225 -18
  28. eodag/plugins/search/build_search_result.py +389 -32
  29. eodag/plugins/search/cop_marine.py +378 -0
  30. eodag/plugins/search/creodias_s3.py +15 -14
  31. eodag/plugins/search/csw.py +5 -7
  32. eodag/plugins/search/data_request_search.py +44 -20
  33. eodag/plugins/search/qssearch.py +508 -203
  34. eodag/plugins/search/static_stac_search.py +99 -36
  35. eodag/resources/constraints/climate-dt.json +13 -0
  36. eodag/resources/constraints/extremes-dt.json +8 -0
  37. eodag/resources/ext_product_types.json +1 -1
  38. eodag/resources/product_types.yml +1897 -34
  39. eodag/resources/providers.yml +3539 -3277
  40. eodag/resources/stac.yml +48 -54
  41. eodag/resources/stac_api.yml +71 -25
  42. eodag/resources/stac_provider.yml +5 -0
  43. eodag/resources/user_conf_template.yml +51 -3
  44. eodag/rest/__init__.py +6 -0
  45. eodag/rest/cache.py +70 -0
  46. eodag/rest/config.py +68 -0
  47. eodag/rest/constants.py +27 -0
  48. eodag/rest/core.py +757 -0
  49. eodag/rest/server.py +397 -258
  50. eodag/rest/stac.py +438 -307
  51. eodag/rest/types/collections_search.py +44 -0
  52. eodag/rest/types/eodag_search.py +232 -43
  53. eodag/rest/types/{stac_queryables.py → queryables.py} +81 -43
  54. eodag/rest/types/stac_search.py +277 -0
  55. eodag/rest/utils/__init__.py +216 -0
  56. eodag/rest/utils/cql_evaluate.py +119 -0
  57. eodag/rest/utils/rfc3339.py +65 -0
  58. eodag/types/__init__.py +99 -9
  59. eodag/types/bbox.py +15 -14
  60. eodag/types/download_args.py +31 -0
  61. eodag/types/search_args.py +58 -7
  62. eodag/types/whoosh.py +81 -0
  63. eodag/utils/__init__.py +72 -9
  64. eodag/utils/constraints.py +37 -37
  65. eodag/utils/exceptions.py +23 -17
  66. eodag/utils/requests.py +138 -0
  67. eodag/utils/rest.py +104 -0
  68. eodag/utils/stac_reader.py +100 -16
  69. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/METADATA +64 -44
  70. eodag-3.0.0b1.dist-info/RECORD +109 -0
  71. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/WHEEL +1 -1
  72. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/entry_points.txt +6 -5
  73. eodag/plugins/apis/cds.py +0 -540
  74. eodag/rest/utils.py +0 -1133
  75. eodag-2.12.0.dist-info/RECORD +0 -94
  76. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/LICENSE +0 -0
  77. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/top_level.txt +0 -0
@@ -19,13 +19,14 @@ import copy
19
19
  import logging
20
20
  from typing import Any, Dict, List, Set, Union
21
21
 
22
- import requests
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 HTTP_REQ_TIMEOUT, USER_AGENT, deepcopy
28
- from eodag.utils.exceptions import TimeOutError, ValidationError
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 value in constraint[provider_key]:
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
- headers = USER_AGENT
172
- logger.debug("fetching constraints from %s", constraints_url)
173
- if hasattr(plugin, "auth"):
174
- res = requests.get(
175
- constraints_url,
176
- headers=headers,
177
- auth=plugin.auth,
178
- timeout=HTTP_REQ_TIMEOUT,
179
- )
180
- else:
181
- res = requests.get(
182
- constraints_url, headers=headers, timeout=HTTP_REQ_TIMEOUT
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
- constraints_data = res.json()
196
- config = plugin.config.__dict__
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 ValidationError(Exception):
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(Exception):
38
+ class PluginNotFoundError(EodagError):
34
39
  """Error when looking for a plugin class that was not defined"""
35
40
 
36
41
 
37
- class PluginImplementationError(Exception):
42
+ class PluginImplementationError(EodagError):
38
43
  """Error when a plugin does not behave as expected"""
39
44
 
40
45
 
41
- class MisconfiguredError(Exception):
46
+ class MisconfiguredError(EodagError):
42
47
  """An error indicating a Search Plugin that is not well configured"""
43
48
 
44
49
 
45
- class AddressNotFound(Exception):
50
+ class AddressNotFound(EodagError):
46
51
  """An error indicating the address of a subdataset was not found"""
47
52
 
48
53
 
49
- class UnsupportedProvider(Exception):
54
+ class UnsupportedProvider(EodagError):
50
55
  """An error indicating that eodag does not support a provider"""
51
56
 
52
57
 
53
- class UnsupportedProductType(Exception):
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(Exception):
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(Exception):
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(Exception):
75
+ class DownloadError(EodagError):
71
76
  """An error indicating something wrong with the download process"""
72
77
 
73
78
 
74
- class NotAvailableError(Exception):
79
+ class NotAvailableError(EodagError):
75
80
  """An error indicating that the product is not available for download"""
76
81
 
77
82
 
78
- class RequestError(Exception):
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[Exception, str]] = set()
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"\n- {str(err_tuple)}"
93
+ repr += f"- {str(err_tuple)}"
88
94
  return repr
89
95
 
90
96
 
91
- class NoMatchingProductType(Exception):
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(Exception):
102
+ class STACOpenerError(EodagError):
97
103
  """An error indicating that a STAC file could not be opened"""
98
104
 
99
105
 
@@ -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)
@@ -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
- logger.debug("read_local_json is not the right STAC opener")
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
- logger.debug("read_http_remote_json is not the right STAC opener")
83
- raise STACOpenerError
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 self.openers:
92
+ while openers:
88
93
  try:
89
- res = self.openers[0](url, as_json)
94
+ res = openers[0](url, as_json)
90
95
  except STACOpenerError:
91
96
  # Remove the opener that just failed
92
- self.openers.pop(0)
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: float
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(future.result())
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