ecmwf-datastores-client 0.1.0__py3-none-any.whl

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.

Potentially problematic release.


This version of ecmwf-datastores-client might be problematic. Click here for more details.

@@ -0,0 +1,39 @@
1
+ """ECMWF Data Stores Service (DSS) API Python client."""
2
+
3
+ # Copyright 2022, European Union.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ try:
18
+ # NOTE: the `version.py` file must not be present in the git repository
19
+ # as it is generated by setuptools at install time
20
+ from ecmwf.datastores.version import __version__
21
+ except ImportError: # pragma: no cover
22
+ # Local copy or not installed with setuptools
23
+ __version__ = "999"
24
+
25
+ from .catalogue import Collection, Collections
26
+ from .client import Client
27
+ from .processing import Jobs, Process, Processes, Remote, Results
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ "Client",
32
+ "Collection",
33
+ "Collections",
34
+ "Jobs",
35
+ "Process",
36
+ "Processes",
37
+ "Remote",
38
+ "Results",
39
+ ]
@@ -0,0 +1,206 @@
1
+ # Copyright 2022, European Union.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import warnings
19
+ from typing import Any, Callable
20
+
21
+ import attrs
22
+ import requests
23
+
24
+ from ecmwf import datastores
25
+ from ecmwf.datastores import config, utils
26
+ from ecmwf.datastores.processing import ApiResponse, ApiResponsePaginated, RequestKwargs
27
+
28
+
29
+ @attrs.define
30
+ class Collections(ApiResponsePaginated):
31
+ """A class to interact with catalogue collections."""
32
+
33
+ @property
34
+ def collection_ids(self) -> list[str]:
35
+ """List of collection IDs."""
36
+ return [collection["id"] for collection in self._json_dict["collections"]]
37
+
38
+
39
+ @attrs.define
40
+ class Collection(ApiResponse):
41
+ """A class to interact with a catalogue collection."""
42
+
43
+ @property
44
+ def begin_datetime(self) -> datetime.datetime | None:
45
+ """Begin datetime of the collection."""
46
+ if (value := self._json_dict["extent"]["temporal"]["interval"][0][0]) is None:
47
+ return value
48
+ return utils.string_to_datetime(value)
49
+
50
+ @property
51
+ def end_datetime(self) -> datetime.datetime | None:
52
+ """End datetime of the collection."""
53
+ if (value := self._json_dict["extent"]["temporal"]["interval"][0][1]) is None:
54
+ return value
55
+ return utils.string_to_datetime(value)
56
+
57
+ @property
58
+ def published_at(self) -> datetime.datetime:
59
+ """When the collection was first published."""
60
+ return utils.string_to_datetime(self._json_dict["published"])
61
+
62
+ @property
63
+ def updated_at(self) -> datetime.datetime:
64
+ """When the collection was last updated."""
65
+ return utils.string_to_datetime(self._json_dict["updated"])
66
+
67
+ @property
68
+ def title(self) -> str:
69
+ """Title of the collection."""
70
+ value = self._json_dict["title"]
71
+ assert isinstance(value, str)
72
+ return value
73
+
74
+ @property
75
+ def description(self) -> str:
76
+ """Description of the collection."""
77
+ value = self._json_dict["description"]
78
+ assert isinstance(value, str)
79
+ return value
80
+
81
+ @property
82
+ def bbox(self) -> tuple[float, float, float, float]:
83
+ """Bounding box of the collection (W, S, E, N)."""
84
+ return tuple(self._json_dict["extent"]["spatial"]["bbox"][0])
85
+
86
+ @property
87
+ def id(self) -> str:
88
+ """Collection ID."""
89
+ return str(self._json_dict["id"])
90
+
91
+ @property
92
+ def _process(self) -> datastores.Process:
93
+ url = self._get_link_href(rel="retrieve")
94
+ return datastores.Process.from_request("get", url, **self._request_kwargs)
95
+
96
+ @property
97
+ def process(self) -> datastores.Process:
98
+ warnings.warn(
99
+ "`process` has been deprecated, and in the future will raise an error."
100
+ "Please use `submit` and `apply_constraints` from now on.",
101
+ DeprecationWarning,
102
+ stacklevel=2,
103
+ )
104
+ return self._process
105
+
106
+ @property
107
+ def form(self) -> list[dict[str, Any]]:
108
+ url = f"{self.url}/form.json"
109
+ return ApiResponse.from_request(
110
+ "get", url, log_messages=False, **self._request_kwargs
111
+ )._json_list
112
+
113
+ @property
114
+ def constraints(self) -> list[dict[str, Any]]:
115
+ url = f"{self.url}/constraints.json"
116
+ return ApiResponse.from_request(
117
+ "get", url, log_messages=False, **self._request_kwargs
118
+ )._json_list
119
+
120
+ def submit(self, request: dict[str, Any]) -> datastores.Remote:
121
+ """Submit a request.
122
+
123
+ Parameters
124
+ ----------
125
+ request: dict[str,Any]
126
+ Request parameters.
127
+
128
+ Returns
129
+ -------
130
+ datastores.Remote
131
+ """
132
+ return self._process.submit(request)
133
+
134
+ def apply_constraints(self, request: dict[str, Any]) -> dict[str, Any]:
135
+ """Apply constraints to the parameters in a request.
136
+
137
+ Parameters
138
+ ----------
139
+ request: dict[str,Any]
140
+ Request parameters.
141
+
142
+ Returns
143
+ -------
144
+ dict[str,Any]
145
+ Dictionary of valid values.
146
+ """
147
+ return self._process.apply_constraints(request)
148
+
149
+ def estimate_costs(self, request: dict[str, Any]) -> dict[str, Any]:
150
+ return self._process.estimate_costs(request)
151
+
152
+
153
+ @attrs.define(slots=False)
154
+ class Catalogue:
155
+ url: str
156
+ headers: dict[str, Any]
157
+ session: requests.Session
158
+ retry_options: dict[str, Any]
159
+ request_options: dict[str, Any]
160
+ download_options: dict[str, Any]
161
+ sleep_max: float
162
+ cleanup: bool
163
+ log_callback: Callable[..., None] | None
164
+ force_exact_url: bool = False
165
+
166
+ def __attrs_post_init__(self) -> None:
167
+ if not self.force_exact_url:
168
+ self.url += f"/{config.SUPPORTED_API_VERSION}"
169
+
170
+ @property
171
+ def _request_kwargs(self) -> RequestKwargs:
172
+ return RequestKwargs(
173
+ headers=self.headers,
174
+ session=self.session,
175
+ retry_options=self.retry_options,
176
+ request_options=self.request_options,
177
+ download_options=self.download_options,
178
+ sleep_max=self.sleep_max,
179
+ cleanup=self.cleanup,
180
+ log_callback=self.log_callback,
181
+ )
182
+
183
+ def get_collections(self, search_stats: bool = False, **params: Any) -> Collections:
184
+ url = f"{self.url}/datasets"
185
+ params["search_stats"] = search_stats
186
+ return Collections.from_request(
187
+ "get", url, params=params, **self._request_kwargs
188
+ )
189
+
190
+ def get_collection(self, collection_id: str) -> Collection:
191
+ url = f"{self.url}/collections/{collection_id}"
192
+ return Collection.from_request("get", url, **self._request_kwargs)
193
+
194
+ def get_licenses(self, **params: Any) -> dict[str, Any]:
195
+ url = f"{self.url}/vocabularies/licences"
196
+ response = ApiResponse.from_request(
197
+ "get", url, params=params, **self._request_kwargs
198
+ )
199
+ return response._json_dict
200
+
201
+ @property
202
+ def messages(self) -> ApiResponse:
203
+ url = f"{self.url}/messages"
204
+ return ApiResponse.from_request(
205
+ "get", url, log_messages=False, **self._request_kwargs
206
+ )
@@ -0,0 +1,400 @@
1
+ # Copyright 2022, European Union.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import warnings
19
+ from typing import Any, Callable, Literal
20
+
21
+ import attrs
22
+ import multiurl.base
23
+ import requests
24
+
25
+ from ecmwf import datastores
26
+ from ecmwf.datastores import config
27
+ from ecmwf.datastores.catalogue import Catalogue
28
+ from ecmwf.datastores.processing import Processing, RequestKwargs
29
+ from ecmwf.datastores.profile import Profile
30
+
31
+
32
+ @attrs.define(slots=False)
33
+ class Client:
34
+ """ECMWF Data Stores Service (DSS) API Python client.
35
+
36
+ Parameters
37
+ ----------
38
+ url: str or None, default: None
39
+ API URL. If None, infer from ECMWF_DATASTORES_URL or ECMWF_DATASTORES_RC_FILE.
40
+ key: str or None, default: None
41
+ API Key. If None, infer from ECMWF_DATASTORES_KEY or ECMWF_DATASTORES_RC_FILE.
42
+ verify: bool, default: True
43
+ Whether to verify the TLS certificate at the remote end.
44
+ timeout: float or tuple[float,float], default: 60
45
+ How many seconds to wait for the server to send data, as a float, or a (connect, read) tuple.
46
+ progress: bool, default: True
47
+ Whether to display the progress bar during download.
48
+ cleanup: bool, default: False
49
+ Whether to delete requests after completion.
50
+ sleep_max: float, default: 120
51
+ Maximum time to wait (in seconds) while checking for a status change.
52
+ retry_after: float, default: 120
53
+ Time to wait (in seconds) between retries.
54
+ maximum_tries: int, default: 500
55
+ Maximum number of retries.
56
+ session: requests.Session
57
+ Requests session.
58
+ """
59
+
60
+ url: str | None = None
61
+ key: str | None = None
62
+ verify: bool = True
63
+ timeout: float | tuple[float, float] = 60
64
+ progress: bool = True
65
+ cleanup: bool = False
66
+ sleep_max: float = 120
67
+ retry_after: float = 120
68
+ maximum_tries: int = 500
69
+ session: requests.Session = attrs.field(factory=requests.Session)
70
+ _log_callback: Callable[..., None] | None = None
71
+
72
+ def __attrs_post_init__(self) -> None:
73
+ if self.url is None:
74
+ self.url = str(config.get_config("url"))
75
+
76
+ if self.key is None:
77
+ try:
78
+ self.key = str(config.get_config("key"))
79
+ except (KeyError, FileNotFoundError):
80
+ warnings.warn("The API key is missing", UserWarning)
81
+
82
+ try:
83
+ self._catalogue_api.messages.log_messages()
84
+ except Exception as exc:
85
+ warnings.warn(str(exc), UserWarning)
86
+
87
+ def _get_headers(self, key_is_mandatory: bool = True) -> dict[str, str]:
88
+ headers = {"User-Agent": f"ecmwf-datastores-client/{datastores.__version__}"}
89
+ if self.key is not None:
90
+ headers["PRIVATE-TOKEN"] = self.key
91
+ elif key_is_mandatory:
92
+ raise ValueError("The API key is needed to access this resource")
93
+ return headers
94
+
95
+ @property
96
+ def _retry_options(self) -> dict[str, Any]:
97
+ return {
98
+ "maximum_tries": self.maximum_tries,
99
+ "retry_after": self.retry_after,
100
+ }
101
+
102
+ @property
103
+ def _download_options(self) -> dict[str, Any]:
104
+ progress_bar = (
105
+ multiurl.base.progress_bar if self.progress else multiurl.base.NoBar
106
+ )
107
+ return {
108
+ "progress_bar": progress_bar,
109
+ }
110
+
111
+ @property
112
+ def _request_options(self) -> dict[str, Any]:
113
+ return {
114
+ "timeout": self.timeout,
115
+ "verify": self.verify,
116
+ }
117
+
118
+ def _get_request_kwargs(self, key_is_mandatory: bool = True) -> RequestKwargs:
119
+ return RequestKwargs(
120
+ headers=self._get_headers(key_is_mandatory=key_is_mandatory),
121
+ session=self.session,
122
+ retry_options=self._retry_options,
123
+ request_options=self._request_options,
124
+ download_options=self._download_options,
125
+ sleep_max=self.sleep_max,
126
+ cleanup=self.cleanup,
127
+ log_callback=self._log_callback,
128
+ )
129
+
130
+ @functools.cached_property
131
+ def _catalogue_api(self) -> Catalogue:
132
+ return Catalogue(
133
+ f"{self.url}/catalogue",
134
+ **self._get_request_kwargs(key_is_mandatory=False),
135
+ )
136
+
137
+ @functools.cached_property
138
+ def _retrieve_api(self) -> Processing:
139
+ return Processing(f"{self.url}/retrieve", **self._get_request_kwargs())
140
+
141
+ @functools.cached_property
142
+ def _profile_api(self) -> Profile:
143
+ return Profile(f"{self.url}/profiles", **self._get_request_kwargs())
144
+
145
+ def accept_licence(self, licence_id: str, revision: int) -> dict[str, Any]:
146
+ return self._profile_api.accept_licence(licence_id, revision=revision)
147
+
148
+ def apply_constraints(
149
+ self, collection_id: str, request: dict[str, Any]
150
+ ) -> dict[str, Any]:
151
+ """Apply constraints to the parameters in a request.
152
+
153
+ Parameters
154
+ ----------
155
+ collection_id: str
156
+ Collection ID (e.g., ``"projections-cmip6"``).
157
+ request: dict[str,Any]
158
+ Request parameters.
159
+
160
+ Returns
161
+ -------
162
+ dict[str,Any]
163
+ Dictionary of valid values.
164
+ """
165
+ return self.get_process(collection_id).apply_constraints(request)
166
+
167
+ def check_authentication(self) -> dict[str, Any]:
168
+ """Verify authentication.
169
+
170
+ Returns
171
+ -------
172
+ dict[str,Any]
173
+ Content of the response.
174
+
175
+ Raises
176
+ ------
177
+ requests.HTTPError
178
+ If the authentication fails.
179
+ """
180
+ return self._profile_api.check_authentication()
181
+
182
+ def download_results(self, request_id: str, target: str | None = None) -> str:
183
+ """Download the results of a request.
184
+
185
+ Parameters
186
+ ----------
187
+ request_id: str
188
+ Request ID.
189
+ target: str or None
190
+ Target path. If None, download to the working directory.
191
+
192
+ Returns
193
+ -------
194
+ str
195
+ Path to the retrieved file.
196
+ """
197
+ return self.get_remote(request_id).download(target)
198
+
199
+ def estimate_costs(self, collection_id: str, request: Any) -> dict[str, Any]:
200
+ return self.get_process(collection_id).estimate_costs(request)
201
+
202
+ def get_accepted_licences(
203
+ self,
204
+ scope: Literal[None, "all", "dataset", "portal"] = None,
205
+ ) -> list[dict[str, Any]]:
206
+ params = {k: v for k, v in zip(["scope"], [scope]) if v is not None}
207
+ licences: list[dict[str, Any]]
208
+ licences = self._profile_api.accepted_licences(**params).get("licences", [])
209
+ return licences
210
+
211
+ def get_collection(self, collection_id: str) -> datastores.Collection:
212
+ """Retrieve a catalogue collection.
213
+
214
+ Parameters
215
+ ----------
216
+ collection_id: str
217
+ Collection ID (e.g., ``"projections-cmip6"``).
218
+
219
+ Returns
220
+ -------
221
+ datastores.Collection
222
+ """
223
+ return self._catalogue_api.get_collection(collection_id)
224
+
225
+ def get_collections(
226
+ self,
227
+ limit: int | None = None,
228
+ sortby: Literal[None, "id", "relevance", "title", "update"] = None,
229
+ query: str | None = None,
230
+ keywords: list[str] | None = None,
231
+ ) -> datastores.Collections:
232
+ """Retrieve catalogue collections.
233
+
234
+ Parameters
235
+ ----------
236
+ limit: int | None
237
+ Number of processes per page.
238
+ sortby: {None, 'id', 'relevance', 'title', 'update'}
239
+ Field to sort results by.
240
+ query: str or None
241
+ Full-text search query.
242
+ keywords: list[str] or None
243
+ Filter by keywords.
244
+
245
+ Returns
246
+ -------
247
+ datastores.Collections
248
+ """
249
+ params: dict[str, Any] = {
250
+ k: v
251
+ for k, v in zip(
252
+ ["limit", "sortby", "q", "kw"], [limit, sortby, query, keywords]
253
+ )
254
+ if v is not None
255
+ }
256
+ return self._catalogue_api.get_collections(**params)
257
+
258
+ def get_jobs(
259
+ self,
260
+ limit: int | None = None,
261
+ sortby: Literal[None, "created", "-created"] = None,
262
+ status: Literal[None, "accepted", "running", "successful", "failed"] = None,
263
+ ) -> datastores.Jobs:
264
+ """Retrieve submitted jobs.
265
+
266
+ Parameters
267
+ ----------
268
+ limit: int or None
269
+ Number of processes per page.
270
+ sortby: {None, 'created', '-created'}
271
+ Field to sort results by.
272
+ status: {None, 'accepted', 'running', 'successful', 'failed'}
273
+ Status of the results.
274
+
275
+ Returns
276
+ -------
277
+ datastores.Jobs
278
+ """
279
+ params = {
280
+ k: v
281
+ for k, v in zip(["limit", "sortby", "status"], [limit, sortby, status])
282
+ if v is not None
283
+ }
284
+ return self._retrieve_api.get_jobs(**params)
285
+
286
+ def get_licences(
287
+ self,
288
+ scope: Literal[None, "all", "dataset", "portal"] = None,
289
+ ) -> list[dict[str, Any]]:
290
+ params = {k: v for k, v in zip(["scope"], [scope]) if v is not None}
291
+ licences: list[dict[str, Any]]
292
+ licences = self._catalogue_api.get_licenses(**params).get("licences", [])
293
+ return licences
294
+
295
+ def get_process(self, collection_id: str) -> datastores.Process:
296
+ return self._retrieve_api.get_process(collection_id)
297
+
298
+ def get_processes(
299
+ self,
300
+ limit: int | None = None,
301
+ sortby: Literal[None, "id", "-id"] = None,
302
+ ) -> datastores.Processes:
303
+ params = {
304
+ k: v for k, v in zip(["limit", "sortby"], [limit, sortby]) if v is not None
305
+ }
306
+ return self._retrieve_api.get_processes(**params)
307
+
308
+ def get_remote(self, request_id: str) -> datastores.Remote:
309
+ """
310
+ Retrieve the remote object of a request.
311
+
312
+ Parameters
313
+ ----------
314
+ request_id: str
315
+ Request ID.
316
+
317
+ Returns
318
+ -------
319
+ datastores.Remote
320
+ """
321
+ return self._retrieve_api.get_job(request_id).get_remote()
322
+
323
+ def get_results(self, request_id: str) -> datastores.Results:
324
+ """
325
+ Retrieve the results of a request.
326
+
327
+ Parameters
328
+ ----------
329
+ request_id: str
330
+ Request ID.
331
+
332
+ Returns
333
+ -------
334
+ datastores.Results
335
+ """
336
+ return self.get_remote(request_id).get_results()
337
+
338
+ def retrieve(
339
+ self,
340
+ collection_id: str,
341
+ request: dict[str, Any],
342
+ target: str | None = None,
343
+ ) -> str:
344
+ """Submit a request and retrieve the results.
345
+
346
+ Parameters
347
+ ----------
348
+ collection_id: str
349
+ Collection ID (e.g., ``"projections-cmip6"``).
350
+ request: dict[str,Any]
351
+ Request parameters.
352
+ target: str or None
353
+ Target path. If None, download to the working directory.
354
+
355
+ Returns
356
+ -------
357
+ str
358
+ Path to the retrieved file.
359
+ """
360
+ return self.submit(collection_id, request).download(target)
361
+
362
+ def star_collection(self, collection_id: str) -> list[str]:
363
+ return self._profile_api.star_collection(collection_id)
364
+
365
+ def submit(self, collection_id: str, request: dict[str, Any]) -> datastores.Remote:
366
+ """Submit a request.
367
+
368
+ Parameters
369
+ ----------
370
+ collection_id: str
371
+ Collection ID (e.g., ``"projections-cmip6"``).
372
+ request: dict[str,Any]
373
+ Request parameters.
374
+
375
+ Returns
376
+ -------
377
+ datastores.Remote
378
+ """
379
+ return self._retrieve_api.submit(collection_id, request)
380
+
381
+ def submit_and_wait_on_results(
382
+ self, collection_id: str, request: dict[str, Any]
383
+ ) -> datastores.Results:
384
+ """Submit a request and wait for the results to be ready.
385
+
386
+ Parameters
387
+ ----------
388
+ collection_id: str
389
+ Collection ID (e.g., ``"projections-cmip6"``).
390
+ request: dict[str,Any]
391
+ Request parameters.
392
+
393
+ Returns
394
+ -------
395
+ datastores.Results
396
+ """
397
+ return self._retrieve_api.submit(collection_id, request).get_results()
398
+
399
+ def unstar_collection(self, collection_id: str) -> None:
400
+ return self._profile_api.unstar_collection(collection_id)
@@ -0,0 +1,45 @@
1
+ # Copyright 2022, European Union.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ SUPPORTED_API_VERSION = "v1"
20
+ CONFIG_PREFIX = "ecmwf_datastores"
21
+
22
+
23
+ def read_config(path: str | None = None) -> dict[str, str]:
24
+ if path is None:
25
+ path = os.getenv(
26
+ f"{CONFIG_PREFIX}_RC_FILE".upper(),
27
+ f"~/.{CONFIG_PREFIX}rc".replace("_", "").lower(),
28
+ )
29
+ path = os.path.expanduser(path)
30
+ try:
31
+ config = {}
32
+ with open(path) as f:
33
+ for line in f.readlines():
34
+ if ":" in line:
35
+ key, value = line.strip().split(":", 1)
36
+ config[key] = value.strip()
37
+ return config
38
+ except FileNotFoundError:
39
+ raise
40
+ except Exception:
41
+ raise ValueError(f"Failed to parse {path!r} file")
42
+
43
+
44
+ def get_config(key: str, config_path: str | None = None) -> str:
45
+ return os.getenv(f"{CONFIG_PREFIX}_{key}".upper()) or read_config(config_path)[key]