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.
- ecmwf/datastores/__init__.py +39 -0
- ecmwf/datastores/catalogue.py +206 -0
- ecmwf/datastores/client.py +400 -0
- ecmwf/datastores/config.py +45 -0
- ecmwf/datastores/legacy_client.py +275 -0
- ecmwf/datastores/processing.py +793 -0
- ecmwf/datastores/profile.py +87 -0
- ecmwf/datastores/py.typed +0 -0
- ecmwf/datastores/utils.py +9 -0
- ecmwf/datastores/version.py +2 -0
- ecmwf_datastores_client-0.1.0.dist-info/METADATA +531 -0
- ecmwf_datastores_client-0.1.0.dist-info/RECORD +15 -0
- ecmwf_datastores_client-0.1.0.dist-info/WHEEL +5 -0
- ecmwf_datastores_client-0.1.0.dist-info/licenses/LICENSE +201 -0
- ecmwf_datastores_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,793 @@
|
|
|
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 functools
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import warnings
|
|
24
|
+
from typing import Any, Callable, Type, TypedDict, TypeVar
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from typing import Self
|
|
28
|
+
except ImportError:
|
|
29
|
+
from typing_extensions import Self
|
|
30
|
+
|
|
31
|
+
import attrs
|
|
32
|
+
import multiurl
|
|
33
|
+
import requests
|
|
34
|
+
|
|
35
|
+
from ecmwf import datastores
|
|
36
|
+
from ecmwf.datastores import config, utils
|
|
37
|
+
|
|
38
|
+
T_ApiResponse = TypeVar("T_ApiResponse", bound="ApiResponse")
|
|
39
|
+
|
|
40
|
+
LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
LEVEL_NAMES_MAPPING = {
|
|
43
|
+
"CRITICAL": 50,
|
|
44
|
+
"FATAL": 50,
|
|
45
|
+
"ERROR": 40,
|
|
46
|
+
"WARNING": 30,
|
|
47
|
+
"WARN": 30,
|
|
48
|
+
"INFO": 20,
|
|
49
|
+
"DEBUG": 10,
|
|
50
|
+
"NOTSET": 0,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RequestKwargs(TypedDict):
|
|
55
|
+
headers: dict[str, str]
|
|
56
|
+
session: requests.Session
|
|
57
|
+
retry_options: dict[str, Any]
|
|
58
|
+
request_options: dict[str, Any]
|
|
59
|
+
download_options: dict[str, Any]
|
|
60
|
+
sleep_max: float
|
|
61
|
+
cleanup: bool
|
|
62
|
+
log_callback: Callable[..., None] | None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ProcessingFailedError(RuntimeError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DownloadError(RuntimeError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LinkError(Exception):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def error_json_to_message(error_json: dict[str, Any]) -> str:
|
|
78
|
+
error_messages = [
|
|
79
|
+
str(error_json[key])
|
|
80
|
+
for key in ("title", "traceback", "detail")
|
|
81
|
+
if key in error_json
|
|
82
|
+
]
|
|
83
|
+
return "\n".join(error_messages)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cads_raise_for_status(response: requests.Response) -> None:
|
|
87
|
+
if 400 <= response.status_code < 500:
|
|
88
|
+
try:
|
|
89
|
+
error_json = response.json()
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
else:
|
|
93
|
+
message = "\n".join(
|
|
94
|
+
[
|
|
95
|
+
f"{response.status_code} Client Error: {response.reason} for url: {response.url}",
|
|
96
|
+
error_json_to_message(error_json),
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
raise requests.HTTPError(message, response=response)
|
|
100
|
+
response.raise_for_status()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_level_and_message(message: str) -> tuple[int, str]:
|
|
104
|
+
level = 20
|
|
105
|
+
for severity in LEVEL_NAMES_MAPPING:
|
|
106
|
+
if message.startswith(severity):
|
|
107
|
+
level = LEVEL_NAMES_MAPPING[severity]
|
|
108
|
+
message = message.replace(severity, "", 1).lstrip(":").lstrip()
|
|
109
|
+
break
|
|
110
|
+
return level, message
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def log(*args: Any, callback: Callable[..., None] | None = None, **kwargs: Any) -> None:
|
|
114
|
+
if callback is None:
|
|
115
|
+
LOGGER.log(*args, **kwargs)
|
|
116
|
+
else:
|
|
117
|
+
callback(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@attrs.define(slots=False)
|
|
121
|
+
class ApiResponse:
|
|
122
|
+
response: requests.Response
|
|
123
|
+
headers: dict[str, str]
|
|
124
|
+
session: requests.Session
|
|
125
|
+
retry_options: dict[str, Any]
|
|
126
|
+
request_options: dict[str, Any]
|
|
127
|
+
download_options: dict[str, Any]
|
|
128
|
+
sleep_max: float
|
|
129
|
+
cleanup: bool
|
|
130
|
+
log_callback: Callable[..., None] | None
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def _request_kwargs(self) -> RequestKwargs:
|
|
134
|
+
return RequestKwargs(
|
|
135
|
+
headers=self.headers,
|
|
136
|
+
session=self.session,
|
|
137
|
+
retry_options=self.retry_options,
|
|
138
|
+
request_options=self.request_options,
|
|
139
|
+
download_options=self.download_options,
|
|
140
|
+
sleep_max=self.sleep_max,
|
|
141
|
+
cleanup=self.cleanup,
|
|
142
|
+
log_callback=self.log_callback,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_request(
|
|
147
|
+
cls: Type[T_ApiResponse],
|
|
148
|
+
method: str,
|
|
149
|
+
url: str,
|
|
150
|
+
headers: dict[str, str],
|
|
151
|
+
session: requests.Session | None,
|
|
152
|
+
retry_options: dict[str, Any],
|
|
153
|
+
request_options: dict[str, Any],
|
|
154
|
+
download_options: dict[str, Any],
|
|
155
|
+
sleep_max: float,
|
|
156
|
+
cleanup: bool,
|
|
157
|
+
log_callback: Callable[..., None] | None,
|
|
158
|
+
log_messages: bool = True,
|
|
159
|
+
**kwargs: Any,
|
|
160
|
+
) -> T_ApiResponse:
|
|
161
|
+
if session is None:
|
|
162
|
+
session = requests.Session()
|
|
163
|
+
robust_request = multiurl.robust(session.request, **retry_options)
|
|
164
|
+
|
|
165
|
+
inputs = kwargs.get("json", {}).get("inputs", {})
|
|
166
|
+
log(
|
|
167
|
+
logging.DEBUG,
|
|
168
|
+
f"{method.upper()} {url} {inputs or ''}".strip(),
|
|
169
|
+
callback=log_callback,
|
|
170
|
+
)
|
|
171
|
+
response = robust_request(
|
|
172
|
+
method, url, headers=headers, **request_options, **kwargs
|
|
173
|
+
)
|
|
174
|
+
log(logging.DEBUG, f"REPLY {response.text}", callback=log_callback)
|
|
175
|
+
|
|
176
|
+
cads_raise_for_status(response)
|
|
177
|
+
|
|
178
|
+
self = cls(
|
|
179
|
+
response,
|
|
180
|
+
headers=headers,
|
|
181
|
+
session=session,
|
|
182
|
+
retry_options=retry_options,
|
|
183
|
+
request_options=request_options,
|
|
184
|
+
download_options=download_options,
|
|
185
|
+
sleep_max=sleep_max,
|
|
186
|
+
cleanup=cleanup,
|
|
187
|
+
log_callback=log_callback,
|
|
188
|
+
)
|
|
189
|
+
if log_messages:
|
|
190
|
+
self.log_messages()
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def url(self) -> str:
|
|
195
|
+
"""URL."""
|
|
196
|
+
return str(self.response.request.url)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def json(self) -> Any:
|
|
200
|
+
"""Content of the response."""
|
|
201
|
+
return self.response.json()
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def _json_dict(self) -> dict[str, Any]:
|
|
205
|
+
json_dict = self.json
|
|
206
|
+
assert isinstance(json_dict, dict)
|
|
207
|
+
return json_dict
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def _json_list(self) -> list[Any]:
|
|
211
|
+
json_list = self.json
|
|
212
|
+
assert isinstance(json_list, list)
|
|
213
|
+
return json_list
|
|
214
|
+
|
|
215
|
+
def log_messages(self) -> None:
|
|
216
|
+
if message_str := self._json_dict.get("message"):
|
|
217
|
+
level, message_str = get_level_and_message(message_str)
|
|
218
|
+
self.log(level, message_str)
|
|
219
|
+
|
|
220
|
+
messages = self._json_dict.get("messages", [])
|
|
221
|
+
dataset_messages = (
|
|
222
|
+
self._json_dict.get("metadata", {})
|
|
223
|
+
.get("datasetMetadata", {})
|
|
224
|
+
.get("messages", [])
|
|
225
|
+
)
|
|
226
|
+
for message_dict in messages + dataset_messages:
|
|
227
|
+
if not (content := message_dict.get("content")):
|
|
228
|
+
continue
|
|
229
|
+
if date := message_dict.get("date"):
|
|
230
|
+
content = f"[{date}] {content}"
|
|
231
|
+
severity = message_dict.get("severity", "notset").upper()
|
|
232
|
+
level = LEVEL_NAMES_MAPPING.get(severity, 20)
|
|
233
|
+
self.log(level, content)
|
|
234
|
+
|
|
235
|
+
def _get_links(self, rel: str | None = None) -> list[dict[str, str]]:
|
|
236
|
+
links = []
|
|
237
|
+
for link in self._json_dict.get("links", []):
|
|
238
|
+
if rel is not None and link.get("rel") == rel:
|
|
239
|
+
links.append(link)
|
|
240
|
+
return links
|
|
241
|
+
|
|
242
|
+
def _get_link_href(self, rel: str | None = None) -> str:
|
|
243
|
+
links = self._get_links(rel)
|
|
244
|
+
if len(links) != 1:
|
|
245
|
+
raise LinkError(f"link not found or not unique {rel=}")
|
|
246
|
+
return links[0]["href"]
|
|
247
|
+
|
|
248
|
+
def _from_rel_href(self, rel: str) -> Self | None:
|
|
249
|
+
rels = self._get_links(rel=rel)
|
|
250
|
+
if len(rels) > 1:
|
|
251
|
+
raise LinkError(f"link not unique {rel=}")
|
|
252
|
+
|
|
253
|
+
if len(rels) == 1:
|
|
254
|
+
out = self.from_request("get", rels[0]["href"], **self._request_kwargs)
|
|
255
|
+
else:
|
|
256
|
+
out = None
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
def log(self, *args: Any, **kwargs: Any) -> None:
|
|
260
|
+
log(*args, callback=self.log_callback, **kwargs)
|
|
261
|
+
|
|
262
|
+
def info(self, *args: Any, **kwargs: Any) -> None:
|
|
263
|
+
self.log(logging.INFO, *args, **kwargs)
|
|
264
|
+
|
|
265
|
+
def warning(self, *args: Any, **kwargs: Any) -> None:
|
|
266
|
+
self.log(logging.WARNING, *args, **kwargs)
|
|
267
|
+
|
|
268
|
+
def error(self, *args: Any, **kwargs: Any) -> None:
|
|
269
|
+
self.log(logging.ERROR, *args, **kwargs)
|
|
270
|
+
|
|
271
|
+
def debug(self, *args: Any, **kwargs: Any) -> None:
|
|
272
|
+
self.log(logging.DEBUG, *args, **kwargs)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@attrs.define
|
|
276
|
+
class ApiResponsePaginated(ApiResponse):
|
|
277
|
+
@property
|
|
278
|
+
def next(self) -> Self | None:
|
|
279
|
+
"""Next page."""
|
|
280
|
+
return self._from_rel_href(rel="next")
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def prev(self) -> Self | None:
|
|
284
|
+
"""Previous page."""
|
|
285
|
+
return self._from_rel_href(rel="prev")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@attrs.define
|
|
289
|
+
class Processes(ApiResponsePaginated):
|
|
290
|
+
@property
|
|
291
|
+
def collection_ids(self) -> list[str]:
|
|
292
|
+
"""Available collection IDs."""
|
|
293
|
+
return [proc["id"] for proc in self._json_dict["processes"]]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@attrs.define
|
|
297
|
+
class Process(ApiResponse):
|
|
298
|
+
@property
|
|
299
|
+
def id(self) -> str:
|
|
300
|
+
"""Process ID."""
|
|
301
|
+
process_id: str = self._json_dict["id"]
|
|
302
|
+
return process_id
|
|
303
|
+
|
|
304
|
+
def submit(self, request: dict[str, Any]) -> datastores.Remote:
|
|
305
|
+
"""Submit a request.
|
|
306
|
+
|
|
307
|
+
Parameters
|
|
308
|
+
----------
|
|
309
|
+
request: dict[str,Any]
|
|
310
|
+
Request parameters.
|
|
311
|
+
|
|
312
|
+
Returns
|
|
313
|
+
-------
|
|
314
|
+
datastores.Remote
|
|
315
|
+
"""
|
|
316
|
+
job = Job.from_request(
|
|
317
|
+
"post",
|
|
318
|
+
f"{self.url}/execution",
|
|
319
|
+
json={"inputs": request},
|
|
320
|
+
**self._request_kwargs,
|
|
321
|
+
)
|
|
322
|
+
return job.get_remote()
|
|
323
|
+
|
|
324
|
+
def apply_constraints(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
325
|
+
"""Apply constraints to the parameters in a request.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------
|
|
329
|
+
request: dict[str,Any]
|
|
330
|
+
Request parameters.
|
|
331
|
+
|
|
332
|
+
Returns
|
|
333
|
+
-------
|
|
334
|
+
dict[str,Any]
|
|
335
|
+
Dictionary of valid values.
|
|
336
|
+
"""
|
|
337
|
+
response = ApiResponse.from_request(
|
|
338
|
+
"post",
|
|
339
|
+
f"{self.url}/constraints",
|
|
340
|
+
json={"inputs": request},
|
|
341
|
+
**self._request_kwargs,
|
|
342
|
+
)
|
|
343
|
+
return response._json_dict
|
|
344
|
+
|
|
345
|
+
def estimate_costs(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
346
|
+
response = ApiResponse.from_request(
|
|
347
|
+
"post",
|
|
348
|
+
f"{self.url}/costing",
|
|
349
|
+
json={"inputs": request},
|
|
350
|
+
**self._request_kwargs,
|
|
351
|
+
)
|
|
352
|
+
return response._json_dict
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@attrs.define(slots=False)
|
|
356
|
+
class Remote:
|
|
357
|
+
"""A class to interact with a submitted job."""
|
|
358
|
+
|
|
359
|
+
url: str
|
|
360
|
+
headers: dict[str, str]
|
|
361
|
+
session: requests.Session
|
|
362
|
+
retry_options: dict[str, Any]
|
|
363
|
+
request_options: dict[str, Any]
|
|
364
|
+
download_options: dict[str, Any]
|
|
365
|
+
sleep_max: float
|
|
366
|
+
cleanup: bool
|
|
367
|
+
log_callback: Callable[..., None] | None
|
|
368
|
+
|
|
369
|
+
def __attrs_post_init__(self) -> None:
|
|
370
|
+
self.log_start_time = None
|
|
371
|
+
self.last_status = None
|
|
372
|
+
self.info(f"Request ID is {self.request_id}")
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def _request_kwargs(self) -> RequestKwargs:
|
|
376
|
+
return RequestKwargs(
|
|
377
|
+
headers=self.headers,
|
|
378
|
+
session=self.session,
|
|
379
|
+
retry_options=self.retry_options,
|
|
380
|
+
request_options=self.request_options,
|
|
381
|
+
download_options=self.download_options,
|
|
382
|
+
sleep_max=self.sleep_max,
|
|
383
|
+
cleanup=self.cleanup,
|
|
384
|
+
log_callback=self.log_callback,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _log_metadata(self, metadata: dict[str, Any]) -> None:
|
|
388
|
+
logs = metadata.get("log", [])
|
|
389
|
+
for self.log_start_time, message in sorted(logs):
|
|
390
|
+
level, message = get_level_and_message(message)
|
|
391
|
+
self.log(level, message)
|
|
392
|
+
|
|
393
|
+
def _get_api_response(self, method: str, **kwargs: Any) -> ApiResponse:
|
|
394
|
+
return ApiResponse.from_request(
|
|
395
|
+
method, self.url, **self._request_kwargs, **kwargs
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def request_id(self) -> str:
|
|
400
|
+
"""Request ID."""
|
|
401
|
+
return self.url.rpartition("/")[2]
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def request_uid(self) -> str:
|
|
405
|
+
warnings.warn(
|
|
406
|
+
"`request_uid` has been deprecated, and in the future will raise an error."
|
|
407
|
+
"Please use `request_id` from now on.",
|
|
408
|
+
DeprecationWarning,
|
|
409
|
+
stacklevel=2,
|
|
410
|
+
)
|
|
411
|
+
return self.request_id
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def json(self) -> dict[str, Any]:
|
|
415
|
+
"""Content of the response."""
|
|
416
|
+
params = {"log": True, "request": True}
|
|
417
|
+
if self.log_start_time:
|
|
418
|
+
params["logStartTime"] = self.log_start_time
|
|
419
|
+
return self._get_api_response("get", params=params)._json_dict
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def collection_id(self) -> str:
|
|
423
|
+
"""Collection ID."""
|
|
424
|
+
return str(self.json["processID"])
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def request(self) -> dict[str, Any]:
|
|
428
|
+
"""Request parameters."""
|
|
429
|
+
return dict(self.json["metadata"]["request"]["ids"])
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def status(self) -> str:
|
|
433
|
+
"""Request status."""
|
|
434
|
+
reply = self.json
|
|
435
|
+
self._log_metadata(reply.get("metadata", {}))
|
|
436
|
+
|
|
437
|
+
status = reply["status"]
|
|
438
|
+
if self.last_status != status:
|
|
439
|
+
self.info(f"status has been updated to {status}")
|
|
440
|
+
self.last_status = status
|
|
441
|
+
return str(status)
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def updated_at(self) -> datetime.datetime:
|
|
445
|
+
"""When the job was last updated."""
|
|
446
|
+
return utils.string_to_datetime(self.json["updated"])
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def created_at(self) -> datetime.datetime:
|
|
450
|
+
"""When the job was created."""
|
|
451
|
+
return utils.string_to_datetime(self.json["created"])
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def creation_datetime(self) -> datetime.datetime:
|
|
455
|
+
warnings.warn(
|
|
456
|
+
"`creation_datetime` has been deprecated, and in the future will raise an error."
|
|
457
|
+
"Please use `created_at` from now on.",
|
|
458
|
+
DeprecationWarning,
|
|
459
|
+
stacklevel=2,
|
|
460
|
+
)
|
|
461
|
+
return self.created_at
|
|
462
|
+
|
|
463
|
+
@property
|
|
464
|
+
def started_at(self) -> datetime.datetime | None:
|
|
465
|
+
"""When the job started. If None, the job has not started."""
|
|
466
|
+
value = self.json.get("started")
|
|
467
|
+
return value if value is None else utils.string_to_datetime(value)
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def start_datetime(self) -> datetime.datetime | None:
|
|
471
|
+
warnings.warn(
|
|
472
|
+
"`start_datetime` has been deprecated, and in the future will raise an error."
|
|
473
|
+
"Please use `started_at` from now on.",
|
|
474
|
+
DeprecationWarning,
|
|
475
|
+
stacklevel=2,
|
|
476
|
+
)
|
|
477
|
+
return self.started_at
|
|
478
|
+
|
|
479
|
+
@property
|
|
480
|
+
def finished_at(self) -> datetime.datetime | None:
|
|
481
|
+
"""When the job finished. If None, the job has not finished."""
|
|
482
|
+
value = self.json.get("finished")
|
|
483
|
+
return value if value is None else utils.string_to_datetime(value)
|
|
484
|
+
|
|
485
|
+
@property
|
|
486
|
+
def end_datetime(self) -> datetime.datetime | None:
|
|
487
|
+
warnings.warn(
|
|
488
|
+
"`end_datetime` has been deprecated, and in the future will raise an error."
|
|
489
|
+
"Please use `finished_at` from now on.",
|
|
490
|
+
DeprecationWarning,
|
|
491
|
+
stacklevel=2,
|
|
492
|
+
)
|
|
493
|
+
return self.finished_at
|
|
494
|
+
|
|
495
|
+
def _wait_on_results(self) -> None:
|
|
496
|
+
sleep = 1.0
|
|
497
|
+
while not self.results_ready:
|
|
498
|
+
self.debug(f"results not ready, waiting for {sleep} seconds")
|
|
499
|
+
time.sleep(sleep)
|
|
500
|
+
sleep = min(sleep * 1.5, self.sleep_max)
|
|
501
|
+
|
|
502
|
+
@property
|
|
503
|
+
def results_ready(self) -> bool:
|
|
504
|
+
"""Check if results are ready."""
|
|
505
|
+
status = self.status
|
|
506
|
+
if status == "successful":
|
|
507
|
+
return True
|
|
508
|
+
if status in ("accepted", "running"):
|
|
509
|
+
return False
|
|
510
|
+
if status == "failed":
|
|
511
|
+
results = self._make_results(wait=False)
|
|
512
|
+
raise ProcessingFailedError(error_json_to_message(results._json_dict))
|
|
513
|
+
if status in ("dismissed", "deleted"):
|
|
514
|
+
raise ProcessingFailedError(f"API state {status!r}")
|
|
515
|
+
raise ProcessingFailedError(f"Unknown API state {status!r}")
|
|
516
|
+
|
|
517
|
+
def _make_results(self, wait: bool) -> Results:
|
|
518
|
+
if wait:
|
|
519
|
+
self._wait_on_results()
|
|
520
|
+
response = self._get_api_response("get")
|
|
521
|
+
try:
|
|
522
|
+
results_url = response._get_link_href(rel="results")
|
|
523
|
+
except LinkError:
|
|
524
|
+
results_url = f"{self.url}/results"
|
|
525
|
+
results = Results.from_request("get", results_url, **self._request_kwargs)
|
|
526
|
+
return results
|
|
527
|
+
|
|
528
|
+
def make_results(self, wait: bool = True) -> Results:
|
|
529
|
+
warnings.warn(
|
|
530
|
+
"`make_results` has been deprecated, and in the future will raise an error."
|
|
531
|
+
"Please use `get_results` from now on.",
|
|
532
|
+
DeprecationWarning,
|
|
533
|
+
stacklevel=2,
|
|
534
|
+
)
|
|
535
|
+
return self._make_results(wait)
|
|
536
|
+
|
|
537
|
+
def get_results(self) -> Results:
|
|
538
|
+
"""Retrieve results.
|
|
539
|
+
|
|
540
|
+
Returns
|
|
541
|
+
-------
|
|
542
|
+
datastores.Results
|
|
543
|
+
"""
|
|
544
|
+
return self._make_results(wait=True)
|
|
545
|
+
|
|
546
|
+
def download(self, target: str | None = None) -> str:
|
|
547
|
+
"""Download the results.
|
|
548
|
+
|
|
549
|
+
Parameters
|
|
550
|
+
----------
|
|
551
|
+
target: str or None
|
|
552
|
+
Target path. If None, download to the working directory.
|
|
553
|
+
|
|
554
|
+
Returns
|
|
555
|
+
-------
|
|
556
|
+
str
|
|
557
|
+
Path to the retrieved file.
|
|
558
|
+
"""
|
|
559
|
+
results = self.get_results()
|
|
560
|
+
return results.download(target)
|
|
561
|
+
|
|
562
|
+
def delete(self) -> dict[str, Any]:
|
|
563
|
+
"""Delete job.
|
|
564
|
+
|
|
565
|
+
Returns
|
|
566
|
+
-------
|
|
567
|
+
dict[str,Any]
|
|
568
|
+
Content of the response.
|
|
569
|
+
"""
|
|
570
|
+
response = self._get_api_response("delete")
|
|
571
|
+
self.cleanup = False
|
|
572
|
+
return response._json_dict
|
|
573
|
+
|
|
574
|
+
def _warn(self) -> None:
|
|
575
|
+
message = (
|
|
576
|
+
".update and .reply are available for backward compatibility."
|
|
577
|
+
" You can now use .download directly without needing to check whether the request is completed."
|
|
578
|
+
)
|
|
579
|
+
warnings.warn(message, DeprecationWarning)
|
|
580
|
+
|
|
581
|
+
def update(self, request_id: str | None = None) -> None:
|
|
582
|
+
self._warn()
|
|
583
|
+
if request_id:
|
|
584
|
+
assert request_id == self.request_id
|
|
585
|
+
try:
|
|
586
|
+
del self.reply
|
|
587
|
+
except AttributeError:
|
|
588
|
+
pass
|
|
589
|
+
self.reply
|
|
590
|
+
|
|
591
|
+
@functools.cached_property
|
|
592
|
+
def reply(self) -> dict[str, Any]:
|
|
593
|
+
self._warn()
|
|
594
|
+
|
|
595
|
+
reply = dict(self.json)
|
|
596
|
+
reply.setdefault("state", reply["status"])
|
|
597
|
+
|
|
598
|
+
if reply["state"] == "successful":
|
|
599
|
+
reply["state"] = "completed"
|
|
600
|
+
elif reply["state"] == "queued":
|
|
601
|
+
reply["state"] = "accepted"
|
|
602
|
+
elif reply["state"] == "failed":
|
|
603
|
+
reply.setdefault("error", {})
|
|
604
|
+
try:
|
|
605
|
+
self.get_results()
|
|
606
|
+
except Exception as exc:
|
|
607
|
+
reply["error"].setdefault("message", str(exc))
|
|
608
|
+
|
|
609
|
+
reply.setdefault("request_id", self.request_id)
|
|
610
|
+
return reply
|
|
611
|
+
|
|
612
|
+
def log(self, *args: Any, **kwargs: Any) -> None:
|
|
613
|
+
log(*args, callback=self.log_callback, **kwargs)
|
|
614
|
+
|
|
615
|
+
def info(self, *args: Any, **kwargs: Any) -> None:
|
|
616
|
+
self.log(logging.INFO, *args, **kwargs)
|
|
617
|
+
|
|
618
|
+
def warning(self, *args: Any, **kwargs: Any) -> None:
|
|
619
|
+
self.log(logging.WARNING, *args, **kwargs)
|
|
620
|
+
|
|
621
|
+
def error(self, *args: Any, **kwargs: Any) -> None:
|
|
622
|
+
self.log(logging.ERROR, *args, **kwargs)
|
|
623
|
+
|
|
624
|
+
def debug(self, *args: Any, **kwargs: Any) -> None:
|
|
625
|
+
self.log(logging.DEBUG, *args, **kwargs)
|
|
626
|
+
|
|
627
|
+
def __del__(self) -> None:
|
|
628
|
+
if self.cleanup:
|
|
629
|
+
try:
|
|
630
|
+
self.delete()
|
|
631
|
+
except Exception as exc:
|
|
632
|
+
warnings.warn(str(exc), UserWarning)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@attrs.define
|
|
636
|
+
class Job(ApiResponse):
|
|
637
|
+
def get_remote(self) -> Remote:
|
|
638
|
+
if self.response.request.method == "POST":
|
|
639
|
+
url = self._get_link_href(rel="monitor")
|
|
640
|
+
else:
|
|
641
|
+
url = self._get_link_href(rel="self")
|
|
642
|
+
return Remote(url, **self._request_kwargs)
|
|
643
|
+
|
|
644
|
+
def make_remote(self) -> Remote:
|
|
645
|
+
warnings.warn(
|
|
646
|
+
"`make_remote` has been deprecated, and in the future will raise an error."
|
|
647
|
+
"Please use `get_remote` from now on.",
|
|
648
|
+
DeprecationWarning,
|
|
649
|
+
stacklevel=2,
|
|
650
|
+
)
|
|
651
|
+
return self.get_remote()
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@attrs.define
|
|
655
|
+
class Jobs(ApiResponsePaginated):
|
|
656
|
+
"""A class to interact with submitted jobs."""
|
|
657
|
+
|
|
658
|
+
@property
|
|
659
|
+
def request_ids(self) -> list[str]:
|
|
660
|
+
"""List of request IDs."""
|
|
661
|
+
return [job["jobID"] for job in self._json_dict["jobs"]]
|
|
662
|
+
|
|
663
|
+
@property
|
|
664
|
+
def request_uids(self) -> list[str]:
|
|
665
|
+
warnings.warn(
|
|
666
|
+
"`request_uids` has been deprecated, and in the future will raise an error."
|
|
667
|
+
"Please use `request_ids` from now on.",
|
|
668
|
+
DeprecationWarning,
|
|
669
|
+
stacklevel=2,
|
|
670
|
+
)
|
|
671
|
+
return self.request_ids
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@attrs.define
|
|
675
|
+
class Results(ApiResponse):
|
|
676
|
+
"""A class to interact with the results of a job."""
|
|
677
|
+
|
|
678
|
+
def _check_size(self, target: str) -> None:
|
|
679
|
+
if (target_size := os.path.getsize(target)) != (size := self.content_length):
|
|
680
|
+
raise DownloadError(
|
|
681
|
+
f"Download failed: downloaded {target_size} byte(s) out of {size}"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
@property
|
|
685
|
+
def asset(self) -> dict[str, Any]:
|
|
686
|
+
return dict(self._json_dict["asset"]["value"])
|
|
687
|
+
|
|
688
|
+
def _download(self, url: str, target: str) -> requests.Response:
|
|
689
|
+
download_options = {"stream": True, "resume_transfers": True}
|
|
690
|
+
download_options.update(self.download_options)
|
|
691
|
+
multiurl.download(
|
|
692
|
+
url,
|
|
693
|
+
target=target,
|
|
694
|
+
**self.retry_options,
|
|
695
|
+
**self.request_options,
|
|
696
|
+
**download_options,
|
|
697
|
+
)
|
|
698
|
+
return requests.Response() # mutliurl robust needs a response
|
|
699
|
+
|
|
700
|
+
def download(
|
|
701
|
+
self,
|
|
702
|
+
target: str | None = None,
|
|
703
|
+
) -> str:
|
|
704
|
+
"""Download the results.
|
|
705
|
+
|
|
706
|
+
Parameters
|
|
707
|
+
----------
|
|
708
|
+
target: str or None
|
|
709
|
+
Target path. If None, download to the working directory.
|
|
710
|
+
|
|
711
|
+
Returns
|
|
712
|
+
-------
|
|
713
|
+
str
|
|
714
|
+
Path to the retrieved file.
|
|
715
|
+
"""
|
|
716
|
+
url = self.location
|
|
717
|
+
if target is None:
|
|
718
|
+
parts = urllib.parse.urlparse(url)
|
|
719
|
+
target = parts.path.strip("/").split("/")[-1]
|
|
720
|
+
|
|
721
|
+
if os.path.exists(target):
|
|
722
|
+
os.remove(target)
|
|
723
|
+
|
|
724
|
+
robust_download = multiurl.robust(self._download, **self.retry_options)
|
|
725
|
+
robust_download(url, target)
|
|
726
|
+
self._check_size(target)
|
|
727
|
+
return target
|
|
728
|
+
|
|
729
|
+
@property
|
|
730
|
+
def location(self) -> str:
|
|
731
|
+
"""File location."""
|
|
732
|
+
result_href = self.asset["href"]
|
|
733
|
+
return urllib.parse.urljoin(self.response.url, result_href)
|
|
734
|
+
|
|
735
|
+
@property
|
|
736
|
+
def content_length(self) -> int:
|
|
737
|
+
"""File size in Bytes."""
|
|
738
|
+
return int(self.asset["file:size"])
|
|
739
|
+
|
|
740
|
+
@property
|
|
741
|
+
def content_type(self) -> str:
|
|
742
|
+
"""File MIME type."""
|
|
743
|
+
return str(self.asset["type"])
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
@attrs.define(slots=False)
|
|
747
|
+
class Processing:
|
|
748
|
+
url: str
|
|
749
|
+
headers: dict[str, str]
|
|
750
|
+
session: requests.Session
|
|
751
|
+
retry_options: dict[str, Any]
|
|
752
|
+
request_options: dict[str, Any]
|
|
753
|
+
download_options: dict[str, Any]
|
|
754
|
+
sleep_max: float
|
|
755
|
+
cleanup: bool
|
|
756
|
+
log_callback: Callable[..., None] | None
|
|
757
|
+
force_exact_url: bool = False
|
|
758
|
+
|
|
759
|
+
def __attrs_post_init__(self) -> None:
|
|
760
|
+
if not self.force_exact_url:
|
|
761
|
+
self.url += f"/{config.SUPPORTED_API_VERSION}"
|
|
762
|
+
|
|
763
|
+
@property
|
|
764
|
+
def _request_kwargs(self) -> RequestKwargs:
|
|
765
|
+
return RequestKwargs(
|
|
766
|
+
headers=self.headers,
|
|
767
|
+
session=self.session,
|
|
768
|
+
retry_options=self.retry_options,
|
|
769
|
+
request_options=self.request_options,
|
|
770
|
+
download_options=self.download_options,
|
|
771
|
+
sleep_max=self.sleep_max,
|
|
772
|
+
cleanup=self.cleanup,
|
|
773
|
+
log_callback=self.log_callback,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def get_processes(self, **params: Any) -> Processes:
|
|
777
|
+
url = f"{self.url}/processes"
|
|
778
|
+
return Processes.from_request("get", url, params=params, **self._request_kwargs)
|
|
779
|
+
|
|
780
|
+
def get_process(self, process_id: str) -> Process:
|
|
781
|
+
url = f"{self.url}/processes/{process_id}"
|
|
782
|
+
return Process.from_request("get", url, **self._request_kwargs)
|
|
783
|
+
|
|
784
|
+
def get_jobs(self, **params: Any) -> Jobs:
|
|
785
|
+
url = f"{self.url}/jobs"
|
|
786
|
+
return Jobs.from_request("get", url, params=params, **self._request_kwargs)
|
|
787
|
+
|
|
788
|
+
def get_job(self, job_id: str) -> Job:
|
|
789
|
+
url = f"{self.url}/jobs/{job_id}"
|
|
790
|
+
return Job.from_request("get", url, **self._request_kwargs)
|
|
791
|
+
|
|
792
|
+
def submit(self, collection_id: str, request: dict[str, Any]) -> Remote:
|
|
793
|
+
return self.get_process(collection_id).submit(request)
|