blackant-sdk 1.0.2__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.
Files changed (70) hide show
  1. blackant/__init__.py +31 -0
  2. blackant/auth/__init__.py +10 -0
  3. blackant/auth/blackant_auth.py +518 -0
  4. blackant/auth/keycloak_manager.py +363 -0
  5. blackant/auth/request_id.py +52 -0
  6. blackant/auth/role_assignment.py +443 -0
  7. blackant/auth/tokens.py +57 -0
  8. blackant/client.py +400 -0
  9. blackant/config/__init__.py +0 -0
  10. blackant/config/docker_config.py +457 -0
  11. blackant/config/keycloak_admin_config.py +107 -0
  12. blackant/docker/__init__.py +12 -0
  13. blackant/docker/builder.py +616 -0
  14. blackant/docker/client.py +983 -0
  15. blackant/docker/dao.py +462 -0
  16. blackant/docker/registry.py +172 -0
  17. blackant/exceptions.py +111 -0
  18. blackant/http/__init__.py +8 -0
  19. blackant/http/client.py +125 -0
  20. blackant/patterns/__init__.py +1 -0
  21. blackant/patterns/singleton.py +20 -0
  22. blackant/services/__init__.py +10 -0
  23. blackant/services/dao.py +414 -0
  24. blackant/services/registry.py +635 -0
  25. blackant/utils/__init__.py +8 -0
  26. blackant/utils/initialization.py +32 -0
  27. blackant/utils/logging.py +337 -0
  28. blackant/utils/request_id.py +13 -0
  29. blackant/utils/store.py +50 -0
  30. blackant_sdk-1.0.2.dist-info/METADATA +117 -0
  31. blackant_sdk-1.0.2.dist-info/RECORD +70 -0
  32. blackant_sdk-1.0.2.dist-info/WHEEL +5 -0
  33. blackant_sdk-1.0.2.dist-info/top_level.txt +5 -0
  34. calculation/__init__.py +0 -0
  35. calculation/base.py +26 -0
  36. calculation/errors.py +2 -0
  37. calculation/impl/__init__.py +0 -0
  38. calculation/impl/my_calculation.py +144 -0
  39. calculation/impl/simple_calc.py +53 -0
  40. calculation/impl/test.py +1 -0
  41. calculation/impl/test_calc.py +36 -0
  42. calculation/loader.py +227 -0
  43. notifinations/__init__.py +8 -0
  44. notifinations/mail_sender.py +212 -0
  45. storage/__init__.py +0 -0
  46. storage/errors.py +10 -0
  47. storage/factory.py +26 -0
  48. storage/interface.py +19 -0
  49. storage/minio.py +106 -0
  50. task/__init__.py +0 -0
  51. task/dao.py +38 -0
  52. task/errors.py +10 -0
  53. task/log_adapter.py +11 -0
  54. task/parsers/__init__.py +0 -0
  55. task/parsers/base.py +13 -0
  56. task/parsers/callback.py +40 -0
  57. task/parsers/cmd_args.py +52 -0
  58. task/parsers/freetext.py +19 -0
  59. task/parsers/objects.py +50 -0
  60. task/parsers/request.py +56 -0
  61. task/resource.py +84 -0
  62. task/states/__init__.py +0 -0
  63. task/states/base.py +14 -0
  64. task/states/error.py +47 -0
  65. task/states/idle.py +12 -0
  66. task/states/ready.py +51 -0
  67. task/states/running.py +21 -0
  68. task/states/set_up.py +40 -0
  69. task/states/tear_down.py +29 -0
  70. task/task.py +358 -0
