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.
Files changed (82) hide show
  1. ergon/__init__.py +13 -0
  2. ergon/bootstrap/src/__project__/__init__.py +0 -0
  3. ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
  4. ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
  5. ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
  6. ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
  7. ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
  8. ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
  9. ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  10. ergon/bootstrap/src/__project__/main.py +9 -0
  11. ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  12. ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
  13. ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  14. ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
  15. ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
  16. ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
  17. ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
  18. ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
  19. ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  20. ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  21. ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  22. ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
  23. ergon/cli.py +174 -0
  24. ergon/connector/__init__.py +64 -0
  25. ergon/connector/connector.py +97 -0
  26. ergon/connector/excel/__init__.py +18 -0
  27. ergon/connector/excel/connector.py +175 -0
  28. ergon/connector/excel/models.py +24 -0
  29. ergon/connector/excel/service.py +98 -0
  30. ergon/connector/pipefy/__init__.py +21 -0
  31. ergon/connector/pipefy/async_connector.py +48 -0
  32. ergon/connector/pipefy/async_service.py +907 -0
  33. ergon/connector/pipefy/connector.py +36 -0
  34. ergon/connector/pipefy/models.py +48 -0
  35. ergon/connector/pipefy/service.py +1016 -0
  36. ergon/connector/pipefy/version.py +1 -0
  37. ergon/connector/postgres/__init__.py +11 -0
  38. ergon/connector/postgres/async_connector.py +119 -0
  39. ergon/connector/postgres/async_service.py +116 -0
  40. ergon/connector/postgres/models.py +34 -0
  41. ergon/connector/rabbitmq/__init__.py +25 -0
  42. ergon/connector/rabbitmq/async_connector.py +120 -0
  43. ergon/connector/rabbitmq/async_service.py +417 -0
  44. ergon/connector/rabbitmq/connector.py +54 -0
  45. ergon/connector/rabbitmq/helper.py +14 -0
  46. ergon/connector/rabbitmq/models.py +92 -0
  47. ergon/connector/rabbitmq/service.py +199 -0
  48. ergon/connector/sqs/__init__.py +15 -0
  49. ergon/connector/sqs/async_connector.py +120 -0
  50. ergon/connector/sqs/async_service.py +246 -0
  51. ergon/connector/sqs/connector.py +120 -0
  52. ergon/connector/sqs/models.py +36 -0
  53. ergon/connector/sqs/service.py +219 -0
  54. ergon/connector/transaction.py +14 -0
  55. ergon/py.typed +0 -0
  56. ergon/service/__init__.py +5 -0
  57. ergon/service/service.py +17 -0
  58. ergon/task/__init__.py +13 -0
  59. ergon/task/base.py +222 -0
  60. ergon/task/exceptions.py +217 -0
  61. ergon/task/helpers.py +691 -0
  62. ergon/task/manager.py +85 -0
  63. ergon/task/mixins/__init__.py +13 -0
  64. ergon/task/mixins/consumer.py +858 -0
  65. ergon/task/mixins/metrics.py +457 -0
  66. ergon/task/mixins/producer.py +486 -0
  67. ergon/task/policies.py +229 -0
  68. ergon/task/runner.py +386 -0
  69. ergon/task/utils.py +64 -0
  70. ergon/telemetry/__init__.py +7 -0
  71. ergon/telemetry/_resource.py +13 -0
  72. ergon/telemetry/logging.py +370 -0
  73. ergon/telemetry/metrics.py +101 -0
  74. ergon/telemetry/tracing.py +152 -0
  75. ergon/utils/__init__.py +5 -0
  76. ergon/utils/env.py +26 -0
  77. ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
  78. ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
  79. ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
  80. ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  82. ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,11 @@
