eodag 3.0.1__py3-none-any.whl → 3.1.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 (87) hide show
  1. eodag/api/core.py +174 -138
  2. eodag/api/product/_assets.py +44 -15
  3. eodag/api/product/_product.py +58 -47
  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 +117 -90
  10. eodag/api/search_result.py +13 -23
  11. eodag/cli.py +26 -5
  12. eodag/config.py +86 -92
  13. eodag/plugins/apis/base.py +1 -1
  14. eodag/plugins/apis/ecmwf.py +42 -22
  15. eodag/plugins/apis/usgs.py +17 -16
  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 +22 -16
  22. eodag/plugins/authentication/sas_auth.py +4 -4
  23. eodag/plugins/authentication/token.py +41 -10
  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 +6 -7
  32. eodag/plugins/download/aws.py +146 -87
  33. eodag/plugins/download/base.py +38 -56
  34. eodag/plugins/download/creodias_s3.py +29 -0
  35. eodag/plugins/download/http.py +173 -183
  36. eodag/plugins/download/s3rest.py +10 -11
  37. eodag/plugins/manager.py +10 -20
  38. eodag/plugins/search/__init__.py +6 -5
  39. eodag/plugins/search/base.py +90 -46
  40. eodag/plugins/search/build_search_result.py +1048 -361
  41. eodag/plugins/search/cop_marine.py +22 -12
  42. eodag/plugins/search/creodias_s3.py +9 -73
  43. eodag/plugins/search/csw.py +11 -11
  44. eodag/plugins/search/data_request_search.py +19 -18
  45. eodag/plugins/search/qssearch.py +99 -258
  46. eodag/plugins/search/stac_list_assets.py +85 -0
  47. eodag/plugins/search/static_stac_search.py +4 -4
  48. eodag/resources/ext_product_types.json +1 -1
  49. eodag/resources/product_types.yml +1134 -325
  50. eodag/resources/providers.yml +906 -2006
  51. eodag/resources/stac_api.yml +2 -2
  52. eodag/resources/user_conf_template.yml +10 -9
  53. eodag/rest/cache.py +2 -2
  54. eodag/rest/config.py +3 -3
  55. eodag/rest/core.py +112 -82
  56. eodag/rest/errors.py +5 -5
  57. eodag/rest/server.py +33 -14
  58. eodag/rest/stac.py +41 -38
  59. eodag/rest/types/collections_search.py +3 -3
  60. eodag/rest/types/eodag_search.py +29 -23
  61. eodag/rest/types/queryables.py +42 -31
  62. eodag/rest/types/stac_search.py +15 -25
  63. eodag/rest/utils/__init__.py +14 -21
  64. eodag/rest/utils/cql_evaluate.py +6 -6
  65. eodag/rest/utils/rfc3339.py +2 -2
  66. eodag/types/__init__.py +141 -32
  67. eodag/types/bbox.py +2 -2
  68. eodag/types/download_args.py +3 -3
  69. eodag/types/queryables.py +183 -72
  70. eodag/types/search_args.py +4 -4
  71. eodag/types/whoosh.py +127 -3
  72. eodag/utils/__init__.py +153 -51
  73. eodag/utils/exceptions.py +28 -21
  74. eodag/utils/import_system.py +2 -2
  75. eodag/utils/repr.py +65 -6
  76. eodag/utils/requests.py +13 -13
  77. eodag/utils/rest.py +2 -2
  78. eodag/utils/s3.py +231 -0
  79. eodag/utils/stac_reader.py +10 -10
  80. {eodag-3.0.1.dist-info → eodag-3.1.0.dist-info}/METADATA +77 -76
  81. eodag-3.1.0.dist-info/RECORD +113 -0
  82. {eodag-3.0.1.dist-info → eodag-3.1.0.dist-info}/WHEEL +1 -1
  83. {eodag-3.0.1.dist-info → eodag-3.1.0.dist-info}/entry_points.txt +4 -2
  84. eodag/utils/constraints.py +0 -244
  85. eodag-3.0.1.dist-info/RECORD +0 -109
  86. {eodag-3.0.1.dist-info → eodag-3.1.0.dist-info}/LICENSE +0 -0
  87. {eodag-3.0.1.dist-info → eodag-3.1.0.dist-info}/top_level.txt +0 -0
eodag/utils/repr.py CHANGED
@@ -18,12 +18,21 @@
18
18
  from __future__ import annotations
19
19
 
