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,460 @@
1
+ import json
2
+ import unicodedata
3
+ import uuid
4
+ from typing import Any, Dict, List, Literal, Optional, Union
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from ...transaction import Transaction
9
+
10
+ ChannelName = Literal["email"]
11
+ DEFAULT_CHANNEL: ChannelName = "email"
12
+ AddressDirection = Literal["send", "receive", "both"]
13
+
14
+ INBOUND_RECEIVED_EVENT_TYPE = "channels.email.received"
15
+ _SUBSCRIPTION_NAMESPACE = uuid.UUID("a62613cf-d5f8-4d46-b69d-d120766942eb")
16
+
17
+
18
+ class ChannelsActivityFilter(BaseModel):
19
+ """Filter inbox activity events by type and metadata.
20
+
21
+ Claim responses contain complete events, so filters are evaluated before a
22
+ transaction is handed to the task. Non-matches are acknowledged only for
23
+ this subscription, preventing them from starving later matching events.
24
+ """
25
+
26
+ received_only: bool = Field(
27
+ default=True,
28
+ description=(
29
+ f"When ``True`` (default), fetch inbound emails only (``event_type={INBOUND_RECEIVED_EVENT_TYPE}``)."
30
+ ),
31
+ )
32
+ correlation_id: Optional[str] = Field(
33
+ default=None,
34
+ description="Filter by platform correlation id.",
35
+ )
36
+ thread_id: Optional[str] = Field(
37
+ default=None,
38
+ description="Filter by thread id.",
39
+ )
40
+ from_address: Optional[str] = Field(
41
+ default=None,
42
+ description="Match sender email (client-side, case-insensitive).",
43
+ )
44
+ subject_contains: Optional[str] = Field(
45
+ default=None,
46
+ description="Substring match on subject (client-side; case and accent insensitive).",
47
+ )
48
+
49
+ @property
50
+ def has_client_side_filters(self) -> bool:
51
+ return self.from_address is not None or self.subject_contains is not None
52
+
53
+ def activity_query_params(self) -> Dict[str, Any]:
54
+ """Build server-side activity query params from this filter."""
55
+ params: Dict[str, Any] = {"channel": DEFAULT_CHANNEL}
56
+ if self.received_only:
57
+ params["event_type"] = INBOUND_RECEIVED_EVENT_TYPE
58
+ if self.correlation_id is not None:
59
+ params["correlation_id"] = self.correlation_id
60
+ if self.thread_id is not None:
61
+ params["thread_id"] = self.thread_id
62
+ return params
63
+
64
+ @staticmethod
65
+ def _field(transaction: Transaction, key: str) -> Any:
66
+ metadata = transaction.metadata or {}
67
+ if key in metadata:
68
+ return metadata[key]
69
+ payload = transaction.payload
70
+ if isinstance(payload, dict):
71
+ return payload.get(key)
72
+ return getattr(payload, key, None)
73
+
74
+ @staticmethod
75
+ def _fold_text(value: str) -> str:
76
+ """Lowercase and strip combining marks so ``código`` matches ``codigo``."""
77
+ decomposed = unicodedata.normalize("NFKD", value)
78
+ return "".join(ch for ch in decomposed if not unicodedata.combining(ch)).lower()
79
+
80
+ def matches(self, transaction: Transaction) -> bool:
81
+ """Return True when *transaction* satisfies this subscription filter."""
82
+ if self.received_only:
83
+ event_type = str(self._field(transaction, "event_type") or "")
84
+ if event_type and event_type != INBOUND_RECEIVED_EVENT_TYPE:
85
+ return False
86
+ if self.correlation_id is not None:
87
+ if str(self._field(transaction, "correlation_id") or "") != self.correlation_id:
88
+ return False
89
+ if self.thread_id is not None:
90
+ if str(self._field(transaction, "thread_id") or "") != self.thread_id:
91
+ return False
92
+ if self.from_address is not None:
93
+ sender = str(self._field(transaction, "from_address") or "").strip().lower()
94
+ if sender != self.from_address.strip().lower():
95
+ return False
96
+ if self.subject_contains is not None:
97
+ subject = str(self._field(transaction, "subject") or "")
98
+ if self._fold_text(self.subject_contains) not in self._fold_text(subject):
99
+ return False
100
+ return True
101
+
102
+ def select(self, transactions: List[Transaction]) -> List[Transaction]:
103
+ """Select transactions that satisfy the client-side filter fields."""
104
+ if not self.has_client_side_filters:
105
+ return transactions
106
+ return [tx for tx in transactions if self.matches(tx)]
107
+
108
+
109
+ class ResolvedInboxAddress(BaseModel):
110
+ """Inbox routing metadata resolved from ``channels.addresses`` or config activity."""
111
+
112
+ address: str
113
+ address_id: str
114
+ config_id: str
115
+ direction: AddressDirection = "both"
116
+ config_name: Optional[str] = Field(
117
+ default=None,
118
+ description="Channel config display name from ``configs.verify`` when available.",
119
+ )
120
+ config_can_send: Optional[bool] = Field(
121
+ default=None,
122
+ description="Outbound capability from ``configs.verify`` (``capabilities.can_send``).",
123
+ )
124
+ config_can_receive: Optional[bool] = Field(
125
+ default=None,
126
+ description="Inbound capability from ``configs.verify`` (``inbound_enabled``).",
127
+ )
128
+
129
+ @property
130
+ def can_receive(self) -> bool:
131
+ if self.config_can_receive is False:
132
+ return False
133
+ return self.direction in ("receive", "both")
134
+
135
+ @property
136
+ def can_send(self) -> bool:
137
+ if self.config_can_send is False:
138
+ return False
139
+ return self.direction in ("send", "both")
140
+
141
+ @property
142
+ def mode(self) -> str:
143
+ """Human-readable summary of what this inbox supports."""
144
+ if self.can_receive and self.can_send:
145
+ return "receive and send"
146
+ if self.can_receive:
147
+ return "receive-only"
148
+ if self.can_send:
149
+ return "send-only"
150
+ return "inactive"
151
+
152
+ def _channel_label(self) -> str:
153
+ if self.config_name and self.config_id:
154
+ return f"{self.config_name!r} ({self.config_id})"
155
+ if self.config_name:
156
+ return repr(self.config_name)
157
+ if self.config_id:
158
+ return self.config_id
159
+ return "unknown channel"
160
+
161
+ def ensure_can_receive(self) -> None:
162
+ """Raise a clear error when this inbox cannot be used as a consumer."""
163
+ if self.can_receive:
164
+ return
165
+
166
+ lines = [
167
+ f"Cannot fetch from inbox {self.address!r}: capability is {self.mode}.",
168
+ " Consumer expects: receive or both.",
169
+ ]
170
+ if self.config_id:
171
+ lines.append(f" Channel config: {self._channel_label()}")
172
+ lines.append(f" Address direction: {self.direction}")
173
+ if self.config_can_receive is not None or self.config_can_send is not None:
174
+ lines.append(f" Platform: inbound_enabled={self.config_can_receive}, can_send={self.config_can_send}")
175
+
176
+ if self.config_can_receive is False:
177
+ lines.append(
178
+ "Fix: use ErgonPlatformChannelsConsumerConfig with a channel that has "
179
+ "inbound enabled (receive or both)."
180
+ )
181
+ elif self.direction == "send":
182
+ lines.append("Fix: this address is send-only — point consumer_config to a receive or both inbox.")
183
+ else:
184
+ lines.append("Fix: use ErgonPlatformChannelsConsumerConfig with a receive-capable inbox.")
185
+ raise ValueError("\n".join(lines))
186
+
187
+ def ensure_can_send(self) -> None:
188
+ """Raise a clear error when this inbox cannot be used as a producer."""
189
+ if self.can_send:
190
+ return
191
+
192
+ lines = [
193
+ f"Cannot send from inbox {self.address!r}: capability is {self.mode}.",
194
+ " Producer expects: send or both.",
195
+ ]
196
+ if self.config_id:
197
+ lines.append(f" Channel config: {self._channel_label()}")
198
+ lines.append(f" Address direction: {self.direction}")
199
+ if self.config_can_receive is not None or self.config_can_send is not None:
200
+ lines.append(f" Platform: inbound_enabled={self.config_can_receive}, can_send={self.config_can_send}")
201
+
202
+ if self.config_can_send is False:
203
+ lines.append(
204
+ "Fix: use ErgonPlatformChannelsProducerConfig with a different channel "
205
+ "config that allows send (can_send=true). "
206
+ "In the example app, set CHANNELS_SEND_CONFIG_ID / CHANNELS_SEND_ADDRESS."
207
+ )
208
+ elif self.direction == "receive":
209
+ lines.append("Fix: this address is receive-only — point producer_config to a send or both inbox.")
210
+ else:
211
+ lines.append("Fix: use ErgonPlatformChannelsProducerConfig with a send-capable inbox.")
212
+ raise ValueError("\n".join(lines))
213
+
214
+ def as_info_dict(self) -> Dict[str, Any]:
215
+ return {
216
+ "address": self.address,
217
+ "address_id": self.address_id,
218
+ "config_id": self.config_id,
219
+ "config_name": self.config_name,
220
+ "direction": self.direction,
221
+ "mode": self.mode,
222
+ "config_can_send": self.config_can_send,
223
+ "config_can_receive": self.config_can_receive,
224
+ "can_receive": self.can_receive,
225
+ "can_send": self.can_send,
226
+ }
227
+
228
+ @classmethod
229
+ def parse_direction(cls, value: Any, *, default: AddressDirection = "both") -> AddressDirection:
230
+ """Parse an address direction from a string."""
231
+ raw = str(value or default).strip().lower()
232
+ if raw in ("send", "receive", "both"):
233
+ return raw
234
+ raise ValueError(f"Unsupported address direction: {value!r} (expected send, receive or both)")
235
+
236
+
237
+ class ErgonPlatformChannelsConfig(BaseModel):
238
+ """Connect to one channels inbox."""
239
+
240
+ address: str = Field(min_length=3, description="Inbox email from the console.")
241
+ config_id: str = Field(min_length=1, description="Channel UUID from the console URL.")
242
+ send_address: Optional[str] = Field(
243
+ default=None,
244
+ description="Separate sender inbox; defaults to ``address``.",
245
+ )
246
+ batch_size: int = Field(default=50, ge=1, description="Max emails per fetch.")
247
+ subscription_id: Optional[str] = Field(
248
+ default=None,
249
+ description="Optional stable inbox subscription UUID.",
250
+ )
251
+ consumer_id: str = Field(default="ergon-framework", min_length=1, max_length=255)
252
+ visibility_timeout_seconds: int = Field(default=300, ge=1, le=3600)
253
+ nack_delay_seconds: int = Field(default=0, ge=0)
254
+ received_only: bool = Field(
255
+ default=True,
256
+ description=f"Fetch inbound emails only (``{INBOUND_RECEIVED_EVENT_TYPE}``).",
257
+ )
258
+ activity_filter: Optional[ChannelsActivityFilter] = Field(
259
+ default=None,
260
+ description="Advanced activity filter; overrides ``received_only`` when set.",
261
+ )
262
+ download_attachments: bool = Field(
263
+ default=False,
264
+ description="Download attachment bytes onto each fetched Transaction.",
265
+ )
266
+ attachment_failure_policy: Literal["raise", "best_effort"] = "raise"
267
+ attachment_download_timeout: float = Field(
268
+ default=20.0,
269
+ ge=1.0,
270
+ le=120.0,
271
+ description="HTTP timeout in seconds for each inbound attachment download.",
272
+ )
273
+
274
+ def to_consumer_config(self) -> "ErgonPlatformChannelsConsumerConfig":
275
+ return ErgonPlatformChannelsConsumerConfig(
276
+ address=self.address,
277
+ config_id=self.config_id,
278
+ batch_size=self.batch_size,
279
+ subscription_id=self.subscription_id,
280
+ consumer_id=self.consumer_id,
281
+ visibility_timeout_seconds=self.visibility_timeout_seconds,
282
+ nack_delay_seconds=self.nack_delay_seconds,
283
+ received_only=self.received_only,
284
+ activity_filter=self.activity_filter,
285
+ download_attachments=self.download_attachments,
286
+ attachment_failure_policy=self.attachment_failure_policy,
287
+ attachment_download_timeout=self.attachment_download_timeout,
288
+ )
289
+
290
+ def to_producer_config(self) -> "ErgonPlatformChannelsProducerConfig":
291
+ return ErgonPlatformChannelsProducerConfig(
292
+ address=self.send_address or self.address,
293
+ config_id=self.config_id,
294
+ )
295
+
296
+
297
+ class ErgonPlatformChannelsConsumerConfig(BaseModel):
298
+ """Read inbound email from one inbox."""
299
+
300
+ address: str = Field(
301
+ min_length=3,
302
+ description="Inbox email to read from.",
303
+ )
304
+ config_id: Optional[str] = Field(
305
+ default=None,
306
+ description="Channel UUID from the console URL. Set together with ``address`` (recommended).",
307
+ )
308
+ batch_size: int = Field(default=50, ge=1, description="Max events per fetch.")
309
+ subscription_id: Optional[str] = Field(
310
+ default=None,
311
+ description=(
312
+ "Stable inbox subscription UUID. When omitted, the connector derives one "
313
+ "from the channel, address, and filter contract so credential rotation "
314
+ "does not reset delivery state."
315
+ ),
316
+ )
317
+ consumer_id: str = Field(
318
+ default="ergon-framework",
319
+ min_length=1,
320
+ max_length=255,
321
+ description="Worker/replica label recorded on the current delivery lease.",
322
+ )
323
+ visibility_timeout_seconds: int = Field(
324
+ default=300,
325
+ ge=1,
326
+ le=3600,
327
+ description="Duration of each atomic claim before another replica may retry it.",
328
+ )
329
+ claim_page_size: int = Field(
330
+ default=100,
331
+ ge=1,
332
+ le=100,
333
+ description="Claim page size used while scanning past filtered events.",
334
+ )
335
+ nack_delay_seconds: int = Field(
336
+ default=0,
337
+ ge=0,
338
+ description="Default delay applied by ``nack_transaction(requeue=True)``.",
339
+ )
340
+ deduplicate_fetched_events: bool = Field(
341
+ default=False,
342
+ description=(
343
+ "Optional in-process skip of already-fetched ids. Prefer the platform "
344
+ "subscription claim state — this is only a local fallback."
345
+ ),
346
+ )
347
+ received_only: bool = Field(
348
+ default=True,
349
+ description=(
350
+ f"When ``True`` (default), fetch inbound emails only (``event_type={INBOUND_RECEIVED_EVENT_TYPE}``)."
351
+ ),
352
+ )
353
+ activity_filter: Optional[ChannelsActivityFilter] = Field(
354
+ default=None,
355
+ description="Advanced activity filter; overrides ``received_only`` when set.",
356
+ )
357
+ download_attachments: bool = Field(
358
+ default=False,
359
+ description="Download attachment bytes onto each fetched Transaction (like Nylas).",
360
+ )
361
+ attachment_failure_policy: Literal["raise", "best_effort"] = Field(
362
+ default="raise",
363
+ description=(
364
+ "Attachment failure policy. 'raise' nacks every claim in the failed fetch; "
365
+ "'best_effort' exposes attachment_failures and blocks ack unless explicitly overridden."
366
+ ),
367
+ )
368
+ attachment_download_timeout: float = Field(
369
+ default=20.0,
370
+ ge=1.0,
371
+ le=120.0,
372
+ description=(
373
+ "Per-file HTTP timeout for attachment downloads. Uses a dedicated client "
374
+ "(no retries) so a stuck Resend CDN cannot block ack/nack."
375
+ ),
376
+ )
377
+
378
+ def effective_activity_filter(self) -> ChannelsActivityFilter:
379
+ if self.activity_filter is not None:
380
+ return self.activity_filter
381
+ return ChannelsActivityFilter(received_only=self.received_only)
382
+
383
+ def activity_query_params(self) -> Dict[str, Any]:
384
+ """Build the immutable filter identity for this subscription."""
385
+ params = self.effective_activity_filter().activity_query_params()
386
+ return params
387
+
388
+ def resolved_subscription_id(self, config_id: str, address_id: str) -> str:
389
+ """Return an explicit or deterministic subscription UUID."""
390
+
391
+ if self.subscription_id is not None:
392
+ return str(uuid.UUID(self.subscription_id))
393
+ identity = json.dumps(
394
+ {
395
+ "config_id": config_id,
396
+ "address_id": address_id,
397
+ "filter": self.effective_activity_filter().model_dump(mode="json"),
398
+ },
399
+ sort_keys=True,
400
+ separators=(",", ":"),
401
+ default=str,
402
+ )
403
+ return str(uuid.uuid5(_SUBSCRIPTION_NAMESPACE, identity))
404
+
405
+
406
+ class ErgonPlatformChannelsProducerConfig(BaseModel):
407
+ """Defaults for outbound send."""
408
+
409
+ address: Optional[str] = Field(
410
+ default=None,
411
+ description="Sender inbox email. Falls back to the consumer inbox when unset.",
412
+ )
413
+ config_id: Optional[str] = Field(
414
+ default=None,
415
+ description="Channel UUID for resolving the sender. Falls back to the consumer config.",
416
+ )
417
+ service_name: Optional[str] = Field(default=None, description="Optional tag on send.")
418
+ default_reply_to: Optional[str] = Field(default=None, description="Default Reply-To header on send.")
419
+
420
+
421
+ class SendMessageAttachment(BaseModel):
422
+ """File attachment for a channels send payload."""
423
+
424
+ filename: str = Field(description="Displayed filename")
425
+ content_type: str = Field(description="MIME type")
426
+ content: str = Field(description="Base64-encoded content (SDK-friendly transport)")
427
+
428
+ model_config = {"populate_by_name": True}
429
+
430
+
431
+ class InboxAttachmentFile(BaseModel):
432
+ """Inbound attachment downloaded from a fetched inbox event."""
433
+
434
+ attachment_id: str
435
+ filename: str
436
+ content: bytes
437
+ content_type: Optional[str] = None
438
+ path: Optional[str] = Field(
439
+ default=None,
440
+ description="Local path when ``download_attachments(..., dest=...)`` wrote the file.",
441
+ )
442
+
443
+
444
+ class SendMessageInput(BaseModel):
445
+ """Email content for ``send_email`` / ``dispatch_transactions``."""
446
+
447
+ to: List[str] = Field(default_factory=list, description="Recipients")
448
+ subject: Optional[str] = Field(default=None, description="Subject")
449
+ html: Optional[str] = Field(default=None, description="HTML body")
450
+ text: Optional[str] = Field(default=None, description="Plain-text body")
451
+ cc: Optional[List[str]] = Field(default=None, description="CC")
452
+ bcc: Optional[List[str]] = Field(default=None, description="BCC")
453
+ reply_to: Optional[str] = Field(default=None, description="Reply-To header")
454
+ in_reply_to: Optional[str] = Field(default=None, description="Message-Id being replied to")
455
+ attachments: Optional[List[SendMessageAttachment]] = Field(default=None, description="Attachments")
456
+
457
+ model_config = {"populate_by_name": True}
458
+
459
+
460
+ SendMessagePayload = Union[SendMessageInput, Dict[str, Any]]
@@ -0,0 +1,39 @@
1
+ from importlib import import_module
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ if TYPE_CHECKING:
5
+ from .activity import ChannelsActivityService
6
+ from .addresses import ChannelsAddressService
7
+ from .attachments import ChannelsAttachmentService
8
+ from .messages import ChannelsMessageService
9
+ from .service import ErgonPlatformChannelsService
10
+
11
+ _EXPORTS = {
12
+ "ChannelsActivityService": "activity",
13
+ "ChannelsAddressService": "addresses",
14
+ "ChannelsAttachmentService": "attachments",
15
+ "ChannelsMessageService": "messages",
16
+ "ErgonPlatformChannelsService": "service",
17
+ }
18
+
19
+
20
+ def __getattr__(name: str) -> Any:
21
+ module_name = _EXPORTS.get(name)
22
+ if module_name is None:
23
+ raise AttributeError(name)
24
+ value = getattr(import_module(f"{__name__}.{module_name}"), name)
25
+ globals()[name] = value
26
+ return value
27
+
28
+
29
+ def __dir__() -> list[str]:
30
+ return sorted({*globals(), *_EXPORTS})
31
+
32
+
33
+ __all__ = [
34
+ "ChannelsActivityService",
35
+ "ChannelsAddressService",
36
+ "ChannelsAttachmentService",
37
+ "ChannelsMessageService",
38
+ "ErgonPlatformChannelsService",
39
+ ]