accli 1.0.3__tar.gz → 1.1.0__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.4
2
2
  Name: accli
3
- Version: 1.0.3
3
+ Version: 1.1.0
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -38,6 +38,7 @@ Requires-Dist: tinydb>=4.5.2
38
38
  Requires-Dist: requests>=2.25.1
39
39
  Requires-Dist: jsonschema>=4.5.1
40
40
  Requires-Dist: pydantic>=2.0
41
+ Requires-Dist: GitPython
41
42
  Dynamic: license-file
42
43
 
43
44
 
@@ -71,6 +72,22 @@ This client uses **device authentication** via Auth0.
71
72
 
72
73
  ### 📦 Installation
73
74
 
75
+ #### Option 1: Standalone Binaries (Recommended)
76
+
77
+ No Python install required. Run the appropriate command for your system:
78
+
79
+ **Linux / macOS:**
80
+ ```bash
81
+ curl -fsSL https://raw.githubusercontent.com/iiasa/accli/master/scripts/install.sh | bash
82
+ ```
83
+
84
+ **Windows (PowerShell):**
85
+ ```powershell
86
+ irm https://raw.githubusercontent.com/iiasa/accli/master/scripts/install.ps1 | iex
87
+ ```
88
+
89
+ #### Option 2: Via PIP
90
+
74
91
  ```bash
75
92
  pip install accli --user
76
93
  ```
@@ -29,6 +29,22 @@ This client uses **device authentication** via Auth0.
29
29
 
30
30
  ### 📦 Installation
31
31
 
32
+ #### Option 1: Standalone Binaries (Recommended)
33
+
34
+ No Python install required. Run the appropriate command for your system:
35
+
36
+ **Linux / macOS:**
37
+ ```bash
38
+ curl -fsSL https://raw.githubusercontent.com/iiasa/accli/master/scripts/install.sh | bash
39
+ ```
40
+
41
+ **Windows (PowerShell):**
42
+ ```powershell
43
+ irm https://raw.githubusercontent.com/iiasa/accli/master/scripts/install.ps1 | iex
44
+ ```
45
+
46
+ #### Option 2: Via PIP
47
+
32
48
  ```bash
33
49
  pip install accli --user
34
50
  ```
@@ -1,19 +1,19 @@
1
1
  import os
2
2
  import io
3
- import sys
4
- import typing
3
+ from typing import Union, List, Tuple
4
+
5
5
  import requests
6
6
  import json
7
7
  import base64
8
8
  import urllib3
9
9
  from urllib.parse import quote
10
10
  from pathlib import Path
11
- from typing import List, Tuple
12
11
 
13
12
  from accli.common import todict
14
13
 
15
14
  ACCLI_DEBUG = os.environ.get('ACCLI_DEBUG', False)
16
15
 
16
+
17
17
  class AccAPIError(Exception):
18
18
  pass
19
19
 
@@ -33,14 +33,15 @@ http_client_wo_cert_verification = urllib3.poolmanager.PoolManager(
33
33
  cert_reqs="CERT_NONE", num_pools=20, retries=retries, maxsize=2000, block=True
34
34
  )
35
35
 
36
+
36
37
  class AcceleratorJobProjectService:
37
38
  def __init__(
38
39
  self,
39
- user_token,
40
+ user_token,
40
41
  server_url='http://accelerator.iiasa.ac.at',
41
42
  verify_cert=(not ACCLI_DEBUG)
42
- ):
43
-
43
+ ):
44
+
44
45
  self.user_token = user_token
45
46
 
46
47
  if verify_cert:
@@ -48,13 +49,13 @@ class AcceleratorJobProjectService:
48
49
  else:
49
50
  self.http_client = http_client_wo_cert_verification
50
51
 
51
- self.cli_base_url = f"{server_url}/v1/ajob-cli"
52
+ self.cli_base_url = f"{server_url}/api/v1/ajob-cli"
52
53
  self.common_request_headers = {
53
54
  'x-authorization': user_token
54
55
  }
55
56
 
56
57
  def http_client_request(self, *args, **kwargs):
57
-
58
+
58
59
  if urllib3.__version__.startswith('1.'):
59
60
  if 'json' in kwargs:
60
61
  json_dict = kwargs.pop('json')
