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,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)