cdsetool 0.2.10__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.10
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.33.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
20
  Requires-Dist: pylint==3.2.2; extra == "test"
21
- Requires-Dist: pytest==8.2.0; 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.10
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.10
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.10"
7
+ version = "0.2.11"
8
8
  authors = [
9
9
  { name="Jacob Vejby", email="javej@sdfi.dk" },
10
10
  ]
@@ -16,9 +16,9 @@ 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.33.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
  ]
@@ -26,7 +26,7 @@ dependencies = [
26
26
  test = [
27
27
  "black==24.4.2",
28
28
  "pylint==3.2.2",
29
- "pytest==8.2.0",
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
@@ -79,27 +81,27 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
79
81
 
80
82
  def __init__(
81
83
  self,
82
- username=None,
83
- password=None,
84
- openid_configuration_endpoint=None,
85
- proxies=None,
86
- ):
87
- self.__username = username
88
- self.__password = password
89
-
90
- self.__proxies = proxies
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
91
93
  self.__openid_conf = None
92
94
  self.__jwks = None
93
- self.__openid_configuration_endpoint = (
95
+ self.__openid_configuration_endpoint: str = (
94
96
  openid_configuration_endpoint
95
97
  or "https://identity.dataspace.copernicus.eu"
96
98
  + "/auth/realms/CDSE/.well-known/openid-configuration"
97
99
  )
98
100
 
99
- self.__access_token = None
100
- self.__refresh_token = None
101
- self.__access_token_expires = datetime.now() - timedelta(hours=8)
102
- 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
103
105
 
104
106
  self.__lock = threading.Lock()
105
107
 
@@ -108,14 +110,19 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
108
110
 
109
111
  self.__ensure_tokens()
110
112
 
111
- def get_session(self):
113
+ def get_session(self) -> requests.Session:
112
114
  """
113
115
  Returns a session with the credentials set as the Authorization header
114
116
  """
115
117
  return self.make_session(self, True, self.RETRIES, self.__proxies)
116
118
 
117
119
  @staticmethod
118
- def make_session(caller, authorization, max_retries, proxies):
120
+ def make_session(
121
+ caller,
122
+ authorization: bool,
123
+ max_retries: Retry,
124
+ proxies: Union[Dict[str, str], None],
125
+ ) -> requests.Session:
119
126
  """
120
127
  Creates a new session. Authorization is only available from callers
121
128
  that are subclasses of Credentials.
@@ -133,7 +140,7 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
133
140
  session.headers.update({"Authorization": f"Bearer {token}"})
134
141
  return session
135
142
 
136
- def __token_exchange(self, data):
143
+ def __token_exchange(self, data: Dict[str, str]) -> timedelta:
137
144
  # Make a session that will retry post, respecting the retry-after
138
145
  # header when we get a 503 and a few other temporary failures.
139
146
  session = self.make_session(
@@ -154,7 +161,7 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
154
161
  raise InvalidCredentialsException(
155
162
  "Unable to exchange token with "
156
163
  + f"username: {self.__username} and "
157
- + f"password: {len(self.__password) * '*'}"
164
+ + f"password: {len(self.__password or '') * '*'}"
158
165
  )
159
166
 
160
167
  if response.status_code != 200:
@@ -166,7 +173,7 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
166
173
  self.__refresh_token = response["refresh_token"]
167
174
  return timedelta(seconds=response["refresh_expires_in"])
168
175
 
169
- def __ensure_tokens(self):
176
+ def __ensure_tokens(self) -> None:
170
177
  with self.__lock:
171
178
  refresh_expire_delta = None
172
179
  if self.__access_token_expires < datetime.now():
@@ -184,6 +191,10 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
184
191
  "client_id": "cdse-public",
185
192
  }
186
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
+ )
187
198
  try:
188
199
  key = self.__jwk_client.get_signing_key_from_jwt(self.__access_token)
189
200
  except jwt.PyJWKClientConnectionError as e:
@@ -203,16 +214,15 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
203
214
  datetime.fromtimestamp(data["iat"]) + refresh_expire_delta
204
215
  )
205
216
 
206
- def __read_credentials(self):
207
- try:
208
- self.__username, _, self.__password = netrc.netrc().authenticators(
209
- self.__token_endpoint
210
- )
211
- except Exception as exc:
212
- 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")
213
223
 
214
224
  @property
215
- def __openid_configuration(self):
225
+ def __openid_configuration(self) -> Dict[str, Any]:
216
226
  if self.__openid_conf:
217
227
  return self.__openid_conf
218
228
 
@@ -228,19 +238,19 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
228
238
  return self.__openid_conf
229
239
 
230
240
  @property
231
- def __token_endpoint(self):
241
+ def __token_endpoint(self) -> str:
232
242
  return self.__openid_configuration["token_endpoint"]
233
243
 
234
244
  @property
235
- def __jwks_uri(self):
245
+ def __jwks_uri(self) -> str:
236
246
  return self.__openid_configuration["jwks_uri"]
237
247
 
238
248
  @property
239
- def __id_token_signing_algos(self):
249
+ def __id_token_signing_algos(self) -> List[str]:
240
250
  return self.__openid_configuration["id_token_signing_alg_values_supported"]
241
251
 
242
252
  @property
243
- def __jwk_client(self):
253
+ def __jwk_client(self) -> jwt.PyJWKClient:
244
254
  if self.__jwks:
245
255
  return self.__jwks
246
256
 
@@ -249,7 +259,9 @@ class Credentials: # pylint: disable=too-few-public-methods disable=too-many-in
249
259
  return self.__jwks
250
260
 
251
261
 
252
- def validate_credentials(username=None, password=None):
262
+ def validate_credentials(
263
+ username: Union[str, None] = None, password: Union[str, None] = None
264
+ ) -> bool:
253
265
  """
254
266
  This function validates CDSE credentials and returns a bool.
255
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,6 +4,7 @@ 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
@@ -13,11 +14,11 @@ 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,7 +70,7 @@ 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
76
  session = Credentials.make_session(
@@ -81,7 +87,7 @@ class FeatureQuery:
81
87
 
82
88
  self.__set_next_url(res)
83
89
 
84
- def __set_next_url(self, res):
90
+ def __set_next_url(self, res) -> None:
85
91
  links = res.get("properties", {}).get("links") or []
86
92
  self.next_url = next(
87
93
  (link for link in links if link.get("rel") == "next"), {}
@@ -91,14 +97,18 @@ class FeatureQuery:
91
97
  self.next_url = self.next_url.replace("exactCount=1", "exactCount=0")
92
98
 
93
99
 
94
- 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:
95
105
  """
96
106
  Returns an iterator over the features matching the search terms
97
107
  """
98
108
  return FeatureQuery(collection, {"maxRecords": 2000, **search_terms}, proxies)
99
109
 
100
110
 
101
- def shape_to_wkt(shape):
111
+ def shape_to_wkt(shape: str) -> str:
102
112
  """
103
113
  Convert a shapefile to a WKT string
104
114
  """
@@ -110,12 +120,11 @@ def shape_to_wkt(shape):
110
120
  )
111
121
 
112
122
 
113
- def geojson_to_wkt(geojson):
123
+ def geojson_to_wkt(geojson_in: Union[str, Dict]) -> str:
114
124
  """
115
125
  Convert a geojson geometry to a WKT string
116
126
  """
117
- if isinstance(geojson, str):
118
- geojson = json.loads(geojson)
127
+ geojson = json.loads(geojson_in) if isinstance(geojson_in, str) else geojson_in
119
128
 
120
129
  if geojson.get("type") == "Feature":
121
130
  geojson = geojson["geometry"]
@@ -134,7 +143,9 @@ def geojson_to_wkt(geojson):
134
143
  return f"POLYGON({paired_coord})"
135
144
 
136
145
 
137
- def describe_collection(collection, proxies=None):
146
+ def describe_collection(
147
+ collection: str, proxies: Union[Dict[str, str], None] = None
148
+ ) -> Dict[str, Any]:
138
149
  """
139
150
  Get a list of valid options for a given collection in key value pairs
140
151
  """
@@ -145,6 +156,8 @@ def describe_collection(collection, proxies=None):
145
156
  )
146
157
 
147
158
  parameters = {}
159
+ if not parameter_node_parent:
160
+ return parameters
148
161
  for parameter_node in parameter_node_parent:
149
162
  name = parameter_node.attrib.get("name")
150
163
  pattern = parameter_node.attrib.get("pattern")
@@ -163,7 +176,11 @@ def describe_collection(collection, proxies=None):
163
176
  return parameters
164
177
 
165
178
 
166
- 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:
167
184
  description = describe_collection(collection, proxies=proxies)
168
185
 
169
186
  query_list = []
@@ -178,7 +195,7 @@ def _query_url(collection, search_terms, proxies=None):
178
195
  )
179
196
 
180
197
 
181
- def _serialize_search_term(search_term):
198
+ def _serialize_search_term(search_term: Any) -> str:
182
199
  if isinstance(search_term, list):
183
200
  return ",".join(search_term)
184
201
 
@@ -191,14 +208,14 @@ def _serialize_search_term(search_term):
191
208
  return str(search_term)
192
209
 
193
210
 
194
- def _validate_search_term(key, search_term, description):
211
+ def _validate_search_term(key: str, search_term: str, description) -> None:
195
212
  _assert_valid_key(key, description)
196
213
  _assert_match_pattern(search_term, description.get(key).get("pattern"))
197
214
  _assert_min_inclusive(search_term, description.get(key).get("minInclusive"))
198
215
  _assert_max_inclusive(search_term, description.get(key).get("maxInclusive"))
199
216
 
200
217
 
201
- def _assert_valid_key(key, description):
218
+ def _assert_valid_key(key: str, description: Dict[str, Any]) -> None:
202
219
  assert key in description.keys(), (
203
220
  f'search_term with name "{key}" '
204
221
  + "was not found for collection."
@@ -206,7 +223,7 @@ def _assert_valid_key(key, description):
206
223
  )
207
224
 
208
225
 
209
- def _assert_match_pattern(search_term, pattern):
226
+ def _assert_match_pattern(search_term: str, pattern: Union[str, None]) -> None:
210
227
  if not pattern:
211
228
  return
212
229
 
@@ -215,7 +232,7 @@ def _assert_match_pattern(search_term, pattern):
215
232
  ), f"search_term {search_term} does not match pattern {pattern}"
216
233
 
217
234
 
218
- def _assert_min_inclusive(search_term, min_inclusive):
235
+ def _assert_min_inclusive(search_term: str, min_inclusive: Union[str, None]) -> None:
219
236
  if not min_inclusive:
220
237
  return
221
238
 
@@ -224,7 +241,7 @@ def _assert_min_inclusive(search_term, min_inclusive):
224
241
  ), f"search_term {search_term} is less than min_inclusive {min_inclusive}"