@@ -66,106 +67,109 @@ class AcceleratorJobProjectService:
66
67
  encoded_data = json.dumps(json_dict).encode('utf-8')
67
68
 
68
69
  kwargs['body'] = encoded_data
69
-
70
+
70
71
  res = self.http_client.request(*args, **kwargs)
71
72
 
72
73
  if str(res.status)[0] in ['4', '5']:
73
74
  raise AccAPIError(
74
- f"Accelerator api error:: status_code={res.status} :: response_data={res.data}",
75
+ f"Accelerator api error:: status_code={res.status} :: response_data={res.data}",
75
76
  )
76
77
  return res
77
78
 
78
79
  def get_file_stat(self, bucket_object_id):
79
80
  res = self.http_client_request(
80
- "GET",
81
+ "GET",
81
82
  f"{self.cli_base_url}/file-stat/{bucket_object_id}",
82
83
  headers=self.common_request_headers
83
84
  )
84
85
  return todict(res.data)
85
-
86
-
86
+
87
87
  def enumerate_files_by_prefix(self, prefix):
88
- project_slug = filename.split('/')[0]
88
+ raise NotImplementedError("filename parameter is not implemented yet")
89
89
 
90
+ project_slug = filename.split('/')[0]
90
91
 
91
92
  b64_encoded_prefix = base64.b64encode(prefix.encode()).decode()
92
93
 
93
94
  res = self.http_client_request(
94
- "GET",
95
+ "GET",
95
96
  f"{self.cli_base_url}/{project_slug}/enumerate-all-files/{b64_encoded_prefix}",
96
97
  headers=self.common_request_headers
97
98
  )
98
99
  if res.data:
99
100
  return todict(res.data)
100
-
101
-
101
+
102
102
  def get_file_url_from_repo(self, filename):
103
103
  project_slug = filename.split('/')[0]
104
104
  res = self.http_client_request(
105
- "GET",
105
+ "GET",
106
106
  f"{self.cli_base_url}/{project_slug}/get-file-download-url/?filename={filename}",
107
107
  headers=self.common_request_headers
108
108
  )
109
109
  if res.data:
110
110
  return todict(res.data)
111
-
111
+ return None
112
+
112
113
  def get_dataset_type(self, *args, **kwargs):
113
114
  return self.get_bucket_object_validation_type(*args, **kwargs)
114
115
 
115
116
  def get_bucket_object_validation_type(self, bucket_object_id):
116
117
  res = self.http_client_request(
117
- "GET",
118
+ "GET",
118
119
  f"{self.cli_base_url}/dataset-type/{bucket_object_id}",
119
120
  headers=self.common_request_headers
120
121
  )
121
122
  if res.data:
122
123
  return todict(res.data)
123
-
124
+ return None
124
125
 
125
126
  def get_filename_dataset_type(self, filename):
126
127
  res = self.http_client_request(
127
- "GET",
128
+ "GET",
128
129
  f"{self.cli_base_url}/filename-dataset-type/?filename={filename}",
129
130
  headers=self.common_request_headers
130
131
  )
131
132
  if res.data:
132
133
  return todict(res.data)
134
+ return None
133
135
 
134
136
  def get_filename_validation_details(self, filename):
135
137
  res = self.http_client_request(
136
- "GET",
138
+ "GET",
137
139
  f"{self.cli_base_url}/filename-validation-detail/?filename={filename}",
138
140
  headers=self.common_request_headers
139
141
  )
140
142
  if res.data:
141
143
  return todict(res.data)
144
+ return None
142
145
 
143
146
  def get_bucket_object_validation_details(self, bucket_object_id):
144
147
  res = self.http_client_request(
145
- "GET",
148
+ "GET",
146
149
  f"{self.cli_base_url}/validation-detail/{bucket_object_id}",
147
150
  headers=self.common_request_headers
148
151
  )
149
152
  if res.data:
150
153
  return todict(res.data)
154
+ return None
151
155
 
152
-
153
156
  def get_file_url(self, bucket_object_id):
154
157
  res = self.http_client_request(
155
- "GET",
158
+ "GET",
156
159
  f"{self.cli_base_url}/file-url/{bucket_object_id}",
157
160
  headers=self.common_request_headers
158
161
  )
