ergon-framework-python 0.2.0__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 (38) 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/connector/nylas/async_connector.py +6 -0
  27. ergon/connector/nylas/async_service.py +3 -0
  28. ergon/connector/nylas/connector.py +6 -0
  29. ergon/connector/nylas/models.py +8 -0
  30. ergon/connector/nylas/service.py +9 -0
  31. ergon/connector/nylas/utils.py +2 -4
  32. ergon/task/mixins/consumer.py +0 -2
  33. {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/METADATA +3 -3
  34. {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/RECORD +38 -21
  35. {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/WHEEL +0 -0
  36. {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/entry_points.txt +0 -0
  37. {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/licenses/LICENSE +0 -0
  38. {ergon_framework_python-0.2.0.dist-info → ergon_framework_python-0.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,300 @@
1
+ from contextlib import suppress
2
+ from typing import Any, Callable, Dict, List, Optional
3
+ from uuid import uuid4
4
+
5
+ from ....transaction import Transaction
6
+ from ..adapters import ActivityAdapter
7
+ from ..models import ErgonPlatformChannelsConsumerConfig
8
+ from .records import SdkRecord
9
+
10
+
11
+ class ChannelsActivityService:
12
+ """Activity history, inbox claims, and lease settlement."""
13
+
14
+ def __init__(self, client: Any) -> None:
15
+ self.client = client
16
+
17
+ def fetch_activity_events(
18
+ self,
19
+ company_id: Optional[str] = None,
20
+ limit: Optional[int] = None,
21
+ offset: int = 0,
22
+ **params: Any,
23
+ ) -> List[Transaction]:
24
+ """Fetch the activity events."""
25
+ response = self.client.channels.company_activity(
26
+ company_id=company_id,
27
+ **self._page_query(params, limit=limit, offset=offset),
28
+ )
29
+ events = SdkRecord.items(
30
+ response,
31
+ keys=["items", "data", "results"],
32
+ )
33
+ return [ActivityAdapter.to_transaction(event, source="activity") for event in events]
34
+
35
+ def fetch_inbox_events(
36
+ self,
37
+ config_id: str,
38
+ address_id: Optional[str] = None,
39
+ limit: Optional[int] = None,
40
+ offset: int = 0,
41
+ **params: Any,
42
+ ) -> List[Transaction]:
43
+ """Fetch the inbox events."""
44
+ response = self.client.channels.configs.activity(
45
+ config_id,
46
+ **self._page_query(
47
+ params,
48
+ limit=limit,
49
+ offset=offset,
50
+ address_id=address_id,
51
+ ),
52
+ )
53
+ events = SdkRecord.items(
54
+ response,
55
+ keys=["items", "data", "results"],
56
+ )
57
+ return [
58
+ ActivityAdapter.to_transaction(
59
+ event,
60
+ source="config_activity",
61
+ )
62
+ for event in events
63
+ ]
64
+
65
+ def claim_inbox_transactions(
66
+ self,
67
+ config_id: str,
68
+ address_id: str,
69
+ config: ErgonPlatformChannelsConsumerConfig,
70
+ limit: int,
71
+ seen_ids: Optional[set[str]] = None,
72
+ ) -> List[Transaction]:
73
+ """Claim a batch and release every lease if pagination/filtering fails."""
74
+ leased: List[Transaction] = []
75
+ try:
76
+ return self._claim_inbox_transactions(
77
+ config_id=config_id,
78
+ address_id=address_id,
79
+ config=config,
80
+ limit=limit,
81
+ seen_ids=seen_ids,
82
+ leased=leased,
83
+ )
84
+ except Exception:
85
+ self.release_claims(
86
+ config_id,
87
+ leased,
88
+ delay_seconds=config.nack_delay_seconds,
89
+ )
90
+ if seen_ids is not None:
91
+ for transaction in leased:
92
+ if transaction.id:
93
+ seen_ids.discard(transaction.id)
94
+ raise
95
+
96
+ def _claim_inbox_transactions(
97
+ self,
98
+ config_id: str,
99
+ address_id: str,
100
+ config: ErgonPlatformChannelsConsumerConfig,
101
+ limit: int,
102
+ seen_ids: Optional[set[str]],
103
+ leased: List[Transaction],
104
+ ) -> List[Transaction]:
105
+ """Claim the inbox transactions."""
106
+ subscription_id = config.resolved_subscription_id(
107
+ config_id,
108
+ address_id,
109
+ )
110
+ cursor: Optional[str] = None
111
+ accepted: List[Transaction] = []
112
+ activity_filter = config.effective_activity_filter()
113
+
114
+ claim = self._activity_claim()
115
+
116
+ while len(accepted) < limit:
117
+ response = claim(
118
+ config_id,
119
+ subscription_id=subscription_id,
120
+ address_id=address_id,
121
+ consumer_id=config.consumer_id,
122
+ limit=min(
123
+ config.claim_page_size,
124
+ max(1, limit - len(accepted)),
125
+ ),
126
+ visibility_timeout_seconds=config.visibility_timeout_seconds,
127
+ cursor=cursor,
128
+ idempotency_key=str(uuid4()),
129
+ )
130
+ items = SdkRecord.items(response, keys=["items"])
131
+ if not items:
132
+ break
133
+ for item in items:
134
+ transaction = ActivityAdapter.claimed_transaction(item)
135
+ leased.append(transaction)
136
+ matches = ActivityAdapter.belongs_to_address(
137
+ transaction,
138
+ address_id,
139
+ )
140
+ matches = matches and activity_filter.matches(transaction)
141
+ duplicate = bool(seen_ids is not None and transaction.id and transaction.id in seen_ids)
142
+ if not matches or duplicate:
143
+ self.ack_inbox_event(config_id, transaction)
144
+ leased.remove(transaction)
145
+ continue
146
+ accepted.append(transaction)
147
+ if seen_ids is not None and transaction.id:
148
+ seen_ids.add(transaction.id)
149
+ if len(accepted) >= limit:
150
+ break
151
+ cursor_value = SdkRecord(response).get("next_cursor")
152
+ cursor = str(cursor_value) if cursor_value else None
153
+ if cursor is None:
154
+ break
155
+ return accepted
156
+
157
+ def _activity_claim(self) -> Callable[..., Any]:
158
+ """Return the SDK claim operation after validating its availability."""
159
+ claim = getattr(
160
+ self.client.channels.configs,
161
+ "activity_claim",
162
+ None,
163
+ )
164
+ if not callable(claim):
165
+ raise RuntimeError("ergon-platform-sdk>=0.2.0 is required: channels.configs.activity_claim is unavailable")
166
+ return claim
167
+
168
+ def get_activity_count(
169
+ self,
170
+ company_id: Optional[str] = None,
171
+ **params: Any,
172
+ ) -> int:
173
+ """Get the activity count."""
174
+ query = {**params, "limit": 1, "page": 1}
175
+ response = self.client.channels.company_activity(
176
+ company_id=company_id,
177
+ **query,
178
+ )
179
+ return SdkRecord.total(response)
180
+
181
+ def get_inbox_events_count(
182
+ self,
183
+ config_id: str,
184
+ address_id: Optional[str] = None,
185
+ **params: Any,
186
+ ) -> int:
187
+ """Get the inbox events count."""
188
+ query: Dict[str, Any] = {**params, "limit": 1, "page": 1}
189
+ if address_id:
190
+ query["address_id"] = address_id
191
+ response = self.client.channels.configs.activity(config_id, **query)
192
+ return SdkRecord.total(response)
193
+
194
+ def get_activity_event(
195
+ self,
196
+ event_id: str,
197
+ company_id: Optional[str] = None,
198
+ **params: Any,
199
+ ) -> Transaction:
200
+ """Get the activity event."""
201
+ event = self.client.channels.company_activity_event(
202
+ company_id=company_id,
203
+ event_id=event_id,
204
+ **params,
205
+ )
206
+ return ActivityAdapter.to_transaction(event, source="activity")
207
+
208
+ def get_inbox_event(
209
+ self,
210
+ config_id: str,
211
+ event_id: str,
212
+ **params: Any,
213
+ ) -> Transaction:
214
+ """Get the inbox event."""
215
+ event = self.client.channels.configs.activity_event(
216
+ config_id,
217
+ event_id,
218
+ **params,
219
+ )
220
+ return ActivityAdapter.to_transaction(
221
+ event,
222
+ source="config_activity",
223
+ )
224
+
225
+ @staticmethod
226
+ def finalize_fetched_transactions(
227
+ transactions: List[Transaction],
228
+ config: ErgonPlatformChannelsConsumerConfig,
229
+ seen_ids: Optional[set[str]] = None,
230
+ ) -> List[Transaction]:
231
+ """Finalize the fetched transactions."""
232
+ return ActivityAdapter.finalize_fetch(
233
+ transactions,
234
+ config,
235
+ seen_ids=seen_ids,
236
+ )
237
+
238
+ def ack_inbox_event(
239
+ self,
240
+ config_id: str,
241
+ transaction: Transaction,
242
+ ) -> Any:
243
+ """Ack the inbox event."""
244
+ delivery = ActivityAdapter.delivery(transaction)
245
+ return self.client.channels.configs.activity_ack(
246
+ config_id,
247
+ transaction.id,
248
+ subscription_id=delivery["subscription_id"],
249
+ lease_token=delivery["lease_token"],
250
+ )
251
+
252
+ def nack_inbox_event(
253
+ self,
254
+ config_id: str,
255
+ transaction: Transaction,
256
+ requeue: bool = True,
257
+ delay_seconds: int = 0,
258
+ ) -> Any:
259
+ """Nack the inbox event."""
260
+ delivery = ActivityAdapter.delivery(transaction)
261
+ return self.client.channels.configs.activity_nack(
262
+ config_id,
263
+ transaction.id,
264
+ subscription_id=delivery["subscription_id"],
265
+ lease_token=delivery["lease_token"],
266
+ requeue=requeue,
267
+ delay_seconds=delay_seconds,
268
+ )
269
+
270
+ def release_claims(
271
+ self,
272
+ config_id: str,
273
+ transactions: List[Transaction],
274
+ delay_seconds: int,
275
+ ) -> None:
276
+ """Best-effort requeue of a batch that cannot be handed to the task."""
277
+ for transaction in transactions:
278
+ with suppress(Exception):
279
+ self.nack_inbox_event(
280
+ config_id,
281
+ transaction,
282
+ requeue=True,
283
+ delay_seconds=delay_seconds,
284
+ )
285
+
286
+ @staticmethod
287
+ def _page_query(
288
+ params: Dict[str, Any],
289
+ limit: Optional[int],
290
+ offset: int,
291
+ **extra: Any,
292
+ ) -> Dict[str, Any]:
293
+ """Page query."""
294
+ query = {key: value for key, value in extra.items() if value is not None}
295
+ query.update(params)
296
+ if limit is not None:
297
+ query.setdefault("limit", limit)
298
+ page_size = limit or 50
299
+ query.setdefault("page", (offset // page_size) + 1)
300
+ return query
@@ -0,0 +1,325 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from ..models import (
4
+ AddressDirection,
5
+ ErgonPlatformChannelsConsumerConfig,
6
+ ResolvedInboxAddress,
7
+ )
8
+ from .records import SdkRecord
9
+
10
+
11
+ class ChannelsAddressService:
12
+ """Resolve consumer and sender addresses through Channels."""
13
+
14
+ def __init__(self, client: Any) -> None:
15
+ self._client = client
16
+ self._by_email: Dict[str, ResolvedInboxAddress] = {}
17
+ self._by_id: Dict[str, ResolvedInboxAddress] = {}
18
+
19
+ def resolve_consumer_inbox(
20
+ self,
21
+ config: ErgonPlatformChannelsConsumerConfig,
22
+ ) -> ResolvedInboxAddress:
23
+ """Resolve the consumer inbox address."""
24
+ if config.config_id:
25
+ return self.resolve_in_config(config.config_id, config.address)
26
+ return self.resolve_email(config.address)
27
+
28
+ def resolve_sender_inbox(
29
+ self,
30
+ address: Optional[str],
31
+ address_id: Optional[str],
32
+ direction: Optional[AddressDirection] = None,
33
+ config_id: Optional[str] = None,
34
+ ) -> ResolvedInboxAddress:
35
+ """Resolve the sender inbox address."""
36
+ if address_id:
37
+ try:
38
+ return self.resolve_id(address_id)
39
+ except ValueError:
40
+ return ResolvedInboxAddress(
41
+ address=address or address_id,
42
+ address_id=address_id,
43
+ config_id=config_id or "",
44
+ direction=direction or "both",
45
+ )
46
+ if address and config_id:
47
+ try:
48
+ return self.resolve_in_config(
49
+ config_id,
50
+ address,
51
+ direction_override=direction,
52
+ )
53
+ except ValueError:
54
+ pass
55
+ if address:
56
+ return self.resolve_email(address)
57
+ raise ValueError(
58
+ "address is required to send a channel message "
59
+ "(set producer_config.address, consumer_config.address, "
60
+ "or address_id in a dict payload)"
61
+ )
62
+
63
+ def resolve_email(self, address_email: str) -> ResolvedInboxAddress:
64
+ """Resolve the email address."""
65
+ cached = self._by_email.get(address_email)
66
+ if cached:
67
+ return cached
68
+ self._load_granted_addresses()
69
+ resolved = self._by_email.get(address_email)
70
+ if not resolved:
71
+ raise ValueError(
72
+ f"Channel address {address_email!r} not found via "
73
+ "channels.addresses (check the API key scope or the address "
74
+ "spelling). Set consumer_config.config_id (console URL UUID) "
75
+ "with the inbox email so the connector can resolve the "
76
+ "address inside that channel config."
77
+ )
78
+ return resolved
79
+
80
+ def resolve_id(self, address_id: str) -> ResolvedInboxAddress:
81
+ """Resolve the address id."""
82
+ cached = self._by_id.get(address_id)
83
+ if cached:
84
+ return cached
85
+ self._load_granted_addresses()
86
+ resolved = self._by_id.get(address_id)
87
+ if not resolved:
88
+ raise ValueError(
89
+ f"Channel address id {address_id!r} not found via "
90
+ "channels.addresses (check the API key scope or the address id)."
91
+ )
92
+ return resolved
93
+
94
+ def resolve_in_config(
95
+ self,
96
+ config_id: str,
97
+ address_email: str,
98
+ direction_override: Optional[AddressDirection] = None,
99
+ address_id: Optional[str] = None,
100
+ ) -> ResolvedInboxAddress:
101
+ """Resolve the address in the config."""
102
+ if address_id:
103
+ return self._remember(
104
+ ResolvedInboxAddress(
105
+ address=address_email,
106
+ address_id=address_id,
107
+ config_id=config_id,
108
+ direction=direction_override or "both",
109
+ )
110
+ )
111
+
112
+ cached = self._by_email.get(address_email)
113
+ if cached and cached.config_id == config_id:
114
+ return cached
115
+
116
+ try:
117
+ page = self._client.channels.configs.addresses(config_id).list(limit=100)
118
+ for entry in SdkRecord.items(
119
+ page,
120
+ keys=["items", "data", "results"],
121
+ ):
122
+ parsed = self._parse_config_entry(
123
+ entry,
124
+ config_id=config_id,
125
+ )
126
+ if parsed and parsed.address == address_email:
127
+ return self._remember(parsed)
128
+ except Exception:
129
+ pass
130
+
131
+ return self._remember(
132
+ self._from_config_activity(
133
+ config_id,
134
+ address_email,
135
+ direction_override=direction_override,
136
+ )
137
+ )
138
+
139
+ def _remember(
140
+ self,
141
+ resolved: ResolvedInboxAddress,
142
+ ) -> ResolvedInboxAddress:
143
+ """Remember the resolved address."""
144
+ resolved = self._with_config_capabilities(resolved)
145
+ self._by_email[resolved.address] = resolved
146
+ self._by_id[resolved.address_id] = resolved
147
+ return resolved
148
+
149
+ def _with_config_capabilities(
150
+ self,
151
+ inbox: ResolvedInboxAddress,
152
+ ) -> ResolvedInboxAddress:
153
+ """With the config capabilities."""
154
+ if not inbox.config_id:
155
+ return inbox
156
+ try:
157
+ verified = self._client.channels.configs.verify(inbox.config_id)
158
+ except Exception:
159
+ return inbox
160
+ record = SdkRecord(verified)
161
+ capabilities = record.get("capabilities") or {}
162
+ if not isinstance(capabilities, dict):
163
+ capabilities = {}
164
+ inbound_enabled = record.get("inbound_enabled")
165
+ return inbox.model_copy(
166
+ update={
167
+ "config_name": record.get("name"),
168
+ "config_can_send": capabilities.get("can_send"),
169
+ "config_can_receive": (inbound_enabled if inbound_enabled is not None else None),
170
+ }
171
+ )
172
+
173
+ def _from_config_activity(
174
+ self,
175
+ config_id: str,
176
+ address_email: str,
177
+ direction_override: Optional[AddressDirection] = None,
178
+ ) -> ResolvedInboxAddress:
179
+ """From the config activity."""
180
+ normalized = address_email.strip().lower()
181
+ created_response = self._client.channels.configs.activity(
182
+ config_id,
183
+ limit=50,
184
+ page=1,
185
+ event_type="channels.address.created",
186
+ )
187
+ for item in SdkRecord.items(
188
+ created_response,
189
+ keys=["items", "data", "results"],
190
+ ):
191
+ resolved = self._match_activity_item(
192
+ config_id,
193
+ address_email,
194
+ normalized,
195
+ item,
196
+ direction_override=direction_override,
197
+ )
198
+ if resolved is not None:
199
+ return resolved
200
+
201
+ limit = 50
202
+ for page in range(1, 6):
203
+ response = self._client.channels.configs.activity(
204
+ config_id,
205
+ limit=limit,
206
+ page=page,
207
+ )
208
+ items = SdkRecord.items(
209
+ response,
210
+ keys=["items", "data", "results"],
211
+ )
212
+ if not items:
213
+ break
214
+ for item in items:
215
+ resolved = self._match_activity_item(
216
+ config_id,
217
+ address_email,
218
+ normalized,
219
+ item,
220
+ direction_override=direction_override,
221
+ )
222
+ if resolved is not None:
223
+ return resolved
224
+ if len(items) < limit:
225
+ break
226
+
227
+ raise ValueError(
228
+ f"Channel address {address_email!r} not found under config "
229
+ f"{config_id!r}. Check the inbox email and CHANNELS_CONFIG_ID "
230
+ "from the console URL."
231
+ )
232
+
233
+ def _match_activity_item(
234
+ self,
235
+ config_id: str,
236
+ address_email: str,
237
+ normalized_email: str,
238
+ item: Any,
239
+ direction_override: Optional[AddressDirection] = None,
240
+ ) -> Optional[ResolvedInboxAddress]:
241
+ """Match the activity item."""
242
+ record = SdkRecord(item)
243
+ summary = str(record.get("summary") or "").strip().lower()
244
+ event_type = str(record.get("event_type") or "")
245
+ if event_type != "channels.address.created" and summary != normalized_email:
246
+ return None
247
+
248
+ event_id = record.get("id")
249
+ if not event_id:
250
+ return None
251
+
252
+ detail = self._client.channels.configs.activity_event(
253
+ config_id,
254
+ str(event_id),
255
+ )
256
+ payload = SdkRecord(detail).get("payload") or {}
257
+ if not isinstance(payload, dict):
258
+ return None
259
+
260
+ payload_address = str(payload.get("address") or summary or "").strip().lower()
261
+ if payload_address and payload_address != normalized_email:
262
+ return None
263
+
264
+ address_id = payload.get("channel_address_id") or payload.get("address_id") or payload.get("id")
265
+ if not address_id and summary == normalized_email and "address.created" in event_type:
266
+ address_id = payload.get("channel_address_id")
267
+ if not address_id:
268
+ return None
269
+
270
+ direction = direction_override or ResolvedInboxAddress.parse_direction(payload.get("direction"))
271
+ return ResolvedInboxAddress(
272
+ address=address_email,
273
+ address_id=str(address_id),
274
+ config_id=config_id,
275
+ direction=direction,
276
+ )
277
+
278
+ def _load_granted_addresses(self) -> None:
279
+ """Load the granted addresses."""
280
+ if self._by_email:
281
+ return
282
+ for entry in SdkRecord.items(
283
+ self._client.channels.addresses(),
284
+ keys=["items", "data", "results"],
285
+ ):
286
+ parsed = self._parse_granted_entry(entry)
287
+ if parsed is not None:
288
+ self._remember(parsed)
289
+
290
+ @classmethod
291
+ def _parse_config_entry(
292
+ cls,
293
+ entry: Any,
294
+ config_id: str,
295
+ ) -> Optional[ResolvedInboxAddress]:
296
+ """Parse the config entry."""
297
+ record = SdkRecord(entry)
298
+ entry_email = record.get("address")
299
+ entry_id = record.get("id") or record.get("channel_address_id")
300
+ if not entry_email or not entry_id:
301
+ return None
302
+ return ResolvedInboxAddress(
303
+ address=str(entry_email),
304
+ address_id=str(entry_id),
305
+ config_id=config_id,
306
+ direction=ResolvedInboxAddress.parse_direction(record.get("direction")),
307
+ )
308
+
309
+ @staticmethod
310
+ def _parse_granted_entry(
311
+ entry: Any,
312
+ ) -> Optional[ResolvedInboxAddress]:
313
+ """Parse the granted entry."""
314
+ record = SdkRecord(entry)
315
+ entry_email = record.get("address")
316
+ entry_id = record.get("id")
317
+ entry_config = record.get("channel_config_id")
318
+ if not entry_email or not entry_id or not entry_config:
319
+ return None
320
+ return ResolvedInboxAddress(
321
+ address=str(entry_email),
322
+ address_id=str(entry_id),
323
+ config_id=str(entry_config),
324
+ direction=ResolvedInboxAddress.parse_direction(record.get("direction")),
325
+ )