cdsetool 0.2.9__tar.gz → 0.2.11__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.9
3
+ Version: 0.2.11
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,15 +10,15 @@ 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.13,>=0.9
14
- Requires-Dist: rich<13.8,>=13.6
15
- Requires-Dist: requests<2.32.0,>=2.28.1
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.1.0; extra == "test"
21
- Requires-Dist: pytest==8.2.0; extra == "test"
20
+ Requires-Dist: pylint==3.2.2; extra == "test"
21
+ Requires-Dist: pytest==8.2.1; 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.9
99
+ pip install cdsetool==0.2.11
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.9
74
+ pip install cdsetool==0.2.11
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.9"
7
+ version = "0.2.11"
8
8
  authors = [
9
9
  { name="Jacob Vejby", email="javej@sdfi.dk" },
10
10
  ]
@@ -16,17 +16,17 @@ classifiers = [
16
16
  "Operating System :: OS Independent",
17
17
  ]
18
18
  dependencies = [
19
- "typer >= 0.9,< 0.13",
20
- "rich >= 13.6,< 13.8",
21
- "requests >= 2.28.1,< 2.32.0",
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.1.0",
29
- "pytest==8.2.0",
28
+ "pylint==3.2.2",
29
+ "pytest==8.2.1",
30
30
  "pytest-cov==5.0.0",
31
31
  "requests-mock==1.12.1",
32
32
  "pytest-mock==3.14.0",
@@ -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
- def _concurrent_process(fun, iterable, workers=4):
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, futures = wait(futures, return_when=FIRST_COMPLETED)
51
- futures = list(futures)
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,7 +5,7 @@ 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
@@ -19,7 +19,7 @@ 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
- search_term = _to_dict(search_term)
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
- search_term = _to_dict(search_term)
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,7 +114,7 @@ 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("=")
@@ -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
@@ -70,35 +72,36 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
70
72
 
71
73
  RETRY_CODES = frozenset([413, 429, 500, 502, 503])
72
74
 
75
+ RETRIES = Retry(
76
+ total=5,
77
+ backoff_factor=0.5,
78
+ raise_on_status=False,
79
+ status_forcelist=RETRY_CODES,
80
+ )
81
+
73
82
  def __init__(
74
83
  self,
75
- username=None,
76
- password=None,
77
- openid_configuration_endpoint=None,
78
- proxies=None,
79
- ):
80
- self.__username = username
81
- self.__password = password
82
-
83
- self.__proxies = proxies
84
- self.__retries = Retry(
85
- total=5,
86
- backoff_factor=0.5,
87
- raise_on_status=False,
88
- status_forcelist=self.RETRY_CODES,
89
- )
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
90
93
  self.__openid_conf = None
91
94
  self.__jwks = None
92
- self.__openid_configuration_endpoint = (
95
+ self.__openid_configuration_endpoint: str = (
93
96
  openid_configuration_endpoint
94
97
  or "https://identity.dataspace.copernicus.eu"
95
98
  + "/auth/realms/CDSE/.well-known/openid-configuration"
96
99
  )
97
100
 
98
- self.__access_token = None
99
- self.__refresh_token = None
100
- self.__access_token_expires = datetime.now() - timedelta(hours=8)
101
- 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
102
105
 
103
106
  self.__lock = threading.Lock()
104
107
 
@@ -107,15 +110,25 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
107
110
 
108
111
  self.__ensure_tokens()
109
112
 
110
- def get_session(self):
113
+ def get_session(self) -> requests.Session:
111
114
  """
112
115
  Returns a session with the credentials set as the Authorization header
113
116
  """
114
- return self.__make_session(True, self.__retries, self.__proxies)
115
-
116
- def __make_session(self, authorization, max_retries, proxies):
117
+ return self.make_session(self, True, self.RETRIES, self.__proxies)
118
+
119
+ @staticmethod
120
+ def make_session(
121
+ caller,
122
+ authorization: bool,
123
+ max_retries: Retry,
124
+ proxies: Union[Dict[str, str], None],
125
+ ) -> requests.Session:
126
+ """
127
+ Creates a new session. Authorization is only available from callers
128
+ that are subclasses of Credentials.
129
+ """
117
130
  if authorization:
118
- self.__ensure_tokens()
131
+ caller.__ensure_tokens() # pylint: disable=protected-access
119
132
 
120
133
  session = requests.Session()
121
134
  session.mount("http://", HTTPAdapter(max_retries=max_retries))
@@ -123,13 +136,15 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
123
136
  if proxies is not None:
124
137
  session.proxies.update(proxies)
125
138
  if authorization:
126
- session.headers.update({"Authorization": f"Bearer {self.__access_token}"})
139
+ token = caller.__access_token # pylint: disable=protected-access
140
+ session.headers.update({"Authorization": f"Bearer {token}"})
127
141
  return session
128
142
 
129
- def __token_exchange(self, data):
143
+ def __token_exchange(self, data: Dict[str, str]) -> timedelta:
130
144
  # Make a session that will retry post, respecting the retry-after
131
145
  # header when we get a 503 and a few other temporary failures.
132
- session = self.__make_session(
146
+ session = self.make_session(
147
+ caller=self,
133
148
  authorization=False,
134
149
  max_retries=Retry(
135
150
  total=2,
@@ -140,14 +155,13 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
140
155
  ),
141
156
  proxies=self.__proxies,
142
157
  )
143
- now = datetime.now()
144
158
  response = session.post(self.__token_endpoint, data=data, timeout=120)
145
159
 
146
160
  if response.status_code == 401:
147
161
  raise InvalidCredentialsException(
148
162
  "Unable to exchange token with "
149
163
  + f"username: {self.__username} and "
150
- + f"password: {len(self.__password) * '*'}"
164
+ + f"password: {len(self.__password or '') * '*'}"
151
165
  )
152
166
 
153
167
  if response.status_code != 200:
@@ -157,13 +171,11 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
157
171
 
158
172
  self.__access_token = response["access_token"]
159
173
  self.__refresh_token = response["refresh_token"]
160
- self.__access_token_expires = now + timedelta(seconds=response["expires_in"])
161
- self.__refresh_token_expires = now + timedelta(
162
- seconds=response["refresh_expires_in"]
163
- )
174
+ return timedelta(seconds=response["refresh_expires_in"])
164
175
 
165
- def __ensure_tokens(self):
176
+ def __ensure_tokens(self) -> None:
166
177
  with self.__lock:
178
+ refresh_expire_delta = None
167
179
  if self.__access_token_expires < datetime.now():
168
180
  if self.__refresh_token_expires < datetime.now():
169
181
  data = {
@@ -178,13 +190,17 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
178
190
  "refresh_token": self.__refresh_token,
179
191
  "client_id": "cdse-public",
180
192
  }
181
- self.__token_exchange(data)
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
+ )
182
198
  try:
183
199
  key = self.__jwk_client.get_signing_key_from_jwt(self.__access_token)
184
200
  except jwt.PyJWKClientConnectionError as e:
185
201
  raise TokenClientConnectionError from e
186
202
  try:
187
- jwt.decode(
203
+ data = jwt.decode(
188
204
  self.__access_token,
189
205
  key=key.key,
190
206
  algorithms=self.__id_token_signing_algos, # pylint: disable=protected-access
@@ -192,22 +208,29 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
192
208
  )
193
209
  except jwt.ExpiredSignatureError as e:
194
210
  raise TokenExpiredSignatureError from e
211
+ if refresh_expire_delta is not None:
212
+ self.__access_token_expires = datetime.fromtimestamp(data["exp"])
213
+ self.__refresh_token_expires = (
214
+ datetime.fromtimestamp(data["iat"]) + refresh_expire_delta
215
+ )
195
216
 
196
- def __read_credentials(self):
197
- try:
198
- self.__username, _, self.__password = netrc.netrc().authenticators(
199
- self.__token_endpoint
200
- )
201
- except Exception as exc:
202
- 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")
203
223
 
204
224
  @property
205
- def __openid_configuration(self):
225
+ def __openid_configuration(self) -> Dict[str, Any]:
206
226
  if self.__openid_conf:
207
227
  return self.__openid_conf
208
228
 
209
- session = self.__make_session(
210
- authorization=False, max_retries=self.__retries, proxies=self.__proxies
229
+ session = self.make_session(
230
+ caller=self,
231
+ authorization=False,
232
+ max_retries=self.RETRIES,
233
+ proxies=self.__proxies,
211
234
  )
212
235
  response = session.get(self.__openid_configuration_endpoint, timeout=120)
213
236
  response.raise_for_status()
@@ -215,19 +238,19 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
215
238
  return self.__openid_conf
216
239
 
217
240
  @property
218
- def __token_endpoint(self):
241
+ def __token_endpoint(self) -> str:
219
242
  return self.__openid_configuration["token_endpoint"]
220
243
 
221
244
  @property
222
- def __jwks_uri(self):
245
+ def __jwks_uri(self) -> str:
223
246
  return self.__openid_configuration["jwks_uri"]
224
247
 
225
248
  @property
226
- def __id_token_signing_algos(self):
249
+ def __id_token_signing_algos(self) -> List[str]:
227
250
  return self.__openid_configuration["id_token_signing_alg_values_supported"]
228
251
 
229
252
  @property
230
- def __jwk_client(self):
253
+ def __jwk_client(self) -> jwt.PyJWKClient:
231
254
  if self.__jwks:
232
255
  return self.__jwks
233
256
 
@@ -236,7 +259,9 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
236
259
  return self.__jwks
237
260
 
238
261
 
239
- def validate_credentials(username=None, password=None):
262
+ def validate_credentials(
263
+ username: Union[str, None] = None, password: Union[str, None] = None
264
+ ) -> bool:
240
265
  """
241
266
  This function validates CDSE credentials and returns a bool.
242
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(feature, path, options=None):
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
 
@@ -86,7 +92,9 @@ def download_feature(feature, path, options=None):
86
92
  return None
87
93
 
88
94
 
89
- def download_features(features, path, options=None):
95
+ def download_features(
96
+ features: FeatureQuery, path: str, options: Union[Dict[str, Any], None] = None
97
+ ) -> Generator[Union[str, None], None, None]:
90
98
  """
91
99
  Generator function that downloads all features in a result set
92
100
 
@@ -100,7 +108,7 @@ def download_features(features, path, options=None):
100
108
  options["monitor"] = _get_monitor(options)
101
109
  options["monitor"].start()
102
110
 
103
- def _download_feature(feature):
111
+ def _download_feature(feature) -> Union[str, None]:
104
112
  return download_feature(feature, path, options)
105
113
 
106
114
  yield from _concurrent_process(
@@ -110,11 +118,11 @@ def download_features(features, path, options=None):
110
118
  options["monitor"].stop()
111
119
 
112
120
 
113
- def _get_feature_url(feature):
121
+ def _get_feature_url(feature) -> str:
114
122
  return feature.get("properties").get("services").get("download").get("url")
115
123
 
116
124
 
117
- def _follow_redirect(url, session):
125
+ def _follow_redirect(url: str, session: Session) -> str:
118
126
  response = session.head(url, allow_redirects=False)
119
127
  while response.status_code in range(300, 400):
120
128
  url = response.headers["Location"]
@@ -123,15 +131,15 @@ def _follow_redirect(url, session):
123
131
  return url
124
132
 
125
133
 
126
- def _get_logger(options):
134
+ def _get_logger(options: Dict) -> NoopLogger:
127
135
  return options.get("logger") or NoopLogger()
128
136
 
129
137
 
130
- def _get_monitor(options):
138
+ def _get_monitor(options: Dict) -> Union[StatusMonitor, NoopMonitor]:
131
139
  return options.get("monitor") or NoopMonitor()
132
140
 
133
141
 
134
- def _get_credentials(options):
142
+ def _get_credentials(options: Dict) -> Credentials:
135
143
  return options.get("credentials") or Credentials(
136
144
  proxies=options.get("proxies", None)
137
145
  )
@@ -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 info(self, *kwargs):
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.remove_status(self)
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,20 +4,21 @@ 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
10
11
  import json
11
- import requests
12
12
  import geopandas as gpd
13
+ 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 = None
44
+ total_results: int = -1
44
45
 
45
- def __init__(self, collection, search_terms, proxies=None):
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 is None:
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,10 +70,13 @@ 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
- with requests.get(self.next_url, timeout=120, proxies=self.proxies) as response:
76
+ session = Credentials.make_session(
77
+ None, False, Credentials.RETRIES, self.proxies
78
+ )
79
+ with session.get(self.next_url) as response:
71
80
  response.raise_for_status()
72
81
  res = response.json()
73
82
  self.features += res.get("features") or []
@@ -78,7 +87,7 @@ class FeatureQuery:
78
87
 
79
88
  self.__set_next_url(res)
80
89
 
81
- def __set_next_url(self, res):
90
+ def __set_next_url(self, res) -> None:
82
91
  links = res.get("properties", {}).get("links") or []
83
92
  self.next_url = next(
84
93
  (link for link in links if link.get("rel") == "next"), {}
@@ -88,14 +97,18 @@ class FeatureQuery:
88
97
  self.next_url = self.next_url.replace("exactCount=1", "exactCount=0")
89
98
 
90
99
 
91
- def query_features(collection, search_terms, proxies=None):
100
+ def query_features(
101
+ collection: str,
102
+ search_terms: Dict[str, Any],
103
+ proxies: Union[Dict[str, str], None] = None,
104
+ ) -> FeatureQuery:
92
105
  """
93
106
  Returns an iterator over the features matching the search terms
94
107
  """
95
108
  return FeatureQuery(collection, {"maxRecords": 2000, **search_terms}, proxies)
96
109
 
97
110
 
98
- def shape_to_wkt(shape):
111
+ def shape_to_wkt(shape: str) -> str:
99
112
  """
100
113
  Convert a shapefile to a WKT string
101
114
  """
@@ -107,12 +120,11 @@ def shape_to_wkt(shape):
107
120
  )
108
121
 
109
122
 
110
- def geojson_to_wkt(geojson):
123
+ def geojson_to_wkt(geojson_in: Union[str, Dict]) -> str:
111
124
  """
112
125
  Convert a geojson geometry to a WKT string
113
126
  """
114
- if isinstance(geojson, str):
115
- geojson = json.loads(geojson)
127
+ geojson = json.loads(geojson_in) if isinstance(geojson_in, str) else geojson_in
116
128
 
117
129
  if geojson.get("type") == "Feature":
118
130
  geojson = geojson["geometry"]
@@ -131,7 +143,9 @@ def geojson_to_wkt(geojson):
131
143
  return f"POLYGON({paired_coord})"
132
144
 
133
145
 
134
- def describe_collection(collection, proxies=None):
146
+ def describe_collection(
147
+ collection: str, proxies: Union[Dict[str, str], None] = None
148
+ ) -> Dict[str, Any]:
135
149
  """
136
150
  Get a list of valid options for a given collection in key value pairs
137
151
  """
@@ -142,6 +156,8 @@ def describe_collection(collection, proxies=None):
142
156
  )
143
157
 
144
158
  parameters = {}
159
+ if not parameter_node_parent:
160
+ return parameters
145
161
  for parameter_node in parameter_node_parent:
146
162
  name = parameter_node.attrib.get("name")
147
163
  pattern = parameter_node.attrib.get("pattern")
@@ -160,7 +176,11 @@ def describe_collection(collection, proxies=None):
160
176
  return parameters
161
177
 
162
178
 
163
- def _query_url(collection, search_terms, proxies=None):
179
+ def _query_url(
180
+ collection: str,
181
+ search_terms: Dict[str, Any],
182
+ proxies: Union[Dict[str, str], None] = None,
183
+ ) -> str:
164
184
  description = describe_collection(collection, proxies=proxies)
165
185
 
166
186
  query_list = []
@@ -175,7 +195,7 @@ def _query_url(collection, search_terms, proxies=None):
175
195
  )
176
196
 
177
197
 
178
- def _serialize_search_term(search_term):
198
+ def _serialize_search_term(search_term: Any) -> str:
179
199
  if isinstance(search_term, list):
180
200
  return ",".join(search_term)
181
201
 
@@ -188,14 +208,14 @@ def _serialize_search_term(search_term):
188
208
  return str(search_term)
189
209
 
190
210
 
191
- def _validate_search_term(key, search_term, description):
211
+ def _validate_search_term(key: str, search_term: str, description) -> None:
192
212
  _assert_valid_key(key, description)
193
213
  _assert_match_pattern(search_term, description.get(key).get("pattern"))
194
214
  _assert_min_inclusive(search_term, description.get(key).get("minInclusive"))
195
215
  _assert_max_inclusive(search_term, description.get(key).get("maxInclusive"))
196
216
 
197
217
 
198
- def _assert_valid_key(key, description):
218
+ def _assert_valid_key(key: str, description: Dict[str, Any]) -> None:
199
219
  assert key in description.keys(), (
200
220
  f'search_term with name "{key}" '
201
221
  + "was not found for collection."
@@ -203,7 +223,7 @@ def _assert_valid_key(key, description):
203
223
  )
204
224
 
205
225
 
206
- def _assert_match_pattern(search_term, pattern):
226
+ def _assert_match_pattern(search_term: str, pattern: Union[str, None]) -> None:
207
227
  if not pattern:
208
228
  return
209
229
 
@@ -212,7 +232,7 @@ def _assert_match_pattern(search_term, pattern):
212
232
  ), f"search_term {search_term} does not match pattern {pattern}"
213
233
 
214
234
 
215
- def _assert_min_inclusive(search_term, min_inclusive):
235
+ def _assert_min_inclusive(search_term: str, min_inclusive: Union[str, None]) -> None:
216
236
  if not min_inclusive:
217
237
  return
218
238
 
@@ -221,7 +241,7 @@ def _assert_min_inclusive(search_term, min_inclusive):
221
241
  ), f"search_term {search_term} is less than min_inclusive {min_inclusive}"
222
242
 
223
243
 
224
- def _assert_max_inclusive(search_term, max_inclusive):
244
+ def _assert_max_inclusive(search_term: str, max_inclusive: Union[str, None]) -> None:
225
245
  if not max_inclusive:
226
246
  return
227
247
 
@@ -230,24 +250,26 @@ def _assert_max_inclusive(search_term, max_inclusive):
230
250
  ), f"search_term {search_term} is greater than max_inclusive {max_inclusive}"