20
20
  import collections.abc
21
+ import re
21
22
  from typing import Any, Optional
22
23
  from urllib.parse import urlparse
23
24
 
24
25
 
25
26
  def str_as_href(link: str) -> str:
26
- """URL to html link"""
27
+ """URL to html link
28
+
29
+ :param link: URL to format
30
+ :returns: HMLT formatted link
31
+
32
+ >>> str_as_href("http://foo.bar")
33
+ "<a href='http://foo.bar' target='_blank'>http://foo.bar</a>"
34
+
35
+ """
27
36
  if urlparse(link).scheme in ("file", "http", "https", "s3"):
28
37
  return f"<a href='{link}' target='_blank'>{link}</a>"
29
38
  else:
@@ -31,7 +40,13 @@ def str_as_href(link: str) -> str:
31
40
 
32
41
 
33
42
  def html_table(input: Any, depth: Optional[int] = None) -> str:
34
- """Transform input to HTML table"""
43
+ """Transform input object to HTML table
44
+
45
+ :param input: input object to represent
46
+ :param depth: maximum depth level until which nested objects should be represented
47
+ in new tables (unlimited by default)
48
+ :returns: HTML table
49
+ """
35
50
  if isinstance(input, collections.abc.Mapping):
36
51
  return dict_to_html_table(input, depth=depth)
37
52
  elif isinstance(input, collections.abc.Sequence) and not isinstance(input, str):
@@ -47,7 +62,14 @@ def dict_to_html_table(
47
62
  depth: Optional[int] = None,
48
63
  brackets: bool = True,
49
64
  ) -> str:
50
- """Transform input dict to HTML table"""
65
+ """Transform input dict to HTML table
66
+
67
+ :param input_dict: input dict to represent
68
+ :param depth: maximum depth level until which nested objects should be represented
69
+ in new tables (unlimited by default)
70
+ :param brackets: whether surrounding brackets should be displayed or not
71
+ :returns: HTML table
72
+ """
51
73
  opening_bracket = "<span style='color: grey;'>{</span>" if brackets else ""
52
74
  closing_bracket = "<span style='color: grey;'>}</span>" if brackets else ""
53
75
  indent = "10px" if brackets else "0"
