cdsetool 0.2.8__tar.gz → 0.2.10__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.8/src/cdsetool.egg-info → cdsetool-0.2.10}/PKG-INFO +10 -10
- {cdsetool-0.2.8 → cdsetool-0.2.10}/README.md +1 -1
- {cdsetool-0.2.8 → cdsetool-0.2.10}/pyproject.toml +9 -9
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool/credentials.py +56 -28
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool/download.py +26 -12
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool/query.py +15 -13
- {cdsetool-0.2.8 → cdsetool-0.2.10/src/cdsetool.egg-info}/PKG-INFO +10 -10
- cdsetool-0.2.10/src/cdsetool.egg-info/requires.txt +13 -0
- cdsetool-0.2.8/src/cdsetool.egg-info/requires.txt +0 -13
- {cdsetool-0.2.8 → cdsetool-0.2.10}/LICENSE +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/setup.cfg +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool/__init__.py +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool/_processing.py +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool/cli.py +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool/logger.py +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool/monitor.py +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool.egg-info/SOURCES.txt +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool.egg-info/dependency_links.txt +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool.egg-info/entry_points.txt +0 -0
- {cdsetool-0.2.8 → cdsetool-0.2.10}/src/cdsetool.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cdsetool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
4
4
|
Summary: Tools & CLI for interacting with CDSE product APIs
|
|
5
5
|
Author-email: Jacob Vejby <javej@sdfi.dk>
|
|
6
6
|
Project-URL: Homepage, https://github.com/SDFIdk/CDSETool
|
|
@@ -10,18 +10,18 @@ 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
|
|
13
|
+
Requires-Dist: typer<0.13,>=0.9
|
|
14
14
|
Requires-Dist: rich<13.8,>=13.6
|
|
15
|
-
Requires-Dist: requests<2.
|
|
15
|
+
Requires-Dist: requests<2.33.0,>=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
|
-
Requires-Dist: black==24.
|
|
20
|
-
Requires-Dist: pylint==3.
|
|
21
|
-
Requires-Dist: pytest==8.
|
|
22
|
-
Requires-Dist: pytest-cov==
|
|
23
|
-
Requires-Dist: requests-mock==1.
|
|
24
|
-
Requires-Dist: pytest-mock==3.
|
|
19
|
+
Requires-Dist: black==24.4.2; extra == "test"
|
|
20
|
+
Requires-Dist: pylint==3.2.2; extra == "test"
|
|
21
|
+
Requires-Dist: pytest==8.2.0; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-cov==5.0.0; extra == "test"
|
|
23
|
+
Requires-Dist: requests-mock==1.12.1; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-mock==3.14.0; extra == "test"
|
|
25
25
|
|
|
26
26
|
# CDSETool
|
|
27
27
|
|
|
@@ -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.10
|
|
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.10"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Jacob Vejby", email="javej@sdfi.dk" },
|
|
10
10
|
]
|
|
@@ -16,20 +16,20 @@ classifiers = [
|
|
|
16
16
|
"Operating System :: OS Independent",
|
|
17
17
|
]
|
|
18
18
|
dependencies = [
|
|
19
|
-
"typer
|
|
19
|
+
"typer >= 0.9,< 0.13",
|
|
20
20
|
"rich >= 13.6,< 13.8",
|
|
21
|
-
"requests >= 2.28.1,< 2.
|
|
21
|
+
"requests >= 2.28.1,< 2.33.0",
|
|
22
22
|
"pyjwt[crypto] ~= 2.8.0",
|
|
23
23
|
"geopandas >= 0.13.2",
|
|
24
24
|
]
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
test = [
|
|
27
|
-
"black==24.
|
|
28
|
-
"pylint==3.
|
|
29
|
-
"pytest==8.
|
|
30
|
-
"pytest-cov==
|
|
31
|
-
"requests-mock==1.
|
|
32
|
-
"pytest-mock==3.
|
|
27
|
+
"black==24.4.2",
|
|
28
|
+
"pylint==3.2.2",
|
|
29
|
+
"pytest==8.2.0",
|
|
30
|
+
"pytest-cov==5.0.0",
|
|
31
|
+
"requests-mock==1.12.1",
|
|
32
|
+
"pytest-mock==3.14.0",
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
[project.urls]
|
|
@@ -56,12 +56,27 @@ class TokenClientConnectionError(Exception):
|
|
|
56
56
|
"""
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
class TokenExpiredSignatureError(Exception):
|
|
60
|
+
"""
|
|
61
|
+
Raised when token signature has expired.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
59
65
|
class Credentials: # pylint: disable=too-few-public-methods disable=too-many-instance-attributes
|
|
60
66
|
"""
|
|
61
67
|
A class for handling credentials for the Copernicus Identity
|
|
62
68
|
and Access Management (IAM) system
|
|
63
69
|
"""
|
|
64
70
|
|
|
71
|
+
RETRY_CODES = frozenset([413, 429, 500, 502, 503])
|
|
72
|
+
|
|
73
|
+
RETRIES = Retry(
|
|
74
|
+
total=5,
|
|
75
|
+
backoff_factor=0.5,
|
|
76
|
+
raise_on_status=False,
|
|
77
|
+
status_forcelist=RETRY_CODES,
|
|
78
|
+
)
|
|
79
|
+
|
|
65
80
|
def __init__(
|
|
66
81
|
self,
|
|
67
82
|
username=None,
|
|
@@ -73,12 +88,6 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
73
88
|
self.__password = password
|
|
74
89
|
|
|
75
90
|
self.__proxies = proxies
|
|
76
|
-
self.__retries = Retry(
|
|
77
|
-
total=5,
|
|
78
|
-
backoff_factor=0.5,
|
|
79
|
-
raise_on_status=False,
|
|
80
|
-
status_forcelist=Retry.RETRY_AFTER_STATUS_CODES,
|
|
81
|
-
)
|
|
82
91
|
self.__openid_conf = None
|
|
83
92
|
self.__jwks = None
|
|
84
93
|
self.__openid_configuration_endpoint = (
|
|
@@ -89,8 +98,8 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
89
98
|
|
|
90
99
|
self.__access_token = None
|
|
91
100
|
self.__refresh_token = None
|
|
92
|
-
self.__access_token_expires = datetime.now()
|
|
93
|
-
self.__refresh_token_expires =
|
|
101
|
+
self.__access_token_expires = datetime.now() - timedelta(hours=8)
|
|
102
|
+
self.__refresh_token_expires = self.__access_token_expires
|
|
94
103
|
|
|
95
104
|
self.__lock = threading.Lock()
|
|
96
105
|
|
|
@@ -103,11 +112,16 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
103
112
|
"""
|
|
104
113
|
Returns a session with the credentials set as the Authorization header
|
|
105
114
|
"""
|
|
106
|
-
return self.
|
|
115
|
+
return self.make_session(self, True, self.RETRIES, self.__proxies)
|
|
107
116
|
|
|
108
|
-
|
|
117
|
+
@staticmethod
|
|
118
|
+
def make_session(caller, authorization, max_retries, proxies):
|
|
119
|
+
"""
|
|
120
|
+
Creates a new session. Authorization is only available from callers
|
|
121
|
+
that are subclasses of Credentials.
|
|
122
|
+
"""
|
|
109
123
|
if authorization:
|
|
110
|
-
|
|
124
|
+
caller.__ensure_tokens() # pylint: disable=protected-access
|
|
111
125
|
|
|
112
126
|
session = requests.Session()
|
|
113
127
|
session.mount("http://", HTTPAdapter(max_retries=max_retries))
|
|
@@ -115,24 +129,25 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
115
129
|
if proxies is not None:
|
|
116
130
|
session.proxies.update(proxies)
|
|
117
131
|
if authorization:
|
|
118
|
-
|
|
132
|
+
token = caller.__access_token # pylint: disable=protected-access
|
|
133
|
+
session.headers.update({"Authorization": f"Bearer {token}"})
|
|
119
134
|
return session
|
|
120
135
|
|
|
121
136
|
def __token_exchange(self, data):
|
|
122
137
|
# Make a session that will retry post, respecting the retry-after
|
|
123
138
|
# header when we get a 503 and a few other temporary failures.
|
|
124
|
-
session = self.
|
|
139
|
+
session = self.make_session(
|
|
140
|
+
caller=self,
|
|
125
141
|
authorization=False,
|
|
126
142
|
max_retries=Retry(
|
|
127
143
|
total=2,
|
|
128
144
|
backoff_factor=0.5,
|
|
129
145
|
allowed_methods=None,
|
|
130
146
|
raise_on_status=False,
|
|
131
|
-
status_forcelist=
|
|
147
|
+
status_forcelist=self.RETRY_CODES,
|
|
132
148
|
),
|
|
133
149
|
proxies=self.__proxies,
|
|
134
150
|
)
|
|
135
|
-
now = datetime.now()
|
|
136
151
|
response = session.post(self.__token_endpoint, data=data, timeout=120)
|
|
137
152
|
|
|
138
153
|
if response.status_code == 401:
|
|
@@ -149,13 +164,11 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
149
164
|
|
|
150
165
|
self.__access_token = response["access_token"]
|
|
151
166
|
self.__refresh_token = response["refresh_token"]
|
|
152
|
-
|
|
153
|
-
self.__refresh_token_expires = now + timedelta(
|
|
154
|
-
seconds=response["refresh_expires_in"]
|
|
155
|
-
)
|
|
167
|
+
return timedelta(seconds=response["refresh_expires_in"])
|
|
156
168
|
|
|
157
169
|
def __ensure_tokens(self):
|
|
158
170
|
with self.__lock:
|
|
171
|
+
refresh_expire_delta = None
|
|
159
172
|
if self.__access_token_expires < datetime.now():
|
|
160
173
|
if self.__refresh_token_expires < datetime.now():
|
|
161
174
|
data = {
|
|
@@ -170,17 +183,25 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
170
183
|
"refresh_token": self.__refresh_token,
|
|
171
184
|
"client_id": "cdse-public",
|
|
172
185
|
}
|
|
173
|
-
self.__token_exchange(data)
|
|
186
|
+
refresh_expire_delta = self.__token_exchange(data)
|
|
174
187
|
try:
|
|
175
188
|
key = self.__jwk_client.get_signing_key_from_jwt(self.__access_token)
|
|
176
189
|
except jwt.PyJWKClientConnectionError as e:
|
|
177
190
|
raise TokenClientConnectionError from e
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
191
|
+
try:
|
|
192
|
+
data = jwt.decode(
|
|
193
|
+
self.__access_token,
|
|
194
|
+
key=key.key,
|
|
195
|
+
algorithms=self.__id_token_signing_algos, # pylint: disable=protected-access
|
|
196
|
+
options={"verify_aud": False},
|
|
197
|
+
)
|
|
198
|
+
except jwt.ExpiredSignatureError as e:
|
|
199
|
+
raise TokenExpiredSignatureError from e
|
|
200
|
+
if refresh_expire_delta is not None:
|
|
201
|
+
self.__access_token_expires = datetime.fromtimestamp(data["exp"])
|
|
202
|
+
self.__refresh_token_expires = (
|
|
203
|
+
datetime.fromtimestamp(data["iat"]) + refresh_expire_delta
|
|
204
|
+
)
|
|
184
205
|
|
|
185
206
|
def __read_credentials(self):
|
|
186
207
|
try:
|
|
@@ -195,8 +216,11 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
195
216
|
if self.__openid_conf:
|
|
196
217
|
return self.__openid_conf
|
|
197
218
|
|
|
198
|
-
session = self.
|
|
199
|
-
|
|
219
|
+
session = self.make_session(
|
|
220
|
+
caller=self,
|
|
221
|
+
authorization=False,
|
|
222
|
+
max_retries=self.RETRIES,
|
|
223
|
+
proxies=self.__proxies,
|
|
200
224
|
)
|
|
201
225
|
response = session.get(self.__openid_configuration_endpoint, timeout=120)
|
|
202
226
|
response.raise_for_status()
|
|
@@ -211,6 +235,10 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
211
235
|
def __jwks_uri(self):
|
|
212
236
|
return self.__openid_configuration["jwks_uri"]
|
|
213
237
|
|
|
238
|
+
@property
|
|
239
|
+
def __id_token_signing_algos(self):
|
|
240
|
+
return self.__openid_configuration["id_token_signing_alg_values_supported"]
|
|
241
|
+
|
|
214
242
|
@property
|
|
215
243
|
def __jwk_client(self):
|
|
216
244
|
if self.__jwks:
|
|
@@ -10,8 +10,14 @@ import random
|
|
|
10
10
|
import tempfile
|
|
11
11
|
import time
|
|
12
12
|
import shutil
|
|
13
|
+
from requests.exceptions import ChunkedEncodingError
|
|
14
|
+
from urllib3.exceptions import ProtocolError
|
|
13
15
|
from cdsetool._processing import _concurrent_process
|
|
14
|
-
from cdsetool.credentials import
|
|
16
|
+
from cdsetool.credentials import (
|
|
17
|
+
Credentials,
|
|
18
|
+
TokenClientConnectionError,
|
|
19
|
+
TokenExpiredSignatureError,
|
|
20
|
+
)
|
|
15
21
|
from cdsetool.logger import NoopLogger
|
|
16
22
|
from cdsetool.monitor import NoopMonitor
|
|
17
23
|
|
|
@@ -39,36 +45,44 @@ def download_feature(feature, path, options=None):
|
|
|
39
45
|
return filename
|
|
40
46
|
|
|
41
47
|
with _get_monitor(options).status() as status:
|
|
42
|
-
(fd, tmp) = tempfile.mkstemp() # pylint: disable=invalid-name
|
|
43
48
|
status.set_filename(filename)
|
|
44
49
|
attempts = 0
|
|
45
|
-
while attempts <
|
|
50
|
+
while attempts < 10:
|
|
51
|
+
attempts += 1
|
|
46
52
|
# Always get a new session, credentials might have expired.
|
|
47
53
|
try:
|
|
48
54
|
session = _get_credentials(options).get_session()
|
|
49
|
-
except TokenClientConnectionError as e:
|
|
55
|
+
except (TokenClientConnectionError, TokenExpiredSignatureError) as e:
|
|
50
56
|
log.warning(e)
|
|
51
57
|
continue
|
|
52
58
|
url = _follow_redirect(url, session)
|
|
53
59
|
with session.get(url, stream=True) as response:
|
|
54
60
|
if response.status_code != 200:
|
|
55
61
|
log.warning(f"Status code {response.status_code}, retrying..")
|
|
56
|
-
attempts += 1
|
|
57
62
|
time.sleep(60 * (1 + (random.random() / 4)))
|
|
58
63
|
continue
|
|
59
64
|
|
|
60
65
|
status.set_filesize(int(response.headers["Content-Length"]))
|
|
61
66
|
|
|
62
|
-
with
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
with tempfile.NamedTemporaryFile() as file:
|
|
68
|
+
# Server might not send all bytes specified by the
|
|
69
|
+
# Content-Length header before closing connection.
|
|
70
|
+
# Log as a warning and try again.
|
|
71
|
+
try:
|
|
72
|
+
for chunk in response.iter_content(chunk_size=1024 * 1024 * 5):
|
|
73
|
+
file.write(chunk)
|
|
74
|
+
status.add_progress(len(chunk))
|
|
75
|
+
except (
|
|
76
|
+
ChunkedEncodingError,
|
|
77
|
+
ConnectionResetError,
|
|
78
|
+
ProtocolError,
|
|
79
|
+
) as e:
|
|
80
|
+
log.warning(e)
|
|
81
|
+
continue
|
|
82
|
+
shutil.copy(file.name, result_path)
|
|
66
83
|
|
|
67
|
-
shutil.move(tmp, result_path)
|
|
68
84
|
return filename
|
|
69
85
|
log.error(f"Failed to download {filename}")
|
|
70
|
-
os.close(fd)
|
|
71
|
-
os.remove(tmp)
|
|
72
86
|
return None
|
|
73
87
|
|
|
74
88
|
|
|
@@ -8,8 +8,8 @@ from xml.etree import ElementTree
|
|
|
8
8
|
from datetime import datetime, date
|
|
9
9
|
import re
|
|
10
10
|
import json
|
|
11
|
-
import requests
|
|
12
11
|
import geopandas as gpd
|
|
12
|
+
from cdsetool.credentials import Credentials
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class _FeatureIterator:
|
|
@@ -67,7 +67,10 @@ class FeatureQuery:
|
|
|
67
67
|
def __fetch_features(self):
|
|
68
68
|
if self.next_url is None:
|
|
69
69
|
return
|
|
70
|
-
|
|
70
|
+
session = Credentials.make_session(
|
|
71
|
+
None, False, Credentials.RETRIES, self.proxies
|
|
72
|
+
)
|
|
73
|
+
with session.get(self.next_url) as response:
|
|
71
74
|
response.raise_for_status()
|
|
72
75
|
res = response.json()
|
|
73
76
|
self.features += res.get("features") or []
|
|
@@ -236,18 +239,17 @@ _describe_docs = {}
|
|
|
236
239
|
def _get_describe_doc(collection, proxies=None):
|
|
237
240
|
if _describe_docs.get(collection):
|
|
238
241
|
return _describe_docs.get(collection)
|
|
239
|
-
|
|
242
|
+
session = Credentials.make_session(None, False, Credentials.RETRIES, proxies)
|
|
243
|
+
with session.get(
|
|
240
244
|
"https://catalogue.dataspace.copernicus.eu"
|
|
241
245
|
+ f"/resto/api/collections/{collection}/describe.xml",
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
+ "for a list of available collections"
|
|
250
|
-
)
|
|
246
|
+
) as res:
|
|
247
|
+
assert res.status_code == 200, (
|
|
248
|
+
f"Unable to find collection with name {collection}. Please see "
|
|
249
|
+
+ "https://documentation.dataspace.copernicus.eu"
|
|
250
|
+
+ "/APIs/OpenSearch.html#collections "
|
|
251
|
+
+ "for a list of available collections"
|
|
252
|
+
)
|
|
251
253
|
|
|
252
|
-
|
|
254
|
+
_describe_docs[collection] = res.content
|
|
253
255
|
return _describe_docs.get(collection)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cdsetool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.10
|
|
4
4
|
Summary: Tools & CLI for interacting with CDSE product APIs
|
|
5
5
|
Author-email: Jacob Vejby <javej@sdfi.dk>
|
|
6
6
|
Project-URL: Homepage, https://github.com/SDFIdk/CDSETool
|
|
@@ -10,18 +10,18 @@ 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
|
|
13
|
+
Requires-Dist: typer<0.13,>=0.9
|
|
14
14
|
Requires-Dist: rich<13.8,>=13.6
|
|
15
|
-
Requires-Dist: requests<2.
|
|
15
|
+
Requires-Dist: requests<2.33.0,>=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
|
-
Requires-Dist: black==24.
|
|
20
|
-
Requires-Dist: pylint==3.
|
|
21
|
-
Requires-Dist: pytest==8.
|
|
22
|
-
Requires-Dist: pytest-cov==
|
|
23
|
-
Requires-Dist: requests-mock==1.
|
|
24
|
-
Requires-Dist: pytest-mock==3.
|
|
19
|
+
Requires-Dist: black==24.4.2; extra == "test"
|
|
20
|
+
Requires-Dist: pylint==3.2.2; extra == "test"
|
|
21
|
+
Requires-Dist: pytest==8.2.0; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-cov==5.0.0; extra == "test"
|
|
23
|
+
Requires-Dist: requests-mock==1.12.1; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-mock==3.14.0; extra == "test"
|
|
25
25
|
|
|
26
26
|
# CDSETool
|
|
27
27
|
|
|
@@ -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.10
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
## Usage
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|