1
+ from .async_connector import AsyncPostgresConnector
2
+ from .async_service import AsyncPostgresService
3
+ from .models import PostgresClient, PostgresConsumerConfig, PostgresProducerConfig
4
+
5
+ __all__ = [
6
+ "AsyncPostgresConnector",
7
+ "AsyncPostgresService",
8
+ "PostgresClient",
9
+ "PostgresConsumerConfig",
10
+ "PostgresProducerConfig",
11
+ ]
@@ -0,0 +1,119 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Any, List, Optional
4
+
5
+ from ..connector import AsyncConnector
6
+ from ..transaction import Transaction
7
+ from .async_service import AsyncPostgresService
8
+ from .models import PostgresClient, PostgresConsumerConfig, PostgresProducerConfig
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class AsyncPostgresConnector(AsyncConnector):
14
+ service: AsyncPostgresService
15
+
16
+ def __init__(
17
+ self,
18
+ client: PostgresClient,
19
+ consumer_config: Optional[PostgresConsumerConfig] = None,
20
+ producer_config: Optional[PostgresProducerConfig] = None,
21
+ ) -> None:
22
+ self.service = AsyncPostgresService(client)
23
+ self._consumer_config = consumer_config
24
+ self._producer_config = producer_config
25
+ self._notify_event: Optional[asyncio.Event] = None
26
+
27
+ async def init_async(self) -> None:
28
+ """
29
+ Called by the task runner on startup.
30
+
31
+ Sets up PG LISTEN if a listen_channel is configured, using an
32
+ asyncio.Event to allow the consume loop to react to notifications.
33
+ """
34
+ if self._consumer_config and self._consumer_config.listen_channel:
35
+ self._notify_event = asyncio.Event()
36
+
37
+ def _on_notify(connection, pid, channel, payload):
38
+ if self._notify_event is not None:
39
+ self._notify_event.set()
40
+
41
+ await self.service.listen(self._consumer_config.listen_channel, _on_notify)
42
+
43
+ async def fetch_transactions_async(
44
+ self,
45
+ batch_size: Optional[int] = None,
46
+ fetch_query: Optional[str] = None,
47
+ fetch_params: Optional[List[Any]] = None,
48
+ *args,
49
+ **kwargs,
50
+ ) -> List[Transaction]:
51
+ if self._consumer_config is None:
52
+ raise ValueError("AsyncPostgresConnector requires a consumer_config to fetch transactions")
53
+
54
+ config = self._consumer_config
55
+ query = fetch_query or config.fetch_query
56
+ params = fetch_params or config.fetch_params
57
+ limit = batch_size or config.batch_size
58
+
59
+ rows = await self.service.fetch(query, params, limit=limit)
60
+
61
+ if not rows:
62
+ return []
63
+
64
+ if self._notify_event is not None:
65
+ self._notify_event.clear()
66
+
67
+ return [
68
+ Transaction(
69
+ id=str(row.get(config.id_column, "")),
70
+ payload=row,
71
+ metadata={},
72
+ )
73
+ for row in rows
74
+ ]
75
+
76
+ async def dispatch_transactions_async(
77
+ self,
78
+ transactions: List[Transaction],
79
+ dispatch_query: Optional[str] = None,
80
+ *args,
81
+ **kwargs,
82
+ ) -> None:
83
+ if self._producer_config is None:
84
+ raise ValueError("AsyncPostgresConnector requires a producer_config to dispatch transactions")
85
+
86
+ query = dispatch_query or self._producer_config.dispatch_query
87
+
88
+ for txn in transactions:
89
+ params = self._build_dispatch_params(txn)
90
+ await self.service.execute(query, params)
91
+
92
+ @staticmethod
93
+ def _build_dispatch_params(transaction: Transaction) -> List[Any]:
94
+ """
95
+ Build parameter list for the dispatch query from the transaction.
96
+
97
+ Passes transaction.id as the first param. Subclasses or callers
98
+ can override this for more complex parameter derivation.
99
+ """
100
+ return [transaction.id]
101
+
102
+ async def wait_for_notify(self, timeout: Optional[float] = None) -> bool:
103
+ """
104
+ Wait for a PG NOTIFY event. Returns True if notified, False on timeout.
105
+
106
+ Useful for the platform layer to build outbox-relay-style tasks
107
+ that sleep until the DB signals new rows.
108
+ """
109
+ if self._notify_event is None:
110
+ return False
111
+
112
+ try:
113
+ await asyncio.wait_for(self._notify_event.wait(), timeout=timeout)
114
+ return True
115
+ except asyncio.TimeoutError:
116
+ return False
117
+
118
+ async def close(self) -> None:
119
+ await self.service.close()
@@ -0,0 +1,116 @@
1
+ import logging
2
+ from typing import Any, Callable, Dict, List, Optional
3
+
4
+ import asyncpg
5
+
6
+ from .models import PostgresClient
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class AsyncPostgresService:
12
+ def __init__(self, client: PostgresClient) -> None:
13
+ self.client = client
14
+
15
+ self._pool: Optional[asyncpg.Pool] = None
16
+ self._listener_conn: Optional[asyncpg.Connection] = None
17
+ self._listen_channel: Optional[str] = None
18
+
19
+ # ---------- Connection Pool ----------
20
+
21
+ async def _get_pool(self) -> asyncpg.Pool:
22
+ if self._pool is None or self._pool._closed: # type: ignore[attr-defined]
23
+ dsn = self.client.get_dsn()
24
+ ssl_mode = "require" if self.client.ssl else None
25
+
26
+ self._pool = await asyncpg.create_pool(
27
+ dsn,
28
+ min_size=self.client.min_pool_size,
29
+ max_size=self.client.max_pool_size,
30
+ ssl=ssl_mode,
31
+ )
32
+ logger.info(
33
+ "PostgreSQL connection pool created (min=%d, max=%d)",
34
+ self.client.min_pool_size,
35
+ self.client.max_pool_size,
36
+ )
37
+
38
+ return self._pool
39
+
40
+ # ---------- Query ----------
41
+
42
+ async def fetch(
43
+ self,
44
+ query: str,
45
+ params: Optional[List[Any]] = None,
46
+ limit: Optional[int] = None,
47
+ ) -> List[Dict[str, Any]]:
48
+ """Execute a SELECT query and return rows as list of dicts."""
49
+ pool = await self._get_pool()
50
+
51
+ effective_query = query
52
+ if limit is not None and "LIMIT" not in query.upper():
53
+ effective_query = f"{query} LIMIT {limit}"
54
+
55
+ async with pool.acquire() as conn:
56
+ rows = await conn.fetch(effective_query, *(params or []))
57
+
58
+ return [dict(row) for row in rows]
59
+
60
+ async def execute(self, query: str, params: Optional[List[Any]] = None) -> str:
61
+ """Execute a single statement (INSERT, UPDATE, DELETE). Returns status string."""
62
+ pool = await self._get_pool()
63
+ async with pool.acquire() as conn:
64
+ result = await conn.execute(query, *(params or []))
65
+ return result
66
+
67
+ async def execute_many(self, query: str, params_list: List[List[Any]]) -> None:
68
+ """Execute a statement for each set of params in the list."""
69
+ pool = await self._get_pool()
70
+ async with pool.acquire() as conn:
71
+ await conn.executemany(query, params_list)
72
+
73
+ # ---------- LISTEN / NOTIFY ----------
74
+
75
+ async def listen(self, channel: str, callback: Callable[..., Any]) -> None:
76
+ """
77
+ Open a dedicated connection and subscribe to a PG NOTIFY channel.
78
+
79
+ The callback receives (connection, pid, channel, payload).
80
+ """
81
+ if self._listener_conn is not None:
82
+ logger.warning("Listener already active on channel=%s, closing before re-listen", self._listen_channel)
83
+ await self.unlisten()
84
+
85
+ dsn = self.client.get_dsn()
86
+ ssl_mode = "require" if self.client.ssl else None
87
+ conn = await asyncpg.connect(dsn, ssl=ssl_mode)
88
+ self._listener_conn = conn
89
+ self._listen_channel = channel
90
+
91
+ await conn.add_listener(channel, callback)
92
+ logger.info("Listening on PG channel=%s", channel)
93
+
94
+ async def unlisten(self) -> None:
95
+ """Remove listener and close the dedicated listener connection."""
96
+ if self._listener_conn is not None:
97
+ if self._listen_channel:
98
+ try:
99
+ await self._listener_conn.remove_listener(self._listen_channel, lambda *a: None)
100
+ except Exception:
101
+ pass
102
+ await self._listener_conn.close()
103
+ self._listener_conn = None
104
+ self._listen_channel = None
105
+ logger.info("PostgreSQL listener connection closed")
106
+
107
+ # ---------- Lifecycle ----------
108
+
109
+ async def close(self) -> None:
110
+ """Close pool and listener connection."""
111
+ await self.unlisten()
112
+
113
+ if self._pool is not None:
114
+ await self._pool.close()
115
+ self._pool = None
116
+ logger.info("PostgreSQL connection pool closed")
@@ -0,0 +1,34 @@
1
+ from typing import Any, List, Optional
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class PostgresClient(BaseModel):
7
+ dsn: Optional[str] = Field(default=None, description="Full DSN (takes precedence over individual params)")
8
+ host: str = Field(default="localhost", description="PostgreSQL host")
9
+ port: int = Field(default=5432, description="PostgreSQL port")
10
+ user: str = Field(default="postgres", description="PostgreSQL user")
11
+ password: str = Field(default="postgres", description="PostgreSQL password")
12
+ database: str = Field(default="postgres", description="PostgreSQL database name")
13
+ min_pool_size: int = Field(default=1, ge=1, description="Minimum connection pool size")
14
+ max_pool_size: int = Field(default=10, ge=1, description="Maximum connection pool size")
15
+ ssl: bool = Field(default=False, description="Enable SSL/TLS connections")
16
+
17
+ def get_dsn(self) -> str:
18
+ if self.dsn:
19
+ return self.dsn
20
+ return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
21
+
22
+
23
+ class PostgresConsumerConfig(BaseModel):
24
+ fetch_query: str = Field(description="SQL query to fetch rows as transactions")
25
+ fetch_params: List[Any] = Field(default_factory=list, description="Bind parameters for the fetch query")
26
+ batch_size: int = Field(default=100, ge=1, description="Max rows to fetch per call")
27
+ listen_channel: Optional[str] = Field(
28
+ default=None, description="Optional PG LISTEN channel for push-based notifications"
29
+ )
30
+ id_column: str = Field(default="id", description="Column name to use as Transaction.id")
31
+
32
+
33
+ class PostgresProducerConfig(BaseModel):
34
+ dispatch_query: str = Field(description="SQL statement to execute per dispatched transaction")
@@ -0,0 +1,25 @@
1
+ from .async_connector import AsyncRabbitMQConnector
2
+ from .async_service import AsyncRabbitMQService
3
+ from .connector import RabbitMQConnector
4
+ from .models import (
5
+ AsyncRabbitmqClient,
6
+ AsyncRabbitmqConsumerConfig,
7
+ AsyncRabbitmqProducerConfig,
8
+ RabbitmqClient,
9
+ RabbitmqConsumerMessage,
10
+ RabbitmqProducerMessage,
11
+ )
12
+ from .service import RabbitMQService
13
+
14
+ __all__ = [
15
+ "AsyncRabbitMQConnector",
16
+ "AsyncRabbitMQService",
17
+ "AsyncRabbitmqClient",
18
+ "AsyncRabbitmqConsumerConfig",
19
+ "AsyncRabbitmqProducerConfig",
20
+ "RabbitMQConnector",
21
+ "RabbitMQService",
22
+ "RabbitmqClient",
23
+ "RabbitmqConsumerMessage",
24
+ "RabbitmqProducerMessage",
25
+ ]
@@ -0,0 +1,120 @@
1
+ import json
2
+ import logging
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from ..connector import AsyncConnector
6
+ from ..transaction import Transaction
7
+ from .async_service import AsyncRabbitMQService
8
+ from .models import AsyncRabbitmqClient, AsyncRabbitmqConsumerConfig, AsyncRabbitmqProducerConfig
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class AsyncRabbitMQConnector(AsyncConnector):
14
+ service: AsyncRabbitMQService
15
+
16
+ def __init__(
17
+ self,
18
+ client: AsyncRabbitmqClient,
19
+ consumer_config: Optional[AsyncRabbitmqConsumerConfig] = None,
20
+ producer_config: Optional[AsyncRabbitmqProducerConfig] = None,
21
+ ) -> None:
22
+ self.service = AsyncRabbitMQService(client)
23
+ self._consumer_config = consumer_config
24
+ self._producer_config = producer_config or AsyncRabbitmqProducerConfig()
25
+
26
+ async def fetch_transactions_async(
27
+ self,
28
+ batch_size: int = 1,
29
+ queue_name: Optional[str] = None,
30
+ exchange_name: Optional[str] = None,
31
+ binding_keys: Optional[List[str]] = None,
32
+ *args,
33
+ **kwargs,
34
+ ) -> List[Transaction]:
35
+ if self._consumer_config is None:
36
+ raise ValueError("AsyncRabbitMQConnector requires a consumer_config to fetch transactions")
37
+
38
+ config = self._consumer_config
39
+ if queue_name or exchange_name or binding_keys:
40
+ overrides: Dict[str, Any] = {}
41
+ if queue_name is not None:
42
+ overrides["queue_name"] = queue_name
43
+ if exchange_name is not None:
44
+ overrides["exchange_name"] = exchange_name
45
+ if binding_keys is not None:
46
+ overrides["binding_keys"] = binding_keys
47
+ config = config.model_copy(update=overrides)
48
+
49
+ messages = await self.service.consume(config, batch_size)
50
+
51
+ if not messages:
52
+ return []
53
+
54
+ transactions = []
55
+ for msg in messages:
56
+ delivery_tag = msg.get("delivery_tag")
57
+
58
+ transactions.append(
59
+ Transaction(
60
+ id=str(delivery_tag),
61
+ payload=msg.get("body"),
62
+ metadata={
63
+ "delivery_tag": delivery_tag,
64
+ "routing_key": msg.get("routing_key", ""),
65
+ "headers": msg.get("headers", {}),
66
+ "content_type": msg.get("content_type"),
67
+ "message_id": msg.get("message_id"),
68
+ "correlation_id": msg.get("correlation_id"),
69
+ "queue_name": config.queue_name,
70
+ "_message": msg.get("_message"),
71
+ },
72
+ )
73
+ )
74
+
75
+ return transactions
76
+
77
+ async def dispatch_transactions_async(
78
+ self,
79
+ transactions: List[Transaction],
80
+ routing_key: Optional[str] = None,
81
+ exchange_name: Optional[str] = None,
82
+ headers: Optional[Dict[str, Any]] = None,
83
+ *args,
84
+ **kwargs,
85
+ ) -> None:
86
+ config = self._producer_config
87
+ if exchange_name:
88
+ config = config.model_copy(update={"exchange_name": exchange_name})
89
+
90
+ for txn in transactions:
91
+ body = txn.payload
92
+ if not isinstance(body, (str, bytes)):
93
+ body = json.dumps(body, default=str)
94
+ if isinstance(body, str):
95
+ body = body.encode("utf-8")
96
+
97
+ rk = routing_key or (txn.metadata.get("routing_key") if txn.metadata else None) or config.routing_key
98
+ msg_headers = headers or (txn.metadata.get("headers") if txn.metadata else None)
99
+
100
+ await self.service.publish(
101
+ config=config,
102
+ body=body,
103
+ routing_key=rk,
104
+ headers=msg_headers,
105
+ )
106
+
107
+ async def ack_transaction(self, transaction: Transaction) -> None:
108
+ raw_message = transaction.metadata.get("_message") if transaction.metadata else None
109
+ if raw_message is None:
110
+ raise ValueError(f"Cannot ack transaction {transaction.id}: no raw message in metadata")
111
+ await self.service.ack(raw_message)
112
+
113
+ async def nack_transaction(self, transaction: Transaction, requeue: bool = True) -> None:
114
+ raw_message = transaction.metadata.get("_message") if transaction.metadata else None
115
+ if raw_message is None:
116
+ raise ValueError(f"Cannot nack transaction {transaction.id}: no raw message in metadata")
117
+ await self.service.nack(raw_message, requeue=requeue)
118
+
119
+ async def close(self) -> None:
120
+ await self.service.close()