@@ -90,7 +112,13 @@ def dict_to_html_table(
90
112
  def list_to_html_table(
91
113
  input_list: collections.abc.Sequence, depth: Optional[int] = None
92
114
  ) -> str:
93
- """Transform input list to HTML table"""
115
+ """Transform input list to HTML table
116
+
117
+ :param input_list: input list to represent
118
+ :param depth: maximum depth level until which nested objects should be represented
119
+ in new tables (unlimited by default)
120
+ :returns: HTML table
121
+ """
94
122
  if depth is not None:
95
123
  depth -= 1
96
124
  separator = (
@@ -103,11 +131,42 @@ def list_to_html_table(
103
131
  + separator.join(
104
132
  [
105
133
  f"""<span style='text-align: left;'>{
106
- html_table(v, depth=depth)
107
- }</span>
134
+ html_table(v, depth=depth)
135
+ }</span>
108
136
  """
109
137
  for v in input_list
110
138
  ]
111
139
  )
112
140
  + "<span style='color: grey;'>]</span>"
113
141
  )
142
+
143
+
144
+ def remove_class_repr(type_repr: str) -> str:
145
+ """Removes class tag from type representation
146
+
147
+ :param type_repr: input type representation
148
+ :returns: type without class tag
149
+
150
+ >>> remove_class_repr(str(type("foo")))
151
+ 'str'
152
+ """
153
+ return re.sub(r"<class '(\w+)'>", r"\1", type_repr)
154
+
155
+
156
+ def shorter_type_repr(long_type: str) -> str:
157
+ """Shorten long type representation
158
+
159
+ :param long_type: long type representation
160
+ :returns: type reprensentation shortened
161
+
162
+ >>> import typing
163
+ >>> shorter_type_repr(str(typing.Literal["foo", "bar"]))
164
+ "Literal['foo', ...]"
165
+ """
166
+ # shorten lists
167
+ shorter = re.sub(r",[^\[^\]]+\]", ", ...]", str(long_type))
168
+ # remove class prefix
169
+ shorter = remove_class_repr(shorter)
170
+ # remove parent objects
171
+ shorter = re.sub(r"\w+\.", "", shorter)
172
+ return shorter
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
 
@@ -30,7 +30,7 @@ logger = logging.getLogger("eodag.utils.requests")
30
30
 
31
31
 
32
32
  def fetch_json(
33
- file_url: str,
33
+ url: str,
34
34
  req_session: Optional[requests.Session] = None,
35
35
  auth: Optional[requests.auth.AuthBase] = None,
36
36
  timeout: float = HTTP_REQ_TIMEOUT,
@@ -38,32 +38,32 @@ def fetch_json(
38
38
  """
39
39
  Fetches http/distant or local json file
40
40
 
41
- :param file_url: url from which the file can be fetched
41
+ :param url: url from which the file can be fetched
42
42
  :param req_session: (optional) requests session
43
43
  :param auth: (optional) authenticated object if request needs authentication
44
44
  :param timeout: (optional) authenticated object
45
45
  :returns: json file content
46
46
  """
47
47
  if req_session is None:
48
- req_session = requests.Session()
48
+ req_session = requests.sessions.Session()
49
49
  try:
50
- if not file_url.lower().startswith("http"):
51
- file_url = path_to_uri(os.path.abspath(file_url))
50
+ if not url.lower().startswith("http"):
51
+ url = path_to_uri(os.path.abspath(url))
52
52
  req_session.mount("file://", LocalFileAdapter())
53
53
 
54
54
  headers = USER_AGENT
55
- logger.debug(f"fetching {file_url}")
55
+ logger.debug(f"fetching {url}")
56
56
  res = req_session.get(
57
- file_url,
57
+ url,
58
58
  headers=headers,
59
59
  auth=auth,
60
60
  timeout=timeout,
61
61
  )
62
62
  res.raise_for_status()
63
63
  except requests.exceptions.Timeout as exc:
64
- raise TimeOutError(exc, timeout=HTTP_REQ_TIMEOUT) from exc
64
+ raise TimeOutError(exc, timeout=timeout) from exc
65
65
  except requests.exceptions.RequestException as exc:
66
- raise RequestError.from_error(exc, f"Unable to fetch {file_url}") from exc
66
+ raise RequestError.from_error(exc, f"Unable to fetch {url}") from exc
67
67
  else:
68
68
  return res.json()
69
69
 
@@ -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
@@ -100,8 +100,8 @@ class LocalFileAdapter(requests.adapters.BaseAdapter):
100
100
  ) -> requests.Response:
101
101
  """Wraps a file, described in request, in a Response object.
102
102
 
103
- :param req: The PreparedRequest being "sent".
104
- :param kwargs: (not used) additionnal arguments of the request
103
+ :param request: The PreparedRequest being "sent".
104
+ :param kwargs: (not used) additional arguments of the request
105
105
  :returns: a Response object containing the file
106
106
  """
107
107
  response = requests.Response()
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,231 @@
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 ZipFile, 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 open_s3_zipped_object(
82
+ bucket_name: str, key_name: str, client_s3: S3Client, partial: bool = True
83
+ ) -> ZipFile:
84
+ """
85
+ Open 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
+ :param partial: fetch partial data if only content info is needed
94
+ :returns: List of files in zip
95
+ """
96
+ response = client_s3.head_object(Bucket=bucket_name, Key=key_name)
97
+ size = response["ContentLength"]
98
+
99
+ # End Of Central Directory bytes
100
+ eocd = fetch(bucket_name, key_name, size - 22, 22, client_s3)
101
+
102
+ # start offset and size of the central directory
103
+ cd_start = parse_int(eocd[16:20])
104
+ cd_size = parse_int(eocd[12:16])
105
+
106
+ # fetch central directory, append EOCD, and open as zipfile
107
+ cd = fetch(bucket_name, key_name, cd_start, cd_size, client_s3)
108
+
109
+ zip_data = (
110
+ cd + eocd if partial else fetch(bucket_name, key_name, 0, size, client_s3)
111
+ )
112
+
113
+ zip = zipfile.ZipFile(io.BytesIO(zip_data))
114
+
115
+ return zip
116
+
117
+
118
+ def list_files_in_s3_zipped_object(
119
+ bucket_name: str, key_name: str, client_s3: S3Client
120
+ ) -> List[ZipInfo]:
121
+ """
122
+ List files in s3 zipped object, without downloading it.
123
+
124
+ See https://stackoverflow.com/questions/41789176/how-to-count-files-inside-zip-in-aws-s3-without-downloading-it;
125
+ Based on https://stackoverflow.com/questions/51351000/read-zip-files-from-s3-without-downloading-the-entire-file
126
+
127
+ :param bucket_name: Bucket name of the object to fetch
128
+ :param key_name: Key name of the object to fetch
129
+ :param client_s3: s3 client used to fetch the object
130
+ :returns: List of files in zip
131
+ """
132
+ with open_s3_zipped_object(bucket_name, key_name, client_s3) as zip_file:
133
+ logger.debug("Found %s files in %s" % (len(zip_file.filelist), key_name))
134
+ return zip_file.filelist
135
+
136
+
137
+ def update_assets_from_s3(
138
+ product: EOProduct,
139
+ auth: AwsAuth,
140
+ s3_endpoint: Optional[str] = None,
141
+ content_url: Optional[str] = None,
142
+ ) -> None:
143
+ """Update ``EOProduct.assets`` using content listed in its ``remote_location`` or given
144
+ ``content_url``.
145
+
146
+ If url points to a zipped archive, its content will also be be listed.
147
+
148
+ :param product: product to update
149
+ :param auth: Authentication plugin
150
+ :param s3_endpoint: s3 endpoint if not hosted on AWS
151
+ :param content_url: s3 URL pointing to the content that must be listed (defaults to
152
+ ``product.remote_location`` if empty)
153
+ """
154
+ required_creds = ["aws_access_key_id", "aws_secret_access_key"]
155
+
156
+ if content_url is None:
157
+ content_url = product.remote_location
158
+
159
+ bucket, prefix = get_bucket_name_and_prefix(content_url)
160
+
161
+ if bucket is None or prefix is None:
162
+ logger.debug(f"No s3 prefix could guessed from {content_url}")
163
+ return None
164
+
165
+ try:
166
+ auth_dict = auth.authenticate()
167
+
168
+ if not all(x in auth_dict for x in required_creds):
169
+ raise MisconfiguredError(
170
+ f"Incomplete credentials for {product.provider}, missing "
171
+ f"{[x for x in required_creds if x not in auth_dict]}"
172
+ )
173
+ if not getattr(auth, "s3_client", None):
174
+ auth.s3_client = boto3.client(
175
+ service_name="s3",
176
+ endpoint_url=s3_endpoint,
177
+ aws_access_key_id=auth_dict.get("aws_access_key_id"),
178
+ aws_secret_access_key=auth_dict.get("aws_secret_access_key"),
179
+ aws_session_token=auth_dict.get("aws_session_token"),
180
+ )
181
+
182
+ logger.debug("Listing assets in %s", prefix)
183
+
184
+ if prefix.endswith(".zip"):
185
+ # List prefix zip content
186
+ assets_urls = [
187
+ f"zip+s3://{bucket}/{prefix}!{f.filename}"
188
+ for f in list_files_in_s3_zipped_object(bucket, prefix, auth.s3_client)
189
+ ]
190
+ else:
191
+ # List files in prefix
192
+ assets_urls = [
193
+ f"s3://{bucket}/{obj['Key']}"
194
+ for obj in auth.s3_client.list_objects(
195
+ Bucket=bucket, Prefix=prefix, MaxKeys=300
196
+ ).get("Contents", [])
197
+ ]
198
+
199
+ for asset_url in assets_urls:
200
+ out_of_zip_url = asset_url.split("!")[-1]
201
+ key, roles = product.driver.guess_asset_key_and_roles(
202
+ out_of_zip_url, product
203
+ )
204
+ parsed_url = urlparse(out_of_zip_url)
205
+ title = os.path.basename(parsed_url.path)
206
+
207
+ if key and key not in product.assets:
208
+ product.assets[key] = {
209
+ "title": title,
210
+ "roles": roles,
211
+ "href": asset_url,
212
+ }
213
+ if mime_type := guess_file_type(asset_url):
214
+ product.assets[key]["type"] = mime_type
215
+
216
+ # sort assets
217
+ product.assets.data = dict(sorted(product.assets.data.items()))
218
+
219
+ # update driver
220
+ product.driver = product.get_driver()
221
+
222
+ except botocore.exceptions.ClientError as e:
223
+ if hasattr(auth.config, "auth_error_code") and str(
224
+ auth.config.auth_error_code
225
+ ) in str(e):
226
+ raise AuthenticationError(
227
+ f"Authentication failed on {s3_endpoint} s3"
228
+ ) from e
229
+ raise NotAvailableError(
230
+ f"assets for product {prefix} could not be found"
231
+ ) 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