231
251
 
232
252
 
233
- _describe_docs = {}
253
+ _describe_docs: Dict[str, bytes] = {}
234
254
 
235
255
 
236
- def _get_describe_doc(collection, proxies=None):
237
- if _describe_docs.get(collection):
238
- return _describe_docs.get(collection)
239
- res = requests.get(
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
262
+ session = Credentials.make_session(None, False, Credentials.RETRIES, proxies)
263
+ with session.get(
240
264
  "https://catalogue.dataspace.copernicus.eu"
241
265
  + 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
- )
266
+ ) as res:
267
+ assert res.status_code == 200, (
268
+ f"Unable to find collection with name {collection}. Please see "
269
+ + "https://documentation.dataspace.copernicus.eu"
270
+ + "/APIs/OpenSearch.html#collections "
271
+ + "for a list of available collections"
272
+ )
251
273
 
252
- _describe_docs[collection] = res.content
253
- return _describe_docs.get(collection)
274
+ _describe_docs[collection] = res.content
275
+ return res.content
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cdsetool
3
- Version: 0.2.9
3
+ Version: 0.2.11
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,15 +10,15 @@ 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.13,>=0.9
14
- Requires-Dist: rich<13.8,>=13.6
15
- Requires-Dist: requests<2.32.0,>=2.28.1
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.1.0; extra == "test"
21
- Requires-Dist: pytest==8.2.0; extra == "test"
20
+ Requires-Dist: pylint==3.2.2; extra == "test"
21
+ Requires-Dist: pytest==8.2.1; 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.9
99
+ pip install cdsetool==0.2.11
100
100
  ```
101
101
 
102
102
  ## Usage
@@ -8,6 +8,7 @@ src/cdsetool/credentials.py
8
8
  src/cdsetool/download.py
9
9
  src/cdsetool/logger.py
10
10
  src/cdsetool/monitor.py
11
+ src/cdsetool/py.typed
11
12
  src/cdsetool/query.py
12
13
  src/cdsetool.egg-info/PKG-INFO
13
14
  src/cdsetool.egg-info/SOURCES.txt
@@ -1,13 +1,13 @@
1
- typer<0.13,>=0.9
2
- rich<13.8,>=13.6
3
- requests<2.32.0,>=2.28.1
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.1.0
10
- pytest==8.2.0
9
+ pylint==3.2.2
10
+ pytest==8.2.1
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