ddsapi 0.6b0__tar.gz → 0.6b3__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: ddsapi
3
- Version: 0.6b0
3
+ Version: 0.6b3
4
4
  Summary: Python Client to access and download data from CMCC Data Delivery System (DDS)
5
5
  Home-page: https://github.com/CMCC-Foundation/ddsapi-client/
6
6
  Author: CMCC Data Delivery System Team
@@ -18,6 +18,10 @@ Classifier: Topic :: Scientific/Engineering :: Hydrology
18
18
  Requires-Python: >=3.7
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
+ Requires-Dist: netCDF4>=1.5.3
22
+ Requires-Dist: scipy>=1.5.2
23
+ Requires-Dist: requests>=2.23.0
24
+ Requires-Dist: xarray>=0.16.0
21
25
 
22
26
  # DDSAPI-Client
23
27
  Python Client to access and download data from [CMCC Data Delivery System (DDS)](https://dds.cmcc.it)
@@ -40,6 +44,6 @@ $ pip install ddsapi
40
44
  To use the tool a file `$HOME/.ddsapirc` must be created as following
41
45
 
42
46
  ```bash
43
- url: https://ddsapi.cmcc.it/v1
47
+ url: https://ddshub.cmcc.it/api/v2
44
48
  key: <api-key>
45
49
  ```
@@ -19,6 +19,6 @@ $ pip install ddsapi
19
19
  To use the tool a file `$HOME/.ddsapirc` must be created as following
20
20
 
21
21
  ```bash
22
- url: https://ddsapi.cmcc.it/v1
22
+ url: https://ddshub.cmcc.it/api/v2
23
23
  key: <api-key>
24
24
  ```
@@ -15,17 +15,30 @@ from __future__ import (
15
15
  division,
16
16
  print_function,
17
17
  unicode_literals,
18
+ annotations
18
19
  )
20
+ import dis
19
21
 
22
+ import pickle
20
23
  import json
21
24
  import time
22
25
  import os
23
26
  import logging
24
27
  import requests
25
28
  import zipfile
26
-
29
+ import shutil
30
+ from typing import Any, Union, Iterable
31
+ import urllib3
27
32
  import xarray as xr
28
33
 
34
+ from .cache import CacheManager
35
+
36
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
37
+
38
+ def str2bool(s: str, default: Any = False) -> bool:
39
+ if isinstance(s, str):
40
+ return s.lower() in ("1", "yes", "y", "true")
41
+ return default
29
42
 
30
43
  def bytes_to_string(n):
31
44
  u = ["", "K", "M", "G", "T", "P"]
@@ -149,6 +162,18 @@ class _Result:
149
162
  def download(self, target=None):
150
163
  self.debug("Downloading from %s", self._download_url)
151
164
  return self._download(self._download_url, target)
165
+
166
+ def _maybe_unzip(self):
167
+ if self._is_zip:
168
+ with zipfile.ZipFile(self.target_path, "r") as zip_ref:
169
+ self.target_path = self.target_path.split(".")[0]
170
+ zip_ref.extractall(self.target_path)
171
+
172
+ def get_files(self) -> Union[str, Iterable[str]]:
173
+ self._maybe_unzip()
174
+ if os.path.isdir(self.target_path):
175
+ return [os.path.join(self.target_path, f) for f in os.listdir(self.target_path)]
176
+ return self.target_path
152
177
 
153
178
  def dataset(self):
154
179
  self.debug("Location %s", self.target_path)
@@ -157,10 +182,7 @@ class _Result:
157
182
  "No file found. Downloading could have not finished yet or it"
158
183
  " failed."
159
184
  )
160
- if self._is_zip:
161
- with zipfile.ZipFile(self.target_path, "r") as zip_ref:
162
- self.target_path = self.target_path.split(".")[0]
163
- zip_ref.extractall(self.target_path)
185
+ self._maybe_unzip()
164
186
  if os.path.isdir(self.target_path):
165
187
  ds_list = []
166
188
  for f in os.listdir(self.target_path):
@@ -171,6 +193,70 @@ class _Result:
171
193
  return ds_list
172
194
 
173
195
  return xr.open_dataset(self.target_path)
