ergon-framework-python 0.2.1__py3-none-any.whl → 0.2.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 (32) hide show
  1. ergon/__init__.py +1 -1
  2. ergon/connector/__init__.py +20 -0
  3. ergon/connector/ergon_platform/__init__.py +78 -11
  4. ergon/connector/ergon_platform/async_connector.py +3 -221
  5. ergon/connector/ergon_platform/channels/__init__.py +54 -0
  6. ergon/connector/ergon_platform/channels/adapters.py +230 -0
  7. ergon/connector/ergon_platform/channels/async_connector.py +327 -0
  8. ergon/connector/ergon_platform/channels/connector.py +294 -0
  9. ergon/connector/ergon_platform/channels/models.py +460 -0
  10. ergon/connector/ergon_platform/channels/services/__init__.py +39 -0
  11. ergon/connector/ergon_platform/channels/services/activity.py +300 -0
  12. ergon/connector/ergon_platform/channels/services/addresses.py +325 -0
  13. ergon/connector/ergon_platform/channels/services/attachments.py +283 -0
  14. ergon/connector/ergon_platform/channels/services/messages.py +127 -0
  15. ergon/connector/ergon_platform/channels/services/records.py +75 -0
  16. ergon/connector/ergon_platform/channels/services/service.py +34 -0
  17. ergon/connector/ergon_platform/connector.py +3 -198
  18. ergon/connector/ergon_platform/models.py +1 -73
  19. ergon/connector/ergon_platform/utils.py +39 -248
  20. ergon/connector/ergon_platform/workflows/__init__.py +52 -0
  21. ergon/connector/ergon_platform/{_operations.py → workflows/_operations.py} +8 -3
  22. ergon/connector/ergon_platform/workflows/async_connector.py +224 -0
  23. ergon/connector/ergon_platform/workflows/connector.py +201 -0
  24. ergon/connector/ergon_platform/workflows/models.py +90 -0
  25. ergon/connector/ergon_platform/workflows/utils.py +250 -0
  26. ergon/task/mixins/consumer.py +0 -2
  27. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/METADATA +3 -3
  28. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/RECORD +32 -15
  29. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/WHEEL +0 -0
  30. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/entry_points.txt +0 -0
  31. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/licenses/LICENSE +0 -0
  32. {ergon_framework_python-0.2.1.dist-info → ergon_framework_python-0.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,224 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from ...connector import AsyncConnector
6
+ from ...transaction import Transaction
7
+ from .._client import create_ergon_client
8
+ from ..models import ErgonPlatformClient
9
+ from ._operations import ErgonPlatformWorkflowsService
10
+ from .models import (
11
+ CreateItemPayload,
12
+ ErgonPlatformConsumerConfig,
13
+ ErgonPlatformProducerConfig,
14
+ )
15
+ from .utils import get_value, normalize_create_payload
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class AsyncErgonPlatformConnector(AsyncConnector):
21
+ def __init__(
22
+ self,
23
+ client: ErgonPlatformClient,
24
+ consumer_config: Optional[ErgonPlatformConsumerConfig] = None,
25
+ producer_config: Optional[ErgonPlatformProducerConfig] = None,
26
+ ) -> None:
27
+ self.client = create_ergon_client(client)
28
+ self.service = ErgonPlatformWorkflowsService(client, self.client)
29
+ self._operations = self.service
30
+ self._consumer_config = consumer_config
31
+ self._producer_config = producer_config or ErgonPlatformProducerConfig()
32
+
33
+ async def fetch_transactions_async(
34
+ self,
35
+ batch_size: Optional[int] = None,
36
+ *args,
37
+ **kwargs,
38
+ ) -> List[Transaction]:
39
+ if self._consumer_config is None:
40
+ raise ValueError("AsyncErgonPlatformConnector requires a consumer_config to fetch transactions")
41
+
42
+ config = self._consumer_config
43
+ limit = batch_size or config.batch_size
44
+ params: Dict[str, Any] = {**config.list_params, **kwargs}
45
+ if config.unassigned:
46
+ params.pop("assigned_to", None)
47
+ params["assigned"] = "no"
48
+
49
+ transactions = await asyncio.to_thread(
50
+ lambda: self._operations.fetch_items(
51
+ config.workflow_id,
52
+ config.phase_id,
53
+ limit=limit,
54
+ offset=config.offset,
55
+ **params,
56
+ )
57
+ )
58
+ if not config.unassigned:
59
+ return transactions
60
+
61
+ claimed_transactions: List[Transaction] = []
62
+ for transaction in transactions:
63
+ try:
64
+ await asyncio.to_thread(lambda: self.client.workflows.items.claim(transaction.id, {}))
65
+ claimed_transactions.append(await self.fetch_transaction_by_id_async(transaction.id))
66
+ except Exception:
67
+ logger.warning(
68
+ "Failed to claim item %s during unassigned fetch; skipping it",
69
+ transaction.id,
70
+ exc_info=True,
71
+ )
72
+ return claimed_transactions
73
+
74
+ async def dispatch_transactions_async(
75
+ self,
76
+ transactions: List[Transaction],
77
+ *args,
78
+ **kwargs,
79
+ ) -> List[str]:
80
+ created_ids: List[str] = []
81
+ for transaction in transactions:
82
+ result = await self._create_from_payload(transaction.payload)
83
+ created_ids.append(self._created_item_id(result))
84
+ return created_ids
85
+
86
+ async def fetch_child_transactions_async(
87
+ self,
88
+ parent_item_id: str,
89
+ *args,
90
+ **kwargs,
91
+ ) -> List[Transaction]:
92
+ return await asyncio.to_thread(lambda: self._operations.fetch_child_items(parent_item_id, **kwargs))
93
+
94
+ async def fetch_transaction_by_id_async(self, transaction_id: str, *args, **kwargs) -> Transaction:
95
+ workflow_id = self._consumer_config.workflow_id if self._consumer_config else ""
96
+ return await asyncio.to_thread(
97
+ lambda: self._operations.get_item_transaction(transaction_id, workflow_id, **kwargs)
98
+ )
99
+
100
+ async def fetch_items_by_query(
101
+ self, workflow_id: str, query: Optional[Dict[str, Any]] = None, **fields: Any
102
+ ) -> List[Transaction]:
103
+ return await asyncio.to_thread(lambda: self._operations.fetch_items_by_query(workflow_id, query, **fields))
104
+
105
+ async def get_transactions_count_async(self, *args, **kwargs) -> int:
106
+ if self._consumer_config is None:
107
+ raise ValueError("AsyncErgonPlatformConnector requires a consumer_config to count transactions")
108
+
109
+ config = self._consumer_config
110
+ params: Dict[str, Any] = {**config.list_params, **kwargs}
111
+ return await asyncio.to_thread(
112
+ lambda: self._operations.get_phase_items_count(
113
+ config.workflow_id,
114
+ config.phase_id,
115
+ **params,
116
+ )
117
+ )
118
+
119
+ async def ack_transaction(self, transaction: Transaction, phase_id: Optional[str] = None) -> None:
120
+ target_phase = phase_id
121
+ if target_phase is None and self._consumer_config is not None:
122
+ target_phase = self._consumer_config.ack_phase_id
123
+ if not target_phase:
124
+ return
125
+ await asyncio.to_thread(lambda: self.client.workflows.items.route(transaction.id, to_phase_id=target_phase))
126
+
127
+ async def release_item(
128
+ self,
129
+ item_id: str,
130
+ data: Optional[Dict[str, Any]] = None,
131
+ *,
132
+ delay_seconds: Optional[int] = None,
133
+ **fields: Any,
134
+ ) -> Any:
135
+ return await asyncio.to_thread(
136
+ lambda: self._operations.release_item(
137
+ item_id,
138
+ data,
139
+ delay_seconds=delay_seconds,
140
+ **fields,
141
+ )
142
+ )
143
+
144
+ async def nack_transaction(self, transaction: Transaction, requeue: bool = True, delay_seconds: int = 10) -> None:
145
+ if requeue:
146
+ await asyncio.to_thread(lambda: self._operations.release_item(transaction.id, delay_seconds=delay_seconds))
147
+ return
148
+
149
+ target_phase = self._consumer_config.nack_phase_id if self._consumer_config is not None else None
150
+ if not target_phase:
151
+ raise ValueError("nack_phase_id is required when nack_transaction is called with requeue=False")
152
+
153
+ await asyncio.to_thread(lambda: self.client.workflows.items.route(transaction.id, to_phase_id=target_phase))
154
+
155
+ async def close(self) -> None:
156
+ await asyncio.to_thread(self.client.close)
157
+
158
+ async def list_phase_fields(
159
+ self,
160
+ phase_id: str,
161
+ *,
162
+ workflow_id: Optional[str] = None,
163
+ include_workflow_fields: bool = True,
164
+ **params: Any,
165
+ ) -> Any:
166
+ return await asyncio.to_thread(
167
+ lambda: self._operations.list_phase_fields(
168
+ phase_id,
169
+ workflow_id=workflow_id,
170
+ include_workflow_fields=include_workflow_fields,
171
+ **params,
172
+ )
173
+ )
174
+
175
+ async def get_pipeline_result(
176
+ self,
177
+ workflow_id: str,
178
+ item_id: str,
179
+ field_id: str,
180
+ buckets_file_id: Optional[str] = None,
181
+ ) -> Dict[str, Any]:
182
+ return await asyncio.to_thread(
183
+ self._operations.get_pipeline_result,
184
+ workflow_id,
185
+ item_id,
186
+ field_id,
187
+ buckets_file_id,
188
+ )
189
+
190
+ async def _create_from_payload(self, payload: CreateItemPayload) -> Any:
191
+ data = normalize_create_payload(payload)
192
+ producer = self._producer_config
193
+
194
+ workflow_id = data.get("workflow_id") or producer.workflow_id
195
+ phase_id = data.get("phase_id") or producer.phase_id
196
+ if not workflow_id:
197
+ raise ValueError("workflow_id is required to create an item (set it in payload or producer_config)")
198
+ if not phase_id:
199
+ raise ValueError("phase_id is required to create an item (set it in payload or producer_config)")
200
+
201
+ attachment = data.get("attachment")
202
+ attachment_field_id = data.get("attachment_field_id") or producer.attachment_field_id
203
+ content_type = data.get("content_type") or producer.default_content_type
204
+ parent_item_id = data.get("parent_item_id") or producer.parent_item_id
205
+
206
+ return await asyncio.to_thread(
207
+ lambda: self._operations.create_item(
208
+ workflow_id,
209
+ phase_id,
210
+ data["title"],
211
+ parent_item_id=parent_item_id,
212
+ field_values=data.get("field_values"),
213
+ attachment=attachment,
214
+ attachment_field_id=attachment_field_id,
215
+ content_type=content_type,
216
+ **data.get("fields", {}),
217
+ )
218
+ )
219
+
220
+ @staticmethod
221
+ def _created_item_id(result: Any) -> str:
222
+ if isinstance(result, dict) and "item" in result:
223
+ return str(get_value(result["item"], "id", ""))
224
+ return str(get_value(result, "id", ""))
@@ -0,0 +1,201 @@
1
+ import logging
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from ...connector import Connector
5
+ from ...transaction import Transaction
6
+ from .._client import create_ergon_client
7
+ from ..models import ErgonPlatformClient
8
+ from ._operations import ErgonPlatformWorkflowsService
9
+ from .models import (
10
+ CreateItemPayload,
11
+ ErgonPlatformConsumerConfig,
12
+ ErgonPlatformProducerConfig,
13
+ )
14
+ from .utils import get_value, normalize_create_payload
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ErgonPlatformConnector(Connector):
20
+ def __init__(
21
+ self,
22
+ client: ErgonPlatformClient,
23
+ consumer_config: Optional[ErgonPlatformConsumerConfig] = None,
24
+ producer_config: Optional[ErgonPlatformProducerConfig] = None,
25
+ ) -> None:
26
+ self.client = create_ergon_client(client)
27
+ self.service = ErgonPlatformWorkflowsService(client, self.client)
28
+ self._operations = self.service
29
+ self._consumer_config = consumer_config
30
+ self._producer_config = producer_config or ErgonPlatformProducerConfig()
31
+
32
+ def fetch_transactions(
33
+ self,
34
+ batch_size: Optional[int] = None,
35
+ *args,
36
+ **kwargs,
37
+ ) -> List[Transaction]:
38
+ if self._consumer_config is None:
39
+ raise ValueError("ErgonPlatformConnector requires a consumer_config to fetch transactions")
40
+
41
+ config = self._consumer_config
42
+ limit = batch_size or config.batch_size
43
+ params: Dict[str, Any] = {**config.list_params, **kwargs}
44
+ if config.unassigned:
45
+ params.pop("assigned_to", None)
46
+ params["assigned"] = "no"
47
+
48
+ transactions = self._operations.fetch_items(
49
+ config.workflow_id,
50
+ config.phase_id,
51
+ limit=limit,
52
+ offset=config.offset,
53
+ **params,
54
+ )
55
+ if not config.unassigned:
56
+ return transactions
57
+
58
+ claimed_transactions: List[Transaction] = []
59
+ for transaction in transactions:
60
+ try:
61
+ self.client.workflows.items.claim(transaction.id, {})
62
+ claimed_transactions.append(self.fetch_transaction_by_id(transaction.id))
63
+ except Exception:
64
+ logger.warning(
65
+ "Failed to claim item %s during unassigned fetch; skipping it",
66
+ transaction.id,
67
+ exc_info=True,
68
+ )
69
+ return claimed_transactions
70
+
71
+ def dispatch_transactions(self, transactions: List[Transaction], *args, **kwargs) -> List[str]:
72
+ created_ids: List[str] = []
73
+ for transaction in transactions:
74
+ result = self._create_from_payload(transaction.payload)
75
+ created_ids.append(self._created_item_id(result))
76
+ return created_ids
77
+
78
+ def fetch_child_transactions(
79
+ self,
80
+ parent_item_id: str,
81
+ *args,
82
+ **kwargs,
83
+ ) -> List[Transaction]:
84
+ return self._operations.fetch_child_items(parent_item_id, **kwargs)
85
+
86
+ def fetch_transaction_by_id(self, transaction_id: str, *args, **kwargs) -> Transaction:
87
+ workflow_id = self._consumer_config.workflow_id if self._consumer_config else ""
88
+ return self._operations.get_item_transaction(transaction_id, workflow_id, **kwargs)
89
+
90
+ def fetch_items_by_query(
91
+ self, workflow_id: str, query: Optional[Dict[str, Any]] = None, **fields: Any
92
+ ) -> List[Transaction]:
93
+ return self._operations.fetch_items_by_query(workflow_id, query, **fields)
94
+
95
+ def get_transactions_count(self, *args, **kwargs) -> int:
96
+ if self._consumer_config is None:
97
+ raise ValueError("ErgonPlatformConnector requires a consumer_config to count transactions")
98
+
99
+ config = self._consumer_config
100
+ params: Dict[str, Any] = {**config.list_params, **kwargs}
101
+ return self._operations.get_phase_items_count(
102
+ config.workflow_id,
103
+ config.phase_id,
104
+ **params,
105
+ )
106
+
107
+ def ack_transaction(self, transaction: Transaction, phase_id: Optional[str] = None) -> None:
108
+ target_phase = phase_id
109
+ if target_phase is None and self._consumer_config is not None:
110
+ target_phase = self._consumer_config.ack_phase_id
111
+ if not target_phase:
112
+ return
113
+ self.client.workflows.items.route(transaction.id, to_phase_id=target_phase)
114
+
115
+ def release_item(
116
+ self,
117
+ item_id: str,
118
+ data: Optional[Dict[str, Any]] = None,
119
+ *,
120
+ delay_seconds: Optional[int] = None,
121
+ **fields: Any,
122
+ ) -> Any:
123
+ return self._operations.release_item(
124
+ item_id,
125
+ data,
126
+ delay_seconds=delay_seconds,
127
+ **fields,
128
+ )
129
+
130
+ def nack_transaction(self, transaction: Transaction, requeue: bool = True, delay_seconds: int = 10) -> None:
131
+ if requeue:
132
+ self._operations.release_item(transaction.id, delay_seconds=delay_seconds)
133
+ return
134
+
135
+ target_phase = self._consumer_config.nack_phase_id if self._consumer_config is not None else None
136
+ if not target_phase:
137
+ raise ValueError("nack_phase_id is required when nack_transaction is called with requeue=False")
138
+
139
+ self.client.workflows.items.route(transaction.id, to_phase_id=target_phase)
140
+ logger.debug("Item %s moved to nack phase %s", transaction.id, target_phase)
141
+
142
+ def close(self) -> None:
143
+ self.client.close()
144
+
145
+ def list_phase_fields(
146
+ self,
147
+ phase_id: str,
148
+ *,
149
+ workflow_id: Optional[str] = None,
150
+ include_workflow_fields: bool = True,
151
+ **params: Any,
152
+ ) -> Any:
153
+ return self._operations.list_phase_fields(
154
+ phase_id,
155
+ workflow_id=workflow_id,
156
+ include_workflow_fields=include_workflow_fields,
157
+ **params,
158
+ )
159
+
160
+ def get_pipeline_result(
161
+ self,
162
+ workflow_id: str,
163
+ item_id: str,
164
+ field_id: str,
165
+ buckets_file_id: Optional[str] = None,
166
+ ) -> Dict[str, Any]:
167
+ return self._operations.get_pipeline_result(workflow_id, item_id, field_id, buckets_file_id)
168
+
169
+ def _create_from_payload(self, payload: CreateItemPayload) -> Any:
170
+ data = normalize_create_payload(payload)
171
+ producer = self._producer_config
172
+
173
+ workflow_id = data.get("workflow_id") or producer.workflow_id
174
+ phase_id = data.get("phase_id") or producer.phase_id
175
+ if not workflow_id:
176
+ raise ValueError("workflow_id is required to create an item (set it in payload or producer_config)")
177
+ if not phase_id:
178
+ raise ValueError("phase_id is required to create an item (set it in payload or producer_config)")
179
+
180
+ attachment = data.get("attachment")
181
+ attachment_field_id = data.get("attachment_field_id") or producer.attachment_field_id
182
+ content_type = data.get("content_type") or producer.default_content_type
183
+ parent_item_id = data.get("parent_item_id") or producer.parent_item_id
184
+
185
+ return self._operations.create_item(
186
+ workflow_id,
187
+ phase_id,
188
+ data["title"],
189
+ parent_item_id=parent_item_id,
190
+ field_values=data.get("field_values"),
191
+ attachment=attachment,
192
+ attachment_field_id=attachment_field_id,
193
+ content_type=content_type,
194
+ **data.get("fields", {}),
195
+ )
196
+
197
+ @staticmethod
198
+ def _created_item_id(result: Any) -> str:
199
+ if isinstance(result, dict) and "item" in result:
200
+ return str(get_value(result["item"], "id", ""))
201
+ return str(get_value(result, "id", ""))
@@ -0,0 +1,90 @@
1
+ from typing import Any, Dict, Optional, Union
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ # Status buckets returned by the Ergon Platform attachment pipeline.
6
+ SUCCESS_STATUSES = {"completed", "complete", "succeeded", "success", "processed", "ready"}
7
+ FAILURE_STATUSES = {"failed", "error", "errored", "skipped", "oversized"}
8
+ PROCESSING_STATUSES = {
9
+ "created",
10
+ "in-progress",
11
+ "in_progress",
12
+ "pending",
13
+ "processing",
14
+ "queued",
15
+ "running",
16
+ "stored",
17
+ "uploaded",
18
+ }
19
+
20
+
21
+ class ErgonPlatformConsumerConfig(BaseModel):
22
+ """Parameters used to list workflow items as transactions."""
23
+
24
+ workflow_id: str = Field(description="Workflow ID to consume items from")
25
+ phase_id: str = Field(description="Phase ID to list items from")
26
+ batch_size: int = Field(default=50, ge=1, description="Max items per fetch")
27
+ offset: int = Field(default=0, ge=0, description="Pagination offset")
28
+ ack_phase_id: Optional[str] = Field(
29
+ default=None,
30
+ description="Phase to route an item to on ack; ack is a no-op when omitted",
31
+ )
32
+ nack_phase_id: Optional[str] = Field(
33
+ default=None,
34
+ description="Phase to route an item to on nack when requeue=False",
35
+ )
36
+ unassigned: bool = Field(
37
+ default=False,
38
+ description="When true, fetch only unassigned cards and claim them during fetch",
39
+ )
40
+ list_params: Dict[str, Any] = Field(
41
+ default_factory=dict,
42
+ description="Extra query params forwarded to the items listing endpoint",
43
+ )
44
+
45
+
46
+ class ErgonPlatformProducerConfig(BaseModel):
47
+ """Defaults applied when creating items via dispatch."""
48
+
49
+ workflow_id: Optional[str] = Field(default=None, description="Default workflow for created items")
50
+ phase_id: Optional[str] = Field(default=None, description="Default phase for created items")
51
+ attachment_field_id: Optional[str] = Field(
52
+ default=None,
53
+ description="Default field ID used when an attachment is provided",
54
+ )
55
+ default_content_type: Optional[str] = Field(
56
+ default=None,
57
+ description="Fallback MIME type when it cannot be guessed from the filename",
58
+ )
59
+ parent_item_id: Optional[str] = Field(
60
+ default=None,
61
+ description="Default parent item ID used to create child items via dispatch",
62
+ )
63
+
64
+
65
+ class CreateItemInput(BaseModel):
66
+ """Payload describing an item to create, with optional attachment upload."""
67
+
68
+ title: str = Field(description="Item title")
69
+ workflow_id: Optional[str] = Field(default=None, description="Workflow ID; falls back to producer config")
70
+ phase_id: Optional[str] = Field(default=None, description="Target phase ID; falls back to producer config")
71
+ parent_item_id: Optional[str] = Field(
72
+ default=None,
73
+ description="Optional parent item ID to create this item as a child",
74
+ )
75
+ field_values: Optional[Dict[str, Any]] = Field(default=None, description="Field values keyed by field ID")
76
+ attachment: Optional[str] = Field(default=None, description="Local file path to upload after creation")
77
+ attachment_field_id: Optional[str] = Field(
78
+ default=None,
79
+ description="Field ID that receives the attachment; falls back to producer config",
80
+ )
81
+ content_type: Optional[str] = Field(default=None, description="Override MIME type for the attachment")
82
+ extra_fields: Dict[str, Any] = Field(
83
+ default_factory=dict,
84
+ description="Additional keyword fields forwarded to create_item",
85
+ )
86
+
87
+ model_config = {"populate_by_name": True}
88
+
89
+
90
+ CreateItemPayload = Union[CreateItemInput, Dict[str, Any]]