task/states/running.py ADDED
@@ -0,0 +1,21 @@
1
+ from calculation.errors import CalculationError
2
+
3
+ from task.states.base import TaskStateBase
4
+ from task.states.error import TaskErrorState
5
+ from task.states.tear_down import TaskTearDownState
6
+
7
+
8
+ class TaskRunningState(TaskStateBase):
9
+ @property
10
+ def name(self):
11
+ return "running"
12
+
13
+ def run(self):
14
+ try:
15
+ self._task.logger.info("Running calculation")
16
+ self._task.calculation.run()
17
+ self._task.change_state(TaskTearDownState(self._task))
18
+ except CalculationError as calculation_error:
19
+ self._task.logger.error("Calculation error occurred during running phase")
20
+ self._task.logger.exception(calculation_error)
21
+ self._task.change_state(TaskErrorState(self._task))
task/states/set_up.py ADDED
@@ -0,0 +1,40 @@
1
+ from calculation.errors import CalculationError
2
+
3
+ from storage.errors import StorageCreationFailedException
4
+ from storage.factory import StorageFactory
5
+
6
+ from task.states.base import TaskStateBase
7
+ from task.states.error import TaskErrorState
8
+ from task.states.running import TaskRunningState
9
+
10
+
11
+ class TaskSetUpState(TaskStateBase):
12
+ def __init__(self, task):
13
+ super().__init__(task)
14
+ try:
15
+ self.__storage = StorageFactory.create()
16
+ except (StorageCreationFailedException, Exception) as exc:
17
+ # Catch all storage errors - storage is optional
18
+ self._task.logger.error("Cannot connect to object storage")
19
+ self._task.logger.info("Continuing, because it is possible to not use it at all")
20
+ self._task.logger.debug(str(exc))
21
+ self.__storage = None # Explicitly set to None
22
+
23
+ @property
24
+ def name(self):
25
+ return "set up"
26
+
27
+ def run(self):
28
+ try:
29
+ self._task.logger.info("Run set up phase of calculation")
30
+ self.__fetch_configured_objects()
31
+ self._task.calculation.set_up()
32
+ self._task.change_state(TaskRunningState(self._task))
33
+ except CalculationError as calculation_error:
34
+ self._task.logger.error("Calculation error occured during set up phase")
35
+ self._task.logger.exception(calculation_error)
36
+ self._task.change_state(TaskErrorState(self._task))
37
+
38
+ def __fetch_configured_objects(self):
39
+ for obj in self._task.configuration.objects:
40
+ self.__storage.download_file(obj.local, obj.remote)
@@ -0,0 +1,29 @@
1
+ import os
2
+
3
+ from calculation.errors import CalculationError
4
+
5
+ from task.states.base import TaskStateBase
6
+ from task.states.error import TaskErrorState
7
+ from task.states.ready import TaskReadyState
8
+
9
+
10
+ class TaskTearDownState(TaskStateBase):
11
+ @property
12
+ def name(self):
13
+ return "tear down"
14
+
15
+ def run(self):
16
+ try:
17
+ self._task.logger.info("Run tear down phase of calculation")
18
+ self._task.calculation.tear_down()
19
+ self.__delete_unpreserved_files()
20
+ self._task.change_state(TaskReadyState(self._task))
21
+ except CalculationError as calculation_error:
22
+ self._task.logger.error("Calculation error occurred during tear down phase")
23
+ self._task.logger.exception(calculation_error)
24
+ self._task.change_state(TaskErrorState(self._task))
25
+
26
+ def __delete_unpreserved_files(self):
27
+ for obj in self._task.configuration.objects:
28
+ if not obj.preserve and os.path.exists(obj.local):
29
+ os.remove(obj.local)
task/task.py ADDED
@@ -0,0 +1,358 @@
1
+ import json
2
+ import threading
3
+ import copy
4
+ import os
5
+ import time
6
+ import random
7
+ import sys
8
+ from contextlib import contextmanager
9
+
10
+ import requests
11
+ from urllib.parse import urlparse
12
+
13
+ # Import BlackAnt HTTP client for proper request handling
14
+ try:
15
+ from blackant.http.client import HTTPClient
16
+ from blackant.utils.logging import get_logger
17
+ except ImportError:
18
+ # Fallback if BlackAnt SDK not available in this context
19
+ HTTPClient = None
20
+ get_logger = None
21
+
22
+ from calculation.base import CalculationBase
23
+
24
+ from task.parsers.request import TaskConfiguration
25
+ from task.parsers.callback import CallbackParameter
26
+ from task.states.idle import TaskIdleState
27
+ from task.states.base import TaskStateBase
28
+ from task.states.error import TaskErrorState
29
+ from task.errors import TaskCallbackFailedError
30
+
31
+
32
+ # Logger writer to redirect stdout/stderr to logger
33
+ class LoggerWriter:
34
+ """A class to wrap the logger and redirect writes to it."""
35
+
36
+ def __init__(self, logger, level="info"):
37
+ self.logger = logger
38
+ self.level = level
39
+
40
+ def write(self, message):
41
+ """Write a message to the logger."""
42
+ if message.strip(): # Avoid logging empty lines
43
+ if self.level == "error":
44
+ self.logger.error(message.strip())
45
+ else:
46
+ self.logger.info(message.strip())
47
+
48
+ def flush(self):
49
+ """Flush the stream (not required, but needed for compatibility)."""
50
+
51
+
52
+ # Context manager to redirect stdout and stderr to a logger
53
+ @contextmanager
54
+ def redirect_stdout_stderr_to_logger(logger):
55
+ """Context manager to redirect stdout and stderr to a logger."""
56
+ original_stdout = sys.stdout
57
+ original_stderr = sys.stderr
58
+
59
+ sys.stdout = LoggerWriter(logger)
60
+ sys.stderr = LoggerWriter(logger, level="error")
61
+
62
+ try:
63
+ yield
64
+ finally:
65
+ sys.stdout = original_stdout
66
+ sys.stderr = original_stderr
67
+
68
+
69
+ class Task: # pylint: disable=too-many-instance-attributes
70
+ """Task entity in science module
71
+
72
+ Whenever the server receives a request,
73
+ a task is created to handle the calculation.
74
+ """
75
+
76
+ __scheduler_callback: CallbackParameter = CallbackParameter(
77
+ url=os.environ["SCHEDULER_URL"],
78
+ method="POST",
79
+ headers={"Content-type": "application/json"},
80
+ request_body={},
81
+ )
82
+
83
+ def __init__(
84
+ self,
85
+ calculation: CalculationBase,
86
+ configuration: TaskConfiguration,
87
+ log_file_handler,
88
+ logger,
89
+ ):
90
+ """Task constructor
91
+
92
+ Args:
93
+ calculation (CalculationBase): Calculation to perform
94
+ configuration (TaskConfiguration): Configuration composed by the incoming GET request
95
+ logger (Logger): Logger to use
96
+ """
97
+ self.calculation = calculation
98
+ self.logger = logger
99
+ self.task_id = None
100
+ self.log_file_handler = log_file_handler
101
+ self.__configuration = configuration
102
+ self.__start_execution_time = None
103
+
104
+ if not self.calculation:
105
+ self.logger.error("The calculation object in not defined.")
106
+ self.__state = TaskErrorState(self)
107
+ else:
108
+ self.__state = TaskIdleState(self)
109
+
110
+ self.__finished = False
111
+ self.__executor = threading.Thread(target=self.__execute_state_machine)
112
+ self.__thread_lock = threading.Lock()
113
+
114
+ def change_state(self, state: TaskStateBase):
115
+ """Handles state changes (State design pattern)
116
+
117
+ Args:
118
+ state (TaskStateBase): New state
119
+ """
120
+ self.__state = state
121
+
122
+ def set_finished(self, finished: bool):
123
+ """Setter for finished state
124
+
125
+ Args:
126
+ finished (bool): Sets whether the task is in finished state
127
+ """
128
+ self.__finished = finished
129
+
130
+ @property
131
+ def state(self) -> str:
132
+ """Returns with the current state's name
133
+
134
+ Returns:
135
+ str: The current state's name
136
+ """
137
+ return self.__state.name
138
+
139
+ @property
140
+ def configuration(self) -> TaskConfiguration:
141
+ """Returns with the copy of the task's configuration
142
+
143
+ Returns:
144
+ TaskConfiguration: The configuration
145
+ """
146
+ return copy.deepcopy(self.__configuration)
147
+
148
+ @property
149
+ def response(self) -> dict:
150
+ """Gives the response for the server about the task state
151
+
152
+ Returns:
153
+ dict: The task's state
154
+ """
155
+
156
+ try:
157
+ with open("/host_hostname", "r") as file:
158
+ host_hostname = file.read().strip()
159
+ except (OSError, IOError):
160
+ self.logger.exception(
161
+ "Cannot get Host name (where the task ran). 'UNKNOWN_HOST_FROM_TASK' is set."
162
+ )
163
+ host_hostname = "UNKNOWN_HOST_FROM_TASK"
164
+
165
+ self.logger.info(f"The host's hostname is: {host_hostname}")
166
+
167
+ return {
168
+ "id": self.task_id,
169
+ "state": self.state,
170
+ "config": self.configuration.json,
171
+ "node": host_hostname,
172
+ }
173
+
174
+ def send_callbacks(self, calculation_result):
175
+ """Sends a callback to both the scheduler and the callback url specified by the customer
176
+
177
+ Args:
178
+ calculation_result (any): The result of the calculation.
179
+ It can be the real result or an error too
180
+ """
181
+
182
+ self.__scheduler_callback.request_body = self.response
183
+ self.__scheduler_callback.request_body["result"] = calculation_result
184
+ self.__scheduler_callback.request_body["calculation_log"] = calculation_result["log"]
185
+ if self.__start_execution_time is not None:
186
+ self.__scheduler_callback.request_body["calculation_execution_time"] = (
187
+ time.time() - self.__start_execution_time
188
+ )
189
+ else:
190
+ self.__scheduler_callback.request_body["calculation_execution_time"] = "NOT_DEFINED"
191
+
192
+ if "error" in calculation_result:
193
+ self.__scheduler_callback.request_body["calculation_result"] = calculation_result[
194
+ "error"
195
+ ]
196
+ elif "result" in calculation_result:
197
+ self.__scheduler_callback.request_body["calculation_result"] = calculation_result[
198
+ "result"
199
+ ]
200
+
201
+ with self.__thread_lock:
202
+ self.__send_callback(self.__scheduler_callback)
203
+
204
+ if self.__configuration.callback.url:
205
+ self.__send_callback(self.__configuration.callback)
206
+ self.logger.info(
207
+ "Callback(s) has been sent-out successfully: {}".format(
208
+ self.__scheduler_callback.request_body["id"]
209
+ )
210
+ )
211
+
212
+ def __send_callback(self, callback_param):
213
+ """Sends a callback to the scheduler
214
+
215
+ Args:
216
+ callback_param (CallbackParameter): Response to be sent
217
+
218
+ Raises:
219
+ TaskCallbackFailedError: Raised when the callback cannot be successfully
220
+ sent after five retries
221
+ """
222
+
223
+ last_exception = None
224
+ response = None
225
+
226
+ for _ in range(5):
227
+ last_exception = None
228
+ response = None
229
+ time.sleep(random.uniform(0.2, 1))
230
+ self.logger.info("Start to send prepared request: {}".format(callback_param))
231
+ try:
232
+ # with requests.Session() as session:
233
+ # request = requests.Request(
234
+ # callback_param.method,
235
+ # callback_param.url,
236
+ # json=callback_param.request_body,
237
+ # headers=callback_param.headers,
238
+ # )
239
+ #
240
+ # prepared = session.prepare_request(request)
241
+ # response = session.send(prepared)
242
+
243
+ # Use BlackAnt HTTPClient for better request handling with connection pooling
244
+ # and authentication support, fallback to direct requests if not available
245
+ if HTTPClient:
246
+ try:
247
+ # Parse URL to extract base_url and endpoint
248
+ parsed_url = urlparse(callback_param.url)
249
+ base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
250
+ endpoint = parsed_url.path or "/"
251
+
252
+ # Create HTTP client instance
253
+ http_client = HTTPClient(base_url=base_url)
254
+
255
+ # Send request through BlackAnt HTTPClient
256
+ response = http_client.send_request(
257
+ endpoint=endpoint,
258
+ method=callback_param.method,
259
+ json=callback_param.request_body,
260
+ anonymous=True # Callback doesn't need authentication
261
+ )
262
+ except Exception as client_error:
263
+ self.logger.warning(f"HTTPClient failed, falling back to requests: {client_error}")
264
+ # Fallback to original requests implementation
265
+ response = requests.request(
266
+ callback_param.method,
267
+ callback_param.url,
268
+ json=callback_param.request_body,
269
+ headers=callback_param.headers,
270
+ )
271
+ else:
272
+ # Fallback to original requests implementation
273
+ response = requests.request(
274
+ callback_param.method,
275
+ callback_param.url,
276
+ json=callback_param.request_body,
277
+ headers=callback_param.headers,
278
+ )
279
+
280
+ if response.status_code != 200:
281
+ self.logger.warning(
282
+ "Could not send callback. Status code: {}. Response: {}".format(
283
+ response.status_code, response.text
284
+ )
285
+ )
286
+ time.sleep(random.uniform(0, 1))
287
+ continue
288
+
289
+ self.logger.info(
290
+ "Response status code: {} ; Response text: {}".format(
291
+ response.status_code, response.text
292
+ )
293
+ )
294
+
295
+ task_id_in_response = json.loads(response.request.body)["id"]
296
+
297
+ if task_id_in_response != callback_param.request_body["id"]:
298
+ self.logger.warning(
299
+ "The Task ID in the request "
300
+ "and response are not the same (<response> - <request>): "
301
+ "{} - {}".format(task_id_in_response, callback_param.request_body["id"])
302
+ )
303
+
304
+ return response
305
+
306
+ # The too-broad exception warning is suppressed because we want to handle exceptions
307
+ except Exception as ex: # pylint: disable=broad-except
308
+ self.logger.warning("Could not send callback")
309
+ last_exception = ex
310
+ time.sleep(random.uniform(0, 1))
311
+
312
+ if last_exception:
313
+ raise TaskCallbackFailedError(last_exception)
314
+ if not response:
315
+ raise TaskCallbackFailedError(
316
+ "Cannot send out the callback. Please see the previous part of log further details"
317
+ )
318
+
319
+ if response.status_code != 200:
320
+ raise TaskCallbackFailedError(
321
+ "Status code: {} , Response: {}".format(response.status_code, response.text)
322
+ )
323
+
324
+ def run(self):
325
+ """Starts the executor thread"""
326
+ self.__executor.start()
327
+
328
+ def stop(self):
329
+ """Sends a stop signal to the calculation"""
330
+ self.calculation.stop()
331
+
332
+ def __execute_state_machine(self):
333
+ """Runs the internal state machine until it does not reach a finished state"""
334
+ # The below 2-3 sec sleep is necessary because sometimes the return statement of POST method
335
+ # is faster than the result request.
336
+ # It means that the Task ID is not available in the DB when the callback handler
337
+ # gets the request about the result of the calculation.
338
+ # The Task running is executed on a separated thread, so it's independent of the POST
339
+ # method. This is the reason why the POST response is faster than Result request.
340
+ time.sleep(random.uniform(2, 3))
341
+
342
+ with redirect_stdout_stderr_to_logger(self.logger):
343
+ while not self.__finished:
344
+ try:
345
+ self.__start_execution_time = time.time()
346
+ self.__state.run()
347
+ # The too-broad exception warning is suppressed because we want to handle exceptions
348
+ except Exception as exc: # pylint: disable=broad-except
349
+ if self.__state.name != "error":
350
+ self.__state = TaskErrorState(self)
351
+ self.logger.error("Unhandled exception caught, going to error state")
352
+ self.logger.exception(exc)
353
+ else:
354
+ self.logger.critical(
355
+ "Unhandled exception caught during error state. Terminating..."
356
+ )
357
+ self.logger.exception(exc)
358
+ return