cdsetool 0.2.10__tar.gz → 0.2.12__tar.gz
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.
- {cdsetool-0.2.10/src/cdsetool.egg-info → cdsetool-0.2.12}/PKG-INFO +9 -9
- {cdsetool-0.2.10 → cdsetool-0.2.12}/README.md +1 -1
- {cdsetool-0.2.10 → cdsetool-0.2.12}/pyproject.toml +19 -8
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool/_processing.py +14 -7
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool/cli.py +13 -11
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool/credentials.py +45 -33
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool/download.py +35 -16
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool/logger.py +8 -3
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool/monitor.py +44 -36
- cdsetool-0.2.12/src/cdsetool/py.typed +0 -0
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool/query.py +46 -26
- {cdsetool-0.2.10 → cdsetool-0.2.12/src/cdsetool.egg-info}/PKG-INFO +9 -9
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool.egg-info/SOURCES.txt +1 -0
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool.egg-info/requires.txt +5 -5
- {cdsetool-0.2.10 → cdsetool-0.2.12}/LICENSE +0 -0
- {cdsetool-0.2.10 → cdsetool-0.2.12}/setup.cfg +0 -0
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool/__init__.py +0 -0
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool.egg-info/dependency_links.txt +0 -0
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool.egg-info/entry_points.txt +0 -0
- {cdsetool-0.2.10 → cdsetool-0.2.12}/src/cdsetool.egg-info/top_level.txt +0 -0
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cdsetool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
4
4
|
Summary: Tools & CLI for interacting with CDSE product APIs
|
|
5
5
|
Author-email: Jacob Vejby <javej@sdfi.dk>
|
|
6
|
-
Project-URL: Homepage, https://github.com/
|
|
7
|
-
Project-URL: Bug Tracker, https://github.com/
|
|
6
|
+
Project-URL: Homepage, https://github.com/CDSETool/CDSETool
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/CDSETool/CDSETool/issues
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: Operating System :: OS Independent
|
|
10
10
|
Requires-Python: >=3.8
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: typer<
|
|
14
|
-
Requires-Dist: rich<
|
|
15
|
-
Requires-Dist: requests<
|
|
13
|
+
Requires-Dist: typer<1,>=0.9
|
|
14
|
+
Requires-Dist: rich<14,>=13.6
|
|
15
|
+
Requires-Dist: requests<3,>=2.28.1
|
|
16
16
|
Requires-Dist: pyjwt[crypto]~=2.8.0
|
|
17
17
|
Requires-Dist: geopandas>=0.13.2
|
|
18
18
|
Provides-Extra: test
|
|
19
19
|
Requires-Dist: black==24.4.2; extra == "test"
|
|
20
|
-
Requires-Dist: pylint==3.2.
|
|
21
|
-
Requires-Dist: pytest==8.2
|
|
20
|
+
Requires-Dist: pylint==3.2.6; extra == "test"
|
|
21
|
+
Requires-Dist: pytest==8.3.2; extra == "test"
|
|
22
22
|
Requires-Dist: pytest-cov==5.0.0; extra == "test"
|
|
23
23
|
Requires-Dist: requests-mock==1.12.1; extra == "test"
|
|
24
24
|
Requires-Dist: pytest-mock==3.14.0; extra == "test"
|
|
@@ -96,7 +96,7 @@ cdsetool download Sentinel2 PATH/TO/DIR --concurrency 4 --search-term startDate=
|
|
|
96
96
|
Install `cdsetool` using pip:
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
|
-
pip install cdsetool==0.2.
|
|
99
|
+
pip install cdsetool==0.2.12
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
## Usage
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cdsetool"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.12"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Jacob Vejby", email="javej@sdfi.dk" },
|
|
10
10
|
]
|
|
@@ -16,25 +16,25 @@ classifiers = [
|
|
|
16
16
|
"Operating System :: OS Independent",
|
|
17
17
|
]
|
|
18
18
|
dependencies = [
|
|
19
|
-
"typer >= 0.9,<
|
|
20
|
-
"rich >= 13.6,<
|
|
21
|
-
"requests >= 2.28.1,<
|
|
19
|
+
"typer >= 0.9,< 1",
|
|
20
|
+
"rich >= 13.6,< 14",
|
|
21
|
+
"requests >= 2.28.1,< 3",
|
|
22
22
|
"pyjwt[crypto] ~= 2.8.0",
|
|
23
23
|
"geopandas >= 0.13.2",
|
|
24
24
|
]
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
test = [
|
|
27
27
|
"black==24.4.2",
|
|
28
|
-
"pylint==3.2.
|
|
29
|
-
"pytest==8.2
|
|
28
|
+
"pylint==3.2.6",
|
|
29
|
+
"pytest==8.3.2",
|
|
30
30
|
"pytest-cov==5.0.0",
|
|
31
31
|
"requests-mock==1.12.1",
|
|
32
32
|
"pytest-mock==3.14.0",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[project.urls]
|
|
36
|
-
"Homepage" = "https://github.com/
|
|
37
|
-
"Bug Tracker" = "https://github.com/
|
|
36
|
+
"Homepage" = "https://github.com/CDSETool/CDSETool"
|
|
37
|
+
"Bug Tracker" = "https://github.com/CDSETool/CDSETool/issues"
|
|
38
38
|
|
|
39
39
|
[project.scripts]
|
|
40
40
|
cdsetool = "cdsetool:cli.main"
|
|
@@ -46,6 +46,17 @@ max-locals = 20
|
|
|
46
46
|
max-line-length = "88"
|
|
47
47
|
disable="fixme"
|
|
48
48
|
|
|
49
|
+
[tool.pyright]
|
|
50
|
+
exclude = ["**/__pycache__", ".venv"]
|
|
51
|
+
|
|
52
|
+
# Can be strict too, see more fine-grained settings at:
|
|
53
|
+
# https://github.com/microsoft/pyright/blob/main/docs/configuration.md#diagnostic-settings-defaults
|
|
54
|
+
typeCheckingMode = "standard"
|
|
55
|
+
|
|
56
|
+
executionEnvironments = [
|
|
57
|
+
{ root = "./" }
|
|
58
|
+
]
|
|
59
|
+
|
|
49
60
|
[tool.pytest.ini_options]
|
|
50
61
|
addopts = [
|
|
51
62
|
"--import-mode=importlib",
|
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
This module provides functions for processing data concurrently
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from concurrent.futures import wait, FIRST_COMPLETED
|
|
5
|
+
from concurrent.futures import Future, wait, FIRST_COMPLETED
|
|
6
6
|
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from typing import Callable, Generator, Iterable, List, Union
|
|
7
8
|
|
|
9
|
+
from cdsetool.query import FeatureQuery
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
|
|
12
|
+
def _concurrent_process(
|
|
13
|
+
fun: Callable[[FeatureQuery], Union[str, None]],
|
|
14
|
+
iterable: Iterable,
|
|
15
|
+
workers: int = 4,
|
|
16
|
+
) -> Generator[Union[str, None], None, None]:
|
|
10
17
|
"""
|
|
11
18
|
Process items in an iterable concurrently
|
|
12
19
|
|
|
@@ -26,17 +33,17 @@ def _concurrent_process(fun, iterable, workers=4):
|
|
|
26
33
|
iterator = iter(iterable)
|
|
27
34
|
|
|
28
35
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
29
|
-
futures = []
|
|
36
|
+
futures: List[Future[Union[str, None]]] = [] # pylint: disable=E1136
|
|
30
37
|
|
|
31
38
|
# Pluck an item from the iterable and submit it to the executor.
|
|
32
39
|
# If the iterable is exhausted, this function is a no-op.
|
|
33
|
-
def submit_item():
|
|
40
|
+
def submit_item() -> None:
|
|
34
41
|
item = next(iterator, None)
|
|
35
42
|
if item is not None:
|
|
36
43
|
futures.append(executor.submit(fun, item))
|
|
37
44
|
|
|
38
45
|
# Fill the futures list up to the low water mark
|
|
39
|
-
def fill_futures():
|
|
46
|
+
def fill_futures() -> None:
|
|
40
47
|
for _ in range(low_water_mark - len(futures)):
|
|
41
48
|
submit_item()
|
|
42
49
|
|
|
@@ -47,8 +54,8 @@ def _concurrent_process(fun, iterable, workers=4):
|
|
|
47
54
|
while futures:
|
|
48
55
|
|
|
49
56
|
# Wait for the first future(s) to complete
|
|
50
|
-
done,
|
|
51
|
-
futures = list(
|
|
57
|
+
done, not_done = wait(futures, return_when=FIRST_COMPLETED)
|
|
58
|
+
futures = list(not_done)
|
|
52
59
|
|
|
53
60
|
for future in done:
|
|
54
61
|
yield future.result()
|
|
@@ -5,21 +5,21 @@ Command line interface
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
7
|
import json as JSON
|
|
8
|
-
from typing import List, Optional
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
9
|
from typing_extensions import Annotated
|
|
10
10
|
import typer
|
|
11
11
|
from cdsetool.query import describe_collection, query_features
|
|
12
12
|
from cdsetool.monitor import StatusMonitor
|
|
13
13
|
from cdsetool.download import download_features
|
|
14
14
|
|
|
15
|
-
app = typer.Typer()
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
16
|
|
|
17
|
-
query_app = typer.Typer()
|
|
17
|
+
query_app = typer.Typer(no_args_is_help=True)
|
|
18
18
|
app.add_typer(query_app, name="query")
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
@query_app.command("search-terms")
|
|
22
|
-
def query_search_terms(collection: str):
|
|
22
|
+
def query_search_terms(collection: str) -> None:
|
|
23
23
|
"""
|
|
24
24
|
List the available search terms for a collection
|
|
25
25
|
"""
|
|
@@ -51,13 +51,12 @@ def query_search(
|
|
|
51
51
|
),
|
|
52
52
|
] = None,
|
|
53
53
|
json: Annotated[bool, typer.Option(help="Output JSON")] = False,
|
|
54
|
-
):
|
|
54
|
+
) -> None:
|
|
55
55
|
"""
|
|
56
56
|
Search for features matching the search terms
|
|
57
57
|
"""
|
|
58
58
|
search_term = search_term or []
|
|
59
|
-
|
|
60
|
-
features = query_features(collection, search_term)
|
|
59
|
+
features = query_features(collection, _to_dict(search_term))
|
|
61
60
|
|
|
62
61
|
for feature in features:
|
|
63
62
|
if json:
|
|
@@ -84,7 +83,7 @@ def download(
|
|
|
84
83
|
+ "Pass multiple times for multiple search terms"
|
|
85
84
|
),
|
|
86
85
|
] = None,
|
|
87
|
-
):
|
|
86
|
+
) -> None:
|
|
88
87
|
"""
|
|
89
88
|
Download all features matching the search terms
|
|
90
89
|
"""
|
|
@@ -93,8 +92,7 @@ def download(
|
|
|
93
92
|
sys.exit(1)
|
|
94
93
|
|
|
95
94
|
search_term = search_term or []
|
|
96
|
-
|
|
97
|
-
features = query_features(collection, search_term)
|
|
95
|
+
features = query_features(collection, _to_dict(search_term))
|
|
98
96
|
|
|
99
97
|
list(
|
|
100
98
|
download_features(
|
|
@@ -116,9 +114,13 @@ def main():
|
|
|
116
114
|
app()
|
|
117
115
|
|
|
118
116
|
|
|
119
|
-
def _to_dict(term_list: List[str]):
|
|
117
|
+
def _to_dict(term_list: List[str]) -> Dict[str, str]:
|
|
120
118
|
search_terms = {}
|
|
121
119
|
for item in term_list:
|
|
122
120
|
key, value = item.split("=")
|
|
123
121
|
search_terms[key] = value
|
|
124
122
|
return search_terms
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
|
@@ -6,6 +6,8 @@ the Copernicus Identity and Access Management (IAM) system.
|
|
|
6
6
|
from datetime import datetime, timedelta
|
|
7
7
|
import netrc
|
|
8
8
|
import threading
|
|
9
|
+
from typing import Any, Dict, List, Union
|
|
10
|
+
|
|
9
11
|
import requests
|
|
10
12
|
import jwt
|
|
11
13
|
from requests.adapters import HTTPAdapter
|
|
@@ -79,27 +81,27 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
79
81
|
|
|
80
82
|
def __init__(
|
|
81
83
|
self,
|
|
82
|
-
username=None,
|
|
83
|
-
password=None,
|
|
84
|
-
openid_configuration_endpoint=None,
|
|
85
|
-
proxies=None,
|
|
86
|
-
):
|
|
87
|
-
self.__username = username
|
|
88
|
-
self.__password = password
|
|
89
|
-
|
|
90
|
-
self.__proxies = proxies
|
|
84
|
+
username: Union[str, None] = None,
|
|
85
|
+
password: Union[str, None] = None,
|
|
86
|
+
openid_configuration_endpoint: Union[str, None] = None,
|
|
87
|
+
proxies: Union[Dict[str, str], None] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.__username: Union[str, None] = username
|
|
90
|
+
self.__password: Union[str, None] = password
|
|
91
|
+
|
|
92
|
+
self.__proxies: Union[Dict[str, str], None] = proxies
|
|
91
93
|
self.__openid_conf = None
|
|
92
94
|
self.__jwks = None
|
|
93
|
-
self.__openid_configuration_endpoint = (
|
|
95
|
+
self.__openid_configuration_endpoint: str = (
|
|
94
96
|
openid_configuration_endpoint
|
|
95
97
|
or "https://identity.dataspace.copernicus.eu"
|
|
96
98
|
+ "/auth/realms/CDSE/.well-known/openid-configuration"
|
|
97
99
|
)
|
|
98
100
|
|
|
99
|
-
self.__access_token = None
|
|
100
|
-
self.__refresh_token = None
|
|
101
|
-
self.__access_token_expires = datetime.now() - timedelta(hours=8)
|
|
102
|
-
self.__refresh_token_expires = self.__access_token_expires
|
|
101
|
+
self.__access_token: Union[str, None] = None
|
|
102
|
+
self.__refresh_token: Union[str, None] = None
|
|
103
|
+
self.__access_token_expires: datetime = datetime.now() - timedelta(hours=8)
|
|
104
|
+
self.__refresh_token_expires: datetime = self.__access_token_expires
|
|
103
105
|
|
|
104
106
|
self.__lock = threading.Lock()
|
|
105
107
|
|
|
@@ -108,14 +110,19 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
108
110
|
|
|
109
111
|
self.__ensure_tokens()
|
|
110
112
|
|
|
111
|
-
def get_session(self):
|
|
113
|
+
def get_session(self) -> requests.Session:
|
|
112
114
|
"""
|
|
113
115
|
Returns a session with the credentials set as the Authorization header
|
|
114
116
|
"""
|
|
115
117
|
return self.make_session(self, True, self.RETRIES, self.__proxies)
|
|
116
118
|
|
|
117
119
|
@staticmethod
|
|
118
|
-
def make_session(
|
|
120
|
+
def make_session(
|
|
121
|
+
caller,
|
|
122
|
+
authorization: bool,
|
|
123
|
+
max_retries: Retry,
|
|
124
|
+
proxies: Union[Dict[str, str], None],
|
|
125
|
+
) -> requests.Session:
|
|
119
126
|
"""
|
|
120
127
|
Creates a new session. Authorization is only available from callers
|
|
121
128
|
that are subclasses of Credentials.
|
|
@@ -133,14 +140,14 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
133
140
|
session.headers.update({"Authorization": f"Bearer {token}"})
|
|
134
141
|
return session
|
|
135
142
|
|
|
136
|
-
def __token_exchange(self, data):
|
|
143
|
+
def __token_exchange(self, data: Dict[str, str]) -> timedelta:
|
|
137
144
|
# Make a session that will retry post, respecting the retry-after
|
|
138
145
|
# header when we get a 503 and a few other temporary failures.
|
|
139
146
|
session = self.make_session(
|
|
140
147
|
caller=self,
|
|
141
148
|
authorization=False,
|
|
142
149
|
max_retries=Retry(
|
|
143
|
-
total=
|
|
150
|
+
total=15,
|
|
144
151
|
backoff_factor=0.5,
|
|
145
152
|
allowed_methods=None,
|
|
146
153
|
raise_on_status=False,
|
|
@@ -154,7 +161,7 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
154
161
|
raise InvalidCredentialsException(
|
|
155
162
|
"Unable to exchange token with "
|
|
156
163
|
+ f"username: {self.__username} and "
|
|
157
|
-
+ f"password: {len(self.__password) * '*'}"
|
|
164
|
+
+ f"password: {len(self.__password or '') * '*'}"
|
|
158
165
|
)
|
|
159
166
|
|
|
160
167
|
if response.status_code != 200:
|
|
@@ -166,7 +173,7 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
166
173
|
self.__refresh_token = response["refresh_token"]
|
|
167
174
|
return timedelta(seconds=response["refresh_expires_in"])
|
|
168
175
|
|
|
169
|
-
def __ensure_tokens(self):
|
|
176
|
+
def __ensure_tokens(self) -> None:
|
|
170
177
|
with self.__lock:
|
|
171
178
|
refresh_expire_delta = None
|
|
172
179
|
if self.__access_token_expires < datetime.now():
|
|
@@ -184,6 +191,10 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
184
191
|
"client_id": "cdse-public",
|
|
185
192
|
}
|
|
186
193
|
refresh_expire_delta = self.__token_exchange(data)
|
|
194
|
+
if not self.__access_token:
|
|
195
|
+
raise InvalidCredentialsException(
|
|
196
|
+
"Internal error: access token not available"
|
|
197
|
+
)
|
|
187
198
|
try:
|
|
188
199
|
key = self.__jwk_client.get_signing_key_from_jwt(self.__access_token)
|
|
189
200
|
except jwt.PyJWKClientConnectionError as e:
|
|
@@ -203,16 +214,15 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
203
214
|
datetime.fromtimestamp(data["iat"]) + refresh_expire_delta
|
|
204
215
|
)
|
|
205
216
|
|
|
206
|
-
def __read_credentials(self):
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
raise NoCredentialsException("No credentials found") from exc
|
|
217
|
+
def __read_credentials(self) -> None:
|
|
218
|
+
rv = netrc.netrc().authenticators(self.__token_endpoint)
|
|
219
|
+
if isinstance(rv, tuple):
|
|
220
|
+
self.__username, _, self.__password = rv
|
|
221
|
+
else:
|
|
222
|
+
raise NoCredentialsException("No credentials found")
|
|
213
223
|
|
|
214
224
|
@property
|
|
215
|
-
def __openid_configuration(self):
|
|
225
|
+
def __openid_configuration(self) -> Dict[str, Any]:
|
|
216
226
|
if self.__openid_conf:
|
|
217
227
|
return self.__openid_conf
|
|
218
228
|
|
|
@@ -228,19 +238,19 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
228
238
|
return self.__openid_conf
|
|
229
239
|
|
|
230
240
|
@property
|
|
231
|
-
def __token_endpoint(self):
|
|
241
|
+
def __token_endpoint(self) -> str:
|
|
232
242
|
return self.__openid_configuration["token_endpoint"]
|
|
233
243
|
|
|
234
244
|
@property
|
|
235
|
-
def __jwks_uri(self):
|
|
245
|
+
def __jwks_uri(self) -> str:
|
|
236
246
|
return self.__openid_configuration["jwks_uri"]
|
|
237
247
|
|
|
238
248
|
@property
|
|
239
|
-
def __id_token_signing_algos(self):
|
|
249
|
+
def __id_token_signing_algos(self) -> List[str]:
|
|
240
250
|
return self.__openid_configuration["id_token_signing_alg_values_supported"]
|
|
241
251
|
|
|
242
252
|
@property
|
|
243
|
-
def __jwk_client(self):
|
|
253
|
+
def __jwk_client(self) -> jwt.PyJWKClient:
|
|
244
254
|
if self.__jwks:
|
|
245
255
|
return self.__jwks
|
|
246
256
|
|
|
@@ -249,7 +259,9 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
249
259
|
return self.__jwks
|
|
250
260
|
|
|
251
261
|
|
|
252
|
-
def validate_credentials(
|
|
262
|
+
def validate_credentials(
|
|
263
|
+
username: Union[str, None] = None, password: Union[str, None] = None
|
|
264
|
+
) -> bool:
|
|
253
265
|
"""
|
|
254
266
|
This function validates CDSE credentials and returns a bool.
|
|
255
267
|
If credentials are none, .netrc will be validated
|
|
@@ -10,6 +10,9 @@ import random
|
|
|
10
10
|
import tempfile
|
|
11
11
|
import time
|
|
12
12
|
import shutil
|
|
13
|
+
from typing import Any, Dict, Generator, Union
|
|
14
|
+
|
|
15
|
+
from requests import Session
|
|
13
16
|
from requests.exceptions import ChunkedEncodingError
|
|
14
17
|
from urllib3.exceptions import ProtocolError
|
|
15
18
|
from cdsetool._processing import _concurrent_process
|
|
@@ -19,10 +22,13 @@ from cdsetool.credentials import (
|
|
|
19
22
|
TokenExpiredSignatureError,
|
|
20
23
|
)
|
|
21
24
|
from cdsetool.logger import NoopLogger
|
|
22
|
-
from cdsetool.monitor import NoopMonitor
|
|
25
|
+
from cdsetool.monitor import NoopMonitor, StatusMonitor
|
|
26
|
+
from cdsetool.query import FeatureQuery
|
|
23
27
|
|
|
24
28
|
|
|
25
|
-
def download_feature(
|
|
29
|
+
def download_feature(
|
|
30
|
+
feature, path: str, options: Union[Dict[str, Any], None] = None
|
|
31
|
+
) -> Union[str, None]:
|
|
26
32
|
"""
|
|
27
33
|
Download a single feature
|
|
28
34
|
|
|
@@ -32,6 +38,7 @@ def download_feature(feature, path, options=None):
|
|
|
32
38
|
log = _get_logger(options)
|
|
33
39
|
url = _get_feature_url(feature)
|
|
34
40
|
title = feature.get("properties").get("title")
|
|
41
|
+
temp_dir_usr = _get_temp_dir(options)
|
|
35
42
|
|
|
36
43
|
if not url or not title:
|
|
37
44
|
log.debug(f"Bad URL ('{url}') or title ('{title}')")
|
|
@@ -52,19 +59,25 @@ def download_feature(feature, path, options=None):
|
|
|
52
59
|
# Always get a new session, credentials might have expired.
|
|
53
60
|
try:
|
|
54
61
|
session = _get_credentials(options).get_session()
|
|
55
|
-
except
|
|
56
|
-
log.warning(
|
|
62
|
+
except TokenClientConnectionError:
|
|
63
|
+
log.warning("Token client connection failed, retrying..")
|
|
64
|
+
continue
|
|
65
|
+
except TokenExpiredSignatureError:
|
|
66
|
+
log.warning("Token signature expired, retrying..")
|
|
57
67
|
continue
|
|
58
68
|
url = _follow_redirect(url, session)
|
|
59
|
-
|
|
69
|
+
name_dir_prefix = filename.replace(".zip", "____")
|
|
70
|
+
with session.get(url, stream=True) as response, tempfile.TemporaryDirectory(
|
|
71
|
+
prefix=name_dir_prefix, dir=temp_dir_usr
|
|
72
|
+
) as temp_dir:
|
|
60
73
|
if response.status_code != 200:
|
|
61
74
|
log.warning(f"Status code {response.status_code}, retrying..")
|
|
62
75
|
time.sleep(60 * (1 + (random.random() / 4)))
|
|
63
76
|
continue
|
|
64
77
|
|
|
65
78
|
status.set_filesize(int(response.headers["Content-Length"]))
|
|
66
|
-
|
|
67
|
-
with
|
|
79
|
+
tmp_file = os.path.join(temp_dir, "download.zip")
|
|
80
|
+
with open(tmp_file, "wb") as file:
|
|
68
81
|
# Server might not send all bytes specified by the
|
|
69
82
|
# Content-Length header before closing connection.
|
|
70
83
|
# Log as a warning and try again.
|
|
@@ -79,14 +92,16 @@ def download_feature(feature, path, options=None):
|
|
|
79
92
|
) as e:
|
|
80
93
|
log.warning(e)
|
|
81
94
|
continue
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
# Close file before copy so all buffers are flushed.
|
|
96
|
+
shutil.copy(tmp_file, result_path)
|
|
84
97
|
return filename
|
|
85
98
|
log.error(f"Failed to download {filename}")
|
|
86
99
|
return None
|
|
87
100
|
|
|
88
101
|
|
|
89
|
-
def download_features(
|
|
102
|
+
def download_features(
|
|
103
|
+
features: FeatureQuery, path: str, options: Union[Dict[str, Any], None] = None
|
|
104
|
+
) -> Generator[Union[str, None], None, None]:
|
|
90
105
|
"""
|
|
91
106
|
Generator function that downloads all features in a result set
|
|
92
107
|
|
|
@@ -100,7 +115,7 @@ def download_features(features, path, options=None):
|
|
|
100
115
|
options["monitor"] = _get_monitor(options)
|
|
101
116
|
options["monitor"].start()
|
|
102
117
|
|
|
103
|
-
def _download_feature(feature):
|
|
118
|
+
def _download_feature(feature) -> Union[str, None]:
|
|
104
119
|
return download_feature(feature, path, options)
|
|
105
120
|
|
|
106
121
|
yield from _concurrent_process(
|
|
@@ -110,11 +125,11 @@ def download_features(features, path, options=None):
|
|
|
110
125
|
options["monitor"].stop()
|
|
111
126
|
|
|
112
127
|
|
|
113
|
-
def _get_feature_url(feature):
|
|
128
|
+
def _get_feature_url(feature) -> str:
|
|
114
129
|
return feature.get("properties").get("services").get("download").get("url")
|
|
115
130
|
|
|
116
131
|
|
|
117
|
-
def _follow_redirect(url, session):
|
|
132
|
+
def _follow_redirect(url: str, session: Session) -> str:
|
|
118
133
|
response = session.head(url, allow_redirects=False)
|
|
119
134
|
while response.status_code in range(300, 400):
|
|
120
135
|
url = response.headers["Location"]
|
|
@@ -123,15 +138,19 @@ def _follow_redirect(url, session):
|
|
|
123
138
|
return url
|
|
124
139
|
|
|
125
140
|
|
|
126
|
-
def _get_logger(options):
|
|
141
|
+
def _get_logger(options: Dict) -> NoopLogger:
|
|
127
142
|
return options.get("logger") or NoopLogger()
|
|
128
143
|
|
|
129
144
|
|
|
130
|
-
def _get_monitor(options):
|
|
145
|
+
def _get_monitor(options: Dict) -> Union[StatusMonitor, NoopMonitor]:
|
|
131
146
|
return options.get("monitor") or NoopMonitor()
|
|
132
147
|
|
|
133
148
|
|
|
134
|
-
def _get_credentials(options):
|
|
149
|
+
def _get_credentials(options: Dict) -> Credentials:
|
|
135
150
|
return options.get("credentials") or Credentials(
|
|
136
151
|
proxies=options.get("proxies", None)
|
|
137
152
|
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _get_temp_dir(options: Dict) -> Union[str, None]:
|
|
156
|
+
return options.get("tmpdir") or None
|
|
@@ -10,17 +10,22 @@ class NoopLogger:
|
|
|
10
10
|
A logger that does nothing.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
def debug(self, *kwargs):
|
|
13
|
+
def debug(self, msg, *args, **kwargs):
|
|
14
14
|
"""
|
|
15
15
|
Log a debug message.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
-
def
|
|
18
|
+
def error(self, msg, *args, **kwargs):
|
|
19
|
+
"""
|
|
20
|
+
Log an error message.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def info(self, msg, *args, **kwargs):
|
|
19
24
|
"""
|
|
20
25
|
Log an info message.
|
|
21
26
|
"""
|
|
22
27
|
|
|
23
|
-
def warning(self, *kwargs):
|
|
28
|
+
def warning(self, msg, *args, **kwargs):
|
|
24
29
|
"""
|
|
25
30
|
Log a warning message.
|
|
26
31
|
"""
|
|
@@ -14,12 +14,13 @@ import sys
|
|
|
14
14
|
import os
|
|
15
15
|
import signal
|
|
16
16
|
import shutil
|
|
17
|
+
from typing import List, Tuple, Union
|
|
17
18
|
|
|
18
19
|
IS_IPYTHON = True
|
|
19
20
|
|
|
20
21
|
try:
|
|
21
|
-
from IPython import get_ipython
|
|
22
|
-
from IPython.display import clear_output
|
|
22
|
+
from IPython import get_ipython # type:ignore[reportMissingImports]
|
|
23
|
+
from IPython.display import clear_output # type:ignore[reportMissingImports]
|
|
23
24
|
|
|
24
25
|
if "IPKernelApp" not in get_ipython().config:
|
|
25
26
|
IS_IPYTHON = False
|
|
@@ -46,16 +47,16 @@ class StatusMonitor(threading.Thread):
|
|
|
46
47
|
status.add_progress(512)
|
|
47
48
|
"""
|
|
48
49
|
|
|
49
|
-
line_length = 80
|
|
50
|
+
line_length: int = 80
|
|
50
51
|
|
|
51
|
-
__is_running = True
|
|
52
|
-
__progress_lines = 0
|
|
52
|
+
__is_running: bool = True
|
|
53
|
+
__progress_lines: int = 0
|
|
53
54
|
|
|
54
|
-
__download_speed_deltas = []
|
|
55
|
+
__download_speed_deltas: List[int] = []
|
|
55
56
|
__done = []
|
|
56
57
|
__status = []
|
|
57
58
|
|
|
58
|
-
def start(self):
|
|
59
|
+
def start(self) -> None:
|
|
59
60
|
"""
|
|
60
61
|
Start the monitor
|
|
61
62
|
"""
|
|
@@ -70,13 +71,13 @@ class StatusMonitor(threading.Thread):
|
|
|
70
71
|
|
|
71
72
|
super().start()
|
|
72
73
|
|
|
73
|
-
def stop(self):
|
|
74
|
+
def stop(self) -> None:
|
|
74
75
|
"""
|
|
75
76
|
Stop the monitor
|
|
76
77
|
"""
|
|
77
78
|
self.__is_running = False
|
|
78
79
|
|
|
79
|
-
def status(self):
|
|
80
|
+
def status(self) -> "Status":
|
|
80
81
|
"""
|
|
81
82
|
Returns a status bar for a single download
|
|
82
83
|
"""
|
|
@@ -84,14 +85,14 @@ class StatusMonitor(threading.Thread):
|
|
|
84
85
|
self.__status.append(status)
|
|
85
86
|
return status
|
|
86
87
|
|
|
87
|
-
def remove_status(self, status):
|
|
88
|
+
def remove_status(self, status: "Status") -> None:
|
|
88
89
|
"""
|
|
89
90
|
Remove a status from the monitor, marking it as done
|
|
90
91
|
"""
|
|
91
92
|
self.__done.append(status)
|
|
92
93
|
self.__status.remove(status)
|
|
93
94
|
|
|
94
|
-
def run(self):
|
|
95
|
+
def run(self) -> None:
|
|
95
96
|
"""
|
|
96
97
|
Main loop for the monitor, printing the status bars every second until stopped
|
|
97
98
|
"""
|
|
@@ -107,12 +108,12 @@ class StatusMonitor(threading.Thread):
|
|
|
107
108
|
print("")
|
|
108
109
|
|
|
109
110
|
@property
|
|
110
|
-
def __download_speed(self):
|
|
111
|
+
def __download_speed(self) -> float:
|
|
111
112
|
if len(self.__download_speed_deltas) < 2:
|
|
112
113
|
return 0
|
|
113
114
|
return sum(self.__download_speed_deltas) / len(self.__download_speed_deltas)
|
|
114
115
|
|
|
115
|
-
def __track_download_speed(self):
|
|
116
|
+
def __track_download_speed(self) -> None:
|
|
116
117
|
speed_t0 = self.__total_downloaded
|
|
117
118
|
time.sleep(1)
|
|
118
119
|
speed_t1 = self.__total_downloaded
|
|
@@ -120,13 +121,13 @@ class StatusMonitor(threading.Thread):
|
|
|
120
121
|
if len(self.__download_speed_deltas) > 10:
|
|
121
122
|
self.__download_speed_deltas.pop(0)
|
|
122
123
|
|
|
123
|
-
def __print_done_lines(self):
|
|
124
|
+
def __print_done_lines(self) -> None:
|
|
124
125
|
for status in self.__done:
|
|
125
126
|
print(status.done_line())
|
|
126
127
|
|
|
127
|
-
def __clear_progress_lines(self):
|
|
128
|
+
def __clear_progress_lines(self) -> None:
|
|
128
129
|
if IS_IPYTHON:
|
|
129
|
-
clear_output(wait=True)
|
|
130
|
+
clear_output(wait=True) # type:ignore[reportPossiblyUnboundVariable]
|
|
130
131
|
return
|
|
131
132
|
|
|
132
133
|
sys.stdout.write("\033[K")
|
|
@@ -139,7 +140,7 @@ class StatusMonitor(threading.Thread):
|
|
|
139
140
|
print("")
|
|
140
141
|
print("")
|
|
141
142
|
|
|
142
|
-
def __draw(self):
|
|
143
|
+
def __draw(self) -> None:
|
|
143
144
|
self.__progress_lines = 1
|
|
144
145
|
|
|
145
146
|
print(
|
|
@@ -149,7 +150,7 @@ class StatusMonitor(threading.Thread):
|
|
|
149
150
|
f"{len(self.__status)} files in progress",
|
|
150
151
|
f"{len(self.__done)} files done",
|
|
151
152
|
f"{bytes_to_human(self.__total_downloaded)} total downloaded",
|
|
152
|
-
f"{bytes_to_human(self.__download_speed)}/s ]]",
|
|
153
|
+
f"{bytes_to_human(int(self.__download_speed))}/s ]]",
|
|
153
154
|
]
|
|
154
155
|
)
|
|
155
156
|
)
|
|
@@ -161,7 +162,7 @@ class StatusMonitor(threading.Thread):
|
|
|
161
162
|
self.__progress_lines += 2
|
|
162
163
|
|
|
163
164
|
@property
|
|
164
|
-
def __total_downloaded(self):
|
|
165
|
+
def __total_downloaded(self) -> int:
|
|
165
166
|
return sum(status.downloaded for status in self.__status) + sum(
|
|
166
167
|
status.size for status in self.__done
|
|
167
168
|
)
|
|
@@ -179,23 +180,23 @@ class NoopMonitor:
|
|
|
179
180
|
A monitor that does nothing
|
|
180
181
|
"""
|
|
181
182
|
|
|
182
|
-
def status(self):
|
|
183
|
+
def status(self) -> "Status":
|
|
183
184
|
"""
|
|
184
185
|
Returns a status bar for a single download
|
|
185
186
|
"""
|
|
186
187
|
return Status(self)
|
|
187
188
|
|
|
188
|
-
def remove_status(self, status):
|
|
189
|
+
def remove_status(self, status) -> None:
|
|
189
190
|
"""
|
|
190
191
|
Remove a status from the monitor
|
|
191
192
|
"""
|
|
192
193
|
|
|
193
|
-
def start(self):
|
|
194
|
+
def start(self) -> None:
|
|
194
195
|
"""
|
|
195
196
|
Start the monitor
|
|
196
197
|
"""
|
|
197
198
|
|
|
198
|
-
def stop(self):
|
|
199
|
+
def stop(self) -> None:
|
|
199
200
|
"""
|
|
200
201
|
Stop the monitor
|
|
201
202
|
"""
|
|
@@ -212,12 +213,12 @@ class Status:
|
|
|
212
213
|
A status bar for a single download
|
|
213
214
|
"""
|
|
214
215
|
|
|
215
|
-
__monitor = None
|
|
216
|
-
filename = None
|
|
217
|
-
size = 0
|
|
218
|
-
downloaded = 0
|
|
216
|
+
__monitor: Union[NoopMonitor, StatusMonitor, None] = None
|
|
217
|
+
filename: Union[str, None] = None
|
|
218
|
+
size: int = 0
|
|
219
|
+
downloaded: int = 0
|
|
219
220
|
|
|
220
|
-
def done_line(self):
|
|
221
|
+
def done_line(self) -> str:
|
|
221
222
|
"""
|
|
222
223
|
Returns a line to print when the download is complete
|
|
223
224
|
"""
|
|
@@ -225,10 +226,16 @@ class Status:
|
|
|
225
226
|
return f"{self.filename} skipped"
|
|
226
227
|
return f"{self.filename} ({bytes_to_human(self.size)})"
|
|
227
228
|
|
|
228
|
-
def status_lines(self):
|
|
229
|
+
def status_lines(self) -> Tuple[str, str]:
|
|
229
230
|
"""
|
|
230
231
|
Returns a tuple of lines to print for the status bar
|
|
231
232
|
"""
|
|
233
|
+
if (
|
|
234
|
+
not self.__monitor
|
|
235
|
+
or isinstance(self.__monitor, NoopMonitor)
|
|
236
|
+
or not self.filename
|
|
237
|
+
):
|
|
238
|
+
return ("", "")
|
|
232
239
|
line_length = self.__monitor.line_length
|
|
233
240
|
if self.downloaded == 0:
|
|
234
241
|
return (
|
|
@@ -250,35 +257,36 @@ class Status:
|
|
|
250
257
|
|
|
251
258
|
return filename_line, progress_line
|
|
252
259
|
|
|
253
|
-
def add_progress(self, chunk_bytes):
|
|
260
|
+
def add_progress(self, chunk_bytes) -> None:
|
|
254
261
|
"""
|
|
255
262
|
Add to the number of bytes downloaded
|
|
256
263
|
"""
|
|
257
264
|
self.downloaded += chunk_bytes
|
|
258
265
|
|
|
259
|
-
def set_filename(self, filename):
|
|
266
|
+
def set_filename(self, filename: str) -> None:
|
|
260
267
|
"""
|
|
261
268
|
Set the name of the file being downloaded
|
|
262
269
|
"""
|
|
263
270
|
self.filename = filename
|
|
264
271
|
|
|
265
|
-
def set_filesize(self, size):
|
|
272
|
+
def set_filesize(self, size: int) -> None:
|
|
266
273
|
"""
|
|
267
274
|
Set the size of the file being downloaded
|
|
268
275
|
"""
|
|
269
276
|
self.size = size
|
|
270
277
|
|
|
271
|
-
def __init__(self, monitor):
|
|
278
|
+
def __init__(self, monitor) -> None:
|
|
272
279
|
self.__monitor = monitor
|
|
273
280
|
|
|
274
281
|
def __enter__(self):
|
|
275
282
|
return self
|
|
276
283
|
|
|
277
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
278
|
-
self.__monitor
|
|
284
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
285
|
+
if self.__monitor:
|
|
286
|
+
self.__monitor.remove_status(self)
|
|
279
287
|
|
|
280
288
|
|
|
281
|
-
def bytes_to_human(num_bytes):
|
|
289
|
+
def bytes_to_human(num_bytes: int) -> str:
|
|
282
290
|
"""
|
|
283
291
|
Convert a number of bytes to a human-readable string
|
|
284
292
|
"""
|
|
File without changes
|
|
@@ -4,6 +4,7 @@ Query the Copernicus Data Space Ecosystem OpenSearch API
|
|
|
4
4
|
https://documentation.dataspace.copernicus.eu/APIs/OpenSearch.html
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from typing import Any, Dict, Union
|
|
7
8
|
from xml.etree import ElementTree
|
|
8
9
|
from datetime import datetime, date
|
|
9
10
|
import re
|
|
@@ -13,11 +14,11 @@ from cdsetool.credentials import Credentials
|
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class _FeatureIterator:
|
|
16
|
-
def __init__(self, feature_query):
|
|
17
|
+
def __init__(self, feature_query) -> None:
|
|
17
18
|
self.index = 0
|
|
18
19
|
self.feature_query = feature_query
|
|
19
20
|
|
|
20
|
-
def __len__(self):
|
|
21
|
+
def __len__(self) -> int:
|
|
21
22
|
return len(self.feature_query)
|
|
22
23
|
|
|
23
24
|
def __iter__(self):
|
|
@@ -40,9 +41,14 @@ class FeatureQuery:
|
|
|
40
41
|
Queries the next batch when the current batch is exhausted.
|
|
41
42
|
"""
|
|
42
43
|
|
|
43
|
-
total_results =
|
|
44
|
+
total_results: int = -1
|
|
44
45
|
|
|
45
|
-
def __init__(
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
collection: str,
|
|
49
|
+
search_terms: Dict[str, Any],
|
|
50
|
+
proxies: Union[Dict[str, str], None] = None,
|
|
51
|
+
) -> None:
|
|
46
52
|
self.features = []
|
|
47
53
|
self.proxies = proxies
|
|
48
54
|
self.next_url = _query_url(
|
|
@@ -52,8 +58,8 @@ class FeatureQuery:
|
|
|
52
58
|
def __iter__(self):
|
|
53
59
|
return _FeatureIterator(self)
|
|
54
60
|
|
|
55
|
-
def __len__(self):
|
|
56
|
-
if self.total_results
|
|
61
|
+
def __len__(self) -> int:
|
|
62
|
+
if self.total_results < 0:
|
|
57
63
|
self.__fetch_features()
|
|
58
64
|
|
|
59
65
|
return self.total_results
|
|
@@ -64,7 +70,7 @@ class FeatureQuery:
|
|
|
64
70
|
|
|
65
71
|
return self.features[index]
|
|
66
72
|
|
|
67
|
-
def __fetch_features(self):
|
|
73
|
+
def __fetch_features(self) -> None:
|
|
68
74
|
if self.next_url is None:
|
|
69
75
|
return
|
|
70
76
|
session = Credentials.make_session(
|
|
@@ -81,7 +87,7 @@ class FeatureQuery:
|
|
|
81
87
|
|
|
82
88
|
self.__set_next_url(res)
|
|
83
89
|
|
|
84
|
-
def __set_next_url(self, res):
|
|
90
|
+
def __set_next_url(self, res) -> None:
|
|
85
91
|
links = res.get("properties", {}).get("links") or []
|
|
86
92
|
self.next_url = next(
|
|
87
93
|
(link for link in links if link.get("rel") == "next"), {}
|
|
@@ -91,14 +97,18 @@ class FeatureQuery:
|
|
|
91
97
|
self.next_url = self.next_url.replace("exactCount=1", "exactCount=0")
|
|
92
98
|
|
|
93
99
|
|
|
94
|
-
def query_features(
|
|
100
|
+
def query_features(
|
|
101
|
+
collection: str,
|
|
102
|
+
search_terms: Dict[str, Any],
|
|
103
|
+
proxies: Union[Dict[str, str], None] = None,
|
|
104
|
+
) -> FeatureQuery:
|
|
95
105
|
"""
|
|
96
106
|
Returns an iterator over the features matching the search terms
|
|
97
107
|
"""
|
|
98
108
|
return FeatureQuery(collection, {"maxRecords": 2000, **search_terms}, proxies)
|
|
99
109
|
|
|
100
110
|
|
|
101
|
-
def shape_to_wkt(shape):
|
|
111
|
+
def shape_to_wkt(shape: str) -> str:
|
|
102
112
|
"""
|
|
103
113
|
Convert a shapefile to a WKT string
|
|
104
114
|
"""
|
|
@@ -110,12 +120,11 @@ def shape_to_wkt(shape):
|
|
|
110
120
|
)
|
|
111
121
|
|
|
112
122
|
|
|
113
|
-
def geojson_to_wkt(
|
|
123
|
+
def geojson_to_wkt(geojson_in: Union[str, Dict]) -> str:
|
|
114
124
|
"""
|
|
115
125
|
Convert a geojson geometry to a WKT string
|
|
116
126
|
"""
|
|
117
|
-
if isinstance(
|
|
118
|
-
geojson = json.loads(geojson)
|
|
127
|
+
geojson = json.loads(geojson_in) if isinstance(geojson_in, str) else geojson_in
|
|
119
128
|
|
|
120
129
|
if geojson.get("type") == "Feature":
|
|
121
130
|
geojson = geojson["geometry"]
|
|
@@ -134,7 +143,9 @@ def geojson_to_wkt(geojson):
|
|
|
134
143
|
return f"POLYGON({paired_coord})"
|
|
135
144
|
|
|
136
145
|
|
|
137
|
-
def describe_collection(
|
|
146
|
+
def describe_collection(
|
|
147
|
+
collection: str, proxies: Union[Dict[str, str], None] = None
|
|
148
|
+
) -> Dict[str, Any]:
|
|
138
149
|
"""
|
|
139
150
|
Get a list of valid options for a given collection in key value pairs
|
|
140
151
|
"""
|
|
@@ -145,6 +156,8 @@ def describe_collection(collection, proxies=None):
|
|
|
145
156
|
)
|
|
146
157
|
|
|
147
158
|
parameters = {}
|
|
159
|
+
if parameter_node_parent is None:
|
|
160
|
+
return parameters
|
|
148
161
|
for parameter_node in parameter_node_parent:
|
|
149
162
|
name = parameter_node.attrib.get("name")
|
|
150
163
|
pattern = parameter_node.attrib.get("pattern")
|
|
@@ -163,7 +176,11 @@ def describe_collection(collection, proxies=None):
|
|
|
163
176
|
return parameters
|
|
164
177
|
|
|
165
178
|
|
|
166
|
-
def _query_url(
|
|
179
|
+
def _query_url(
|
|
180
|
+
collection: str,
|
|
181
|
+
search_terms: Dict[str, Any],
|
|
182
|
+
proxies: Union[Dict[str, str], None] = None,
|
|
183
|
+
) -> str:
|
|
167
184
|
description = describe_collection(collection, proxies=proxies)
|
|
168
185
|
|
|
169
186
|
query_list = []
|
|
@@ -178,7 +195,7 @@ def _query_url(collection, search_terms, proxies=None):
|
|
|
178
195
|
)
|
|
179
196
|
|
|
180
197
|
|
|
181
|
-
def _serialize_search_term(search_term):
|
|
198
|
+
def _serialize_search_term(search_term: Any) -> str:
|
|
182
199
|
if isinstance(search_term, list):
|
|
183
200
|
return ",".join(search_term)
|
|
184
201
|
|
|
@@ -191,14 +208,14 @@ def _serialize_search_term(search_term):
|
|
|
191
208
|
return str(search_term)
|
|
192
209
|
|
|
193
210
|
|
|
194
|
-
def _validate_search_term(key, search_term, description):
|
|
211
|
+
def _validate_search_term(key: str, search_term: str, description) -> None:
|
|
195
212
|
_assert_valid_key(key, description)
|
|
196
213
|
_assert_match_pattern(search_term, description.get(key).get("pattern"))
|
|
197
214
|
_assert_min_inclusive(search_term, description.get(key).get("minInclusive"))
|
|
198
215
|
_assert_max_inclusive(search_term, description.get(key).get("maxInclusive"))
|
|
199
216
|
|
|
200
217
|
|
|
201
|
-
def _assert_valid_key(key, description):
|
|
218
|
+
def _assert_valid_key(key: str, description: Dict[str, Any]) -> None:
|
|
202
219
|
assert key in description.keys(), (
|
|
203
220
|
f'search_term with name "{key}" '
|
|
204
221
|
+ "was not found for collection."
|
|
@@ -206,7 +223,7 @@ def _assert_valid_key(key, description):
|
|
|
206
223
|
)
|
|
207
224
|
|
|
208
225
|
|
|
209
|
-
def _assert_match_pattern(search_term, pattern):
|
|
226
|
+
def _assert_match_pattern(search_term: str, pattern: Union[str, None]) -> None:
|
|
210
227
|
if not pattern:
|
|
211
228
|
return
|
|
212
229
|
|
|
@@ -215,7 +232,7 @@ def _assert_match_pattern(search_term, pattern):
|
|
|
215
232
|
), f"search_term {search_term} does not match pattern {pattern}"
|
|
216
233
|
|
|
217
234
|
|
|
218
|
-
def _assert_min_inclusive(search_term, min_inclusive):
|
|
235
|
+
def _assert_min_inclusive(search_term: str, min_inclusive: Union[str, None]) -> None:
|
|
219
236
|
if not min_inclusive:
|
|
220
237
|
return
|
|
221
238
|
|
|
@@ -224,7 +241,7 @@ def _assert_min_inclusive(search_term, min_inclusive):
|
|
|
224
241
|
), f"search_term {search_term} is less than min_inclusive {min_inclusive}"
|
|
225
242
|
|
|
226
243
|
|
|
227
|
-
def _assert_max_inclusive(search_term, max_inclusive):
|
|
244
|
+
def _assert_max_inclusive(search_term: str, max_inclusive: Union[str, None]) -> None:
|
|
228
245
|
if not max_inclusive:
|
|
229
246
|
return
|
|
230
247
|
|
|
@@ -233,12 +250,15 @@ def _assert_max_inclusive(search_term, max_inclusive):
|
|
|
233
250
|
), f"search_term {search_term} is greater than max_inclusive {max_inclusive}"
|
|
234
251
|
|
|
235
252
|
|
|
236
|
-
_describe_docs = {}
|
|
253
|
+
_describe_docs: Dict[str, bytes] = {}
|
|
237
254
|
|
|
238
255
|
|
|
239
|
-
def _get_describe_doc(
|
|
240
|
-
|
|
241
|
-
|
|
256
|
+
def _get_describe_doc(
|
|
257
|
+
collection: str, proxies: Union[Dict[str, str], None] = None
|
|
258
|
+
) -> bytes:
|
|
259
|
+
docs = _describe_docs.get(collection)
|
|
260
|
+
if docs:
|
|
261
|
+
return docs
|
|
242
262
|
session = Credentials.make_session(None, False, Credentials.RETRIES, proxies)
|
|
243
263
|
with session.get(
|
|
244
264
|
"https://catalogue.dataspace.copernicus.eu"
|
|
@@ -252,4 +272,4 @@ def _get_describe_doc(collection, proxies=None):
|
|
|
252
272
|
)
|
|
253
273
|
|
|
254
274
|
_describe_docs[collection] = res.content
|
|
255
|
-
|
|
275
|
+
return res.content
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cdsetool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
4
4
|
Summary: Tools & CLI for interacting with CDSE product APIs
|
|
5
5
|
Author-email: Jacob Vejby <javej@sdfi.dk>
|
|
6
|
-
Project-URL: Homepage, https://github.com/
|
|
7
|
-
Project-URL: Bug Tracker, https://github.com/
|
|
6
|
+
Project-URL: Homepage, https://github.com/CDSETool/CDSETool
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/CDSETool/CDSETool/issues
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: Operating System :: OS Independent
|
|
10
10
|
Requires-Python: >=3.8
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Requires-Dist: typer<
|
|
14
|
-
Requires-Dist: rich<
|
|
15
|
-
Requires-Dist: requests<
|
|
13
|
+
Requires-Dist: typer<1,>=0.9
|
|
14
|
+
Requires-Dist: rich<14,>=13.6
|
|
15
|
+
Requires-Dist: requests<3,>=2.28.1
|
|
16
16
|
Requires-Dist: pyjwt[crypto]~=2.8.0
|
|
17
17
|
Requires-Dist: geopandas>=0.13.2
|
|
18
18
|
Provides-Extra: test
|
|
19
19
|
Requires-Dist: black==24.4.2; extra == "test"
|
|
20
|
-
Requires-Dist: pylint==3.2.
|
|
21
|
-
Requires-Dist: pytest==8.2
|
|
20
|
+
Requires-Dist: pylint==3.2.6; extra == "test"
|
|
21
|
+
Requires-Dist: pytest==8.3.2; extra == "test"
|
|
22
22
|
Requires-Dist: pytest-cov==5.0.0; extra == "test"
|
|
23
23
|
Requires-Dist: requests-mock==1.12.1; extra == "test"
|
|
24
24
|
Requires-Dist: pytest-mock==3.14.0; extra == "test"
|
|
@@ -96,7 +96,7 @@ cdsetool download Sentinel2 PATH/TO/DIR --concurrency 4 --search-term startDate=
|
|
|
96
96
|
Install `cdsetool` using pip:
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
|
-
pip install cdsetool==0.2.
|
|
99
|
+
pip install cdsetool==0.2.12
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
## Usage
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
typer<
|
|
2
|
-
rich<
|
|
3
|
-
requests<
|
|
1
|
+
typer<1,>=0.9
|
|
2
|
+
rich<14,>=13.6
|
|
3
|
+
requests<3,>=2.28.1
|
|
4
4
|
pyjwt[crypto]~=2.8.0
|
|
5
5
|
geopandas>=0.13.2
|
|
6
6
|
|
|
7
7
|
[test]
|
|
8
8
|
black==24.4.2
|
|
9
|
-
pylint==3.2.
|
|
10
|
-
pytest==8.2
|
|
9
|
+
pylint==3.2.6
|
|
10
|
+
pytest==8.3.2
|
|
11
11
|
pytest-cov==5.0.0
|
|
12
12
|
requests-mock==1.12.1
|
|
13
13
|
pytest-mock==3.14.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|