159
162
 
160
163
  if res.data:
161
164
  return todict(res.data)
162
-
165
+ return None
163
166
 
164
167
  def get_file_stream(self, bucket_object_id):
165
168
  url = self.get_file_url(bucket_object_id)
166
169
  if url:
167
170
  resp = self.http_client_request("GET", url, preload_content=False)
168
171
  return resp
172
+ return None
169
173
 
170
174
  def check_job_health(self):
171
175
 
@@ -174,15 +178,15 @@ class AcceleratorJobProjectService:
174
178
  f"{self.cli_base_url}/is-healthy/",
175
179
  headers=self.common_request_headers
176
180
  )
177
-
181
+
178
182
  if res.data:
179
183
  res = todict(res.data)
180
-
184
+
181
185
  is_healthy = res['is_healthy']
182
186
  return is_healthy
183
-
187
+
184
188
  def add_log_file(self, data: bytes, filename):
185
-
189
+
186
190
  res = self.http_client_request(
187
191
  "GET",
188
192
  f"{self.cli_base_url}/presigned-log-upload-url/?filename={filename}",
@@ -193,7 +197,7 @@ class AcceleratorJobProjectService:
193
197
  res = todict(res.data)
194
198
  else:
195
199
  raise ValueError(f"Unable to get presign url in response: {res.data}")
196
-
200
+
197
201
  upload_url = res['upload_url']
198
202
  app_bucket_id = res['app_bucket_id']
199
203
  res_filename = res['filename']
@@ -206,7 +210,6 @@ class AcceleratorJobProjectService:
206
210
  verify=False,
207
211
  )
208
212
 
209
-
210
213
  self.http_client_request(
211
214
  "POST",
212
215
  f"{self.cli_base_url}/register-log-file/",
@@ -216,19 +219,18 @@ class AcceleratorJobProjectService:
216
219
  ),
217
220
  headers=self.common_request_headers
218
221
  )
219
-
220
- return is_healthy
221
222
 
223
+ return is_healthy
222
224
 
223
225
  def get_multipart_put_create_signed_url(
224
- self,
225
- app_bucket_id,
226
- object_name,
227
- upload_id,
228
- part_number
226
+ self,
227
+ app_bucket_id,
228
+ object_name,
229
+ upload_id,
230
+ part_number
229
231
  ):