225
242
 
226
243
 
227
- def _assert_max_inclusive(search_term, max_inclusive):
244
+ def _assert_max_inclusive(search_term: str, max_inclusive: Union[str, None]) -> None:
228
245
  if not max_inclusive:
229
246
  return
230
247
 
@@ -233,12 +250,15 @@ def _assert_max_inclusive(search_term, max_inclusive):
233
250
  ), f"search_term {search_term} is greater than max_inclusive {max_inclusive}"
234
251
 
235
252
 
236
- _describe_docs = {}
253
+ _describe_docs: Dict[str, bytes] = {}
237
254
 
238
255
 
239
- def _get_describe_doc(collection, proxies=None):
240
- if _describe_docs.get(collection):
241
- return _describe_docs.get(collection)
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
242
262
  session = Credentials.make_session(None, False, Credentials.RETRIES, proxies)
243
263
  with session.get(
244
264
  "https://catalogue.dataspace.copernicus.eu"
@@ -252,4 +272,4 @@ def _get_describe_doc(collection, proxies=None):
252
272
  )
253
273
 
254
274
  _describe_docs[collection] = res.content
255
- return _describe_docs.get(collection)
275
+ return res.content
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cdsetool
3
- Version: 0.2.10
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.33.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
20
  Requires-Dist: pylint==3.2.2; extra == "test"
21
- Requires-Dist: pytest==8.2.0; 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.10
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.33.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
9
  pylint==3.2.2
10
- pytest==8.2.0
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