196
+
197
+ class EnvVarNames:
198
+ RC_FILE: str = "DDSAPI_RC"
199
+ URL: str = "DDSAPI_URL"
200
+ KEY: str = "DDSAPI_KEY"
201
+ CACHEDIR: str = "DDSAPI_CLIENT_CACHE_DIR"
202
+ DISABLE_CACHE: str = "DDS_CACHE_DISABLE"
203
+
204
+ class Config:
205
+ """Configuration class.
206
+
207
+ The configuration defined in the `.ddsapirc` file overwrites those
208
+ defined using environmental variables."""
209
+
210
+ _RC_FILENAME: str = ".ddsapirc"
211
+ _CACHE_FILENAME: str = ".cache"
212
+ url: None | str = None
213
+ key: None | str = None
214
+ cachedir: None | str = None
215
+ verify: None | int = None
216
+
217
+ def __init__(self, url: str | None = None, key: str | None = None) -> None:
218
+ dotrc = os.environ.get(
219
+ EnvVarNames.RC_FILE,
220
+ os.path.expanduser(os.path.join("~", Config._RC_FILENAME)),
221
+ )
222
+ self._init_conf_with_env_vars()
223
+ self._maybe_overwrite_from_rc_file(dotrc)
224
+ self._maybe_apply_defaults()
225
+ self._apply_user_defined(url=url, key=key)
226
+
227
+ def _apply_user_defined(self, url, key) -> None:
228
+ if url:
229
+ self.url = url
230
+ if key:
231
+ self.key = key
232
+
233
+ def _maybe_apply_defaults(self) -> None:
234
+ if self.verify is None:
235
+ self.verify = 1
236
+ if not self.cachedir:
237
+ self.cachedir = os.path.expanduser(
238
+ os.path.join("~", Config._CACHE_FILENAME)
239
+ )
240
+
241
+ def _init_conf_with_env_vars(self) -> None:
242
+ self.url = os.environ.get(EnvVarNames.URL)
243
+ self.key = os.environ.get(EnvVarNames.KEY)
244
+ self.cachedir = os.environ.get(EnvVarNames.CACHEDIR)
245
+
246
+ def _maybe_overwrite_from_rc_file(self, rcfile: str) -> None:
247
+ if os.path.exists(rcfile):
248
+ config = read_config(rcfile)
249
+ else:
250
+ config = {}
251
+ if not self.key:
252
+ self.key = config.get("key")
253
+ if not self.url:
254
+ self.url = config.get("url")
255
+ if not self.verify:
256
+ verify_ = config.get("verify")
257
+ self.verify = int(verify_) if verify_ else None
258
+ if not self.cachedir:
259
+ self.cachedir = config.get("cachedir")
174
260
 
175
261
 
176
262
  class Client:
@@ -241,8 +327,8 @@ class Client:
241
327
 
