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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cdsetool
3
- Version: 0.2.7
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~=0.9.0
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.0; extra == "test"
20
- Requires-Dist: pylint==3.0.3; extra == "test"
21
- Requires-Dist: pytest==8.1.1; extra == "test"
22
- Requires-Dist: pytest-cov==4.1.0; extra == "test"
23
- Requires-Dist: requests-mock==1.11.0; extra == "test"
24
- Requires-Dist: pytest-mock==3.12.0; extra == "test"
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.7
99
+ pip install cdsetool==0.2.9
100
100
  ```
101
101
 
102
102
  ## Usage
@@ -71,7 +71,7 @@ cdsetool download Sentinel2 PATH/TO/DIR --concurrency 4 --search-term startDate=
71
71
  Install `cdsetool` using pip:
72
72
 
73
73
  ```bash
74
- pip install cdsetool==0.2.7
74
+ pip install cdsetool==0.2.9
75
75
  ```
76
76
 
77
77
  ## Usage
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdsetool"
7
- version = "0.2.7"
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 ~= 0.9.0",
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.0",
28
- "pylint==3.0.3",
29
- "pytest==8.1.1",
30
- "pytest-cov==4.1.0",
31
- "requests-mock==1.11.0",
32
- "pytest-mock==3.12.0",
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=Retry.RETRY_AFTER_STATUS_CODES,
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 = datetime.now()
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=Retry.RETRY_AFTER_STATUS_CODES,
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
- key = self.__jwk_client.get_signing_key_from_jwt(self.__access_token)
169
- jwt.decode(
170
- self.__access_token,
171
- key=key.key,
172
- algorithms=key._algorithms, # pylint: disable=protected-access
173
- options={"verify_aud": False},
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 Credentials
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 < 5:
50
+ while attempts < 10:
51
+ attempts += 1
46
52
  # Always get a new session, credentials might have expired.
47
- session = _get_credentials(options).get_session()
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
- content_length = int(response.headers["Content-Length"])
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
- for feature in _concurrent_process(
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 not None:
69
- res = requests.get(self.next_url, timeout=120, proxies=self.proxies).json()
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.7
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~=0.9.0
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.0; extra == "test"
20
- Requires-Dist: pylint==3.0.3; extra == "test"
21
- Requires-Dist: pytest==8.1.1; extra == "test"
22
- Requires-Dist: pytest-cov==4.1.0; extra == "test"
23
- Requires-Dist: requests-mock==1.11.0; extra == "test"
24
- Requires-Dist: pytest-mock==3.12.0; extra == "test"
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.7
99
+ pip install cdsetool==0.2.9
100
100
  ```
101
101
 
102
102
  ## Usage
@@ -0,0 +1,13 @@
1
+ typer<0.13,>=0.9
2
+ rich<13.8,>=13.6
3
+ requests<2.32.0,>=2.28.1
4
+ pyjwt[crypto]~=2.8.0
5
+ geopandas>=0.13.2
6
+
7
+ [test]
8
+ black==24.4.2
9
+ pylint==3.1.0
10
+ pytest==8.2.0
11
+ pytest-cov==5.0.0
12
+ requests-mock==1.12.1
13
+ pytest-mock==3.14.0
@@ -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()
@@ -1,13 +0,0 @@
1
- typer~=0.9.0
2
- rich<13.8,>=13.6
3
- requests<2.32.0,>=2.28.1
4
- pyjwt[crypto]~=2.8.0
5
- geopandas>=0.13.2
6
-
7
- [test]
8
- black==24.2.0
9
- pylint==3.0.3
10
- pytest==8.1.1
11
- pytest-cov==4.1.0
12
- requests-mock==1.11.0
13
- pytest-mock==3.12.0
File without changes
File without changes
File without changes