ergon-framework-python 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.
- ergon/__init__.py +13 -0
- ergon/bootstrap/src/__project__/__init__.py +0 -0
- ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
- ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
- ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
- ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
- ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
- ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
- ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
- ergon/bootstrap/src/__project__/main.py +9 -0
- ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
- ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
- ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
- ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
- ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
- ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
- ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
- ergon/cli.py +174 -0
- ergon/connector/__init__.py +64 -0
- ergon/connector/connector.py +97 -0
- ergon/connector/excel/__init__.py +18 -0
- ergon/connector/excel/connector.py +175 -0
- ergon/connector/excel/models.py +24 -0
- ergon/connector/excel/service.py +98 -0
- ergon/connector/pipefy/__init__.py +21 -0
- ergon/connector/pipefy/async_connector.py +48 -0
- ergon/connector/pipefy/async_service.py +907 -0
- ergon/connector/pipefy/connector.py +36 -0
- ergon/connector/pipefy/models.py +48 -0
- ergon/connector/pipefy/service.py +1016 -0
- ergon/connector/pipefy/version.py +1 -0
- ergon/connector/postgres/__init__.py +11 -0
- ergon/connector/postgres/async_connector.py +119 -0
- ergon/connector/postgres/async_service.py +116 -0
- ergon/connector/postgres/models.py +34 -0
- ergon/connector/rabbitmq/__init__.py +25 -0
- ergon/connector/rabbitmq/async_connector.py +120 -0
- ergon/connector/rabbitmq/async_service.py +417 -0
- ergon/connector/rabbitmq/connector.py +54 -0
- ergon/connector/rabbitmq/helper.py +14 -0
- ergon/connector/rabbitmq/models.py +92 -0
- ergon/connector/rabbitmq/service.py +199 -0
- ergon/connector/sqs/__init__.py +15 -0
- ergon/connector/sqs/async_connector.py +120 -0
- ergon/connector/sqs/async_service.py +246 -0
- ergon/connector/sqs/connector.py +120 -0
- ergon/connector/sqs/models.py +36 -0
- ergon/connector/sqs/service.py +219 -0
- ergon/connector/transaction.py +14 -0
- ergon/py.typed +0 -0
- ergon/service/__init__.py +5 -0
- ergon/service/service.py +17 -0
- ergon/task/__init__.py +13 -0
- ergon/task/base.py +222 -0
- ergon/task/exceptions.py +217 -0
- ergon/task/helpers.py +691 -0
- ergon/task/manager.py +85 -0
- ergon/task/mixins/__init__.py +13 -0
- ergon/task/mixins/consumer.py +858 -0
- ergon/task/mixins/metrics.py +457 -0
- ergon/task/mixins/producer.py +486 -0
- ergon/task/policies.py +229 -0
- ergon/task/runner.py +386 -0
- ergon/task/utils.py +64 -0
- ergon/telemetry/__init__.py +7 -0
- ergon/telemetry/_resource.py +13 -0
- ergon/telemetry/logging.py +370 -0
- ergon/telemetry/metrics.py +101 -0
- ergon/telemetry/tracing.py +152 -0
- ergon/utils/__init__.py +5 -0
- ergon/utils/env.py +26 -0
- ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
- ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
- ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
- ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
- ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SQSClient(BaseModel):
|
|
7
|
+
region_name: str = Field(description="AWS region name (e.g. us-east-1)")
|
|
8
|
+
aws_access_key_id: Optional[str] = Field(default=None, description="AWS access key ID. None = use env/IAM role")
|
|
9
|
+
aws_secret_access_key: Optional[str] = Field(
|
|
10
|
+
default=None, description="AWS secret access key. None = use env/IAM role"
|
|
11
|
+
)
|
|
12
|
+
aws_session_token: Optional[str] = Field(default=None, description="AWS session token for temporary credentials")
|
|
13
|
+
endpoint_url: Optional[str] = Field(default=None, description="Custom endpoint URL (e.g. LocalStack)")
|
|
14
|
+
queue_url: Optional[str] = Field(default=None, description="Default SQS queue URL, can be overridden per-call")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SQSConsumerConfig(BaseModel):
|
|
18
|
+
queue_url: Optional[str] = Field(default=None, description="Override default queue URL for consuming")
|
|
19
|
+
wait_time_seconds: int = Field(default=20, description="Long polling wait time in seconds (0-20)")
|
|
20
|
+
visibility_timeout: Optional[int] = Field(
|
|
21
|
+
default=None, description="Override queue's default visibility timeout in seconds"
|
|
22
|
+
)
|
|
23
|
+
attribute_names: List[str] = Field(default=["All"], description="System attribute names to include in response")
|
|
24
|
+
message_attribute_names: List[str] = Field(
|
|
25
|
+
default=["All"], description="Message attribute names to include in response"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SQSProducerConfig(BaseModel):
|
|
30
|
+
queue_url: Optional[str] = Field(default=None, description="Override default queue URL for producing")
|
|
31
|
+
delay_seconds: int = Field(default=0, description="Delay in seconds before message becomes visible (0-900)")
|
|
32
|
+
message_group_id: Optional[str] = Field(default=None, description="Message group ID for FIFO queues")
|
|
33
|
+
message_deduplication_id: Optional[str] = Field(default=None, description="Deduplication ID for FIFO queues")
|
|
34
|
+
message_attributes: Optional[Dict[str, Dict]] = Field(
|
|
35
|
+
default=None, description="Custom message attributes to attach to sent messages"
|
|
36
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
import boto3
|
|
6
|
+
|
|
7
|
+
from .models import SQSClient
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SQSService:
|
|
13
|
+
def __init__(self, client: SQSClient) -> None:
|
|
14
|
+
self.client = client
|
|
15
|
+
|
|
16
|
+
kwargs: Dict[str, Any] = {"region_name": client.region_name}
|
|
17
|
+
if client.aws_access_key_id:
|
|
18
|
+
kwargs["aws_access_key_id"] = client.aws_access_key_id
|
|
19
|
+
if client.aws_secret_access_key:
|
|
20
|
+
kwargs["aws_secret_access_key"] = client.aws_secret_access_key
|
|
21
|
+
if client.aws_session_token:
|
|
22
|
+
kwargs["aws_session_token"] = client.aws_session_token
|
|
23
|
+
if client.endpoint_url:
|
|
24
|
+
kwargs["endpoint_url"] = client.endpoint_url
|
|
25
|
+
|
|
26
|
+
self._sqs = boto3.client("sqs", **kwargs)
|
|
27
|
+
|
|
28
|
+
# ---------- Receive ----------
|
|
29
|
+
|
|
30
|
+
def receive_messages(
|
|
31
|
+
self,
|
|
32
|
+
queue_url: Optional[str] = None,
|
|
33
|
+
max_number_of_messages: int = 1,
|
|
34
|
+
wait_time_seconds: int = 20,
|
|
35
|
+
visibility_timeout: Optional[int] = None,
|
|
36
|
+
attribute_names: Optional[List[str]] = None,
|
|
37
|
+
message_attribute_names: Optional[List[str]] = None,
|
|
38
|
+
) -> List[Dict[str, Any]]:
|
|
39
|
+
"""
|
|
40
|
+
Receive up to `max_number_of_messages` from the queue.
|
|
41
|
+
|
|
42
|
+
SQS caps MaxNumberOfMessages at 10 per API call, so for larger
|
|
43
|
+
batch sizes this loops until the requested count is reached or
|
|
44
|
+
the queue returns no more messages.
|
|
45
|
+
"""
|
|
46
|
+
url = queue_url or self.client.queue_url
|
|
47
|
+
if not url:
|
|
48
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
49
|
+
|
|
50
|
+
collected: List[Dict[str, Any]] = []
|
|
51
|
+
remaining = max_number_of_messages
|
|
52
|
+
|
|
53
|
+
first_call = True
|
|
54
|
+
while remaining > 0:
|
|
55
|
+
fetch_count = min(remaining, 10)
|
|
56
|
+
|
|
57
|
+
params: Dict[str, Any] = {
|
|
58
|
+
"QueueUrl": url,
|
|
59
|
+
"MaxNumberOfMessages": fetch_count,
|
|
60
|
+
"WaitTimeSeconds": wait_time_seconds if first_call else 0,
|
|
61
|
+
"AttributeNames": attribute_names or ["All"],
|
|
62
|
+
"MessageAttributeNames": message_attribute_names or ["All"],
|
|
63
|
+
}
|
|
64
|
+
if visibility_timeout is not None:
|
|
65
|
+
params["VisibilityTimeout"] = visibility_timeout
|
|
66
|
+
|
|
67
|
+
response = self._sqs.receive_message(**params)
|
|
68
|
+
messages = response.get("Messages", [])
|
|
69
|
+
first_call = False
|
|
70
|
+
|
|
71
|
+
if not messages:
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
collected.extend(messages)
|
|
75
|
+
remaining -= len(messages)
|
|
76
|
+
|
|
77
|
+
if len(messages) < fetch_count:
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
return collected
|
|
81
|
+
|
|
82
|
+
# ---------- Send ----------
|
|
83
|
+
|
|
84
|
+
def send_message(
|
|
85
|
+
self,
|
|
86
|
+
message_body: Any,
|
|
87
|
+
queue_url: Optional[str] = None,
|
|
88
|
+
delay_seconds: int = 0,
|
|
89
|
+
message_attributes: Optional[Dict[str, Dict]] = None,
|
|
90
|
+
message_group_id: Optional[str] = None,
|
|
91
|
+
message_deduplication_id: Optional[str] = None,
|
|
92
|
+
) -> Dict[str, Any]:
|
|
93
|
+
url = queue_url or self.client.queue_url
|
|
94
|
+
if not url:
|
|
95
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
96
|
+
|
|
97
|
+
body = json.dumps(message_body) if not isinstance(message_body, str) else message_body
|
|
98
|
+
|
|
99
|
+
params: Dict[str, Any] = {
|
|
100
|
+
"QueueUrl": url,
|
|
101
|
+
"MessageBody": body,
|
|
102
|
+
"DelaySeconds": delay_seconds,
|
|
103
|
+
}
|
|
104
|
+
if message_attributes:
|
|
105
|
+
params["MessageAttributes"] = message_attributes
|
|
106
|
+
if message_group_id:
|
|
107
|
+
params["MessageGroupId"] = message_group_id
|
|
108
|
+
if message_deduplication_id:
|
|
109
|
+
params["MessageDeduplicationId"] = message_deduplication_id
|
|
110
|
+
|
|
111
|
+
return self._sqs.send_message(**params)
|
|
112
|
+
|
|
113
|
+
def send_message_batch(
|
|
114
|
+
self,
|
|
115
|
+
entries: List[Dict[str, Any]],
|
|
116
|
+
queue_url: Optional[str] = None,
|
|
117
|
+
) -> Dict[str, Any]:
|
|
118
|
+
"""
|
|
119
|
+
Send messages in batches of up to 10 (SQS limit).
|
|
120
|
+
Each entry must have at minimum 'Id' and 'MessageBody'.
|
|
121
|
+
"""
|
|
122
|
+
url = queue_url or self.client.queue_url
|
|
123
|
+
if not url:
|
|
124
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
125
|
+
|
|
126
|
+
successful = []
|
|
127
|
+
failed = []
|
|
128
|
+
|
|
129
|
+
for i in range(0, len(entries), 10):
|
|
130
|
+
chunk = entries[i : i + 10]
|
|
131
|
+
response = self._sqs.send_message_batch(QueueUrl=url, Entries=chunk)
|
|
132
|
+
successful.extend(response.get("Successful", []))
|
|
133
|
+
failed.extend(response.get("Failed", []))
|
|
134
|
+
|
|
135
|
+
return {"Successful": successful, "Failed": failed}
|
|
136
|
+
|
|
137
|
+
# ---------- Delete ----------
|
|
138
|
+
|
|
139
|
+
def delete_message(self, receipt_handle: str, queue_url: Optional[str] = None) -> None:
|
|
140
|
+
url = queue_url or self.client.queue_url
|
|
141
|
+
if not url:
|
|
142
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
143
|
+
|
|
144
|
+
self._sqs.delete_message(QueueUrl=url, ReceiptHandle=receipt_handle)
|
|
145
|
+
|
|
146
|
+
def delete_message_batch(
|
|
147
|
+
self,
|
|
148
|
+
entries: List[Dict[str, str]],
|
|
149
|
+
queue_url: Optional[str] = None,
|
|
150
|
+
) -> Dict[str, Any]:
|
|
151
|
+
"""
|
|
152
|
+
Delete messages in batches of up to 10.
|
|
153
|
+
Each entry must have 'Id' and 'ReceiptHandle'.
|
|
154
|
+
"""
|
|
155
|
+
url = queue_url or self.client.queue_url
|
|
156
|
+
if not url:
|
|
157
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
158
|
+
|
|
159
|
+
successful = []
|
|
160
|
+
failed = []
|
|
161
|
+
|
|
162
|
+
for i in range(0, len(entries), 10):
|
|
163
|
+
chunk = entries[i : i + 10]
|
|
164
|
+
response = self._sqs.delete_message_batch(QueueUrl=url, Entries=chunk)
|
|
165
|
+
successful.extend(response.get("Successful", []))
|
|
166
|
+
failed.extend(response.get("Failed", []))
|
|
167
|
+
|
|
168
|
+
return {"Successful": successful, "Failed": failed}
|
|
169
|
+
|
|
170
|
+
# ---------- Queue Management ----------
|
|
171
|
+
|
|
172
|
+
def list_queues(self, prefix: Optional[str] = None) -> List[str]:
|
|
173
|
+
params: Dict[str, Any] = {}
|
|
174
|
+
if prefix:
|
|
175
|
+
params["QueueNamePrefix"] = prefix
|
|
176
|
+
|
|
177
|
+
response = self._sqs.list_queues(**params)
|
|
178
|
+
return response.get("QueueUrls", [])
|
|
179
|
+
|
|
180
|
+
def get_queue_attributes(
|
|
181
|
+
self,
|
|
182
|
+
attribute_names: Optional[List[str]] = None,
|
|
183
|
+
queue_url: Optional[str] = None,
|
|
184
|
+
) -> Dict[str, str]:
|
|
185
|
+
url = queue_url or self.client.queue_url
|
|
186
|
+
if not url:
|
|
187
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
188
|
+
|
|
189
|
+
response = self._sqs.get_queue_attributes(
|
|
190
|
+
QueueUrl=url,
|
|
191
|
+
AttributeNames=attribute_names or ["All"],
|
|
192
|
+
)
|
|
193
|
+
return response.get("Attributes", {})
|
|
194
|
+
|
|
195
|
+
def create_queue(
|
|
196
|
+
self,
|
|
197
|
+
queue_name: str,
|
|
198
|
+
attributes: Optional[Dict[str, str]] = None,
|
|
199
|
+
) -> str:
|
|
200
|
+
params: Dict[str, Any] = {"QueueName": queue_name}
|
|
201
|
+
if attributes:
|
|
202
|
+
params["Attributes"] = attributes
|
|
203
|
+
|
|
204
|
+
response = self._sqs.create_queue(**params)
|
|
205
|
+
return response["QueueUrl"]
|
|
206
|
+
|
|
207
|
+
def delete_queue(self, queue_url: Optional[str] = None) -> None:
|
|
208
|
+
url = queue_url or self.client.queue_url
|
|
209
|
+
if not url:
|
|
210
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
211
|
+
|
|
212
|
+
self._sqs.delete_queue(QueueUrl=url)
|
|
213
|
+
|
|
214
|
+
def purge_queue(self, queue_url: Optional[str] = None) -> None:
|
|
215
|
+
url = queue_url or self.client.queue_url
|
|
216
|
+
if not url:
|
|
217
|
+
raise ValueError("queue_url must be provided either in SQSClient or per-call")
|
|
218
|
+
|
|
219
|
+
self._sqs.purge_queue(QueueUrl=url)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Transaction(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
Generic transaction model.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
id: str
|
|
12
|
+
payload: Any
|
|
13
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
14
|
+
model_config = ConfigDict(frozen=True)
|
ergon/py.typed
ADDED
|
File without changes
|
ergon/service/service.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ServiceConfig(BaseModel):
|
|
7
|
+
"""
|
|
8
|
+
Generic service configuration.
|
|
9
|
+
|
|
10
|
+
service: class implementing a service
|
|
11
|
+
args: positional arguments passed to service's __init__
|
|
12
|
+
kwargs: keyword arguments passed to service's __init__
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
service: type
|
|
16
|
+
args: tuple[Any, ...] = ()
|
|
17
|
+
kwargs: dict[str, Any] = {}
|
ergon/task/__init__.py
ADDED
ergon/task/base.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from abc import ABC, ABCMeta, abstractmethod
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, model_validator
|
|
6
|
+
|
|
7
|
+
from .. import telemetry
|
|
8
|
+
from ..connector import Connector, ConnectorConfig
|
|
9
|
+
from ..service import ServiceConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskConfig(BaseModel):
|
|
13
|
+
"""
|
|
14
|
+
Declarative configuration for a task.
|
|
15
|
+
Specifies:
|
|
16
|
+
- which Task class to run
|
|
17
|
+
- input/output connectors
|
|
18
|
+
- logger configuration
|
|
19
|
+
- worker scaling
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
task: object
|
|
24
|
+
max_workers: int = 1
|
|
25
|
+
connectors: Dict[str, ConnectorConfig] = Field(default_factory=dict)
|
|
26
|
+
services: Dict[str, ServiceConfig] = Field(default_factory=dict)
|
|
27
|
+
policies: List[Any] = Field(default_factory=list)
|
|
28
|
+
logging: Optional[telemetry.logging.LoggingConfig] = None
|
|
29
|
+
metrics: Optional[telemetry.metrics.MetricsConfig] = None
|
|
30
|
+
tracing: Optional[telemetry.tracing.TracingConfig] = None
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def validate(self) -> "TaskConfig":
|
|
34
|
+
if not self.connectors and not self.services:
|
|
35
|
+
raise ValueError("Task must define at least one connector or service.")
|
|
36
|
+
|
|
37
|
+
if self.max_workers < 1:
|
|
38
|
+
raise ValueError("max_workers must be at least 1.")
|
|
39
|
+
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TaskMeta(ABCMeta):
|
|
44
|
+
"""
|
|
45
|
+
Metaclass that injects connectors, services and execution policies into task instances.
|
|
46
|
+
Consumed by BaseTask and BaseAsyncTask.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __call__(
|
|
50
|
+
cls,
|
|
51
|
+
connectors: Dict[str, Connector],
|
|
52
|
+
services: Dict[str, Any],
|
|
53
|
+
policies: List[Any],
|
|
54
|
+
worker_id: Optional[int] = None,
|
|
55
|
+
task_config: Optional[TaskConfig] = None,
|
|
56
|
+
*args,
|
|
57
|
+
**kwargs,
|
|
58
|
+
):
|
|
59
|
+
# ---------------------------------------------------------
|
|
60
|
+
# Create instance normally
|
|
61
|
+
# ---------------------------------------------------------
|
|
62
|
+
self = super().__call__(
|
|
63
|
+
connectors=connectors,
|
|
64
|
+
services=services,
|
|
65
|
+
policies=policies,
|
|
66
|
+
worker_id=worker_id,
|
|
67
|
+
task_config=task_config,
|
|
68
|
+
*args,
|
|
69
|
+
**kwargs,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------
|
|
73
|
+
# Store DI containers
|
|
74
|
+
# ---------------------------------------------------------
|
|
75
|
+
self.connectors = connectors
|
|
76
|
+
self.services = services
|
|
77
|
+
self.policies = policies
|
|
78
|
+
self.worker_id = worker_id
|
|
79
|
+
self.task_config = task_config
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------
|
|
82
|
+
# Expose connectors as attributes
|
|
83
|
+
# Example: pipefy_connector, rabbitmq_connector
|
|
84
|
+
# ---------------------------------------------------------
|
|
85
|
+
for name, conn in connectors.items():
|
|
86
|
+
setattr(self, f"{name}_connector", conn)
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------
|
|
89
|
+
# Expose services as attributes
|
|
90
|
+
# Example: openai_service, s3_service
|
|
91
|
+
# ---------------------------------------------------------
|
|
92
|
+
for name, service in services.items():
|
|
93
|
+
setattr(self, f"{name}_service", service)
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------
|
|
96
|
+
# Expose policies as attributes
|
|
97
|
+
# Example: policy_name
|
|
98
|
+
# ---------------------------------------------------------
|
|
99
|
+
for policy in policies:
|
|
100
|
+
name = policy.name or policy.__class__.__name__
|
|
101
|
+
setattr(self, f"{name}_policy", policy)
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------
|
|
104
|
+
# Task identity
|
|
105
|
+
# ---------------------------------------------------------
|
|
106
|
+
self.name = getattr(self, "name", cls.__name__)
|
|
107
|
+
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------- BASE TASKS ----------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class BaseTask(ABC, metaclass=TaskMeta):
|
|
115
|
+
"""
|
|
116
|
+
Base class for all tasks in the Ergon Task Framework.
|
|
117
|
+
|
|
118
|
+
Behavior (produce/consume) is added via mixins:
|
|
119
|
+
- ProduceMixin
|
|
120
|
+
- ConsumeMixin
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
name: str = "base"
|
|
124
|
+
connectors: Dict[str, Connector]
|
|
125
|
+
services: Dict[str, Any]
|
|
126
|
+
policies: List[Any]
|
|
127
|
+
worker_id: Optional[int]
|
|
128
|
+
task_config: Optional["TaskConfig"]
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
connectors: Dict[str, Connector],
|
|
133
|
+
services: Dict[str, Any],
|
|
134
|
+
policies: List[Any],
|
|
135
|
+
worker_id: Optional[int] = None,
|
|
136
|
+
task_config: Optional["TaskConfig"] = None,
|
|
137
|
+
*args,
|
|
138
|
+
**kwargs,
|
|
139
|
+
):
|
|
140
|
+
# Don't pass args/kwargs to object.__init__()
|
|
141
|
+
super().__init__()
|
|
142
|
+
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def execute(self) -> Any:
|
|
145
|
+
"""Main entry point for running the task."""
|
|
146
|
+
raise NotImplementedError
|
|
147
|
+
|
|
148
|
+
def exit(self):
|
|
149
|
+
"""
|
|
150
|
+
Gracefully exit the task.
|
|
151
|
+
|
|
152
|
+
Tasks may override this to:
|
|
153
|
+
- close resources
|
|
154
|
+
- flush buffers
|
|
155
|
+
- notify services
|
|
156
|
+
|
|
157
|
+
IMPORTANT: By default, this does NOT kill the interpreter.
|
|
158
|
+
"""
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class BaseAsyncTask(ABC, metaclass=TaskMeta):
|
|
163
|
+
"""
|
|
164
|
+
Asynchronous base class for all async tasks in the Ergon Task Framework.
|
|
165
|
+
|
|
166
|
+
Behavior (async produce/consume) will be added via async mixins.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
name: str = "base_async"
|
|
170
|
+
connectors: Dict[str, Any]
|
|
171
|
+
services: Dict[str, Any]
|
|
172
|
+
policies: List[Any]
|
|
173
|
+
worker_id: Optional[int]
|
|
174
|
+
task_config: Optional["TaskConfig"]
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
connectors: Dict[str, Any],
|
|
179
|
+
services: Dict[str, Any],
|
|
180
|
+
policies: List[Any],
|
|
181
|
+
worker_id: Optional[int] = None,
|
|
182
|
+
task_config: Optional["TaskConfig"] = None,
|
|
183
|
+
*args,
|
|
184
|
+
**kwargs,
|
|
185
|
+
):
|
|
186
|
+
# Don't pass args/kwargs to object.__init__()
|
|
187
|
+
super().__init__()
|
|
188
|
+
|
|
189
|
+
@abstractmethod
|
|
190
|
+
async def execute(self) -> Any:
|
|
191
|
+
"""Main async entry point for running the task."""
|
|
192
|
+
raise NotImplementedError
|
|
193
|
+
|
|
194
|
+
async def exit(self):
|
|
195
|
+
"""Optional async cleanup hook."""
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class TaskExecMetadata(BaseModel):
|
|
200
|
+
task_name: str
|
|
201
|
+
execution_id: str
|
|
202
|
+
execution_start_time: str
|
|
203
|
+
pid: int
|
|
204
|
+
worker_id: Optional[int]
|
|
205
|
+
host_name: str = Field(default_factory=lambda: TaskExecMetadata._get_host_name())
|
|
206
|
+
host_ip: str = Field(default_factory=lambda: TaskExecMetadata._get_host_ip())
|
|
207
|
+
|
|
208
|
+
# ---------- STATIC HELPERS ----------
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _get_host_ip() -> str:
|
|
211
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
212
|
+
try:
|
|
213
|
+
s.connect(("8.8.8.8", 80))
|
|
214
|
+
return s.getsockname()[0]
|
|
215
|
+
except Exception:
|
|
216
|
+
return "127.0.0.1"
|
|
217
|
+
finally:
|
|
218
|
+
s.close()
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _get_host_name() -> str:
|
|
222
|
+
return socket.gethostname()
|