cdsetool 0.2.7__tar.gz → 0.2.9__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.7/src/cdsetool.egg-info → cdsetool-0.2.9}/PKG-INFO +9 -9
- {cdsetool-0.2.7 → cdsetool-0.2.9}/README.md +1 -1
- {cdsetool-0.2.7 → cdsetool-0.2.9}/pyproject.toml +8 -8
- cdsetool-0.2.9/src/cdsetool/_processing.py +57 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool/credentials.py +35 -11
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool/download.py +34 -19
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool/query.py +5 -2
- {cdsetool-0.2.7 → cdsetool-0.2.9/src/cdsetool.egg-info}/PKG-INFO +9 -9
- cdsetool-0.2.9/src/cdsetool.egg-info/requires.txt +13 -0
- cdsetool-0.2.7/src/cdsetool/_processing.py +0 -38
- cdsetool-0.2.7/src/cdsetool.egg-info/requires.txt +0 -13
- {cdsetool-0.2.7 → cdsetool-0.2.9}/LICENSE +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/setup.cfg +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool/__init__.py +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool/cli.py +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool/logger.py +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool/monitor.py +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool.egg-info/SOURCES.txt +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool.egg-info/dependency_links.txt +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/src/cdsetool.egg-info/entry_points.txt +0 -0
- {cdsetool-0.2.7 → cdsetool-0.2.9}/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.9
|
|
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
15
|
Requires-Dist: requests<2.32.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.2
|
|
20
|
-
Requires-Dist: pylint==3.0
|
|
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.1.0; 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.9
|
|
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.9"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Jacob Vejby", email="javej@sdfi.dk" },
|
|
10
10
|
]
|
|
@@ -16,7 +16,7 @@ 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
21
|
"requests >= 2.28.1,< 2.32.0",
|
|
22
22
|
"pyjwt[crypto] ~= 2.8.0",
|
|
@@ -24,12 +24,12 @@ dependencies = [
|
|
|
24
24
|
]
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
test = [
|
|
27
|
-
"black==24.2
|
|
28
|
-
"pylint==3.0
|
|
29
|
-
"pytest==8.
|
|
30
|
-
"pytest-cov==
|
|
31
|
-
"requests-mock==1.
|
|
32
|
-
"pytest-mock==3.
|
|
27
|
+
"black==24.4.2",
|
|
28
|
+
"pylint==3.1.0",
|
|
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]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides functions for processing data concurrently
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from concurrent.futures import wait, FIRST_COMPLETED
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _concurrent_process(fun, iterable, workers=4):
|
|
10
|
+
"""
|
|
11
|
+
Process items in an iterable concurrently
|
|
12
|
+
|
|
13
|
+
Items are taken from the iterable as soon as a worker becomes available
|
|
14
|
+
|
|
15
|
+
Returns an iterable of the results
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Futures are submitted in batches instead of all at once to avoid
|
|
19
|
+
# requesting too many items from the iterable at once, which is important if
|
|
20
|
+
# the iterable is a generator that is producing items on the fly.
|
|
21
|
+
#
|
|
22
|
+
# The 1.5 factor is a small overhead to keep jobs > workers at all times, instead
|
|
23
|
+
# of jobs == workers, which could cause the workers to be idle while waiting for
|
|
24
|
+
# the iterable to produce more items.
|
|
25
|
+
low_water_mark = int(workers * 1.5)
|
|
26
|
+
iterator = iter(iterable)
|
|
27
|
+
|
|
28
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
29
|
+
futures = []
|
|
30
|
+
|
|
31
|
+
# Pluck an item from the iterable and submit it to the executor.
|
|
32
|
+
# If the iterable is exhausted, this function is a no-op.
|
|
33
|
+
def submit_item():
|
|
34
|
+
item = next(iterator, None)
|
|
35
|
+
if item is not None:
|
|
36
|
+
futures.append(executor.submit(fun, item))
|
|
37
|
+
|
|
38
|
+
# Fill the futures list up to the low water mark
|
|
39
|
+
def fill_futures():
|
|
40
|
+
for _ in range(low_water_mark - len(futures)):
|
|
41
|
+
submit_item()
|
|
42
|
+
|
|
43
|
+
# Submit the first batch of items
|
|
44
|
+
fill_futures()
|
|
45
|
+
|
|
46
|
+
# Continue until no more futures are queued
|
|
47
|
+
while futures:
|
|
48
|
+
|
|
49
|
+
# Wait for the first future(s) to complete
|
|
50
|
+
done, futures = wait(futures, return_when=FIRST_COMPLETED)
|
|
51
|
+
futures = list(futures)
|
|
52
|
+
|
|
53
|
+
for future in done:
|
|
54
|
+
yield future.result()
|
|
55
|
+
|
|
56
|
+
# Submit items to replace the ones that are done.
|
|
57
|
+
fill_futures()
|
|
@@ -50,12 +50,26 @@ class TokenExchangeException(Exception):
|
|
|
50
50
|
"""
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
class TokenClientConnectionError(Exception):
|
|
54
|
+
"""
|
|
55
|
+
Raised when token connection fails.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TokenExpiredSignatureError(Exception):
|
|
60
|
+
"""
|
|
61
|
+
Raised when token signature has expired.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
53
65
|
class Credentials: # pylint: disable=too-few-public-methods disable=too-many-instance-attributes
|
|
54
66
|
"""
|
|
55
67
|
A class for handling credentials for the Copernicus Identity
|
|
56
68
|
and Access Management (IAM) system
|
|
57
69
|
"""
|
|
58
70
|
|
|
71
|
+
RETRY_CODES = frozenset([413, 429, 500, 502, 503])
|
|
72
|
+
|
|
59
73
|
def __init__(
|
|
60
74
|
self,
|
|
61
75
|
username=None,
|
|
@@ -71,7 +85,7 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
71
85
|
total=5,
|
|
72
86
|
backoff_factor=0.5,
|
|
73
87
|
raise_on_status=False,
|
|
74
|
-
status_forcelist=
|
|
88
|
+
status_forcelist=self.RETRY_CODES,
|
|
75
89
|
)
|
|
76
90
|
self.__openid_conf = None
|
|
77
91
|
self.__jwks = None
|
|
@@ -83,8 +97,8 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
83
97
|
|
|
84
98
|
self.__access_token = None
|
|
85
99
|
self.__refresh_token = None
|
|
86
|
-
self.__access_token_expires = datetime.now()
|
|
87
|
-
self.__refresh_token_expires =
|
|
100
|
+
self.__access_token_expires = datetime.now() - timedelta(hours=8)
|
|
101
|
+
self.__refresh_token_expires = self.__access_token_expires
|
|
88
102
|
|
|
89
103
|
self.__lock = threading.Lock()
|
|
90
104
|
|
|
@@ -122,7 +136,7 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
122
136
|
backoff_factor=0.5,
|
|
123
137
|
allowed_methods=None,
|
|
124
138
|
raise_on_status=False,
|
|
125
|
-
status_forcelist=
|
|
139
|
+
status_forcelist=self.RETRY_CODES,
|
|
126
140
|
),
|
|
127
141
|
proxies=self.__proxies,
|
|
128
142
|
)
|
|
@@ -165,13 +179,19 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
165
179
|
"client_id": "cdse-public",
|
|
166
180
|
}
|
|
167
181
|
self.__token_exchange(data)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
182
|
+
try:
|
|
183
|
+
key = self.__jwk_client.get_signing_key_from_jwt(self.__access_token)
|
|
184
|
+
except jwt.PyJWKClientConnectionError as e:
|
|
185
|
+
raise TokenClientConnectionError from e
|
|
186
|
+
try:
|
|
187
|
+
jwt.decode(
|
|
188
|
+
self.__access_token,
|
|
189
|
+
key=key.key,
|
|
190
|
+
algorithms=self.__id_token_signing_algos, # pylint: disable=protected-access
|
|
191
|
+
options={"verify_aud": False},
|
|
192
|
+
)
|
|
193
|
+
except jwt.ExpiredSignatureError as e:
|
|
194
|
+
raise TokenExpiredSignatureError from e
|
|
175
195
|
|
|
176
196
|
def __read_credentials(self):
|
|
177
197
|
try:
|
|
@@ -202,6 +222,10 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
|
|
|
202
222
|
def __jwks_uri(self):
|
|
203
223
|
return self.__openid_configuration["jwks_uri"]
|
|
204
224
|
|
|
225
|
+
@property
|
|
226
|
+
def __id_token_signing_algos(self):
|
|
227
|
+
return self.__openid_configuration["id_token_signing_alg_values_supported"]
|
|
228
|
+
|
|
205
229
|
@property
|
|
206
230
|
def __jwk_client(self):
|
|
207
231
|
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,34 +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:
|
|
54
|
+
session = _get_credentials(options).get_session()
|
|
55
|
+
except (TokenClientConnectionError, TokenExpiredSignatureError) as e:
|
|
56
|
+
log.warning(e)
|
|
57
|
+
continue
|
|
48
58
|
url = _follow_redirect(url, session)
|
|
49
59
|
with session.get(url, stream=True) as response:
|
|
50
60
|
if response.status_code != 200:
|
|
51
61
|
log.warning(f"Status code {response.status_code}, retrying..")
|
|
52
|
-
attempts += 1
|
|
53
62
|
time.sleep(60 * (1 + (random.random() / 4)))
|
|
54
63
|
continue
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
status.set_filesize(int(response.headers["Content-Length"]))
|
|
66
|
+
|
|
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)
|
|
57
83
|
|
|
58
|
-
status.set_filesize(content_length)
|
|
59
|
-
|
|
60
|
-
with open(fd, "wb") as file:
|
|
61
|
-
for chunk in response.iter_content(chunk_size=1024 * 1024 * 5):
|
|
62
|
-
file.write(chunk)
|
|
63
|
-
status.add_progress(len(chunk))
|
|
64
|
-
|
|
65
|
-
shutil.move(tmp, result_path)
|
|
66
84
|
return filename
|
|
67
85
|
log.error(f"Failed to download {filename}")
|
|
68
|
-
os.close(fd)
|
|
69
|
-
os.remove(tmp)
|
|
70
86
|
return None
|
|
71
87
|
|
|
72
88
|
|
|
@@ -87,10 +103,9 @@ def download_features(features, path, options=None):
|
|
|
87
103
|
def _download_feature(feature):
|
|
88
104
|
return download_feature(feature, path, options)
|
|
89
105
|
|
|
90
|
-
|
|
106
|
+
yield from _concurrent_process(
|
|
91
107
|
_download_feature, features, options.get("concurrency", 1)
|
|
92
|
-
)
|
|
93
|
-
yield feature
|
|
108
|
+
)
|
|
94
109
|
|
|
95
110
|
options["monitor"].stop()
|
|
96
111
|
|
|
@@ -65,8 +65,11 @@ class FeatureQuery:
|
|
|
65
65
|
return self.features[index]
|
|
66
66
|
|
|
67
67
|
def __fetch_features(self):
|
|
68
|
-
if self.next_url is
|
|
69
|
-
|
|
68
|
+
if self.next_url is None:
|
|
69
|
+
return
|
|
70
|
+
with requests.get(self.next_url, timeout=120, proxies=self.proxies) as response:
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
res = response.json()
|
|
70
73
|
self.features += res.get("features") or []
|
|
71
74
|
|
|
72
75
|
total_results = res.get("properties", {}).get("totalResults")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cdsetool
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
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
15
|
Requires-Dist: requests<2.32.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.2
|
|
20
|
-
Requires-Dist: pylint==3.0
|
|
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.1.0; 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.9
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
## Usage
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
This module provides functions for processing data concurrently
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from concurrent.futures import wait, FIRST_COMPLETED
|
|
6
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _concurrent_process(fun, iterable, workers=4):
|
|
10
|
-
"""
|
|
11
|
-
Process items in an iterable concurrently
|
|
12
|
-
|
|
13
|
-
Items are taken from the iterable as soon as a worker becomes available
|
|
14
|
-
|
|
15
|
-
Returns an iterable of the results
|
|
16
|
-
"""
|
|
17
|
-
low_water_mark = int(workers * 1.5)
|
|
18
|
-
iterator = iter(iterable)
|
|
19
|
-
|
|
20
|
-
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
21
|
-
futures = []
|
|
22
|
-
|
|
23
|
-
def submit_item():
|
|
24
|
-
item = next(iterator, None)
|
|
25
|
-
if item is not None:
|
|
26
|
-
futures.append(executor.submit(fun, item))
|
|
27
|
-
|
|
28
|
-
for _ in range(low_water_mark):
|
|
29
|
-
submit_item()
|
|
30
|
-
|
|
31
|
-
while futures:
|
|
32
|
-
done, futures = wait(futures, return_when=FIRST_COMPLETED)
|
|
33
|
-
futures = list(futures)
|
|
34
|
-
for future in done:
|
|
35
|
-
yield future.result()
|
|
36
|
-
|
|
37
|
-
if len(futures) < low_water_mark:
|
|
38
|
-
submit_item()
|
|
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
|