230
232
  res = self.http_client_request(
231
- "GET",
233
+ "GET",
232
234
  f"{self.cli_base_url}/put-create-signed-url",
233
235
  fields=dict(
234
236
  app_bucket_id=app_bucket_id,
@@ -242,13 +244,13 @@ class AcceleratorJobProjectService:
242
244
  return todict(res.data)
243
245
 
244
246
  def get_multipart_put_update_signed_url(
245
- self,
246
- filename,
247
- upload_id,
248
- part_number,
247
+ self,
248
+ filename,
249
+ upload_id,
250
+ part_number,
249
251
  ):
250
252
  res = self.http_client_request(
251
- "GET",
253
+ "GET",
252
254
  f"{self.cli_base_url}/put-update-signed-url",
253
255
  fields=dict(
254
256
  filename=filename,
@@ -263,7 +265,7 @@ class AcceleratorJobProjectService:
263
265
  def get_put_create_multipart_upload_id(self, filename):
264
266
  encoded_filename = quote(filename)
265
267
  res = self.http_client_request(
266
- "GET",
268
+ "GET",
267
269
  f"{self.cli_base_url}/multipart-upload-id/?filename={encoded_filename}",
268
270
  headers=self.common_request_headers
269
271
  )
@@ -272,11 +274,10 @@ class AcceleratorJobProjectService:
272
274
 
273
275
  return data['upload_id'], data['app_bucket_id'], data['uniqified_filename']
274
276
 
275
-
276
277
  def get_validator_create_multipart_upload_id(self, filename):
277
278
  encoded_filename = quote(filename)
278
279
  res = self.http_client_request(
279
- "GET",
280
+ "GET",
280
281
  f"{self.cli_base_url}/validator-multipart-upload-id/?filename={encoded_filename}",
281
282
  headers=self.common_request_headers
282
283
  )
@@ -288,7 +289,7 @@ class AcceleratorJobProjectService:
288
289
  def get_put_update_multipart_upload_id(self, filename):
289
290
  encoded_filename = quote(filename)
290
291
  res = self.http_client_request(
291
- "GET",
292
+ "GET",
292
293
  f"{self.cli_base_url}/update-multipart-upload-id/?filename={encoded_filename}",
293
294
  headers=self.common_request_headers
294
295
  )
@@ -296,19 +297,19 @@ class AcceleratorJobProjectService:
296
297
  return todict(res.data)
297
298
 
298
299
  def complete_job_multipart_upload(
299
- self,
300
- app_bucket_id,
301
- filename,
302
- upload_id,
303
- parts: List[Tuple[str, str]],
304
- is_log_file=False
300
+ self,
301
+ app_bucket_id,
302
+ filename,
303
+ upload_id,
304
+ parts: List[Tuple[str, str]],
305
+ is_log_file=False
305
306
  ):
306
307
  headers = {"Content-Type": "application/json"}
307
308
 
308
309
  headers.update(self.common_request_headers)
309
310
 
310
311
  res = self.http_client_request(
311
- "PUT",
312
+ "PUT",
312
313
  f"{self.cli_base_url}/complete-create-multipart-upload",
313
314
  json=dict(
314
315
  app_bucket_id=app_bucket_id,
@@ -322,20 +323,19 @@ class AcceleratorJobProjectService:
322
323
 
323
324
  return todict(res.data)
324
325
 
325
-
326
326
  def complete_validator_multipart_upload(
327
- self,
328
- app_bucket_id,
329
- filename,
330
- upload_id,
331
- parts: List[Tuple[str, str]]
327
+ self,
328
+ app_bucket_id,
329
+ filename,
330
+ upload_id,
331
+ parts: List[Tuple[str, str]]
332
332
  ):
333
333
  headers = {"Content-Type": "application/json"}
334
334
 
335
335
  headers.update(self.common_request_headers)
336
336
 
337
337
  res = self.http_client_request(
338
- "PUT",
338
+ "PUT",
339
339
  f"{self.cli_base_url}/complete-validator-multipart-upload",
340
340
  json=dict(
341
341
  app_bucket_id=app_bucket_id,
@@ -349,7 +349,7 @@ class AcceleratorJobProjectService:
349
349
  return todict(res.data)
350
350
 
351
351
  def complete_update_multipart_upload(
352
- self, filename, upload_id, parts: List[Tuple[str, str]]
352
+ self, filename, upload_id, parts: List[Tuple[str, str]]
353
353
  ):
354
354
 
355
355
  headers = {"Content-Type": "application/json"}
@@ -357,7 +357,7 @@ class AcceleratorJobProjectService:
357
357
  headers.update(self.common_request_headers)
358
358
 
359
359
  res = self.http_client_request(
360
- "PUT",
360
+ "PUT",
361
361
  f"{self.cli_base_url}/complete-update-multipart-upload",
362
362
  json=dict(
363
363
  filename=filename,
@@ -370,13 +370,13 @@ class AcceleratorJobProjectService:
370
370
  return todict(res.data)
371
371
 
372
372
  def abort_create_multipart_upload(self, app_bucket_id, filename, upload_id):
373
-
373
+
374
374
  headers = {"Content-Type": "application/json"}
375
375
 
376
376
  headers.update(self.common_request_headers)
377
377
 
378
378
  res = self.http_client_request(
379
- "PUT",
379
+ "PUT",
380
380
  f"{self.cli_base_url}/abort-create-multipart-upload",
381
381
  json=dict(
382
382
  app_bucket_id=app_bucket_id,
@@ -387,7 +387,7 @@ class AcceleratorJobProjectService:
387
387
  )
388
388
 
389
389
  def abort_update_multipart_upload(self, filename, upload_id):
390
-
390
+
391
391
  headers = {
392
392
  # "Content-Type": "application/json"
393
393
  }
@@ -395,7 +395,7 @@ class AcceleratorJobProjectService:
395
395
  headers.update(self.common_request_headers)
396
396
 
397
397
  res = self.http_client_request(
398
- "PUT",
398
+ "PUT",
399
399
  f"{self.cli_base_url}/abort-update-multipart-upload",
400
400
  json=dict(
401
401
  filename=filename,
@@ -403,7 +403,7 @@ class AcceleratorJobProjectService:
403
403
  ),
404
404
  headers=headers
405
405
  )
406
-
406
+
407
407
  # # TODO @wrufesh ensure it requires special token
408
408
  # def register_iamc_validation(
409
409
  # self, validated_bucket_object_id, indexdb_bucket_object_id
@@ -424,18 +424,18 @@ class AcceleratorJobProjectService:
424
424
  # )
425
425
 
426
426
  def register_validation(
427
- self,
428
- validated_bucket_object_id: int,
429
- dataset_template_id: int,
430
- validated_metadata: dict,
431
- validation_supporting_bucket_object_ids: List[int]
427
+ self,
428
+ validated_bucket_object_id: int,
429
+ dataset_template_id: int,
430
+ validated_metadata: dict,
431
+ validation_supporting_bucket_object_ids: List[int]
432
432
  ):
433
433
  headers = {"Content-Type": "application/json"}
434
434
 
435
435
  headers.update(self.common_request_headers)
436
436
 
437
437
  res = self.http_client_request(
438
- "PUT",
438
+ "PUT",
439
439
  f"{self.cli_base_url}/register-validation",
440
440
  json=dict(
441
441
  validated_bucket_object_id=validated_bucket_object_id,
@@ -447,25 +447,23 @@ class AcceleratorJobProjectService:
447
447
  )
448
448
 
449
449
  def get_dataset_template_details(
450
- self,
451
- dataset_template_id
450
+ self,
451
+ dataset_template_id
452
452
  ):
453
453
  headers = {"Content-Type": "application/json"}
454
454
 
455
455
  headers.update(self.common_request_headers)
456
456
 
457
457
  res = self.http_client_request(
458
- "GET",
458
+ "GET",
459
459
  f"{self.cli_base_url}/dataset-template-detail/{dataset_template_id}",
460
460
  headers=headers
461
461
  )
462
462
 
463
463
  return todict(res.data)
464
464
 
465
-
466
-
467
465
  def read_part_data(self, stream, size, part_data=b"", progress=None):
468
- """Read part data of given size from stream."""
466
+ """Read part data of the given size from the stream."""
469
467
  size -= len(part_data)
470
468
  while size:
471
469
  data = stream.read(size)
@@ -483,7 +481,7 @@ class AcceleratorJobProjectService:
483
481
  headers = dict()
484
482
  headers["Content-Type"] = "application/octet-stream"
485
483
 
486
- part_size, part_count = 50 * 1024**2, -1
484
+ part_size, part_count = 50 * 1024 ** 2, -1
487
485
 
488
486
  upload_id = None
489
487
  app_bucket_id = None
@@ -507,7 +505,7 @@ class AcceleratorJobProjectService:
507
505
  )
508
506
 
509
507
  # If part_data_size is less or equal to part_size,
510
- # then we have reached last part.
508
+ # then we have reached the last part.
511
509
  if len(part_data) <= part_size:
512
510
  part_count = part_number
513
511
  stop = True
@@ -523,7 +521,7 @@ class AcceleratorJobProjectService:
523
521
  app_bucket_id,
524
522
  uniqified_filename,
525
523
  ) = self.get_put_create_multipart_upload_id(
526
- filename,
524
+ filename,
527
525
  # headers=headers
528
526
  )
529
527
 
@@ -560,7 +558,7 @@ class AcceleratorJobProjectService:
560
558
  headers = dict()
561
559
  headers["Content-Type"] = "application/octet-stream"
562
560
 
563
- part_size, part_count = 50 * 1024**2, -1
561
+ part_size, part_count = 50 * 1024 ** 2, -1
564
562
 
565
563
  upload_id = None
566
564
  app_bucket_id = None
@@ -584,7 +582,7 @@ class AcceleratorJobProjectService:
584
582
  )
585
583
 
586
584
  # If part_data_size is less or equal to part_size,
587
- # then we have reached last part.
585
+ # then we have reached the last part.
588
586
  if len(part_data) <= part_size:
589
587
  part_count = part_number
590
588
  stop = True
@@ -630,12 +628,11 @@ class AcceleratorJobProjectService:
630
628
 
631
629
  raise err
632
630
 
633
-
634
631
  def add_filestream_as_validation_supporter(self, filename, file_stream, is_log_file=False):
635
632
  headers = dict()
636
633
  headers["Content-Type"] = "application/octet-stream"
637
634
 
638
- part_size, part_count = 50 * 1024**2, -1
635
+ part_size, part_count = 50 * 1024 ** 2, -1
639
636
 
640
637
  upload_id = None
641
638
  app_bucket_id = None
@@ -659,7 +656,7 @@ class AcceleratorJobProjectService:
659
656
  )
660
657
 
661
658
  # If part_data_size is less or equal to part_size,
662
- # then we have reached last part.
659
+ # then we have reached the last part.
663
660
  if len(part_data) <= part_size:
664
661
  part_count = part_number
665
662
  stop = True
@@ -675,7 +672,7 @@ class AcceleratorJobProjectService:
675
672
  app_bucket_id,
676
673
  uniqified_filename,
677
674
  ) = self.get_validator_create_multipart_upload_id(
678
- filename,
675
+ filename,
679
676
  # headers=headers
680
677
  )
681
678
 
@@ -717,7 +714,7 @@ class AcceleratorJobProjectService:
717
714
  headers.update(self.common_request_headers)
718
715
 
719
716
  res = self.http_client_request(
720
- "POST",
717
+ "POST",
721
718
  f"{self.cli_base_url}/webhook-event",
722
719
  json=dict(
723
720
  type='STATUS_UPDATE',
@@ -728,12 +725,13 @@ class AcceleratorJobProjectService:
728
725
  headers=headers
729
726
  )
730
727
 
728
+
731
729
  class Fs:
732
730
  @staticmethod
733
731
  def write_stream_remote(filestream, dest_filepath, user_token, server_url):
734
732
 
735
733
  accelerator_job_service = AcceleratorJobProjectService(
736
- user_token,
734
+ user_token,
737
735
  server_url=server_url,
738
736
  verify_cert=(not ACCLI_DEBUG)
739
737
  )
@@ -755,9 +753,9 @@ class Fs:
755
753
 
756
754
  if not (user_token and server_url):
757
755
  raise ValueError("Remote data repository credentials not found.")
758
-
756
+
759
757
  accelerator_job_service = AcceleratorJobProjectService(
760
- user_token,
758
+ user_token,
761
759
  server_url=server_url,
762
760
  verify_cert=(not ACCLI_DEBUG)
763
761
  )
@@ -765,15 +763,14 @@ class Fs:
765
763
  return accelerator_job_service.get_file_url_from_repo(
766
764
  remote_filepath
767
765
  )
768
-
769
766
 
770
767
  @staticmethod
771
- def write_file(source: typing.Union[str, io.BytesIO], dest_filepath):
768
+ def write_file(source: Union[str, io.BytesIO], dest_filepath):
772
769
  user_token = os.environ.get("ACC_JOB_TOKEN", None)
773
770
  server_url = os.environ.get("ACC_JOB_GATEWAY_SERVER", None)
774
771
 
775
772
  if isinstance(source, io.BytesIO):
776
- if (user_token and server_url):
773
+ if user_token and server_url:
777
774
  Fs.write_stream_remote(source, dest_filepath, user_token, server_url)
778
775
  else:
779
776
  Fs.write_stream_local(source, dest_filepath)
@@ -785,7 +782,7 @@ class Fs:
785
782
  raise ValueError("Source path does not exist")
786
783
 
787
784
  with open(source, 'rb') as fstream:
788
- if (user_token and server_url):
785
+ if user_token and server_url:
789
786
  Fs.write_stream_remote(fstream, dest_filepath, user_token, server_url)
790
787
  else:
791
788
  Fs.write_stream_local(fstream, dest_filepath)
@@ -797,9 +794,9 @@ class Fs:
797
794
 
798
795
  if not (user_token and server_url):
799
796
  raise ValueError("Remote data repository credentials not found.")
800
-
797
+
801
798
  accelerator_job_service = AcceleratorJobProjectService(
802
- user_token,
799
+ user_token,
803
800
  server_url=server_url,
804
801
  verify_cert=(not ACCLI_DEBUG)
805
802
  )