242
328
  def __init__(
243
329
  self,
244
- url=os.environ.get("DDSAPI_URL"),
245
- key=os.environ.get("DDSAPI_KEY"),
330
+ url=None,
331
+ key=None,
246
332
  direct_path=False,
247
333
  quiet=False,
248
334
  debug=False,
@@ -267,27 +353,18 @@ class Client:
267
353
  logging.basicConfig(
268
354
  level=level, format="%(asctime)s %(levelname)s %(message)s"
269
355
  )
356
+ config = Config()
357
+ self.url = config.url
358
+ self.key = config.key
359
+ self.verify = config.verify
360
+ self.cache: CacheManager = CacheManager(
361
+ cache_dir=config.cachedir,
362
+ disabled=str2bool(os.environ.get(EnvVarNames.DISABLE_CACHE), default=True))
363
+ self.cachedir = config.cachedir
364
+
365
+ if self.url is None or self.key is None:
366
+ raise Exception("Missing/incomplete configuration file")
270
367
 
271
- dotrc = os.environ.get("DDSAPI_RC", os.path.expanduser("~/.ddsapirc"))
272
-
273
- if url is None or key is None:
274
- if os.path.exists(dotrc):
275
- config = read_config(dotrc)
276
-
277
- if key is None:
278
- key = config.get("key")
279
-
280
- if url is None:
281
- url = config.get("url")
282
-
283
- if verify is None:
284
- verify = int(config.get("verify", 1))
285
-
286
- if url is None or key is None or key is None:
287
- raise Exception(f"Missing/incomplete configuration file: {dotrc}")
288
-
289
- self.url = url
290
- self.key = key
291
368
  self.direct_path = direct_path
292
369
 
293
370
  self.quiet = quiet
@@ -562,6 +639,10 @@ class Client:
562
639
  session = self.session
563
640
  if "format" not in request:
564
641
  request["temp_file"] = target
642
+ cached_target = self.cache.maybe_get_from_cache(dataset_id=dataset_id, product_id=product_id, request=request)
643
+ if cached_target:
644
+ return cached_target
645
+
565
646
  jreply = self._submit(
566
647
  f"{self.url}/datasets/{dataset_id}/{product_id}/execute",
567
648
  request,
@@ -616,14 +697,29 @@ class Client:
616
697
  result = _Result(
617
698
  self, request_id=request_id, auth_token=self.auth_token
618
699
  )
700
+ import tempfile
701
+ if target is None:
702
+ _target = tempfile.NamedTemporaryFile(delete=False).name
703
+ else:
704
+ _target = target
705
+ try:
706
+ result.download(_target)
707
+ self.cache.add_to_cache(
708
+ dataset_id=dataset_id,
709
+ product_id=product_id,
710
+ request=request,
711
+ target=result.get_files(), overwrite=False)
712
+ except RuntimeError as err:
713
+ self.logger.error(str(err))
714
+ return
715
+
619
716
  if target is not None:
620
- try:
621
- result.download(target)
622
- except RuntimeError as err:
623
- self.logger.error(str(err))
624
- return
625
717
  return result
626
- return result.dataset()
718
+ else:
719
+ if self.cache.disabled:
720
+ return result.dataset()
721
+ else:
722
+ return self.cache.maybe_get_from_cache(dataset_id=dataset_id, product_id=product_id, request=request)
627
723
 
628
724
  if msg["status"] == "RUNNING" or msg["status"] == "PENDING":
629
725
  if self.timeout and (time.time() - start > self.timeout):
@@ -732,10 +828,14 @@ class Client:
732
828
  if res is not None:
733
829
  if not retriable(res.status_code, res.reason):
734
830
  return res
831
+ try:
832
+ text = res.json()['detail']
833
+ except requests.exceptions.JSONDecodeError:
834
+ text = res.text
735
835
  self.warning(
736
836
  "Recovering from HTTP error [%s %s], attemps %s of %s",
737
837
  res.status_code,
738
- res.json()["detail"],
838
+ text,
739
839
  tries,
740
840
  self.retry_max,
741
841
  )
@@ -0,0 +1,107 @@
1
+ """This module contains simple query-hash-based cache mechanism."""
2
+
3
+ import os
4
+ import json
5
+ from typing import Mapping, Optional, Iterable, Union
6
+ from uuid import uuid4
7
+ from dataclasses import dataclass, field
8
+ import pickle
9
+ import shutil
10
+
11
+ import xarray as xr
12
+ import hashlib
13
+
14
+
15
+ @dataclass
16
+ class CacheManager:
17
+ cache_dir: str = field(kw_only=True)
18
+ disabled: bool = field(default=False, init=True, kw_only=True)
19
+ cache_config: str = field(default=".config", init=False)
20
+ _cache: Mapping[str, str] = field(init=False)
21
+
22
+ def __post_init__(self):
23
+ self._init()
24
+ self._cache = self._read_cache() if not self.disabled else {}
25
+
26
+ def _init(self) -> None:
27
+ self._cache = {}
28
+ if not os.path.exists(self.cache_dir):
29
+ os.makedirs(self.cache_dir)
30
+ if os.path.exists(os.path.join(self.cache_dir, self.cache_config)):
31
+ return
32
+ self._update_cache_config()
33
+
34
+ def _update_cache_config(self) -> None:
35
+ with open(os.path.join(self.cache_dir, self.cache_config), "wb") as file:
36
+ pickle.dump(self._cache, file=file)
37
+
38
+ def _read_cache(self) -> dict:
39
+ with open(os.path.join(self.cache_dir, self.cache_config), "rb") as file:
40
+ return pickle.load(file)
41
+
42
+ def _compute_hash(
43
+ self, dataset_id: str, product_id: str, request: dict
44
+ ) -> int:
45
+ return hashlib.sha256(
46
+ json.dumps(
47
+ dict(**{"dataset_id": dataset_id, "product_id": product_id}, **request)
48
+ ).encode()
49
+ ).hexdigest()
50
+
51
+ def _maybe_get_file_from_cache(
52
+ self, dataset_id: str, product_id: str, request: dict
53
+ ) -> Optional[str]:
54
+ query_hash: int = self._compute_hash(dataset_id, product_id, request)
55
+ return self._cache.get(query_hash)
56
+
57
+ def add_to_cache(
58
+ self,
59
+ dataset_id: str,
60
+ product_id: str,
61
+ request: dict,
62
+ target: Union[Iterable[str], str],
63
+ *,
64
+ overwrite: bool = False,
65
+ ) -> None:
66
+ if self.disabled:
67
+ return
68
+ query_hash: int = self._compute_hash(dataset_id, product_id, request)
69
+ if query_hash in self._cache and not overwrite:
70
+ return
71
+ if isinstance(target, list):
72
+ cache_files = []
73
+ for f in target:
74
+ _, ext = os.path.splitext(f)
75
+ res_file = f"{uuid4()}{ext}"
76
+ cache_files.append(res_file)
77
+ shutil.move(f, os.path.join(self.cache_dir, res_file))
78
+ self._cache[query_hash] = cache_files
79
+ elif isinstance(target, str):
80
+ _, ext = os.path.splitext(target)
81
+ res_file = f"{uuid4()}{ext}"
82
+ shutil.move(target, os.path.join(self.cache_dir, res_file))
83
+ self._cache[query_hash] = res_file
84
+ self._update_cache_config()
85
+
86
+ def maybe_get_from_cache(
87
+ self, dataset_id: str, product_id: str, request: dict
88
+ ) -> Union[xr.Dataset, Iterable[xr.Dataset], None]:
89
+ target: Union[str, Iterable[str]] = self._maybe_get_file_from_cache(
90
+ dataset_id=dataset_id, product_id=product_id, request=request
91
+ )
92
+ if self.disabled:
93
+ return
94
+ if not target:
95
+ return
96
+ if isinstance(target, list):
97
+ ds_list = []
98
+ for f in target:
99
+ ds = xr.open_dataset(os.path.join(self.cache_dir, f))
100
+ ds_list.append(ds)
101
+ if len(ds_list) == 1:
102
+ return ds_list[0]
103
+ return ds_list
104
+ elif isinstance(target, str):
105
+ return xr.open_dataset(os.path.join(self.cache_dir, target))
106
+ else:
107
+ raise TypeError
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ddsapi
3
- Version: 0.6b0
3
+ Version: 0.6b3
4
4
  Summary: Python Client to access and download data from CMCC Data Delivery System (DDS)
5
5
  Home-page: https://github.com/CMCC-Foundation/ddsapi-client/
6
6
  Author: CMCC Data Delivery System Team
@@ -18,6 +18,10 @@ Classifier: Topic :: Scientific/Engineering :: Hydrology
18
18
  Requires-Python: >=3.7
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
+ Requires-Dist: netCDF4>=1.5.3
22
+ Requires-Dist: scipy>=1.5.2
23
+ Requires-Dist: requests>=2.23.0
24
+ Requires-Dist: xarray>=0.16.0
21
25
 
22
26
  # DDSAPI-Client
23
27
  Python Client to access and download data from [CMCC Data Delivery System (DDS)](https://dds.cmcc.it)
@@ -40,6 +44,6 @@ $ pip install ddsapi
40
44
  To use the tool a file `$HOME/.ddsapirc` must be created as following
41
45
 
42
46
  ```bash
43
- url: https://ddsapi.cmcc.it/v1
47
+ url: https://ddshub.cmcc.it/api/v2
44
48
  key: <api-key>
45
49
  ```
@@ -3,8 +3,11 @@ README.md
3
3
  setup.py
4
4
  ddsapi/__init__.py
5
5
  ddsapi/api.py
6
+ ddsapi/cache.py
6
7
  ddsapi.egg-info/PKG-INFO
7
8
  ddsapi.egg-info/SOURCES.txt
8
9
  ddsapi.egg-info/dependency_links.txt
9
10
  ddsapi.egg-info/requires.txt
10
- ddsapi.egg-info/top_level.txt
11
+ ddsapi.egg-info/top_level.txt
12
+ tests/__init__.py
13
+ tests/test_config.py
@@ -1,4 +1,4 @@
1
1
  netCDF4>=1.5.3
2
2
  scipy>=1.5.2
3
3
  requests>=2.23.0
4
- xarray==0.16.0
4
+ xarray>=0.16.0
@@ -1 +1,2 @@
1
1
  ddsapi
2
+ tests
@@ -17,7 +17,7 @@ with open("README.md", "r") as f:
17
17
 
18
18
  setuptools.setup(
19
19
  name="ddsapi",
20
- version="0.6b0",
20
+ version="0.6b3",
21
21
  author="CMCC Data Delivery System Team",
22
22
  author_email="dds-support@cmcc.it",
23
23
  description=(
@@ -32,7 +32,7 @@ setuptools.setup(
32
32
  "netCDF4>=1.5.3",
33
33
  "scipy>=1.5.2",
34
34
  "requests>=2.23.0",
35
- "xarray==0.16.0",
35
+ "xarray>=0.16.0",
36
36
  ],
37
37
  classifiers=[
38
38
  "Development Status :: 4 - Beta",
File without changes
@@ -0,0 +1,88 @@
1
+ import os
2
+
3
+ import pytest
4
+ from unittest import mock
5
+
6
+ from ddsapi.api import Config, EnvVarNames
7
+
8
+
9
+ def _read_config_mock(*ar) -> dict:
10
+ return {
11
+ "url": "url_from_file",
12
+ "key": "key_from_file",
13
+ "verify": "0",
14
+ "cachedir": "cachedir_from_file",
15
+ }
16
+
17
+
18
+ def _read_config_mock2(*ar) -> dict:
19
+ return {
20
+ "url": "url_from_file",
21
+ "key": "key_from_file",
22
+ "verify": "0",
23
+ }
24
+
25
+
26
+ @mock.patch.dict(os.environ, {EnvVarNames.URL: "dds-url"})
27
+ @mock.patch("ddsapi.api.read_config", return_value={})
28
+ def test_get_url_from_env_var(read_config):
29
+ conf = Config()
30
+ assert conf.url == "dds-url"
31
+ assert conf.key is None
32
+ assert conf.cachedir == os.path.expanduser(
33
+ os.path.join("~", Config._CACHE_FILENAME)
34
+ )
35
+ assert conf.verify == 1
36
+
37
+
38
+ @mock.patch.dict(os.environ, {EnvVarNames.KEY: "dds-key"})
39
+ @mock.patch("ddsapi.api.read_config", return_value={})
40
+ def test_get_key_from_env_var(read_config):
41
+ conf = Config()
42
+ assert conf.key == "dds-key"
43
+ assert conf.url is None
44
+ assert conf.cachedir == os.path.expanduser(
45
+ os.path.join("~", Config._CACHE_FILENAME)
46
+ )
47
+ assert conf.verify == 1
48
+
49
+
50
+ @mock.patch.dict(os.environ, {EnvVarNames.CACHEDIR: "dds-cache"})
51
+ @mock.patch("ddsapi.api.read_config", return_value={})
52
+ def test_get_cache_from_env_var(read_config):
53
+ conf = Config()
54
+ assert conf.cachedir == "dds-cache"
55
+ assert conf.key is None
56
+ assert conf.url is None
57
+ assert conf.verify == 1
58
+
59
+
60
+ @mock.patch("ddsapi.api.read_config", _read_config_mock)
61
+ def test_read_conf_from_rc_file():
62
+ conf = Config()
63
+ assert conf.key == "key_from_file"
64
+ assert conf.url == "url_from_file"
65
+ assert conf.cachedir == "cachedir_from_file"
66
+ assert conf.verify == 0
67
+
68
+
69
+ @mock.patch.dict(os.environ, {EnvVarNames.CACHEDIR: "dds-cache"})
70
+ @mock.patch("ddsapi.api.read_config", _read_config_mock2)
71
+ def test_conf_from_file_overwrites():
72
+ conf = Config()
73
+ assert conf.key == "key_from_file"
74
+ assert conf.url == "url_from_file"
75
+ assert conf.cachedir == "dds-cache"
76
+ assert conf.verify == 0
77
+
78
+
79
+ @mock.patch.dict(os.environ, {EnvVarNames.KEY: "key0"})
80
+ @mock.patch("ddsapi.api.read_config", return_value={"key": "key1"})
81
+ def test_user_defined_overwrites_all(read_config):
82
+ conf = Config(key="key2")
83
+ assert conf.key == "key2"
84
+ assert conf.url is None
85
+ assert conf.cachedir == os.path.expanduser(
86
+ os.path.join("~", Config._CACHE_FILENAME)
87
+ )
88
+ assert conf.verify == 1
File without changes
File without changes
File without changes