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.
- blackant/__init__.py +31 -0
- blackant/auth/__init__.py +10 -0
- blackant/auth/blackant_auth.py +518 -0
- blackant/auth/keycloak_manager.py +363 -0
- blackant/auth/request_id.py +52 -0
- blackant/auth/role_assignment.py +443 -0
- blackant/auth/tokens.py +57 -0
- blackant/client.py +400 -0
- blackant/config/__init__.py +0 -0
- blackant/config/docker_config.py +457 -0
- blackant/config/keycloak_admin_config.py +107 -0
- blackant/docker/__init__.py +12 -0
- blackant/docker/builder.py +616 -0
- blackant/docker/client.py +983 -0
- blackant/docker/dao.py +462 -0
- blackant/docker/registry.py +172 -0
- blackant/exceptions.py +111 -0
- blackant/http/__init__.py +8 -0
- blackant/http/client.py +125 -0
- blackant/patterns/__init__.py +1 -0
- blackant/patterns/singleton.py +20 -0
- blackant/services/__init__.py +10 -0
- blackant/services/dao.py +414 -0
- blackant/services/registry.py +635 -0
- blackant/utils/__init__.py +8 -0
- blackant/utils/initialization.py +32 -0
- blackant/utils/logging.py +337 -0
- blackant/utils/request_id.py +13 -0
- blackant/utils/store.py +50 -0
- blackant_sdk-1.0.2.dist-info/METADATA +117 -0
- blackant_sdk-1.0.2.dist-info/RECORD +70 -0
- blackant_sdk-1.0.2.dist-info/WHEEL +5 -0
- blackant_sdk-1.0.2.dist-info/top_level.txt +5 -0
- calculation/__init__.py +0 -0
- calculation/base.py +26 -0
- calculation/errors.py +2 -0
- calculation/impl/__init__.py +0 -0
- calculation/impl/my_calculation.py +144 -0
- calculation/impl/simple_calc.py +53 -0
- calculation/impl/test.py +1 -0
- calculation/impl/test_calc.py +36 -0
- calculation/loader.py +227 -0
- notifinations/__init__.py +8 -0
- notifinations/mail_sender.py +212 -0
- storage/__init__.py +0 -0
- storage/errors.py +10 -0
- storage/factory.py +26 -0
- storage/interface.py +19 -0
- storage/minio.py +106 -0
- task/__init__.py +0 -0
- task/dao.py +38 -0
- task/errors.py +10 -0
- task/log_adapter.py +11 -0
- task/parsers/__init__.py +0 -0
- task/parsers/base.py +13 -0
- task/parsers/callback.py +40 -0
- task/parsers/cmd_args.py +52 -0
- task/parsers/freetext.py +19 -0
- task/parsers/objects.py +50 -0
- task/parsers/request.py +56 -0
- task/resource.py +84 -0
- task/states/__init__.py +0 -0
- task/states/base.py +14 -0
- task/states/error.py +47 -0
- task/states/idle.py +12 -0
- task/states/ready.py +51 -0
- task/states/running.py +21 -0
- task/states/set_up.py +40 -0
- task/states/tear_down.py +29 -0
- task/task.py +358 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MyCalculation - Default calculation implementation for BlackAnt SDK.
|
|
4
|
+
|
|
5
|
+
This class provides a production-ready wrapper around the simple_calc module,
|
|
6
|
+
implementing the CalculationBase interface required by the BlackAnt task system.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from calculation.base import CalculationBase
|
|
13
|
+
from calculation.impl.simple_calc import calculate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MyCalculation(CalculationBase):
|
|
17
|
+
"""
|
|
18
|
+
Default calculation implementation using simple_calc logic.
|
|
19
|
+
|
|
20
|
+
This class implements the CalculationBase interface and provides
|
|
21
|
+
state machine compatibility (set_up → run → tear_down).
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
_logger: Logger instance for calculation events
|
|
25
|
+
_kwargs: Keyword arguments passed to calculation
|
|
26
|
+
_result: Calculation result (None until run() completes)
|
|
27
|
+
_is_running: Flag indicating calculation is in progress
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, logger: logging.Logger, **kwargs: Any) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Initialize MyCalculation with logger and configuration.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
logger: Logger instance for calculation events
|
|
36
|
+
**kwargs: Additional keyword arguments for calculation configuration
|
|
37
|
+
Expected keys:
|
|
38
|
+
- data: Dict with calculation input (default: kwargs itself)
|
|
39
|
+
- numbers: List of numbers to calculate (fallback)
|
|
40
|
+
"""
|
|
41
|
+
super().__init__(logger)
|
|
42
|
+
self._kwargs = kwargs
|
|
43
|
+
self._result: Optional[Dict[str, Any]] = None
|
|
44
|
+
self._is_running = False
|
|
45
|
+
|
|
46
|
+
self._logger.info("MyCalculation instance created")
|
|
47
|
+
self._logger.debug(f"Configuration: {kwargs}")
|
|
48
|
+
|
|
49
|
+
def set_up(self) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Prepare calculation resources (state machine: idle → set_up).
|
|
52
|
+
|
|
53
|
+
This method is called by the task system before run().
|
|
54
|
+
Performs validation and resource initialization.
|
|
55
|
+
"""
|
|
56
|
+
self._logger.info("MyCalculation setup started")
|
|
57
|
+
|
|
58
|
+
# Validate input data structure
|
|
59
|
+
data = self._kwargs.get('data', self._kwargs)
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
self._logger.warning(f"Expected dict data, got {type(data).__name__}")
|
|
62
|
+
|
|
63
|
+
self._logger.info("MyCalculation setup completed successfully")
|
|
64
|
+
|
|
65
|
+
def run(self) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Execute the calculation (state machine: set_up → running).
|
|
68
|
+
|
|
69
|
+
This method performs the actual calculation using simple_calc.calculate().
|
|
70
|
+
Results are stored in self._result and accessible via the result property.
|
|
71
|
+
"""
|
|
72
|
+
self._logger.info("MyCalculation execution started")
|
|
73
|
+
self._is_running = True
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Extract data from kwargs (task sends data in 'data' key or root level)
|
|
77
|
+
data = self._kwargs.get('data', self._kwargs)
|
|
78
|
+
|
|
79
|
+
# Execute calculation using simple_calc function
|
|
80
|
+
self._logger.debug(f"Calling calculate() with data: {data}")
|
|
81
|
+
self._result = calculate(data)
|
|
82
|
+
|
|
83
|
+
self._logger.info(f"Calculation completed successfully: {self._result}")
|
|
84
|
+
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
self._logger.error(f"Calculation failed: {exc}", exc_info=True)
|
|
87
|
+
self._result = {
|
|
88
|
+
'error': str(exc),
|
|
89
|
+
'status': 'failed'
|
|
90
|
+
}
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
finally:
|
|
94
|
+
self._is_running = False
|
|
95
|
+
|
|
96
|
+
def tear_down(self) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Clean up calculation resources (state machine: running → tear_down).
|
|
99
|
+
|
|
100
|
+
This method is called by the task system after run() completes.
|
|
101
|
+
Performs cleanup and resource release.
|
|
102
|
+
"""
|
|
103
|
+
self._logger.info("MyCalculation teardown started")
|
|
104
|
+
|
|
105
|
+
# Release resources (if any)
|
|
106
|
+
self._is_running = False
|
|
107
|
+
|
|
108
|
+
self._logger.info("MyCalculation teardown completed")
|
|
109
|
+
|
|
110
|
+
def stop(self) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Stop calculation execution (manual interrupt).
|
|
113
|
+
|
|
114
|
+
This method is called when the task is manually stopped.
|
|
115
|
+
Sets the running flag to False to signal interruption.
|
|
116
|
+
"""
|
|
117
|
+
self._logger.warning("MyCalculation stop requested")
|
|
118
|
+
self._is_running = False
|
|
119
|
+
|
|
120
|
+
if self._result is None:
|
|
121
|
+
self._result = {
|
|
122
|
+
'status': 'stopped',
|
|
123
|
+
'message': 'Calculation was stopped before completion'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
self._logger.info("MyCalculation stopped")
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def result(self) -> Optional[Dict[str, Any]]:
|
|
130
|
+
"""
|
|
131
|
+
Get calculation result.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Optional[Dict[str, Any]]: Calculation result or None if not yet executed
|
|
135
|
+
Expected structure:
|
|
136
|
+
{
|
|
137
|
+
'result': int/float, # Sum of numbers
|
|
138
|
+
'count': int, # Number count
|
|
139
|
+
'average': float, # Average value
|
|
140
|
+
'timestamp': str, # ISO format timestamp
|
|
141
|
+
'service_name': str # Service identifier
|
|
142
|
+
}
|
|
143
|
+
"""
|
|
144
|
+
return self._result
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Simple calculation implementation for BlackAnt SDK build_service testing."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def calculate(data):
|
|
11
|
+
"""Simple calculation function.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
data: Input data dictionary with 'numbers' list
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
dict: Calculation result
|
|
18
|
+
"""
|
|
19
|
+
numbers = data.get('numbers', [])
|
|
20
|
+
|
|
21
|
+
# Simple calculation: sum of numbers
|
|
22
|
+
result = sum(numbers)
|
|
23
|
+
|
|
24
|
+
# Add some processing time to simulate real calculation
|
|
25
|
+
time.sleep(0.5)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
'result': result,
|
|
29
|
+
'count': len(numbers),
|
|
30
|
+
'average': result / len(numbers) if numbers else 0,
|
|
31
|
+
'timestamp': datetime.now().isoformat(),
|
|
32
|
+
'service_name': os.getenv('SERVICE_NAME', 'simple-calc')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
"""Main function for standalone execution."""
|
|
38
|
+
# Test data
|
|
39
|
+
test_data = {
|
|
40
|
+
'numbers': [1, 2, 3, 4, 5, 10, 15, 20]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
print("🧮 BlackAnt Calculation Service Starting...")
|
|
44
|
+
print(f"📊 Input data: {test_data}")
|
|
45
|
+
|
|
46
|
+
result = calculate(test_data)
|
|
47
|
+
|
|
48
|
+
print(f"✅ Calculation completed: {json.dumps(result, indent=2)}")
|
|
49
|
+
print("🎯 Service ready for BlackAnt deployment!")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == '__main__':
|
|
53
|
+
main()
|
calculation/impl/test.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
print('test')
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Test calculation module for BlackAnt SDK live test."""
|
|
2
|
+
|
|
3
|
+
from calculation.base import BaseCalculation
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestCalculation(BaseCalculation):
|
|
7
|
+
"""Simple test calculation for SDK verification."""
|
|
8
|
+
|
|
9
|
+
def calculate(self, input_data: dict) -> dict:
|
|
10
|
+
"""
|
|
11
|
+
Simple calculation that processes a list of numbers.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
input_data: dict with 'numbers' key containing list of numbers
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
dict with sum, average, min, max
|
|
18
|
+
"""
|
|
19
|
+
numbers = input_data.get('numbers', [])
|
|
20
|
+
|
|
21
|
+
if not numbers:
|
|
22
|
+
return {
|
|
23
|
+
'error': 'No numbers provided',
|
|
24
|
+
'status': 'failed'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
result = {
|
|
28
|
+
'sum': sum(numbers),
|
|
29
|
+
'average': sum(numbers) / len(numbers),
|
|
30
|
+
'min': min(numbers),
|
|
31
|
+
'max': max(numbers),
|
|
32
|
+
'count': len(numbers),
|
|
33
|
+
'status': 'success'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result
|
calculation/loader.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CalculationLoader - Dynamic calculation loading system for BlackAnt SDK.
|
|
4
|
+
|
|
5
|
+
This module provides thread-safe, singleton-based dynamic loading of calculation
|
|
6
|
+
implementations. Supports environment variable configuration and automatic fallback.
|
|
7
|
+
|
|
8
|
+
Designed for production use with Gunicorn multi-worker environments (8 workers).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import importlib
|
|
14
|
+
from typing import Type, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from calculation.base import CalculationBase
|
|
17
|
+
from blackant.utils.logging import get_logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CalculationLoader:
|
|
21
|
+
"""
|
|
22
|
+
Thread-safe singleton loader for dynamic calculation instantiation.
|
|
23
|
+
|
|
24
|
+
This loader supports:
|
|
25
|
+
- Environment variable configuration (CALCULATION_MODULE, CALCULATION_CLASS)
|
|
26
|
+
- Thread-safe caching for multi-worker environments
|
|
27
|
+
- Automatic fallback to MyCalculation
|
|
28
|
+
- Type validation (must inherit from CalculationBase)
|
|
29
|
+
|
|
30
|
+
Thread Safety:
|
|
31
|
+
Designed for Gunicorn with --workers=8 configuration.
|
|
32
|
+
Uses threading.Lock for cache synchronization across threads.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
>>> loader = CalculationLoader()
|
|
36
|
+
>>> CalculationClass = loader.get_calculation()
|
|
37
|
+
>>> calc = CalculationClass(logger, **kwargs)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Class-level singleton instance
|
|
41
|
+
_instance: Optional['CalculationLoader'] = None
|
|
42
|
+
_instance_lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
def __new__(cls) -> 'CalculationLoader':
|
|
45
|
+
"""
|
|
46
|
+
Create or return singleton instance (thread-safe).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
CalculationLoader: Singleton instance
|
|
50
|
+
"""
|
|
51
|
+
if not cls._instance:
|
|
52
|
+
with cls._instance_lock:
|
|
53
|
+
# Double-check locking pattern for thread safety
|
|
54
|
+
if not cls._instance:
|
|
55
|
+
cls._instance = super().__new__(cls)
|
|
56
|
+
cls._instance._initialize()
|
|
57
|
+
return cls._instance
|
|
58
|
+
|
|
59
|
+
def _initialize(self) -> None:
|
|
60
|
+
"""Initialize loader state (called once by __new__)."""
|
|
61
|
+
self._cache: Dict[str, Type[CalculationBase]] = {}
|
|
62
|
+
self._cache_lock = threading.Lock()
|
|
63
|
+
self._logger = get_logger("CalculationLoader")
|
|
64
|
+
|
|
65
|
+
self._logger.info("CalculationLoader singleton initialized")
|
|
66
|
+
|
|
67
|
+
def get_calculation(self) -> Type[CalculationBase]:
|
|
68
|
+
"""
|
|
69
|
+
Get calculation class based on environment configuration.
|
|
70
|
+
|
|
71
|
+
Environment Variables:
|
|
72
|
+
CALCULATION_MODULE: Python module path (default: calculation.impl.my_calculation)
|
|
73
|
+
CALCULATION_CLASS: Class name (default: MyCalculation)
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Type[CalculationBase]: Calculation class ready for instantiation
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
TypeError: If loaded class does not inherit from CalculationBase
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
>>> os.environ['CALCULATION_MODULE'] = 'calculation.impl.simple_calc'
|
|
83
|
+
>>> os.environ['CALCULATION_CLASS'] = 'SimpleCalculation'
|
|
84
|
+
>>> loader = CalculationLoader()
|
|
85
|
+
>>> CalcClass = loader.get_calculation()
|
|
86
|
+
>>> calc = CalcClass(logger)
|
|
87
|
+
"""
|
|
88
|
+
# Get configuration from environment
|
|
89
|
+
module_path = os.getenv('CALCULATION_MODULE', 'calculation.impl.my_calculation')
|
|
90
|
+
class_name = os.getenv('CALCULATION_CLASS', 'MyCalculation')
|
|
91
|
+
|
|
92
|
+
cache_key = f"{module_path}.{class_name}"
|
|
93
|
+
|
|
94
|
+
# Check cache first (thread-safe read)
|
|
95
|
+
with self._cache_lock:
|
|
96
|
+
if cache_key in self._cache:
|
|
97
|
+
self._logger.debug(f"Cache hit: {cache_key}")
|
|
98
|
+
return self._cache[cache_key]
|
|
99
|
+
|
|
100
|
+
# Cache miss - load calculation
|
|
101
|
+
self._logger.info(f"Loading calculation: {module_path}.{class_name}")
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
calc_class = self._load_calculation(module_path, class_name)
|
|
105
|
+
|
|
106
|
+
# Cache the result (thread-safe write)
|
|
107
|
+
with self._cache_lock:
|
|
108
|
+
self._cache[cache_key] = calc_class
|
|
109
|
+
|
|
110
|
+
self._logger.info(f"Successfully loaded and cached: {cache_key}")
|
|
111
|
+
return calc_class
|
|
112
|
+
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
self._logger.error(f"Failed to load {cache_key}: {exc}")
|
|
115
|
+
self._logger.warning("Falling back to default MyCalculation")
|
|
116
|
+
|
|
117
|
+
# Fallback to default MyCalculation
|
|
118
|
+
return self._load_fallback()
|
|
119
|
+
|
|
120
|
+
def _load_calculation(self, module_path: str, class_name: str) -> Type[CalculationBase]:
|
|
121
|
+
"""
|
|
122
|
+
Load calculation class from module path.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
module_path: Python module path (e.g., 'calculation.impl.my_calculation')
|
|
126
|
+
class_name: Class name (e.g., 'MyCalculation')
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Type[CalculationBase]: Loaded calculation class
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ImportError: If module cannot be imported
|
|
133
|
+
AttributeError: If class does not exist in module
|
|
134
|
+
TypeError: If class is not a CalculationBase subclass
|
|
135
|
+
"""
|
|
136
|
+
# Import module
|
|
137
|
+
try:
|
|
138
|
+
module = importlib.import_module(module_path)
|
|
139
|
+
except ImportError as exc:
|
|
140
|
+
self._logger.error(f"Cannot import module '{module_path}': {exc}")
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
# Get class from module
|
|
144
|
+
try:
|
|
145
|
+
calc_class = getattr(module, class_name)
|
|
146
|
+
except AttributeError as exc:
|
|
147
|
+
self._logger.error(f"Class '{class_name}' not found in '{module_path}': {exc}")
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
# Validate it's a CalculationBase subclass
|
|
151
|
+
if not isinstance(calc_class, type):
|
|
152
|
+
raise TypeError(f"{class_name} is not a class (got {type(calc_class).__name__})")
|
|
153
|
+
|
|
154
|
+
if not issubclass(calc_class, CalculationBase):
|
|
155
|
+
raise TypeError(
|
|
156
|
+
f"{class_name} must inherit from CalculationBase "
|
|
157
|
+
f"(current bases: {[b.__name__ for b in calc_class.__bases__]})"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
self._logger.debug(f"Validated: {class_name} is a valid CalculationBase subclass")
|
|
161
|
+
return calc_class
|
|
162
|
+
|
|
163
|
+
def _load_fallback(self) -> Type[CalculationBase]:
|
|
164
|
+
"""
|
|
165
|
+
Load fallback calculation (MyCalculation).
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Type[CalculationBase]: MyCalculation class
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ImportError: If even fallback cannot be loaded (critical error)
|
|
172
|
+
"""
|
|
173
|
+
fallback_module = 'calculation.impl.my_calculation'
|
|
174
|
+
fallback_class = 'MyCalculation'
|
|
175
|
+
|
|
176
|
+
cache_key = f"{fallback_module}.{fallback_class}"
|
|
177
|
+
|
|
178
|
+
# Check if fallback is already cached
|
|
179
|
+
with self._cache_lock:
|
|
180
|
+
if cache_key in self._cache:
|
|
181
|
+
return self._cache[cache_key]
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
from calculation.impl.my_calculation import MyCalculation
|
|
185
|
+
|
|
186
|
+
# Cache fallback
|
|
187
|
+
with self._cache_lock:
|
|
188
|
+
self._cache[cache_key] = MyCalculation
|
|
189
|
+
|
|
190
|
+
self._logger.info("Fallback MyCalculation loaded successfully")
|
|
191
|
+
return MyCalculation
|
|
192
|
+
|
|
193
|
+
except ImportError as exc:
|
|
194
|
+
self._logger.critical(f"CRITICAL: Cannot load fallback MyCalculation: {exc}")
|
|
195
|
+
raise ImportError(
|
|
196
|
+
"CalculationLoader failed: cannot load configured calculation "
|
|
197
|
+
"and fallback MyCalculation is also unavailable. "
|
|
198
|
+
"Ensure calculation/impl/my_calculation.py exists."
|
|
199
|
+
) from exc
|
|
200
|
+
|
|
201
|
+
def clear_cache(self) -> None:
|
|
202
|
+
"""
|
|
203
|
+
Clear calculation cache (useful for testing or hot-reload).
|
|
204
|
+
|
|
205
|
+
Thread-safe operation.
|
|
206
|
+
"""
|
|
207
|
+
with self._cache_lock:
|
|
208
|
+
cache_size = len(self._cache)
|
|
209
|
+
self._cache.clear()
|
|
210
|
+
self._logger.info(f"Cache cleared ({cache_size} entries removed)")
|
|
211
|
+
|
|
212
|
+
def get_cache_info(self) -> Dict[str, int]:
|
|
213
|
+
"""
|
|
214
|
+
Get cache statistics.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Dict[str, int]: Cache information
|
|
218
|
+
{
|
|
219
|
+
'size': Number of cached calculations,
|
|
220
|
+
'entries': List of cached keys
|
|
221
|
+
}
|
|
222
|
+
"""
|
|
223
|
+
with self._cache_lock:
|
|
224
|
+
return {
|
|
225
|
+
'size': len(self._cache),
|
|
226
|
+
'entries': list(self._cache.keys())
|
|
227
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Email sender module.
|
|
2
|
+
|
|
3
|
+
Module for sending email messages via SMTP.
|
|
4
|
+
|
|
5
|
+
Example usage:
|
|
6
|
+
from mail_sender import EmailSender
|
|
7
|
+
mail_sender_inst = EmailSender()
|
|
8
|
+
mail_sender_inst.send_mail(
|
|
9
|
+
subject="My subject",
|
|
10
|
+
mail_body="My body",
|
|
11
|
+
addr_to="who_get_mail@mail.com",
|
|
12
|
+
addr_from="no_reply@blackant.app",
|
|
13
|
+
addr_cc="get_copy_mail@mail.com",
|
|
14
|
+
attachments=["file1.txt", "file2.json"],
|
|
15
|
+
reply_to="personal_mail@mail.com"
|
|
16
|
+
)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import smtplib
|
|
22
|
+
from email.mime.application import MIMEApplication
|
|
23
|
+
from email.mime.multipart import MIMEMultipart
|
|
24
|
+
from email.mime.text import MIMEText
|
|
25
|
+
from logging import Logger
|
|
26
|
+
from typing import List, Optional, Union
|
|
27
|
+
|
|
28
|
+
from blackant.utils.logging import get_logger
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EmailSender: # pylint: disable=too-few-public-methods
|
|
32
|
+
"""Email sender class for SMTP operations.
|
|
33
|
+
|
|
34
|
+
This class handles email sending operations with support for
|
|
35
|
+
attachments, CC recipients, and reply-to addresses.
|
|
36
|
+
|
|
37
|
+
The init method creates an SMTP client and handles authentication.
|
|
38
|
+
Emails are constructed using the email.mime module and sent to recipients.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, logger: Optional[Logger] = None) -> None:
|
|
42
|
+
"""Initialize the logger object and SMTP client.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
logger: External logger to use. If None, creates default logger.
|
|
46
|
+
|
|
47
|
+
Environment variables:
|
|
48
|
+
SMTP_HOST: SMTP server hostname (default: mail.blackant.app)
|
|
49
|
+
SMTP_PORT: SMTP server port (default: 26)
|
|
50
|
+
SMTP_USER: SMTP username (required)
|
|
51
|
+
SMTP_PASSWORD: SMTP password (required)
|
|
52
|
+
"""
|
|
53
|
+
self.__logger = logger or get_logger("email_sender")
|
|
54
|
+
|
|
55
|
+
# Get SMTP configuration from environment
|
|
56
|
+
smtp_host = os.getenv("SMTP_HOST", "mail.blackant.app")
|
|
57
|
+
smtp_port = int(os.getenv("SMTP_PORT", "26"))
|
|
58
|
+
smtp_user = os.getenv("SMTP_USER")
|
|
59
|
+
smtp_password = os.getenv("SMTP_PASSWORD")
|
|
60
|
+
|
|
61
|
+
if not smtp_user or not smtp_password:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"SMTP_USER and SMTP_PASSWORD environment variables are required. "
|
|
64
|
+
"Please set them before using EmailSender."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.__logger.info("Initialize the SMTP client")
|
|
68
|
+
try:
|
|
69
|
+
self.__smtp_client = smtplib.SMTP(host=smtp_host, port=smtp_port)
|
|
70
|
+
except Exception:
|
|
71
|
+
self.__logger.exception("Cannot create the SMTP client")
|
|
72
|
+
raise
|
|
73
|
+
self.__logger.info("The SMTP client has been initialized")
|
|
74
|
+
try:
|
|
75
|
+
self.__smtp_client.login(user=smtp_user, password=smtp_password)
|
|
76
|
+
except Exception:
|
|
77
|
+
self.__logger.exception("Cannot login to SMTP server")
|
|
78
|
+
raise
|
|
79
|
+
self.__logger.info("Login to SMTP server has been successful")
|
|
80
|
+
|
|
81
|
+
def __string_to_list(self, string_param: Union[str, List, tuple, None]) -> List[str]:
|
|
82
|
+
"""Convert input to list type.
|
|
83
|
+
|
|
84
|
+
Attempts to convert the input argument to list type.
|
|
85
|
+
Handles most built-in types that can be cast to list.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
string_param: The object to convert, typically a string.
|
|
89
|
+
Can also handle other types (e.g., Tuple).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
list: The converted list.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
list_to_return: list = []
|
|
96
|
+
if not string_param:
|
|
97
|
+
return list_to_return
|
|
98
|
+
if isinstance(string_param, str):
|
|
99
|
+
list_to_return.extend(re.split(r"[,;]", string_param))
|
|
100
|
+
return list_to_return
|
|
101
|
+
try:
|
|
102
|
+
return list(string_param)
|
|
103
|
+
except TypeError as type_error:
|
|
104
|
+
self.__logger.exception("Cannot convert the string to list.")
|
|
105
|
+
raise type_error
|
|
106
|
+
|
|
107
|
+
def send_mail( # pylint: disable=too-many-arguments
|
|
108
|
+
self,
|
|
109
|
+
subject: str,
|
|
110
|
+
mail_body: str,
|
|
111
|
+
addr_to: Union[str, List[str]],
|
|
112
|
+
addr_from: str = "no-reply@blackant.app",
|
|
113
|
+
addr_cc: Optional[Union[str, List[str]]] = None,
|
|
114
|
+
attachments: Optional[List[str]] = None,
|
|
115
|
+
reply_to: Optional[str] = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Send an email message.
|
|
118
|
+
|
|
119
|
+
Constructs and sends an email based on the provided arguments.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
subject: Email subject line.
|
|
123
|
+
mail_body: Email content (Text/HTML).
|
|
124
|
+
addr_to: Recipient email address.
|
|
125
|
+
addr_from: Sender email address. Defaults to no-reply@blackant.app.
|
|
126
|
+
addr_cc: Carbon copy recipient email address.
|
|
127
|
+
attachments: List of file paths to attach.
|
|
128
|
+
reply_to: Email address to use for replies.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
addr_to = self.__string_to_list(addr_to)
|
|
132
|
+
addr_cc = self.__string_to_list(addr_cc)
|
|
133
|
+
|
|
134
|
+
if attachments:
|
|
135
|
+
if not isinstance(attachments, list):
|
|
136
|
+
raise TypeError("Attachments has to be list type.")
|
|
137
|
+
if all(single_attachment is None for single_attachment in attachments):
|
|
138
|
+
self.__logger.warning(
|
|
139
|
+
"All elements of attachment list are None. Nothing will be attached to mail."
|
|
140
|
+
)
|
|
141
|
+
attachments = []
|
|
142
|
+
try:
|
|
143
|
+
self.__logger.info("E-mail will be sent to {}".format(addr_to))
|
|
144
|
+
if addr_cc:
|
|
145
|
+
self.__logger.info("Copy will be sent to {}".format(addr_cc))
|
|
146
|
+
message = self.__get_message_body(
|
|
147
|
+
subject=subject,
|
|
148
|
+
mail_body=mail_body,
|
|
149
|
+
addr_to=addr_to,
|
|
150
|
+
addr_from=addr_from,
|
|
151
|
+
addr_cc=addr_cc,
|
|
152
|
+
attachments=attachments,
|
|
153
|
+
reply_to=reply_to,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
self.__logger.debug("The created message: {}".format(message.as_string()))
|
|
157
|
+
|
|
158
|
+
addr_to.extend(addr_cc)
|
|
159
|
+
self.__logger.info("Starting to send out the E-mail.")
|
|
160
|
+
self.__smtp_client.sendmail(addr_from, addr_to, message.as_string())
|
|
161
|
+
self.__logger.info("The E-mail has been sent out successfully.")
|
|
162
|
+
except smtplib.SMTPConnectError:
|
|
163
|
+
self.__logger.exception("Cannot connect to SMTP server.")
|
|
164
|
+
except smtplib.SMTPDataError:
|
|
165
|
+
self.__logger.exception("Data error in mail sending.")
|
|
166
|
+
finally:
|
|
167
|
+
self.__logger.info("Closing the SMTP connection.")
|
|
168
|
+
self.__smtp_client.quit()
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def __get_message_body( # pylint: disable=too-many-arguments
|
|
172
|
+
subject: str,
|
|
173
|
+
mail_body: str,
|
|
174
|
+
addr_to: List[str],
|
|
175
|
+
addr_from: str,
|
|
176
|
+
addr_cc: List[str],
|
|
177
|
+
attachments: Optional[List[str]] = None,
|
|
178
|
+
reply_to: Optional[str] = None
|
|
179
|
+
) -> MIMEMultipart:
|
|
180
|
+
"""Construct the email message body.
|
|
181
|
+
|
|
182
|
+
Creates the email content based on the provided arguments.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
subject: Email subject line.
|
|
186
|
+
mail_body: Email content (Text/HTML).
|
|
187
|
+
addr_to: Recipient email address.
|
|
188
|
+
addr_from: Sender email address. Defaults to no-reply@blackant.app.
|
|
189
|
+
addr_cc: Carbon copy recipient email address.
|
|
190
|
+
attachments: List of file paths to attach.
|
|
191
|
+
reply_to: Email address to use for replies.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
MIMEMultipart: The constructed email message.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
msg = MIMEMultipart("alternative")
|
|
198
|
+
msg["To"] = ", ".join(addr_to) if addr_to else ""
|
|
199
|
+
msg["CC"] = ", ".join(addr_cc) if addr_cc else ""
|
|
200
|
+
msg["From"] = addr_from
|
|
201
|
+
msg["Subject"] = subject
|
|
202
|
+
msg.attach(MIMEText(mail_body, "html"))
|
|
203
|
+
if reply_to:
|
|
204
|
+
msg.add_header("reply-to", reply_to)
|
|
205
|
+
for single_file in attachments or []:
|
|
206
|
+
with open(single_file, "rb") as opened_file:
|
|
207
|
+
part = MIMEApplication(opened_file.read(), Name=os.path.basename(single_file))
|
|
208
|
+
part["Content-Disposition"] = 'attachment; filename="{}"'.format(
|
|
209
|
+
os.path.basename(single_file)
|
|
210
|
+
)
|
|
211
|
+
msg.attach(part)
|
|
212
|
+
return msg
|
storage/__init__.py
ADDED
|
File without changes
|