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,283 @@
1
+ import logging
2
+ import re
3
+ import unicodedata
4
+ from concurrent.futures import Future, ThreadPoolExecutor
5
+ from pathlib import Path, PurePosixPath, PureWindowsPath
6
+ from typing import Any, Dict, List, Literal, Optional, Union
7
+
8
+ from ....transaction import Transaction
9
+ from ..adapters import ActivityAdapter
10
+ from ..models import InboxAttachmentFile
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ChannelsAttachmentService:
16
+ """Hydrate or download inbound attachment files."""
17
+
18
+ _MAX_CONCURRENT_DOWNLOADS = 4
19
+
20
+ def __init__(
21
+ self,
22
+ client: Any,
23
+ download_client: Optional[Any] = None,
24
+ ) -> None:
25
+ self._client = download_client or client
26
+
27
+ def download_bytes(
28
+ self,
29
+ config_id: str,
30
+ event_id: str,
31
+ attachment_id: str,
32
+ ) -> bytes:
33
+ """Download the bytes of the attachment."""
34
+ return self._client.channels.configs.activity_attachment_file(
35
+ config_id,
36
+ event_id,
37
+ attachment_id,
38
+ )
39
+
40
+ def _download_attachment(
41
+ self,
42
+ config_id: str,
43
+ event_id: str,
44
+ attachment_id: str,
45
+ filename: str,
46
+ ) -> bytes:
47
+ logger.info(
48
+ "Downloading attachment %s (%s)",
49
+ filename,
50
+ attachment_id,
51
+ )
52
+ return self.download_bytes(config_id, event_id, attachment_id)
53
+
54
+ def hydrate_inbox_attachments(
55
+ self,
56
+ config_id: str,
57
+ transaction: Transaction,
58
+ failure_policy: Literal["raise", "best_effort"] = "raise",
59
+ ) -> Transaction:
60
+ """Hydrate the inbox attachments."""
61
+ return self.hydrate_inbox_transactions(
62
+ config_id,
63
+ [transaction],
64
+ failure_policy=failure_policy,
65
+ )[0]
66
+
67
+ def hydrate_inbox_transactions(
68
+ self,
69
+ config_id: str,
70
+ transactions: List[Transaction],
71
+ failure_policy: Literal["raise", "best_effort"] = "raise",
72
+ ) -> List[Transaction]:
73
+ """Hydrate a batch with bounded concurrent attachment downloads."""
74
+ hydrated = list(transactions)
75
+ enriched: Dict[int, List[Dict[str, Any]]] = {}
76
+ failures: Dict[int, List[Dict[str, str]]] = {}
77
+ jobs: List[tuple[int, int, str, str, str]] = []
78
+
79
+ for transaction_index, transaction in enumerate(transactions):
80
+ event_id = transaction.id
81
+ if not event_id:
82
+ continue
83
+ attachments = ActivityAdapter.attachments(transaction)
84
+ enriched[transaction_index] = list(attachments)
85
+ for attachment_index, metadata in enumerate(attachments):
86
+ if metadata.get("content") is not None:
87
+ continue
88
+ attachment_id = ActivityAdapter.attachment_id(metadata)
89
+ if not attachment_id:
90
+ continue
91
+ filename = str(metadata.get("filename") or attachment_id)
92
+ jobs.append(
93
+ (
94
+ transaction_index,
95
+ attachment_index,
96
+ event_id,
97
+ attachment_id,
98
+ filename,
99
+ )
100
+ )
101
+
102
+ if not jobs:
103
+ return hydrated
104
+
105
+ max_workers = min(self._MAX_CONCURRENT_DOWNLOADS, len(jobs))
106
+ future_jobs: List[tuple[int, int, str, str, str, Future[bytes]]] = []
107
+ with ThreadPoolExecutor(
108
+ max_workers=max_workers,
109
+ thread_name_prefix="channels-attachment",
110
+ ) as executor:
111
+ for job in jobs:
112
+ transaction_index, attachment_index, event_id, attachment_id, filename = job
113
+ future = executor.submit(
114
+ self._download_attachment,
115
+ config_id,
116
+ event_id,
117
+ attachment_id,
118
+ filename,
119
+ )
120
+ future_jobs.append((*job, future))
121
+
122
+ changed: set[int] = set()
123
+ for (
124
+ transaction_index,
125
+ attachment_index,
126
+ event_id,
127
+ attachment_id,
128
+ filename,
129
+ future,
130
+ ) in future_jobs:
131
+ try:
132
+ content = future.result()
133
+ except Exception as exc:
134
+ if failure_policy == "raise":
135
+ raise
136
+ logger.warning(
137
+ "Skipping attachment %s (%s) for event %s",
138
+ filename,
139
+ attachment_id,
140
+ event_id,
141
+ exc_info=True,
142
+ )
143
+ failures.setdefault(transaction_index, []).append(
144
+ {
145
+ "attachment_id": attachment_id,
146
+ "filename": filename,
147
+ "error": f"{type(exc).__name__}: {exc}",
148
+ }
149
+ )
150
+ continue
151
+ original = enriched[transaction_index][attachment_index]
152
+ enriched[transaction_index][attachment_index] = {
153
+ **original,
154
+ "content": content,
155
+ }
156
+ changed.add(transaction_index)
157
+
158
+ for transaction_index in changed | failures.keys():
159
+ transaction = ActivityAdapter.with_attachments(
160
+ transactions[transaction_index],
161
+ enriched[transaction_index],
162
+ )
163
+ transaction_failures = failures.get(transaction_index)
164
+ if transaction_failures:
165
+ metadata = dict(transaction.metadata or {})
166
+ metadata["attachment_failures"] = transaction_failures
167
+ transaction = transaction.model_copy(update={"metadata": metadata})
168
+ hydrated[transaction_index] = transaction
169
+ return hydrated
170
+
171
+ @staticmethod
172
+ def _safe_component(value: str, *, fallback: str) -> str:
173
+ """Safe component."""
174
+ normalized = unicodedata.normalize("NFKC", value).replace("\\", "/").strip()
175
+ windows = PureWindowsPath(value)
176
+ if normalized.startswith("/") or value.startswith(("\\\\", "//")) or windows.drive:
177
+ raise ValueError(f"Unsafe absolute attachment filename: {value!r}")
178
+ name = PurePosixPath(normalized).name
179
+ if name in {"", ".", ".."}:
180
+ name = fallback
181
+ name = "".join("_" if unicodedata.category(char).startswith("C") else char for char in name)
182
+ name = re.sub(r"[/\\:*?\"<>|]", "_", name).strip(" .")
183
+ return name or fallback
184
+
185
+ @classmethod
186
+ def _output_filename(
187
+ cls,
188
+ filename: str,
189
+ attachment_id: str,
190
+ ) -> str:
191
+ """Output filename."""
192
+ safe_id = cls._safe_component(
193
+ attachment_id,
194
+ fallback="attachment",
195
+ )
196
+ safe_name = cls._safe_component(filename, fallback=safe_id)
197
+ path = PurePosixPath(safe_name)
198
+ return f"{path.stem}--{safe_id}{path.suffix}"
199
+
200
+ def download_inbox_attachments(
201
+ self,
202
+ config_id: str,
203
+ transaction: Transaction,
204
+ dest: Optional[Union[str, Path]] = None,
205
+ ) -> List[InboxAttachmentFile]:
206
+ """Download the inbox attachments."""
207
+ event_id = transaction.id
208
+ if not event_id:
209
+ raise ValueError("transaction.id is required to download attachments")
210
+
211
+ dest_root = Path(dest).resolve() if dest is not None else None
212
+ event_component = self._safe_component(
213
+ str(event_id),
214
+ fallback="event",
215
+ )
216
+ dest_dir = dest_root / event_component if dest_root is not None else None
217
+ jobs: List[tuple[str, str, Optional[str]]] = []
218
+ for metadata in ActivityAdapter.attachments(transaction):
219
+ attachment_id = ActivityAdapter.attachment_id(metadata)
220
+ if not attachment_id:
221
+ continue
222
+ filename = self._output_filename(
223
+ str(metadata.get("filename") or attachment_id),
224
+ attachment_id,
225
+ )
226
+ jobs.append(
227
+ (
228
+ attachment_id,
229
+ filename,
230
+ metadata.get("content_type"),
231
+ )
232
+ )
233
+
234
+ if not jobs:
235
+ return []
236
+
237
+ max_workers = min(self._MAX_CONCURRENT_DOWNLOADS, len(jobs))
238
+ futures: List[tuple[str, str, Optional[str], Future[bytes]]] = []
239
+ with ThreadPoolExecutor(
240
+ max_workers=max_workers,
241
+ thread_name_prefix="channels-attachment",
242
+ ) as executor:
243
+ for attachment_id, filename, content_type in jobs:
244
+ future = executor.submit(
245
+ self._download_attachment,
246
+ config_id,
247
+ event_id,
248
+ attachment_id,
249
+ filename,
250
+ )
251
+ futures.append(
252
+ (
253
+ attachment_id,
254
+ filename,
255
+ content_type,
256
+ future,
257
+ )
258
+ )
259
+
260
+ downloaded: List[InboxAttachmentFile] = []
261
+ for attachment_id, filename, content_type, future in futures:
262
+ content = future.result()
263
+ path: Optional[str] = None
264
+ if dest_dir is not None:
265
+ dest_dir.mkdir(parents=True, exist_ok=True)
266
+ resolved_dir = dest_dir.resolve()
267
+ file_path = (resolved_dir / filename).resolve()
268
+ try:
269
+ file_path.relative_to(resolved_dir)
270
+ except ValueError as exc:
271
+ raise ValueError("Attachment destination escapes the event directory") from exc
272
+ file_path.write_bytes(content)
273
+ path = str(file_path)
274
+ downloaded.append(
275
+ InboxAttachmentFile(
276
+ attachment_id=attachment_id,
277
+ filename=filename,
278
+ content=content,
279
+ content_type=content_type,
280
+ path=path,
281
+ )
282
+ )
283
+ return downloaded
@@ -0,0 +1,127 @@
1
+ import html as html_module
2
+ from typing import Any, Dict, List, Optional, Union
3
+
4
+ from ....transaction import Transaction
5
+ from ..adapters import ActivityAdapter
6
+ from ..models import SendMessageInput
7
+ from .records import SdkRecord
8
+
9
+
10
+ class ChannelsMessageService:
11
+ """Thread reads and outbound message operations."""
12
+
13
+ def __init__(self, client: Any) -> None:
14
+ self.client = client
15
+
16
+ def fetch_thread_messages(
17
+ self,
18
+ thread_id: str,
19
+ limit: Optional[int] = None,
20
+ offset: int = 0,
21
+ **params: Any,
22
+ ) -> List[Transaction]:
23
+ """Fetch the thread messages."""
24
+ response = self.client.channels.thread_messages(thread_id, **params)
25
+ messages = SdkRecord.items(
26
+ response,
27
+ keys=["messages", "items", "data"],
28
+ )
29
+ if limit is not None:
30
+ messages = messages[offset : offset + limit]
31
+ elif offset:
32
+ messages = messages[offset:]
33
+ return [ActivityAdapter.to_transaction(message, source="thread") for message in messages]
34
+
35
+ def send_message(
36
+ self,
37
+ address_id: str,
38
+ channel: str,
39
+ config: Dict[str, Any],
40
+ resource_id: Optional[str] = None,
41
+ service_name: Optional[str] = None,
42
+ ) -> Any:
43
+ """Send the message."""
44
+ if not address_id:
45
+ raise ValueError("address_id is required to send a channel message")
46
+ if not channel:
47
+ raise ValueError("channel is required to send a channel message")
48
+ request: Dict[str, Any] = {
49
+ "address_id": address_id,
50
+ "channel": channel,
51
+ "config": config,
52
+ }
53
+ if resource_id is not None:
54
+ request["resource_id"] = resource_id
55
+ if service_name is not None:
56
+ request["service_name"] = service_name
57
+ return self.client.channels.send(request)
58
+
59
+ @staticmethod
60
+ def normalize_recipients(to: Union[str, List[str]]) -> List[str]:
61
+ """Normalize the recipients."""
62
+ if isinstance(to, str):
63
+ return [to]
64
+ return list(to)
65
+
66
+ @staticmethod
67
+ def _drop_none(mapping: Dict[str, Any]) -> Dict[str, Any]:
68
+ """Drop the none values."""
69
+ return {key: value for key, value in mapping.items() if value is not None}
70
+
71
+ @classmethod
72
+ def normalize_send_payload(
73
+ cls,
74
+ payload: object,
75
+ ) -> Dict[str, Any]:
76
+ if isinstance(payload, SendMessageInput):
77
+ config: Dict[str, Any] = cls._drop_none(
78
+ {
79
+ "to": list(payload.to) if payload.to else None,
80
+ "subject": payload.subject,
81
+ "html": payload.html,
82
+ "text": payload.text,
83
+ "cc": list(payload.cc) if payload.cc else None,
84
+ "bcc": list(payload.bcc) if payload.bcc else None,
85
+ "reply_to": payload.reply_to,
86
+ "in_reply_to": payload.in_reply_to,
87
+ "attachments": (
88
+ [attachment.model_dump(mode="json") for attachment in payload.attachments]
89
+ if payload.attachments
90
+ else None
91
+ ),
92
+ }
93
+ )
94
+ if config.get("text") and not config.get("html"):
95
+ config["html"] = f"<p>{html_module.escape(config['text'])}</p>"
96
+ return {"top": {}, "config": config}
97
+
98
+ if isinstance(payload, dict):
99
+ data = dict(payload)
100
+ config = dict(data.pop("config", {}) or {})
101
+ top = {
102
+ "address_id": data.pop("address_id", None),
103
+ "channel": data.pop("channel", None),
104
+ "resource_id": data.pop("resource_id", None),
105
+ "service_name": data.pop("service_name", None),
106
+ }
107
+ for key, value in data.items():
108
+ config.setdefault(key, value)
109
+ return {"top": top, "config": cls._drop_none(config)}
110
+
111
+ raise TypeError(f"Unsupported send payload type: {type(payload)}")
112
+
113
+ @staticmethod
114
+ def send_response_id(result: Any) -> str:
115
+ """Send the response id."""
116
+ record = SdkRecord(result)
117
+ for key in (
118
+ "log_id",
119
+ "provider_message_id",
120
+ "thread_id",
121
+ "provider_id",
122
+ "id",
123
+ ):
124
+ value = record.get(key)
125
+ if value:
126
+ return str(value)
127
+ return ""
@@ -0,0 +1,75 @@
1
+ from typing import Any, List, Optional
2
+
3
+
4
+ class SdkRecord:
5
+ """Uniform read API over dict-or-object SDK payloads."""
6
+
7
+ def __init__(self, raw: Any) -> None:
8
+ self.raw = raw
9
+
10
+ def get(self, key: str, default: Any = None) -> Any:
11
+ """Get a value from the record."""
12
+ if isinstance(self.raw, dict):
13
+ return self.raw.get(key, default)
14
+ return getattr(self.raw, key, default)
15
+
16
+ @classmethod
17
+ def items(
18
+ cls,
19
+ response: Any,
20
+ keys: Optional[List[str]] = None,
21
+ ) -> List[Any]:
22
+ """Get items from a response."""
23
+ candidate_keys = keys or ["items", "messages", "data", "results"]
24
+ if isinstance(response, list):
25
+ return response
26
+ if isinstance(response, dict):
27
+ for key in candidate_keys:
28
+ value = response.get(key)
29
+ if isinstance(value, list):
30
+ return value
31
+ return []
32
+ for key in candidate_keys:
33
+ value = getattr(response, key, None)
34
+ if isinstance(value, (list, tuple)):
35
+ return list(value)
36
+ return []
37
+
38
+ @classmethod
39
+ def total(cls, response: Any) -> int:
40
+ """Get the total from a response."""
41
+ record = cls(response)
42
+ for key in ("total", "total_count", "count"):
43
+ value = record.get(key)
44
+ if value is not None:
45
+ return int(value)
46
+ return 0
47
+
48
+ @classmethod
49
+ def serialize(cls, obj: Any) -> Any:
50
+ """Serialize an object to a dictionary."""
51
+ if obj is None or isinstance(
52
+ obj,
53
+ (str, int, float, bool, bytes),
54
+ ):
55
+ return obj
56
+ if isinstance(obj, dict):
57
+ return {key: cls.serialize(value) for key, value in obj.items()}
58
+ if isinstance(obj, (list, tuple)):
59
+ return [cls.serialize(item) for item in obj]
60
+ if hasattr(obj, "model_dump"):
61
+ return obj.model_dump(mode="json")
62
+ if hasattr(obj, "to_dict"):
63
+ return obj.to_dict()
64
+ if hasattr(obj, "__dict__"):
65
+ return {key: cls.serialize(value) for key, value in obj.__dict__.items() if not key.startswith("_")}
66
+ return obj
67
+
68
+ @classmethod
69
+ def first_id(cls, mapping: dict, *keys: str) -> str:
70
+ """Get the first id from a mapping."""
71
+ for key in keys:
72
+ value = mapping.get(key)
73
+ if value:
74
+ return str(value)
75
+ return ""
@@ -0,0 +1,34 @@
1
+ from typing import Any, Optional
2
+
3
+ from ...models import ErgonPlatformClient
4
+ from .activity import ChannelsActivityService
5
+ from .addresses import ChannelsAddressService
6
+ from .attachments import ChannelsAttachmentService
7
+ from .messages import ChannelsMessageService
8
+
9
+
10
+ class ErgonPlatformChannelsService:
11
+ """Domain-organized services over one shared Channels SDK client."""
12
+
13
+ def __init__(
14
+ self,
15
+ config: ErgonPlatformClient,
16
+ client: Any,
17
+ download_client: Optional[Any] = None,
18
+ ) -> None:
19
+ self.config = config
20
+ self.client = client
21
+ self._download_client = download_client
22
+ self.activity = ChannelsActivityService(client)
23
+ self.addresses = ChannelsAddressService(client)
24
+ self.attachments = ChannelsAttachmentService(
25
+ client,
26
+ download_client=download_client,
27
+ )
28
+ self.messages = ChannelsMessageService(client)
29
+
30
+ def close(self) -> None:
31
+ """Close the services."""
32
+ download = self._download_client
33
+ if download is not None and download is not self.client and hasattr(download, "close"):
34
+ download.close()