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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cdsetool
3
- Version: 0.2.8
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~=0.9.0
13
+ Requires-Dist: typer<0.13,>=0.9
14
14
  Requires-Dist: rich<13.8,>=13.6
15
- Requires-Dist: requests<2.32.0,>=2.28.1
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.3.0; extra == "test"
20
- Requires-Dist: pylint==3.1.0; 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.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.8
99
+ pip install cdsetool==0.2.10
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.8
74
+ pip install cdsetool==0.2.10
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.8"
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 ~= 0.9.0",
19
+ "typer >= 0.9,< 0.13",
20
20
  "rich >= 13.6,< 13.8",
21
- "requests >= 2.28.1,< 2.32.0",
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.3.0",
28
- "pylint==3.1.0",
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.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 = datetime.now()
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.__make_session(True, self.__retries, self.__proxies)
115
+ return self.make_session(self, True, self.RETRIES, self.__proxies)
107
116
 
108
- def __make_session(self, authorization, max_retries, proxies):
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
- self.__ensure_tokens()
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
- session.headers.update({"Authorization": f"Bearer {self.__access_token}"})
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.__make_session(
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=Retry.RETRY_AFTER_STATUS_CODES,
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
- self.__access_token_expires = now + timedelta(seconds=response["expires_in"])
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
- jwt.decode(
179
- self.__access_token,
180
- key=key.key,
181
- algorithms=key._algorithms, # pylint: disable=protected-access
182
- options={"verify_aud": False},
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.__make_session(
199
- authorization=False, max_retries=self.__retries, proxies=self.__proxies
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 Credentials, TokenClientConnectionError
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 < 5:
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 open(fd, "wb") as file:
63
- for chunk in response.iter_content(chunk_size=1024 * 1024 * 5):
64
- file.write(chunk)
65
- status.add_progress(len(chunk))
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
- with requests.get(self.next_url, timeout=120, proxies=self.proxies) as response:
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
- res = requests.get(
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
- timeout=120,
243
- proxies=proxies,
244
- )
245
- assert res.status_code == 200, (
246
- f"Unable to find collection with name {collection}. Please see "
247
- + "https://documentation.dataspace.copernicus.eu"
248
- + "/APIs/OpenSearch.html#collections "
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
- _describe_docs[collection] = res.content
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.8
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~=0.9.0
13
+ Requires-Dist: typer<0.13,>=0.9
14
14
  Requires-Dist: rich<13.8,>=13.6
15
- Requires-Dist: requests<2.32.0,>=2.28.1
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.3.0; extra == "test"
20
- Requires-Dist: pylint==3.1.0; 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.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.8
99
+ pip install cdsetool==0.2.10
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.33.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.2.2
10
+ pytest==8.2.0
11
+ pytest-cov==5.0.0
12
+ requests-mock==1.12.1
13
+ pytest-mock==3.14.0
@@ -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.3.0
9
- pylint==3.1.0
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